fix: add security log investigation filters #27

Merged
jimmy merged 1 commits from dev into main 2026-03-07 10:14:41 +08:00
2 changed files with 172 additions and 22 deletions
Showing only changes of commit 35a3019e0d - Show all commits
+53 -2
View File
@@ -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")
+119 -20
View File
@@ -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" };
</script>
@@ -99,7 +145,7 @@
<div class="space-y-2">
{#each stats.top_ips as entry}
<div class="flex items-center gap-3 text-sm">
<code class="text-xs font-mono text-surface-600 dark:text-surface-300 w-36 shrink-0">{entry.ip}</code>
<button onclick={() => applyIpFilter(entry.ip)} class="text-xs font-mono text-surface-600 hover:text-primary-600 dark:text-surface-300 dark:hover:text-primary-300 w-36 shrink-0 text-left transition-colors">{entry.ip}</button>
<div class="flex-1 bg-surface-100 dark:bg-surface-700 rounded-full h-2 overflow-hidden">
<div class="bg-red-500 dark:bg-red-400 h-full rounded-full" style="width: {(entry.count / stats.top_ips[0].count) * 100}%"></div>
</div>
@@ -112,27 +158,80 @@
<!-- Log table -->
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Event Log</h3>
<div class="flex gap-1.5">
{#each ["all", "auth_failure", "suspicious_request"] as f}
<button onclick={() => filter = f} class="px-2.5 py-1 text-xs rounded-md transition-colors {filter === f ? 'bg-surface-800 text-white dark:bg-surface-200 dark:text-surface-900' : 'bg-surface-100 text-surface-500 hover:bg-surface-200 dark:bg-surface-700 dark:hover:bg-surface-600'}">
{f === "all" ? "All" : f === "auth_failure" ? "Auth" : "Requests"}
{#if f !== "all"}
<span class="ml-1 opacity-60">{logs.filter(l => l.type === f).length}</span>
{/if}
</button>
{/each}
<div class="flex items-start justify-between gap-3 mb-3">
<div>
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Event Log</h3>
<p class="text-xs text-surface-400 mt-1">Filter recent events by type, IP, or date range.</p>
</div>
{#if hasActiveFilters}
<button onclick={clearFilters} class="px-2.5 py-1 text-xs rounded-md transition-colors bg-surface-100 text-surface-500 hover:bg-surface-200 dark:bg-surface-700 dark:text-surface-300 dark:hover:bg-surface-600">
Clear filters
</button>
{/if}
</div>
{#if filteredLogs.length > 0}
<div class="flex flex-wrap gap-1.5 mb-3">
{#each ["all", "auth_failure", "suspicious_request"] as f}
<button onclick={() => applyTypeFilter(f)} class="px-2.5 py-1 text-xs rounded-md transition-colors {filter === f ? 'bg-surface-800 text-white dark:bg-surface-200 dark:text-surface-900' : 'bg-surface-100 text-surface-500 hover:bg-surface-200 dark:bg-surface-700 dark:text-surface-300 dark:hover:bg-surface-600'}">
{f === "all" ? "All" : f === "auth_failure" ? "Auth" : "Requests"}
</button>
{/each}
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-3">
<label class="block">
<span class="block text-xs font-medium text-surface-500 mb-1">Filter by IP</span>
<input
type="text"
bind:value={ipFilter}
placeholder="e.g. 1.2.3.4"
onkeydown={async (e) => { if (e.key === 'Enter') await loadLogs(); }}
class="w-full px-3 py-2 text-sm border border-surface-200 dark:border-surface-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-surface-700 dark:text-white"
/>
</label>
<label class="block">
<span class="block text-xs font-medium text-surface-500 mb-1">From</span>
<input
type="date"
bind:value={startDate}
onchange={loadLogs}
class="w-full px-3 py-2 text-sm border border-surface-200 dark:border-surface-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-surface-700 dark:text-white"
/>
</label>
<label class="block">
<span class="block text-xs font-medium text-surface-500 mb-1">To</span>
<input
type="date"
bind:value={endDate}
onchange={loadLogs}
class="w-full px-3 py-2 text-sm border border-surface-200 dark:border-surface-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-surface-700 dark:text-white"
/>
</label>
</div>
<div class="flex items-center gap-2 mb-3">
<button onclick={loadLogs} class="px-3 py-1.5 text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors dark:bg-primary-900/30 dark:text-primary-300">
Apply filters
</button>
{#if ipFilter.trim()}
<button onclick={() => applyIpFilter(ipFilter.trim())} class="px-2.5 py-1 text-xs rounded-md transition-colors bg-surface-100 text-surface-500 hover:bg-surface-200 dark:bg-surface-700 dark:text-surface-300 dark:hover:bg-surface-600">
Use typed IP
</button>
{/if}
</div>
{#if filterError}
<p class="text-sm text-rose-500 mb-3">{filterError}</p>
{/if}
{#if logs.length > 0}
<div class="max-h-96 overflow-auto rounded-lg bg-surface-950 p-3 space-y-0.5">
{#each filteredLogs as e}
{#each logs as e}
<div class="text-xs font-mono flex gap-2 leading-5">
<span class="{typeColors[e.type] || 'text-surface-500'} w-12 shrink-0">{typeLabels[e.type] || e.type}</span>
<span class="text-surface-500 shrink-0">{e.timestamp}</span>
{#if e.ip}
<span class="text-surface-400 shrink-0 w-28">{e.ip}</span>
<button onclick={() => applyIpFilter(e.ip)} class="text-surface-400 hover:text-primary-300 shrink-0 w-28 text-left transition-colors">{e.ip}</button>
{/if}
{#if e.username}
<span class="text-red-400 shrink-0">{e.username}</span>
@@ -142,7 +241,7 @@
{/each}
</div>
{:else if !loading}
<p class="text-sm text-surface-400">No security events found</p>
<p class="text-sm text-surface-400">{hasActiveFilters ? "No security events match the current filters" : "No security events found"}</p>
{/if}
</div>
</div>