mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 17:08:10 +00:00
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:
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user