From 35a3019e0dc1f29f9b2de560804f88b8e346c136 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Sat, 7 Mar 2026 09:59:59 +0800 Subject: [PATCH] fix: add security log investigation filters Add server-side IP and date range filtering for security logs so investigations can narrow events without relying on client-side filtering alone. Co-Authored-By: Claude Opus 4.6 (1M context) --- dashboard/backend/routers/security.py | 55 ++++++- dashboard/frontend/src/routes/Security.svelte | 139 +++++++++++++++--- 2 files changed, 172 insertions(+), 22 deletions(-) diff --git a/dashboard/backend/routers/security.py b/dashboard/backend/routers/security.py index 5ed3014..812af0c 100644 --- a/dashboard/backend/routers/security.py +++ b/dashboard/backend/routers/security.py @@ -1,7 +1,7 @@ import docker import re import json -from datetime import datetime, timezone, timedelta +from datetime import datetime, timezone, timedelta, time from collections import Counter from fastapi import APIRouter, Query from config import DOCKER_HOST @@ -130,10 +130,60 @@ def _parse_caddy_logs(lines: str, limit: int) -> list[dict]: return entries[-limit:] +def _parse_timestamp(value: str) -> datetime | None: + if not value: + return None + try: + return datetime.strptime(value, "%Y-%m-%d %H:%M:%S").replace(tzinfo=_tz_cst) + except ValueError: + return None + + +def _filter_security_entries( + entries: list[dict], + event_type: str | None, + ip: str | None, + start_date: str | None, + end_date: str | None, +) -> list[dict]: + normalized_ip = ip.strip() if ip else None + start_dt = None + end_dt = None + + if start_date: + start_dt = datetime.combine(datetime.strptime(start_date, "%Y-%m-%d").date(), time.min, tzinfo=_tz_cst) + if end_date: + end_dt = datetime.combine(datetime.strptime(end_date, "%Y-%m-%d").date(), time.max, tzinfo=_tz_cst) + + filtered = [] + for entry in entries: + if event_type and entry.get("type") != event_type: + continue + if normalized_ip and entry.get("ip", "").strip() != normalized_ip: + continue + + if start_dt or end_dt: + entry_ts = _parse_timestamp(entry.get("timestamp", "")) + if entry_ts is None: + continue + if start_dt and entry_ts < start_dt: + continue + if end_dt and entry_ts > end_dt: + continue + + filtered.append(entry) + + return filtered + + @router.get("/logs") def security_logs( tail: int = Query(200, le=2000), limit: int = Query(100, le=500), + type: str | None = Query(None), + ip: str | None = Query(None), + start_date: str | None = Query(None), + end_date: str | None = Query(None), ): """Fetch and parse security-relevant logs from Authelia and Caddy.""" results = [] @@ -153,7 +203,8 @@ def security_logs( # Sort by timestamp descending results.sort(key=lambda x: x.get("timestamp", ""), reverse=True) - return {"logs": results[:limit]} + filtered = _filter_security_entries(results, type, ip, start_date, end_date) + return {"logs": filtered[:limit]} @router.get("/stats") diff --git a/dashboard/frontend/src/routes/Security.svelte b/dashboard/frontend/src/routes/Security.svelte index cea9ae9..843611b 100644 --- a/dashboard/frontend/src/routes/Security.svelte +++ b/dashboard/frontend/src/routes/Security.svelte @@ -6,6 +6,10 @@ let stats = $state(null); let loading = $state(false); let filter = $state("all"); + let ipFilter = $state(""); + let startDate = $state(""); + let endDate = $state(""); + let filterError = $state(""); onMount(loadAll); @@ -23,23 +27,65 @@ } } + function buildLogsQuery() { + const params = new URLSearchParams({ limit: "300" }); + if (filter !== "all") params.set("type", filter); + if (ipFilter.trim()) params.set("ip", ipFilter.trim()); + if (startDate) params.set("start_date", startDate); + if (endDate) params.set("end_date", endDate); + return params.toString(); + } + + function validateFilters() { + if (startDate && endDate && startDate > endDate) { + filterError = "From date must be on or before To date"; + return false; + } + filterError = ""; + return true; + } + async function loadLogs() { + if (!validateFilters()) { + logs = []; + return; + } + try { - const res = await get("/security/logs?limit=300"); + const res = await get(`/security/logs?${buildLogsQuery()}`); logs = res.logs; } catch (e) { console.error("Failed to load security logs:", e); } } - let filteredLogs = $derived( - filter === "all" ? logs : logs.filter(l => l.type === filter) - ); + async function applyTypeFilter(nextFilter) { + filter = nextFilter; + await loadLogs(); + } + + async function applyIpFilter(ip) { + ipFilter = ip; + await loadLogs(); + } + + async function clearFilters() { + filter = "all"; + ipFilter = ""; + startDate = ""; + endDate = ""; + filterError = ""; + await loadLogs(); + } let maxTimelineCount = $derived( stats ? Math.max(1, ...stats.timeline.map(t => t.count)) : 1 ); + let hasActiveFilters = $derived( + filter !== "all" || !!ipFilter.trim() || !!startDate || !!endDate + ); + const typeLabels = { auth_failure: "Auth", suspicious_request: "Request", error: "Error" }; const typeColors = { auth_failure: "text-red-400", suspicious_request: "text-amber-400", error: "text-surface-500" }; @@ -99,7 +145,7 @@
{#each stats.top_ips as entry}
- {entry.ip} +
@@ -112,27 +158,80 @@
-
-

Event Log

-
- {#each ["all", "auth_failure", "suspicious_request"] as f} - - {/each} +
+
+

Event Log

+

Filter recent events by type, IP, or date range.

+ {#if hasActiveFilters} + + {/if}
- {#if filteredLogs.length > 0} + +
+ {#each ["all", "auth_failure", "suspicious_request"] as f} + + {/each} +
+ +
+ + + +
+ +
+ + {#if ipFilter.trim()} + + {/if} +
+ + {#if filterError} +

{filterError}

+ {/if} + + {#if logs.length > 0}
- {#each filteredLogs as e} + {#each logs as e}
{typeLabels[e.type] || e.type} {e.timestamp} {#if e.ip} - {e.ip} + {/if} {#if e.username} {e.username} @@ -142,7 +241,7 @@ {/each}
{:else if !loading} -

No security events found

+

{hasActiveFilters ? "No security events match the current filters" : "No security events found"}

{/if}