fix: add security log investigation filters #27
@@ -1,7 +1,7 @@
|
|||||||
import docker
|
import docker
|
||||||
import re
|
import re
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta, time
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from fastapi import APIRouter, Query
|
from fastapi import APIRouter, Query
|
||||||
from config import DOCKER_HOST
|
from config import DOCKER_HOST
|
||||||
@@ -130,10 +130,60 @@ def _parse_caddy_logs(lines: str, limit: int) -> list[dict]:
|
|||||||
return entries[-limit:]
|
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")
|
@router.get("/logs")
|
||||||
def security_logs(
|
def security_logs(
|
||||||
tail: int = Query(200, le=2000),
|
tail: int = Query(200, le=2000),
|
||||||
limit: int = Query(100, le=500),
|
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."""
|
"""Fetch and parse security-relevant logs from Authelia and Caddy."""
|
||||||
results = []
|
results = []
|
||||||
@@ -153,7 +203,8 @@ def security_logs(
|
|||||||
|
|
||||||
# Sort by timestamp descending
|
# Sort by timestamp descending
|
||||||
results.sort(key=lambda x: x.get("timestamp", ""), reverse=True)
|
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")
|
@router.get("/stats")
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
let stats = $state(null);
|
let stats = $state(null);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let filter = $state("all");
|
let filter = $state("all");
|
||||||
|
let ipFilter = $state("");
|
||||||
|
let startDate = $state("");
|
||||||
|
let endDate = $state("");
|
||||||
|
let filterError = $state("");
|
||||||
|
|
||||||
onMount(loadAll);
|
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() {
|
async function loadLogs() {
|
||||||
|
if (!validateFilters()) {
|
||||||
|
logs = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await get("/security/logs?limit=300");
|
const res = await get(`/security/logs?${buildLogsQuery()}`);
|
||||||
logs = res.logs;
|
logs = res.logs;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to load security logs:", e);
|
console.error("Failed to load security logs:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let filteredLogs = $derived(
|
async function applyTypeFilter(nextFilter) {
|
||||||
filter === "all" ? logs : logs.filter(l => l.type === filter)
|
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(
|
let maxTimelineCount = $derived(
|
||||||
stats ? Math.max(1, ...stats.timeline.map(t => t.count)) : 1
|
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 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" };
|
const typeColors = { auth_failure: "text-red-400", suspicious_request: "text-amber-400", error: "text-surface-500" };
|
||||||
</script>
|
</script>
|
||||||
@@ -99,7 +145,7 @@
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
{#each stats.top_ips as entry}
|
{#each stats.top_ips as entry}
|
||||||
<div class="flex items-center gap-3 text-sm">
|
<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="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 class="bg-red-500 dark:bg-red-400 h-full rounded-full" style="width: {(entry.count / stats.top_ips[0].count) * 100}%"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,27 +158,80 @@
|
|||||||
|
|
||||||
<!-- Log table -->
|
<!-- 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="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">
|
<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>
|
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Event Log</h3>
|
||||||
<div class="flex gap-1.5">
|
<p class="text-xs text-surface-400 mt-1">Filter recent events by type, IP, or date range.</p>
|
||||||
{#each ["all", "auth_failure", "suspicious_request"] as f}
|
</div>
|
||||||
<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'}">
|
{#if hasActiveFilters}
|
||||||
{f === "all" ? "All" : f === "auth_failure" ? "Auth" : "Requests"}
|
<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">
|
||||||
{#if f !== "all"}
|
Clear filters
|
||||||
<span class="ml-1 opacity-60">{logs.filter(l => l.type === f).length}</span>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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>
|
||||||
{#if filteredLogs.length > 0}
|
|
||||||
|
<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">
|
<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">
|
<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="{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>
|
<span class="text-surface-500 shrink-0">{e.timestamp}</span>
|
||||||
{#if e.ip}
|
{#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}
|
||||||
{#if e.username}
|
{#if e.username}
|
||||||
<span class="text-red-400 shrink-0">{e.username}</span>
|
<span class="text-red-400 shrink-0">{e.username}</span>
|
||||||
@@ -142,7 +241,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else if !loading}
|
{: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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user