Categorize audit log by threat level with filtering UI
Deploy Dashboard / deploy (push) Successful in 26s

This commit is contained in:
Gan, Jimmy
2026-02-22 12:38:59 +08:00
parent 8e8f465b40
commit eb3391cec4
2 changed files with 67 additions and 10 deletions
+36 -3
View File
@@ -60,11 +60,44 @@ def system_stats():
def audit_log(lines: int = Query(100, le=500)): def audit_log(lines: int = Query(100, le=500)):
log_path = os.path.join(VOLUME_ROOT, "docker/nas-dashboard/audit.log") log_path = os.path.join(VOLUME_ROOT, "docker/nas-dashboard/audit.log")
if not os.path.exists(log_path): if not os.path.exists(log_path):
return {"lines": []} return {"entries": []}
with open(log_path, "rb") as f: with open(log_path, "rb") as f:
f.seek(0, 2) f.seek(0, 2)
size = f.tell() size = f.tell()
buf = min(size, lines * 200) buf = min(size, lines * 200)
f.seek(max(0, size - buf)) f.seek(max(0, size - buf))
data = f.read().decode(errors="replace").splitlines() raw = f.read().decode(errors="replace").splitlines()[-lines:]
return {"lines": data[-lines:]}
entries = []
for line in raw:
parts = line.split(" ", 6)
if len(parts) < 7:
continue
ts, ip, user, method, path, status_str, dur = parts
status = int(status_str) if status_str.isdigit() else 0
level = _classify(method, path, status, user)
if level == "noise":
continue
entries.append({"ts": ts, "ip": ip, "user": user, "method": method, "path": path, "status": status, "duration": dur, "level": level})
return {"entries": entries}
def _classify(method, path, status, user):
# High — failed auth attempts
if "/auth/login" in path and status in (401, 403):
return "high"
if "/auth/passkey/login/verify" in path and status == 400:
return "high"
# Medium — 403/404 on sensitive paths, or any 5xx
if status >= 500:
return "medium"
if status == 403 and "/files/" in path:
return "medium"
# Low — normal auth activity
if "/auth/" in path and status == 200:
return "low"
# Noise — health checks, static assets
if path in ("/api/health",) or path.startswith("/assets/") or path == "/favicon.ico" or path == "/":
return "noise"
# Info — everything else
return "info"
+31 -7
View File
@@ -114,14 +114,20 @@
saving = false; saving = false;
} }
let auditLines = $state([]); let auditEntries = $state([]);
let auditLoading = $state(false); let auditLoading = $state(false);
let auditFilter = $state("all");
const levelColors = { high: "text-rose-400", medium: "text-amber-400", low: "text-emerald-400", info: "text-surface-400" };
const levelLabels = { high: "HIGH", medium: "MED", low: "LOW", info: "INFO" };
let filteredEntries = $derived(auditFilter === "all" ? auditEntries : auditEntries.filter(e => e.level === auditFilter));
async function loadAuditLog() { async function loadAuditLog() {
auditLoading = true; auditLoading = true;
try { try {
const res = await get("/system/audit-log?lines=100"); const res = await get("/system/audit-log?lines=200");
auditLines = res.lines.reverse(); auditEntries = res.entries.reverse();
} catch {} } catch {}
auditLoading = false; auditLoading = false;
} }
@@ -206,12 +212,30 @@
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-surface-700">Audit Log</h3> <h3 class="text-sm font-semibold text-surface-700">Audit Log</h3>
<button onclick={loadAuditLog} disabled={auditLoading} class="px-3 py-1.5 text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors disabled:opacity-50"> <button onclick={loadAuditLog} disabled={auditLoading} class="px-3 py-1.5 text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors disabled:opacity-50">
{auditLoading ? "Loading..." : auditLines.length ? "Refresh" : "Load"} {auditLoading ? "Loading..." : auditEntries.length ? "Refresh" : "Load"}
</button> </button>
</div> </div>
{#if auditLines.length > 0} {#if auditEntries.length > 0}
<div class="max-h-80 overflow-auto rounded-lg bg-surface-950 p-3"> <div class="flex gap-1.5 mb-3">
<pre class="text-xs text-surface-300 font-mono whitespace-pre leading-5">{auditLines.join('\n')}</pre> {#each ["all", "high", "medium", "low", "info"] as lvl}
<button onclick={() => auditFilter = lvl} class="px-2.5 py-1 text-xs rounded-md transition-colors {auditFilter === lvl ? 'bg-surface-800 text-white' : 'bg-surface-100 text-surface-500 hover:bg-surface-200'}">
{lvl === "all" ? "All" : levelLabels[lvl] || lvl}
{#if lvl !== "all"}
<span class="ml-1 opacity-60">{auditEntries.filter(e => e.level === lvl).length}</span>
{/if}
</button>
{/each}
</div>
<div class="max-h-80 overflow-auto rounded-lg bg-surface-950 p-3 space-y-0.5">
{#each filteredEntries as e}
<div class="text-xs font-mono flex gap-2 leading-5">
<span class="{levelColors[e.level]} w-8 shrink-0">{levelLabels[e.level]}</span>
<span class="text-surface-500 shrink-0">{e.ts.replace('T', ' ')}</span>
<span class="text-surface-400 shrink-0 w-10">{e.status}</span>
<span class="text-surface-300 shrink-0">{e.user}</span>
<span class="text-surface-500">{e.method} {e.path}</span>
</div>
{/each}
</div> </div>
{:else if !auditLoading} {:else if !auditLoading}
<p class="text-sm text-surface-400">Click Load to view recent audit log entries</p> <p class="text-sm text-surface-400">Click Load to view recent audit log entries</p>