Categorize audit log by threat level with filtering UI
This commit is contained in:
@@ -60,11 +60,44 @@ def system_stats():
|
||||
def audit_log(lines: int = Query(100, le=500)):
|
||||
log_path = os.path.join(VOLUME_ROOT, "docker/nas-dashboard/audit.log")
|
||||
if not os.path.exists(log_path):
|
||||
return {"lines": []}
|
||||
return {"entries": []}
|
||||
with open(log_path, "rb") as f:
|
||||
f.seek(0, 2)
|
||||
size = f.tell()
|
||||
buf = min(size, lines * 200)
|
||||
f.seek(max(0, size - buf))
|
||||
data = f.read().decode(errors="replace").splitlines()
|
||||
return {"lines": data[-lines:]}
|
||||
raw = f.read().decode(errors="replace").splitlines()[-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"
|
||||
|
||||
@@ -114,14 +114,20 @@
|
||||
saving = false;
|
||||
}
|
||||
|
||||
let auditLines = $state([]);
|
||||
let auditEntries = $state([]);
|
||||
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() {
|
||||
auditLoading = true;
|
||||
try {
|
||||
const res = await get("/system/audit-log?lines=100");
|
||||
auditLines = res.lines.reverse();
|
||||
const res = await get("/system/audit-log?lines=200");
|
||||
auditEntries = res.entries.reverse();
|
||||
} catch {}
|
||||
auditLoading = false;
|
||||
}
|
||||
@@ -206,12 +212,30 @@
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<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">
|
||||
{auditLoading ? "Loading..." : auditLines.length ? "Refresh" : "Load"}
|
||||
{auditLoading ? "Loading..." : auditEntries.length ? "Refresh" : "Load"}
|
||||
</button>
|
||||
</div>
|
||||
{#if auditLines.length > 0}
|
||||
<div class="max-h-80 overflow-auto rounded-lg bg-surface-950 p-3">
|
||||
<pre class="text-xs text-surface-300 font-mono whitespace-pre leading-5">{auditLines.join('\n')}</pre>
|
||||
{#if auditEntries.length > 0}
|
||||
<div class="flex gap-1.5 mb-3">
|
||||
{#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>
|
||||
{:else if !auditLoading}
|
||||
<p class="text-sm text-surface-400">Click Load to view recent audit log entries</p>
|
||||
|
||||
Reference in New Issue
Block a user