Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 199 additions & 0 deletions backend/src/audit/__tests__/alerting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
/**
* Real-time critical-event audit alerting — Issue #396
*/
import { describe, expect, it, vi } from 'vitest';

import {
AuditAlerter,
CRITICAL_EVENT_RULES,
SEVERITY_ORDER,
webhookAlertSink,
type AuditAlert,
type AuditAlertSink,
} from '../alerting.js';
import { normalizeAuditEvent, type AuditEvent } from '../event-schema.js';

function event(overrides: Partial<AuditEvent> = {}): AuditEvent {
return { ...normalizeAuditEvent({ action: 'auth.post', resource: 'auth' }), ...overrides };
}

function collectingSink(): AuditAlertSink & { alerts: AuditAlert[] } {
const alerts: AuditAlert[] = [];
return {
name: 'test',
alerts,
async send(alert) {
alerts.push(alert);
},
};
}

describe('CRITICAL_EVENT_RULES', () => {
it('flags role and permission changes as critical', () => {
const rule = CRITICAL_EVENT_RULES.find((candidate) => candidate.id === 'privilege.escalation')!;
expect(rule.severity).toBe('critical');
expect(rule.match(event({ action: 'roles.post', resource: 'roles' }))).toBe(true);
});

it('flags failed authentication as a medium-severity event', () => {
const rule = CRITICAL_EVENT_RULES.find((candidate) => candidate.id === 'auth.failure')!;
expect(rule.match(event({ action: 'auth.post', resource: 'auth', outcome: 'failure' }))).toBe(true);
expect(rule.match(event({ action: 'auth.post', resource: 'auth', outcome: 'success' }))).toBe(false);
});

it('flags large payments as high severity but ignores small ones', () => {
const rule = CRITICAL_EVENT_RULES.find((candidate) => candidate.id === 'payment.large_value')!;

expect(
rule.match(event({ action: 'payments.post', resource: 'payments', details: { amount: 250_000 } }))
).toBe(true);
expect(
rule.match(event({ action: 'payments.post', resource: 'payments', details: { amount: 12 } }))
).toBe(false);
});

it('flags sanctions hits as critical', () => {
const rule = CRITICAL_EVENT_RULES.find((candidate) => candidate.id === 'compliance.sanctions_hit')!;
expect(rule.match(event({ action: 'sanctions.screen', resource: 'sanctions' }))).toBe(true);
});

it('orders severities so thresholds are comparable', () => {
expect(SEVERITY_ORDER.critical).toBeGreaterThan(SEVERITY_ORDER.high);
expect(SEVERITY_ORDER.high).toBeGreaterThan(SEVERITY_ORDER.medium);
});
});

describe('AuditAlerter', () => {
it('dispatches an alert for an above-threshold event', async () => {
const sink = collectingSink();
const alerter = new AuditAlerter({ sinks: [sink] });

const alert = await alerter.dispatch(event({ action: 'roles.delete', resource: 'roles' }));

expect(alert?.ruleId).toBe('privilege.escalation');
expect(alert?.severity).toBe('critical');
expect(sink.alerts).toHaveLength(1);
});

it('ignores events below the configured threshold', async () => {
const sink = collectingSink();
const alerter = new AuditAlerter({ sinks: [sink], minSeverity: 'critical' });

const alert = await alerter.dispatch(event({ action: 'auth.post', resource: 'auth', outcome: 'failure' }));

expect(alert).toBeUndefined();
expect(sink.alerts).toHaveLength(0);
});

it('ignores events that match no rule', async () => {
const sink = collectingSink();
const alerter = new AuditAlerter({ sinks: [sink] });

const alert = await alerter.dispatch(event({ action: 'projects.get', resource: 'projects' }));

expect(alert).toBeUndefined();
expect(sink.alerts).toHaveLength(0);
});

it('folds repeats inside the dedupe window into one alert with an occurrence count', async () => {
const sink = collectingSink();
const alerter = new AuditAlerter({ sinks: [sink], dedupeWindowMs: 60_000 });

await alerter.dispatch(event({ action: 'auth.post', resource: 'auth', outcome: 'failure', actor: 'u1' }));
await alerter.dispatch(event({ action: 'auth.post', resource: 'auth', outcome: 'failure', actor: 'u1' }));
const third = await alerter.dispatch(
event({ action: 'auth.post', resource: 'auth', outcome: 'failure', actor: 'u1' })
);

expect(third?.occurrences).toBe(3);
expect(sink.alerts).toHaveLength(1);
expect(alerter.listRecentAlerts()).toHaveLength(1);
});

it('raises a fresh alert once the dedupe window has elapsed', async () => {
const sink = collectingSink();
let now = 1_000_000;
const alerter = new AuditAlerter({ sinks: [sink], dedupeWindowMs: 1_000, now: () => now });

await alerter.dispatch(event({ action: 'auth.post', resource: 'auth', outcome: 'failure', actor: 'u1' }));
now += 5_000;
await alerter.dispatch(event({ action: 'auth.post', resource: 'auth', outcome: 'failure', actor: 'u1' }));

expect(sink.alerts).toHaveLength(2);
expect(alerter.listRecentAlerts()).toHaveLength(2);
});

it('keeps alerts from different actors separate', async () => {
const sink = collectingSink();
const alerter = new AuditAlerter({ sinks: [sink] });

await alerter.dispatch(event({ action: 'auth.post', resource: 'auth', outcome: 'failure', actor: 'u1' }));
await alerter.dispatch(event({ action: 'auth.post', resource: 'auth', outcome: 'failure', actor: 'u2' }));

expect(sink.alerts).toHaveLength(2);
});

it('never rejects when a sink fails', async () => {
const failing: AuditAlertSink = {
name: 'failing',
send: vi.fn().mockRejectedValue(new Error('webhook down')),
};
const alerter = new AuditAlerter({ sinks: [failing] });

await expect(
alerter.dispatch(event({ action: 'roles.delete', resource: 'roles' }))
).resolves.toBeDefined();
});

it('classifies and reports alertability without dispatching', () => {
const alerter = new AuditAlerter({ sinks: [], minSeverity: 'high' });

expect(alerter.isAlertable(event({ action: 'roles.post', resource: 'roles' }))).toBe(true);
expect(alerter.isAlertable(event({ action: 'auth.post', resource: 'auth', outcome: 'failure' }))).toBe(false);
});

it('counts retained alerts by severity', async () => {
const alerter = new AuditAlerter({ sinks: [] });

await alerter.dispatch(event({ action: 'roles.post', resource: 'roles', actor: 'u1' }));
await alerter.dispatch(event({ action: 'auth.post', resource: 'auth', outcome: 'failure', actor: 'u2' }));

expect(alerter.alertCounts().critical).toBe(1);
expect(alerter.alertCounts().medium).toBe(1);
});

it('caps retained alert history', async () => {
const alerter = new AuditAlerter({ sinks: [], maxRecentAlerts: 2 });

await alerter.dispatch(event({ action: 'roles.post', resource: 'roles', actor: 'a' }));
await alerter.dispatch(event({ action: 'roles.post', resource: 'roles', actor: 'b' }));
await alerter.dispatch(event({ action: 'roles.post', resource: 'roles', actor: 'c' }));

expect(alerter.listRecentAlerts()).toHaveLength(2);
});
});

