mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 01:14:14 +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,169 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
annotateEntries,
|
||||
computeAuditStats,
|
||||
isUnusualHour,
|
||||
} from '../services/AuditAnomalyService';
|
||||
import type { AuditLogEntry } from '../services/DatabaseService';
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
function entry(overrides: Partial<AuditLogEntry> = {}): AuditLogEntry {
|
||||
return {
|
||||
id: 0,
|
||||
timestamp: Date.now(),
|
||||
username: 'alice',
|
||||
method: 'POST',
|
||||
path: '/api/stacks/deploy',
|
||||
status_code: 200,
|
||||
node_id: null,
|
||||
ip_address: '10.0.0.1',
|
||||
summary: 'Deployed stack web',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AuditAnomalyService - isUnusualHour', () => {
|
||||
it('returns false when baseline is too small to trust', () => {
|
||||
expect(isUnusualHour(3, [9, 10, 11])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when hour is inside the actor typical range', () => {
|
||||
const baseline = [9, 10, 10, 11, 11, 12, 13, 14, 15, 16];
|
||||
expect(isUnusualHour(11, baseline)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when hour is well outside the baseline', () => {
|
||||
const baseline = [9, 10, 10, 11, 11, 12, 13, 14, 15, 16];
|
||||
expect(isUnusualHour(3, baseline)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuditAnomalyService - annotateEntries', () => {
|
||||
it('flags first_seen_actor when the actor has no prior history', () => {
|
||||
const now = Date.now();
|
||||
const current = [entry({ id: 1, timestamp: now, username: 'newbie' })];
|
||||
const result = annotateEntries(current, [], now);
|
||||
expect(result[0].flags).toContain('first_seen_actor');
|
||||
});
|
||||
|
||||
it('does not flag first_seen_actor when actor appears in history', () => {
|
||||
const now = Date.now();
|
||||
const history = [entry({ id: 1, timestamp: now - DAY, username: 'alice' })];
|
||||
const current = [entry({ id: 2, timestamp: now, username: 'alice' })];
|
||||
const result = annotateEntries(current, history, now);
|
||||
expect(result[0].flags).not.toContain('first_seen_actor');
|
||||
});
|
||||
|
||||
it('flags new_ip when actor has history but not from this ip', () => {
|
||||
const now = Date.now();
|
||||
const history = Array.from({ length: 6 }, (_, i) =>
|
||||
entry({ id: i + 1, timestamp: now - (i + 1) * HOUR, ip_address: '10.0.0.1' })
|
||||
);
|
||||
const current = [entry({ id: 99, timestamp: now, ip_address: '45.76.1.2' })];
|
||||
const result = annotateEntries(current, history, now);
|
||||
expect(result[0].flags).toContain('new_ip');
|
||||
});
|
||||
|
||||
it('does not flag new_ip when ip matches historical value', () => {
|
||||
const now = Date.now();
|
||||
const history = [entry({ id: 1, timestamp: now - HOUR, ip_address: '10.0.0.1' })];
|
||||
const current = [entry({ id: 2, timestamp: now, ip_address: '10.0.0.1' })];
|
||||
const result = annotateEntries(current, history, now);
|
||||
expect(result[0].flags).not.toContain('new_ip');
|
||||
});
|
||||
|
||||
it('ignores ips older than the 30-day window when scoring new_ip', () => {
|
||||
const now = Date.now();
|
||||
const history = [
|
||||
entry({ id: 1, timestamp: now - 45 * DAY, ip_address: '10.0.0.9' }),
|
||||
entry({ id: 2, timestamp: now - 2 * DAY, ip_address: '10.0.0.1' }),
|
||||
];
|
||||
const current = [entry({ id: 3, timestamp: now, ip_address: '10.0.0.9' })];
|
||||
const result = annotateEntries(current, history, now);
|
||||
expect(result[0].flags).toContain('new_ip');
|
||||
});
|
||||
|
||||
it('flags unusual_hour when entry falls outside the 7-day hour distribution', () => {
|
||||
const now = new Date('2026-04-18T03:15:00Z').getTime();
|
||||
const history = Array.from({ length: 10 }, (_, i) => {
|
||||
const ts = new Date('2026-04-15T10:00:00Z').getTime() + i * HOUR * 0.5;
|
||||
return entry({ id: i + 1, timestamp: ts, ip_address: '10.0.0.1' });
|
||||
});
|
||||
const current = [entry({ id: 99, timestamp: now })];
|
||||
const result = annotateEntries(current, history, now);
|
||||
expect(result[0].flags).toContain('unusual_hour');
|
||||
});
|
||||
|
||||
it('does not flag unusual_hour when baseline is smaller than the minimum', () => {
|
||||
const now = Date.now();
|
||||
const history = [entry({ id: 1, timestamp: now - HOUR })];
|
||||
const current = [entry({ id: 2, timestamp: now })];
|
||||
const result = annotateEntries(current, history, now);
|
||||
expect(result[0].flags).not.toContain('unusual_hour');
|
||||
});
|
||||
|
||||
it('returns empty flags for entries without a username', () => {
|
||||
const now = Date.now();
|
||||
const current = [entry({ id: 1, username: '' })];
|
||||
const result = annotateEntries(current, [], now);
|
||||
expect(result[0].flags).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuditAnomalyService - computeAuditStats', () => {
|
||||
function buildEntries(now: number): { last24h: AuditLogEntry[]; last7d: AuditLogEntry[]; last30d: AuditLogEntry[] } {
|
||||
const last24h: AuditLogEntry[] = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
last24h.push(entry({
|
||||
id: i + 1,
|
||||
timestamp: now - i * HOUR,
|
||||
status_code: i < 3 ? 500 : 200,
|
||||
ip_address: i === 5 ? '45.76.1.2' : '10.0.0.1',
|
||||
}));
|
||||
}
|
||||
const older: AuditLogEntry[] = [];
|
||||
for (let i = 0; i < 60; i++) {
|
||||
older.push(entry({
|
||||
id: 100 + i,
|
||||
timestamp: now - DAY - i * HOUR,
|
||||
ip_address: '10.0.0.1',
|
||||
}));
|
||||
}
|
||||
return {
|
||||
last24h,
|
||||
last7d: [...last24h, ...older.filter(e => e.timestamp >= now - 7 * DAY)],
|
||||
last30d: [...last24h, ...older],
|
||||
};
|
||||
}
|
||||
|
||||
it('summarizes events, actors, failures, and peak hour', () => {
|
||||
const now = new Date('2026-04-18T12:00:00Z').getTime();
|
||||
const { last24h, last7d, last30d } = buildEntries(now);
|
||||
const stats = computeAuditStats({ now, last24h, last7d, last30d });
|
||||
expect(stats.events_24h.value).toBe(20);
|
||||
expect(stats.actors_24h.value).toBe(1);
|
||||
expect(stats.failure_rate.value).toBe(15);
|
||||
expect(stats.activity_by_hour).toHaveLength(24);
|
||||
expect(stats.activity_by_hour.reduce((a, b) => a + b, 0)).toBe(20);
|
||||
});
|
||||
|
||||
it('flags the new_ip detail when an actor uses an ip not seen in prior 29 days', () => {
|
||||
const now = new Date('2026-04-18T12:00:00Z').getTime();
|
||||
const { last24h, last7d, last30d } = buildEntries(now);
|
||||
const stats = computeAuditStats({ now, last24h, last7d, last30d });
|
||||
expect(stats.actors_24h.detail).toMatch(/new ip/);
|
||||
});
|
||||
|
||||
it('surfaces peak hour when it falls outside working hours', () => {
|
||||
const now = new Date(2026, 3, 18, 12, 0, 0).getTime();
|
||||
const nightBase = new Date(2026, 3, 18, 3, 15, 0).getTime();
|
||||
const nightEntries: AuditLogEntry[] = Array.from({ length: 10 }, (_, i) =>
|
||||
entry({ id: i + 1, timestamp: nightBase - i * 5000 })
|
||||
);
|
||||
const stats = computeAuditStats({ now, last24h: nightEntries, last7d: nightEntries, last30d: nightEntries });
|
||||
expect(stats.unusual_hour.severity).toBe('warn');
|
||||
expect(stats.unusual_hour.value).toBe(3);
|
||||
});
|
||||
});
|
||||
+35
-1
@@ -27,6 +27,7 @@ import { AutoHealService } from './services/AutoHealService';
|
||||
import { DockerEventManager } from './services/DockerEventManager';
|
||||
import { ImageUpdateService } from './services/ImageUpdateService';
|
||||
import { UpdatePreviewService } from './services/UpdatePreviewService';
|
||||
import { annotateEntries, computeAuditStats, HISTORY_WINDOW_MS } from './services/AuditAnomalyService';
|
||||
import { templateService } from './services/TemplateService';
|
||||
import { ErrorParser } from './utils/ErrorParser';
|
||||
import { NodeRegistry } from './services/NodeRegistry';
|
||||
@@ -6238,11 +6239,25 @@ app.get('/api/audit-log', async (req: Request, res: Response): Promise<void> =>
|
||||
const search = req.query.search as string | undefined;
|
||||
const from = req.query.from ? parseInt(req.query.from as string) : undefined;
|
||||
const to = req.query.to ? parseInt(req.query.to as string) : undefined;
|
||||
const withAnomalies = req.query.with_anomalies === '1';
|
||||
|
||||
if (isDebugEnabled()) {
|
||||
console.log(`[Audit:diag] Query: page=${page} limit=${limit} username=${username || '-'} method=${method || '-'} search=${search || '-'}`);
|
||||
}
|
||||
const result = DatabaseService.getInstance().getAuditLogs({ page, limit, username, method, from, to, search });
|
||||
const db = DatabaseService.getInstance();
|
||||
const result = db.getAuditLogs({ page, limit, username, method, from, to, search });
|
||||
|
||||
if (withAnomalies && result.entries.length > 0) {
|
||||
const now = Date.now();
|
||||
const historyFrom = now - HISTORY_WINDOW_MS;
|
||||
const oldestInPage = result.entries.reduce(
|
||||
(min, e) => Math.min(min, e.timestamp),
|
||||
result.entries[0].timestamp
|
||||
);
|
||||
const history = db.getAuditLogsInRange(historyFrom, oldestInPage);
|
||||
res.json({ ...result, entries: annotateEntries(result.entries, history, now) });
|
||||
return;
|
||||
}
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('[AuditLog] Failed to fetch audit log:', error);
|
||||
@@ -6250,6 +6265,25 @@ app.get('/api/audit-log', async (req: Request, res: Response): Promise<void> =>
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/audit-log/stats', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePermission(req, res, 'system:audit')) return;
|
||||
|
||||
try {
|
||||
const now = Date.now();
|
||||
const db = DatabaseService.getInstance();
|
||||
const cutoff24h = now - 24 * 60 * 60 * 1000;
|
||||
const cutoff7d = now - 7 * 24 * 60 * 60 * 1000;
|
||||
const last30d = db.getAuditLogsInRange(now - HISTORY_WINDOW_MS, now);
|
||||
const last7d = last30d.filter(e => e.timestamp >= cutoff7d);
|
||||
const last24h = last7d.filter(e => e.timestamp >= cutoff24h);
|
||||
res.json(computeAuditStats({ now, last24h, last7d, last30d }));
|
||||
} catch (error) {
|
||||
console.error('[AuditLog] Failed to compute audit stats:', error);
|
||||
res.status(500).json({ error: 'Failed to compute audit stats' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/audit-log/export', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
if (!requirePermission(req, res, 'system:audit')) return;
|
||||
|
||||
@@ -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