feat(audit-log): signal rail, day-banded stream, anomaly detection (#682)

Add a Stream view to the Audit Log that leads with a four-tile signal
rail (events, actors, failure rate with inline sparkline, peak hour)
and presents the feed grouped by day with severity dots, relative
times, and inline anomaly callouts. The existing Table view is
preserved behind a toggle for power users.

Anomaly flags are computed at read time against strictly prior history
and returned on demand via ?with_anomalies=1:
- unusual_hour: hour outside the actor's central 7-day window
- new_ip: IP unseen for this actor in the last 30 days
- first_seen_actor: no prior history in the 30-day window

New /audit-log/stats endpoint returns the signal-rail aggregates over
24h/7d/30d windows; stats are derived from a single 30-day scan.
This commit is contained in:
Anso
2026-04-18 18:24:27 -04:00
committed by GitHub
parent 95278843cf
commit 591dc75d1e
7 changed files with 868 additions and 138 deletions
+180
View File
@@ -0,0 +1,180 @@
import type { AuditLogEntry } from './DatabaseService';
export type AnomalyFlag = 'unusual_hour' | 'new_ip' | 'first_seen_actor';
export const HISTORY_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
const HOUR_BASELINE_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
const MIN_HOURS_FOR_BASELINE = 5;
/**
* Returns true when `hour` sits outside the central 90% of the actor's
* typical activity window. Requires a minimum baseline to avoid flagging
* actors whose first few logins happen to be during off-hours.
*/
export function isUnusualHour(hour: number, baselineHours: number[]): boolean {
if (baselineHours.length < MIN_HOURS_FOR_BASELINE) return false;
const sorted = [...baselineHours].sort((a, b) => a - b);
const lo = sorted[Math.floor(sorted.length * 0.05)];
const hi = sorted[Math.floor(sorted.length * 0.95)];
return hour < lo || hour > hi;
}
interface ActorBaseline {
hoursLast7d: number[];
ipsLast30d: Set<string>;
}
function buildBaselines(history: AuditLogEntry[], now: number): Map<string, ActorBaseline> {
const baselines = new Map<string, ActorBaseline>();
const hourCutoff = now - HOUR_BASELINE_WINDOW_MS;
const ipCutoff = now - HISTORY_WINDOW_MS;
for (const entry of history) {
if (!entry.username) continue;
let b = baselines.get(entry.username);
if (!b) {
b = { hoursLast7d: [], ipsLast30d: new Set() };
baselines.set(entry.username, b);
}
if (entry.timestamp >= hourCutoff) {
b.hoursLast7d.push(new Date(entry.timestamp).getHours());
}
if (entry.timestamp >= ipCutoff && entry.ip_address) {
b.ipsLast30d.add(entry.ip_address);
}
}
return baselines;
}
/**
* Annotate a page of entries with anomaly flags computed against strictly
* prior history. The caller is responsible for supplying history entries
* that do NOT overlap with the entries being annotated; typically pull
* entries where `timestamp < min(entries.timestamp)` from the last 30 days.
*/
export interface AuditStatTile {
value: number | null;
label: string;
detail: string | null;
severity: 'ok' | 'warn' | 'alert';
}
export interface AuditStats {
events_24h: AuditStatTile;
actors_24h: AuditStatTile;
failure_rate: AuditStatTile;
unusual_hour: AuditStatTile;
activity_by_hour: number[];
failures_by_hour: number[];
}
export function computeAuditStats(input: {
now: number;
last24h: AuditLogEntry[];
last7d: AuditLogEntry[];
last30d: AuditLogEntry[];
}): AuditStats {
const { now, last24h, last7d, last30d } = input;
const events24 = last24h.length;
const prior7d = last7d.length - events24;
const avg7dPerDay = Math.max(0, prior7d) / 6;
const deltaPct = avg7dPerDay > 0 ? Math.round(((events24 - avg7dPerDay) / avg7dPerDay) * 100) : null;
const actors24 = new Set(last24h.map(e => e.username).filter(Boolean));
const olderIpByActor = new Map<string, Set<string>>();
for (const e of last30d) {
if (!e.username || !e.ip_address) continue;
if (e.timestamp >= now - 24 * 60 * 60 * 1000) continue;
let set = olderIpByActor.get(e.username);
if (!set) { set = new Set(); olderIpByActor.set(e.username, set); }
set.add(e.ip_address);
}
let newIpCount = 0;
let sampleNewIpActor: string | null = null;
for (const e of last24h) {
if (!e.username || !e.ip_address) continue;
const prior = olderIpByActor.get(e.username);
if (prior && prior.size > 0 && !prior.has(e.ip_address)) {
newIpCount++;
if (!sampleNewIpActor) sampleNewIpActor = e.username;
}
}
const failureCount = last24h.filter(e => e.status_code >= 400).length;
const failureRate = events24 > 0 ? failureCount / events24 : 0;
const failurePct = Math.round(failureRate * 100);
const activityByHour = Array.from({ length: 24 }, () => 0);
const failuresByHour = Array.from({ length: 24 }, () => 0);
for (const e of last24h) {
const hour = new Date(e.timestamp).getHours();
activityByHour[hour]++;
if (e.status_code >= 400) failuresByHour[hour]++;
}
const peakHour = activityByHour.reduce(
(best, count, hour) => (count > best.count ? { count, hour } : best),
{ count: -1, hour: 0 }
);
const peakIsOffHours = peakHour.count > 0 && (peakHour.hour < 8 || peakHour.hour >= 18);
return {
events_24h: {
value: events24,
label: 'events · 24h',
detail: deltaPct === null ? 'no 7d baseline yet' : `${deltaPct >= 0 ? '+' : ''}${deltaPct}% vs 7d avg`,
severity: deltaPct !== null && deltaPct > 150 ? 'warn' : 'ok',
},
actors_24h: {
value: actors24.size,
label: 'actors',
detail: newIpCount > 0
? `${newIpCount} new ip${newIpCount === 1 ? '' : 's'}${sampleNewIpActor ? ` · ${sampleNewIpActor}` : ''}`
: null,
severity: newIpCount > 0 ? 'warn' : 'ok',
},
failure_rate: {
value: failurePct,
label: 'failure rate',
detail: `${failureCount} of ${events24} request${events24 === 1 ? '' : 's'}`,
severity: failurePct >= 20 ? 'alert' : failurePct >= 5 ? 'warn' : 'ok',
},
unusual_hour: {
value: peakIsOffHours ? peakHour.hour : null,
label: 'peak hour',
detail: peakIsOffHours
? `${peakHour.count} event${peakHour.count === 1 ? '' : 's'} at ${String(peakHour.hour).padStart(2, '0')}:00`
: 'inside working hours',
severity: peakIsOffHours ? 'warn' : 'ok',
},
activity_by_hour: activityByHour,
failures_by_hour: failuresByHour,
};
}
export function annotateEntries(
entries: AuditLogEntry[],
history: AuditLogEntry[],
now: number = Date.now()
): (AuditLogEntry & { flags: AnomalyFlag[] })[] {
const baselines = buildBaselines(history, now);
return entries.map(entry => {
const flags: AnomalyFlag[] = [];
if (!entry.username) return { ...entry, flags };
const baseline = baselines.get(entry.username);
if (!baseline) {
flags.push('first_seen_actor');
} else {
const entryHour = new Date(entry.timestamp).getHours();
if (isUnusualHour(entryHour, baseline.hoursLast7d)) {
flags.push('unusual_hour');
}
if (entry.ip_address && baseline.ipsLast30d.size > 0 && !baseline.ipsLast30d.has(entry.ip_address)) {
flags.push('new_ip');
}
}
return { ...entry, flags };
});
}
+6
View File
@@ -2066,6 +2066,12 @@ export class DatabaseService {
this.db.prepare('DELETE FROM audit_log WHERE timestamp < ?').run(cutoff);
}
public getAuditLogsInRange(from: number, to: number): AuditLogEntry[] {
return this.db.prepare(
'SELECT * FROM audit_log WHERE timestamp >= ? AND timestamp < ? ORDER BY timestamp ASC'
).all(from, to) as AuditLogEntry[];
}
// --- API Tokens ---
public addApiToken(token: Omit<ApiToken, 'id' | 'last_used_at' | 'revoked_at'>): number {