describe('webhookAlertSink', () => {
it('POSTs the alert payload to the configured URL', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
const sink = webhookAlertSink('https://hooks.example.test/audit', fetchMock as unknown as typeof fetch);

await sink.send({
id: 'a1',
ruleId: 'privilege.escalation',
severity: 'critical',
description: 'Role change',
occurrences: 1,
firstSeenAt: new Date().toISOString(),
lastSeenAt: new Date().toISOString(),
actor: 'u1',
action: 'roles.post',
resource: 'roles',
});

expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe('https://hooks.example.test/audit');
expect(JSON.parse((init as RequestInit).body as string).alert.ruleId).toBe('privilege.escalation');
});
});
199 changes: 199 additions & 0 deletions backend/src/audit/__tests__/auditService-396.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
/**
* AuditService integration for issue #396 — alerting, compliance reporting,
* retention/archival and log-injection defence.
*/
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { AuditAlerter, type AuditAlert, type AuditAlertSink } from '../alerting.js';
import { AuditService } from '../../services/auditService.js';

const DAY = 24 * 60 * 60 * 1000;

let dir: string;

function collectingSink(): AuditAlertSink & { alerts: AuditAlert[] } {
const alerts: AuditAlert[] = [];
return {
name: 'test',
alerts,
async send(alert) {
alerts.push(alert);
},
};
}

function serviceWithSink(sink: AuditAlertSink, policy = {}): AuditService {
return new AuditService({
policy,
archiveDir: dir,
alerter: new AuditAlerter({ sinks: [sink] }),
});
}

beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'audit-service-396-'));
});

afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});

describe('AuditService log-injection defence (#396)', () => {
it('strips newlines from details before hashing and storage', async () => {
const service = new AuditService({ alerting: false });

const entry = await service.logAction({
action: 'auth.post',
resource: 'auth',
details: { note: 'ok\n2026-01-01 forged admin.delete' },
});

expect(entry.details?.note).toBe('ok 2026-01-01 forged admin.delete');
expect(JSON.stringify(entry.details)).not.toContain('\n');
});

it('neutralises spreadsheet formulas in CSV exports', async () => {
const service = new AuditService({ alerting: false });
await service.logAction({ action: 'auth.post', resource: 'auth', userId: '=cmd|calc' });

const csv = await service.exportToCSV();
expect(csv).toContain('"\'=cmd|calc"');
expect(csv).not.toMatch(/(^|,)"=cmd/m);
});
});

describe('AuditService real-time alerting (#396)', () => {
it('raises a critical alert when a privilege change is logged', async () => {
const sink = collectingSink();
const service = serviceWithSink(sink);

await service.logAction({ action: 'roles.post', resource: 'roles', userId: 'admin-1' });

expect(sink.alerts).toHaveLength(1);
expect(service.getAlerts()[0]?.ruleId).toBe('privilege.escalation');
expect(service.getAlertCounts().critical).toBe(1);
});

it('raises an alert for a failed authentication attempt', async () => {
const sink = collectingSink();
const service = serviceWithSink(sink);

await service.logAction({ action: 'auth.post', resource: 'auth', outcome: 'failure' });

expect(service.getAlerts()[0]?.ruleId).toBe('auth.failure');
});

it('does not alert for routine non-sensitive operations', async () => {
const sink = collectingSink();
const service = serviceWithSink(sink);

await service.logAction({ action: 'projects.get', resource: 'projects' });

expect(sink.alerts).toHaveLength(0);
expect(service.getAlerts()).toEqual([]);
});

it('alerts when an entry is flagged as suspicious', async () => {
const sink = collectingSink();
const service = serviceWithSink(sink);
const entry = await service.logAction({ action: 'projects.get', resource: 'projects' });

await service.flagSuspicious(entry.id, ['impossible travel']);

expect(sink.alerts.at(-1)?.action).toBe('audit.suspicious.flag');
});

it('can disable alerting entirely', async () => {
const sink = collectingSink();
const service = new AuditService({ alerting: false, alerter: new AuditAlerter({ sinks: [sink] }) });

await service.logAction({ action: 'roles.post', resource: 'roles' });

expect(sink.alerts).toHaveLength(0);
});
});

describe('AuditService compliance reporting (#396)', () => {
it('reports satisfied controls and gaps over logged entries', async () => {
const service = new AuditService({ alerting: false });

await service.logAction({
action: 'roles.post',
resource: 'roles',
userId: 'admin-1',
outcome: 'success',
ipAddress: '203.0.113.9',
});

const report = service.getComplianceReport({ frameworks: ['SOC2'] });

expect(report.totalEvents).toBe(1);
expect(report.controls.find((control) => control.id === 'CC6.2')?.coverage).toBe('satisfied');
expect(report.controls.find((control) => control.id === 'CC8.1')?.coverage).toBe('gap');
});

it('exports the compliance report as CSV', async () => {
const service = new AuditService({ alerting: false });
await service.logAction({ action: 'roles.post', resource: 'roles', userId: 'admin-1' });

expect(service.getComplianceReportCsv().split('\n')[0]).toContain('Framework');
});
});

describe('AuditService retention and archival (#396)', () => {
it('reports how many entries sit in each retention tier', async () => {
const service = serviceWithSink(collectingSink(), { archiveAfterDays: 1, deleteAfterDays: 30 });
await service.logAction({ action: 'auth.post', resource: 'auth' });

expect(service.getRetentionTiers()).toEqual({ hot: 1, archive: 0, purge: 0 });
});

it('archives entries past the horizon and keeps the chain verifiable afterwards', async () => {
const service = serviceWithSink(collectingSink(), { archiveAfterDays: 1, deleteAfterDays: 365 });
await service.logAction({ action: 'auth.post', resource: 'auth' });
await service.logAction({ action: 'auth.post', resource: 'auth' });

const result = await service.archiveOldEntries(Date.now() + 2 * DAY);

expect(result.archived).toBe(2);
expect(result.archiveIds).toHaveLength(1);
expect(await service.getEntryCount()).toBe(0);
// The evicted chain must not be reported as tampered with.
await expect(service.verifyIntegrity()).resolves.toEqual({ valid: true });
expect((await service.listArchives()).length).toBe(1);
});

it('continues the chain correctly for entries logged after an eviction', async () => {
const service = serviceWithSink(collectingSink(), { archiveAfterDays: 1, deleteAfterDays: 365 });
await service.logAction({ action: 'auth.post', resource: 'auth' });
await service.archiveOldEntries(Date.now() + 2 * DAY);

await service.logAction({ action: 'admin.get', resource: 'admin' });

expect(await service.getEntryCount()).toBe(1);
await expect(service.verifyIntegrity()).resolves.toEqual({ valid: true });
});

it('still detects genuine tampering after entries have been archived', async () => {
const service = serviceWithSink(collectingSink(), { archiveAfterDays: 1, deleteAfterDays: 365 });
await service.logAction({ action: 'auth.post', resource: 'auth' });
await service.archiveOldEntries(Date.now() + 2 * DAY);
await service.logAction({ action: 'admin.get', resource: 'admin' });

const entry = await service.getEntry((await service.queryEntries({ limit: 1 })).entries[0]!.id);
// Mutate the retained entry the way an attacker with storage access would.
(entry as unknown as { action: string }).action = 'auth.post';

await expect(service.verifyIntegrity()).resolves.toEqual({ valid: false, brokenAt: entry!.id });
});

it('exposes the configured retention policy', async () => {
const service = serviceWithSink(collectingSink(), { archiveAfterDays: 7, deleteAfterDays: 14 });

expect(service.getRetentionPolicy()).toMatchObject({ archiveAfterDays: 7, deleteAfterDays: 14 });
});
});
Loading
Loading