diff --git a/backend/src/audit/__tests__/alerting.test.ts b/backend/src/audit/__tests__/alerting.test.ts new file mode 100644 index 00000000..dff1c9b6 --- /dev/null +++ b/backend/src/audit/__tests__/alerting.test.ts @@ -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 { + 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'); + }); +}); diff --git a/backend/src/audit/__tests__/auditService-396.test.ts b/backend/src/audit/__tests__/auditService-396.test.ts new file mode 100644 index 00000000..9cc2fe25 --- /dev/null +++ b/backend/src/audit/__tests__/auditService-396.test.ts @@ -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 }); + }); +}); diff --git a/backend/src/audit/__tests__/compliance-report.test.ts b/backend/src/audit/__tests__/compliance-report.test.ts new file mode 100644 index 00000000..d55f499e --- /dev/null +++ b/backend/src/audit/__tests__/compliance-report.test.ts @@ -0,0 +1,135 @@ +/** + * SOC2 / PCI-DSS compliance reporting — Issue #396 + */ +import { describe, expect, it } from 'vitest'; + +import { COMPLIANCE_CONTROLS, complianceReportToCsv, generateComplianceReport } from '../compliance-report.js'; +import { normalizeAuditEvent, type AuditEventInput } from '../event-schema.js'; + +function events(...inputs: AuditEventInput[]) { + return inputs.map((input) => normalizeAuditEvent(input)); +} + +const FULL_EVIDENCE: AuditEventInput[] = [ + // Access provisioning (SOC2 CC6.2) + { action: 'roles.post', resource: 'roles', outcome: 'success', ipAddress: '203.0.113.1', actor: 'admin-1' }, + // Failed auth (SOC2 CC7.3 / PCI 10.2.4) + { action: 'auth.post', resource: 'auth', outcome: 'failure', ipAddress: '203.0.113.2', actor: 'attacker' }, + // Cardholder data access (PCI 10.2.1) + { action: 'payments.get', resource: 'card', outcome: 'success', ipAddress: '203.0.113.3', actor: 'ops-1' }, + // Credential change (PCI 10.2.5) + { + action: 'api-keys.rotate', + resource: 'secrets', + outcome: 'success', + ipAddress: '203.0.113.4', + actor: 'admin-2', + }, +]; + +describe('COMPLIANCE_CONTROLS', () => { + it('covers both SOC2 and PCI-DSS', () => { + const frameworks = new Set(COMPLIANCE_CONTROLS.map((control) => control.framework)); + expect(frameworks).toEqual(new Set(['SOC2', 'PCI-DSS'])); + }); + + it('uses unique control identifiers per framework', () => { + const ids = COMPLIANCE_CONTROLS.map((control) => `${control.framework}:${control.id}`); + expect(new Set(ids).size).toBe(ids.length); + }); +}); + +describe('generateComplianceReport', () => { + it('marks controls with matching evidence as satisfied', () => { + const report = generateComplianceReport(events(...FULL_EVIDENCE)); + + const cc62 = report.controls.find((control) => control.framework === 'SOC2' && control.id === 'CC6.2'); + expect(cc62?.coverage).toBe('satisfied'); + // Both the role grant and the API-key rotation evidence access provisioning. + expect(cc62?.matchedEvents).toBe(2); + expect(cc62?.evidence.map((evidence) => evidence.actor)).toContain('admin-1'); + }); + + it('marks controls with no matching evidence as gaps', () => { + const report = generateComplianceReport(events(...FULL_EVIDENCE)); + + const sanctions = report.controls.find((control) => control.id === '10.2.2'); + expect(sanctions?.coverage).toBe('gap'); + expect(sanctions?.matchedEvents).toBe(0); + expect(sanctions?.evidence).toEqual([]); + }); + + it('summarises satisfied controls and gaps per framework', () => { + const report = generateComplianceReport(events(...FULL_EVIDENCE)); + + expect(report.summary.satisfied + report.summary.gaps).toBe(report.controls.length); + expect(report.summary.byFramework['PCI-DSS'].satisfied).toBeGreaterThan(0); + expect(report.summary.byFramework.SOC2.satisfied).toBeGreaterThan(0); + }); + + it('can be scoped to a single framework', () => { + const report = generateComplianceReport(events(...FULL_EVIDENCE), { frameworks: ['PCI-DSS'] }); + + expect(report.controls.every((control) => control.framework === 'PCI-DSS')).toBe(true); + expect(report.summary.byFramework.SOC2).toEqual({ satisfied: 0, gaps: 0 }); + }); + + it('honours the from/to reporting window', () => { + const base = Date.UTC(2026, 0, 1); + const day = 24 * 60 * 60 * 1000; + const all = events(...FULL_EVIDENCE.map((input, index) => ({ ...input, timestamp: base + index * day }))); + const midpoint = all[1]!.timestamp; + + const report = generateComplianceReport(all, { from: midpoint }); + expect(report.totalEvents).toBe(3); + + const empty = generateComplianceReport(all, { to: all[0]!.timestamp - 1 }); + expect(empty.totalEvents).toBe(0); + }); + + it('reports PCI-DSS field completeness and the fields that are missing', () => { + const report = generateComplianceReport([ + normalizeAuditEvent({ action: 'auth.post', resource: 'auth' }), + ...events(...FULL_EVIDENCE), + ]); + + expect(report.fieldCompleteness.pciDssComplete).toBe(4); + expect(report.fieldCompleteness.missingFields.originOfEvent).toBe(1); + expect(report.fieldCompleteness.missingFields.successOrFailure).toBe(1); + }); + + it('limits the evidence attached to each control', () => { + const many = events( + ...Array.from({ length: 8 }, (_, index) => ({ + action: 'roles.post', + resource: 'roles', + actor: `admin-${index}`, + outcome: 'success' as const, + })) + ); + + const report = generateComplianceReport(many, { evidenceLimit: 2 }); + const cc62 = report.controls.find((control) => control.id === 'CC6.2')!; + + expect(cc62.matchedEvents).toBe(8); + expect(cc62.evidence).toHaveLength(2); + }); + + it('returns an in-window event count and generation metadata', () => { + const report = generateComplianceReport(events(...FULL_EVIDENCE)); + + expect(report.totalEvents).toBe(4); + expect(new Date(report.generatedAt).toString()).not.toBe('Invalid Date'); + }); +}); + +describe('complianceReportToCsv', () => { + it('renders a header plus one row per control', () => { + const report = generateComplianceReport(events(...FULL_EVIDENCE)); + const [header, ...rows] = complianceReportToCsv(report).split('\n'); + + expect(header).toContain('Framework'); + expect(header).toContain('Coverage'); + expect(rows).toHaveLength(report.controls.length); + }); +}); diff --git a/backend/src/audit/__tests__/event-schema.test.ts b/backend/src/audit/__tests__/event-schema.test.ts new file mode 100644 index 00000000..bbe4c4be --- /dev/null +++ b/backend/src/audit/__tests__/event-schema.test.ts @@ -0,0 +1,144 @@ +/** + * Audit event schema, log-injection and CSV-injection defence — Issue #396 + */ +import { describe, expect, it } from 'vitest'; + +import { + AuditEventValidationError, + checkPciDssFieldCompleteness, + escapeCsvCell, + normalizeAuditEvent, + sanitizeAuditDetails, + sanitizeAuditText, + sanitizeAuditValue, +} from '../event-schema.js'; + +describe('sanitizeAuditText', () => { + it('collapses newlines so a detail value cannot forge an extra log line', () => { + const forged = 'login\n2026-01-01 INFO actor=root action=admin.delete'; + const safe = sanitizeAuditText(forged); + + expect(safe).not.toContain('\n'); + expect(safe).toBe('login 2026-01-01 INFO actor=root action=admin.delete'); + }); + + it('strips carriage returns and control characters', () => { + expect(sanitizeAuditText('pay\u0000ment\u0007 ok\r\n')).toBe('payment ok'); + }); + + it('strips ANSI escape sequences', () => { + expect(sanitizeAuditText('\u001B[31mred\u001B[0m')).toBe('red'); + }); + + it('truncates to the requested length', () => { + expect(sanitizeAuditText('abcdef', 4)).toBe('abcd'); + }); +}); + +describe('sanitizeAuditValue', () => { + it('sanitises nested objects and arrays', () => { + const result = sanitizeAuditValue({ + nested: { forged: 'a\nb' }, + list: ['x\ry', 1, true], + }); + + expect(result).toEqual({ nested: { forged: 'a b' }, list: ['x y', 1, true] }); + }); + + it('caps recursion depth rather than throwing on cyclic-ish structures', () => { + const deep = { a: { b: { c: { d: { e: 'too deep' } } } } }; + expect(JSON.stringify(sanitizeAuditValue(deep))).toContain('[TRUNCATED_DEPTH]'); + }); + + it('replaces non-finite numbers so JSON serialisation stays valid', () => { + expect(sanitizeAuditValue({ n: Number.NaN })).toEqual({ n: null }); + }); + + it('keeps detail bags bounded and returns undefined for empty input', () => { + expect(sanitizeAuditDetails(undefined)).toBeUndefined(); + expect(sanitizeAuditDetails({ ok: 'yes' })).toEqual({ ok: 'yes' }); + }); +}); + +describe('escapeCsvCell', () => { + it('neutralises spreadsheet formula injection', () => { + // Quoting alone is not enough: spreadsheet apps still execute cells that + // begin with a formula character, so it is prefixed with an apostrophe. + const quote = String.fromCharCode(39); + + expect(escapeCsvCell('=cmd|"/c calc"!A1')).toBe(`"${quote}=cmd|""/c calc""!A1"`); + expect(escapeCsvCell('+1234')).toBe(`"${quote}+1234"`); + expect(escapeCsvCell('-1234')).toBe(`"${quote}-1234"`); + expect(escapeCsvCell('@SUM(A1)')).toBe(`"${quote}@SUM(A1)"`); + }); + + it('quotes and escapes embedded double quotes', () => { + expect(escapeCsvCell('say "hi"')).toBe('"say ""hi"""'); + }); + + it('renders empty cells for null and undefined', () => { + expect(escapeCsvCell(null)).toBe('""'); + expect(escapeCsvCell(undefined)).toBe('""'); + }); +}); + +describe('normalizeAuditEvent', () => { + it('fills in the canonical actor and a numeric timestamp', () => { + const event = normalizeAuditEvent({ action: 'auth.post', resource: 'auth' }); + + expect(event.actor).toBe('system'); + expect(typeof event.timestamp).toBe('number'); + expect(event.timestamp).toBeGreaterThan(0); + }); + + it('sanitises actor, action, resource and details', () => { + const event = normalizeAuditEvent({ + actor: 'user\n1', + action: 'auth.post', + resource: 'auth', + details: { note: 'line1\nline2' }, + }); + + expect(event.actor).toBe('user 1'); + expect(event.details).toEqual({ note: 'line1 line2' }); + }); + + it('rejects actions containing characters that could break the log format', () => { + expect(() => normalizeAuditEvent({ action: 'auth post; rm -rf /', resource: 'auth' })).toThrow( + AuditEventValidationError + ); + }); + + it('rejects an empty resource', () => { + expect(() => normalizeAuditEvent({ action: 'auth.post', resource: '' })).toThrow(AuditEventValidationError); + }); + + it('accepts an ISO timestamp and normalises it to epoch milliseconds', () => { + const iso = '2026-01-01T00:00:00.000Z'; + expect(normalizeAuditEvent({ action: 'auth.post', resource: 'auth', timestamp: iso }).timestamp).toBe( + Date.parse(iso) + ); + }); +}); + +describe('checkPciDssFieldCompleteness', () => { + it('reports the fields PCI-DSS 10.3 expects but that are missing', () => { + const event = normalizeAuditEvent({ action: 'auth.post', resource: 'auth' }); + const result = checkPciDssFieldCompleteness(event); + + expect(result.complete).toBe(false); + expect(result.missing).toEqual(expect.arrayContaining(['successOrFailure', 'originOfEvent'])); + }); + + it('reports completeness when every required field is present', () => { + const event = normalizeAuditEvent({ + action: 'auth.post', + resource: 'auth', + outcome: 'success', + ipAddress: '203.0.113.7', + actor: 'user-1', + }); + + expect(checkPciDssFieldCompleteness(event)).toEqual({ complete: true, missing: [] }); + }); +}); diff --git a/backend/src/audit/__tests__/retention.test.ts b/backend/src/audit/__tests__/retention.test.ts new file mode 100644 index 00000000..0d175760 --- /dev/null +++ b/backend/src/audit/__tests__/retention.test.ts @@ -0,0 +1,165 @@ +/** + * Audit log retention and archival policy — Issue #396 + */ +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + AuditArchiveStore, + classifyAge, + enforceRetention, + planRetention, + type AuditRetentionPolicy, + type ChainedAuditEntry, +} from '../retention.js'; + +const DAY = 24 * 60 * 60 * 1000; +const NOW = Date.UTC(2026, 5, 1); + +const POLICY: AuditRetentionPolicy = { + retentionDays: 30, + archiveAfterDays: 90, + deleteAfterDays: 365, + archiveDir: '', +}; + +function entry(id: string, ageDays: number, extra: Partial = {}): ChainedAuditEntry { + return { + id, + timestamp: NOW - ageDays * DAY, + actor: 'u1', + action: 'auth.post', + resource: 'auth', + previousHash: `prev-${id}`, + hash: `hash-${id}`, + ...extra, + }; +} + +let dir: string; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'audit-archive-')); +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +describe('classifyAge', () => { + it('keeps recent entries hot', () => { + expect(classifyAge(NOW - 5 * DAY, NOW, POLICY)).toBe('hot'); + }); + + it('moves entries past the archive horizon into cold storage', () => { + expect(classifyAge(NOW - 120 * DAY, NOW, POLICY)).toBe('archive'); + }); + + it('marks entries past the delete horizon for purge', () => { + expect(classifyAge(NOW - 400 * DAY, NOW, POLICY)).toBe('purge'); + }); +}); + +describe('planRetention', () => { + it('splits entries into the three tiers', () => { + const plan = planRetention([entry('hot', 1), entry('arch', 100), entry('purge', 400)], NOW, POLICY); + + expect(plan.hot.map((e) => e.id)).toEqual(['hot']); + expect(plan.archive.map((e) => e.id)).toEqual(['arch']); + expect(plan.purge.map((e) => e.id)).toEqual(['purge']); + }); + + it('treats unparseable timestamps as hot rather than silently deleting them', () => { + const plan = planRetention([entry('weird', 0, { timestamp: 'not-a-date' })], NOW, POLICY); + expect(plan.hot).toHaveLength(1); + }); +}); + +describe('AuditArchiveStore', () => { + it('writes an append-only NDJSON archive plus a manifest', async () => { + const store = new AuditArchiveStore(dir); + // `a` is 100 days old, `b` is 101 days old, so `b` sorts first (oldest). + const manifest = await store.writeArchive([entry('a', 100), entry('b', 101)]); + + expect(manifest.entryCount).toBe(2); + expect(manifest.lastHash).toBe('hash-a'); + expect(manifest.firstPreviousHash).toBe('prev-b'); + expect(manifest.payloadSha256).toHaveLength(64); + + const files = await readdir(dir); + expect(files).toEqual(expect.arrayContaining([`${manifest.id}.ndjson`, `${manifest.id}.manifest.json`])); + }); + + it('round-trips archived entries in ascending chain order', async () => { + const store = new AuditArchiveStore(dir); + const manifest = await store.writeArchive([entry('a', 100), entry('b', 101)]); + + const restored = await store.readArchive(manifest.id); + expect(restored.map((e) => e.id)).toEqual(['b', 'a']); + }); + + it('refuses to overwrite an existing archive', async () => { + const store = new AuditArchiveStore(dir); + await store.writeArchive([entry('a', 100)], 'fixed-id'); + await expect(store.writeArchive([entry('b', 101)], 'fixed-id')).rejects.toThrow(); + }); + + it('detects tampering with an archived payload', async () => { + const store = new AuditArchiveStore(dir); + const manifest = await store.writeArchive([entry('a', 100)], 'tamper-me'); + + const file = join(dir, `${manifest.id}.ndjson`); + await writeFile(file, (await readFile(file, 'utf8')).replace('hash-a', 'hash-evil')); + + await expect(store.readArchive(manifest.id)).rejects.toThrow(/integrity check/); + }); + + it('rejects an empty archive request', async () => { + await expect(new AuditArchiveStore(dir).writeArchive([])).rejects.toThrow('empty entry set'); + }); + + it('lists archives newest first and tolerates a missing directory', async () => { + await expect(new AuditArchiveStore(join(dir, 'nope')).listArchives()).resolves.toEqual([]); + + const store = new AuditArchiveStore(dir); + await store.writeArchive([entry('a', 100)], 'one'); + await store.writeArchive([entry('b', 101)], 'two'); + + expect((await store.listArchives()).map((m) => m.id).sort()).toEqual(['one', 'two']); + }); +}); + +describe('enforceRetention', () => { + it('archives the archive tier, purges the purge tier and keeps the rest hot', async () => { + const store = new AuditArchiveStore(dir); + const { result, remaining } = await enforceRetention({ + entries: [entry('hot', 1), entry('arch', 100), entry('purge', 400)], + now: NOW, + policy: POLICY, + store, + }); + + expect(result.archived).toBe(1); + expect(result.purged).toBe(1); + expect(result.hot).toBe(1); + expect(result.archiveIds).toHaveLength(1); + expect(remaining.map((e) => e.id)).toEqual(['hot']); + }); + + it('does not touch cold storage when nothing is old enough', async () => { + const store = new AuditArchiveStore(dir); + const { result } = await enforceRetention({ + entries: [entry('hot', 1)], + now: NOW, + policy: POLICY, + store, + }); + + expect(result.archived).toBe(0); + expect(result.archiveIds).toEqual([]); + await expect(store.listArchives()).resolves.toEqual([]); + }); +}); diff --git a/backend/src/audit/alerting.ts b/backend/src/audit/alerting.ts new file mode 100644 index 00000000..58c33133 --- /dev/null +++ b/backend/src/audit/alerting.ts @@ -0,0 +1,304 @@ +/** + * Real-time audit alerting for critical events — Issue #396 + * + * Acceptance criterion: *"Real-time audit alerting for critical events."* + * + * The chain verifier already alerts on hash mismatches, but nothing watches the + * audit stream itself. This module classifies every audit event against a + * declarative rule set and pushes anything at/above the configured severity to + * the registered sinks (structured log by default, optional webhook). + * + * Alerts are de-duplicated inside a sliding window so a burst of failed logins + * from one actor produces one alert with an occurrence count rather than + * thousands of pages. + */ + +import { logger } from '../utils/logger.js'; +import type { AuditEvent } from './event-schema.js'; + +export type AuditSeverity = 'info' | 'low' | 'medium' | 'high' | 'critical'; + +/** Numeric ordering used for threshold comparisons. */ +export const SEVERITY_ORDER: Record = { + info: 0, + low: 1, + medium: 2, + high: 3, + critical: 4, +}; + +export interface CriticalEventRule { + /** Stable identifier, also used as the de-duplication key. */ + id: string; + severity: AuditSeverity; + description: string; + match: (event: AuditEvent) => boolean; +} + +/** Payment amount above which a payment event is treated as materially risky. */ +const LARGE_PAYMENT_THRESHOLD = Number(process.env.AUDIT_LARGE_PAYMENT_THRESHOLD ?? 10_000); + +const action = (event: AuditEvent): string => event.action.toLowerCase(); +const resource = (event: AuditEvent): string => event.resource.toLowerCase(); +const has = (event: AuditEvent, ...needles: string[]): boolean => { + const haystack = `${action(event)} ${resource(event)} ${event.requestPath ?? ''}`.toLowerCase(); + return needles.some((needle) => haystack.includes(needle)); +}; + +function paymentAmount(event: AuditEvent): number | undefined { + const raw = event.details?.['amount'] ?? event.details?.['amountUsd'] ?? event.details?.['total']; + const numeric = typeof raw === 'string' ? Number(raw) : raw; + return typeof numeric === 'number' && Number.isFinite(numeric) ? numeric : undefined; +} + +/** + * Declarative rules for security-relevant events. First match wins, so the + * rules are ordered most-severe first. + */ +export const CRITICAL_EVENT_RULES: CriticalEventRule[] = [ + { + id: 'audit.integrity_failure', + severity: 'critical', + description: 'Audit log integrity check reported a broken hash chain', + match: (event) => has(event, 'audit.integrity', 'chain.verify', 'tamper'), + }, + { + id: 'audit.suspicious_flag', + severity: 'high', + description: 'An audit entry was flagged as suspicious', + match: (event) => action(event).includes('audit.suspicious'), + }, + { + id: 'privilege.escalation', + severity: 'critical', + description: 'Role, permission or impersonation change', + match: (event) => has(event, 'roles', 'permissions', 'impersonate', 'privilege'), + }, + { + id: 'auth.secret_rotation', + severity: 'high', + description: 'API key or secret created, rotated or revoked', + match: (event) => has(event, 'api-keys', 'apikey', 'secrets', 'rotate', 'credential'), + }, + { + id: 'auth.failure', + severity: 'medium', + description: 'Failed authentication or authorization attempt', + match: (event) => has(event, 'auth', 'login', '2fa', 'mfa', 'otp', 'token', 'oauth') && event.outcome === 'failure', + }, + { + id: 'payment.large_value', + severity: 'high', + description: `Payment, transfer payout or refund at or above the ${LARGE_PAYMENT_THRESHOLD} threshold`, + match: (event) => { + if (!has(event, 'payment', 'transfer', 'payout', 'withdrawal', 'refund', 'escrow')) return false; + const amount = paymentAmount(event); + return amount !== undefined && amount >= LARGE_PAYMENT_THRESHOLD; + }, + }, + { + id: 'payment.failure', + severity: 'medium', + description: 'Failed payment, transfer or payout operation', + match: (event) => has(event, 'payment', 'transfer', 'payout', 'refund') && event.outcome === 'failure', + }, + { + id: 'compliance.sanctions_hit', + severity: 'critical', + description: 'Sanctions screening or AML rule produced a hit', + match: (event) => has(event, 'sanctions', 'aml', 'ofac'), + }, + { + id: 'audit.mutation', + severity: 'high', + description: 'Audit log mutation or deletion attempt', + match: (event) => action(event).includes('audit') && has(event, 'delete', 'clear', 'purge', 'export'), + }, +]; + +export interface AuditAlert { + id: string; + ruleId: string; + severity: AuditSeverity; + description: string; + /** Number of events folded into this alert inside the de-duplication window. */ + occurrences: number; + firstSeenAt: string; + lastSeenAt: string; + actor: string; + action: string; + resource: string; + resourceId?: string; + ipAddress?: string; + correlationId?: string; + details?: Record; +} + +/** Destination for dispatched alerts. */ +export interface AuditAlertSink { + name: string; + send(alert: AuditAlert): Promise; +} + +/** Default sink: structured application log. */ +export const loggerAlertSink: AuditAlertSink = { + name: 'logger', + async send(alert) { + const level = alert.severity === 'critical' || alert.severity === 'high' ? 'error' : 'warn'; + logger[level]({ alert }, `[audit-alert] ${alert.ruleId}: ${alert.description}`); + }, +}; + +/** Optional sink that POSTs alerts to a chat/on-call webhook. */ +export function webhookAlertSink(url: string, fetchImpl: typeof fetch = fetch): AuditAlertSink { + return { + name: 'webhook', + async send(alert) { + await fetchImpl(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: `[${alert.severity.toUpperCase()}] ${alert.description}`, + alert, + }), + }); + }, + }; +} + +export interface AuditAlerterOptions { + sinks?: AuditAlertSink[]; + /** Minimum severity that produces an alert. Defaults to `medium`. */ + minSeverity?: AuditSeverity; + /** Window in which identical alerts are folded together. Defaults to 60s. */ + dedupeWindowMs?: number; + /** Cap on retained alert history. Defaults to 500. */ + maxRecentAlerts?: number; + rules?: CriticalEventRule[]; + now?: () => number; +} + +/** + * Classifies audit events and dispatches alerts for critical ones. + * + * Sink failures are swallowed (logged) so alerting can never break the request + * path that produced the audit entry. + */ +export class AuditAlerter { + private readonly sinks: AuditAlertSink[]; + private readonly minSeverity: AuditSeverity; + private readonly dedupeWindowMs: number; + private readonly maxRecentAlerts: number; + private readonly rules: CriticalEventRule[]; + private readonly now: () => number; + private readonly recent: AuditAlert[] = []; + + constructor(options: AuditAlerterOptions = {}) { + this.sinks = options.sinks ?? [loggerAlertSink]; + this.minSeverity = options.minSeverity ?? (process.env.AUDIT_ALERT_MIN_SEVERITY as AuditSeverity) ?? 'medium'; + this.dedupeWindowMs = options.dedupeWindowMs ?? 60_000; + this.maxRecentAlerts = options.maxRecentAlerts ?? 500; + this.rules = options.rules ?? CRITICAL_EVENT_RULES; + this.now = options.now ?? (() => Date.now()); + } + + /** The highest-severity rule matching the event, if any. */ + classify(event: AuditEvent): CriticalEventRule | undefined { + return this.rules.find((rule) => rule.match(event)); + } + + /** True when the event would raise an alert at the configured threshold. */ + isAlertable(event: AuditEvent): boolean { + const rule = this.classify(event); + return rule !== undefined && SEVERITY_ORDER[rule.severity] >= SEVERITY_ORDER[this.minSeverity]; + } + + /** + * Classify and dispatch. Returns the alert when one was raised, or + * `undefined` when the event was below threshold. A repeat of an active alert + * increments `occurrences` and returns the same alert. + */ + async dispatch(event: AuditEvent): Promise { + const rule = this.classify(event); + if (!rule || SEVERITY_ORDER[rule.severity] < SEVERITY_ORDER[this.minSeverity]) return undefined; + + const nowMs = this.now(); + const alert = this.dedupe(rule, event, nowMs); + + if (alert.occurrences > 1) { + // Folded into an active alert: no second page, just an audit trail. + logger.warn({ alertId: alert.id, ruleId: alert.ruleId, occurrences: alert.occurrences }, 'Audit alert repeated'); + return alert; + } + + await this.emit(alert); + return alert; + } + + private dedupe(rule: CriticalEventRule, event: AuditEvent, nowMs: number): AuditAlert { + const timestamp = new Date(nowMs).toISOString(); + const existing = this.recent.find( + (candidate) => + candidate.ruleId === rule.id && + candidate.actor === event.actor && + candidate.resource === event.resource && + nowMs - Date.parse(candidate.lastSeenAt) <= this.dedupeWindowMs + ); + + if (existing) { + existing.occurrences += 1; + existing.lastSeenAt = timestamp; + return existing; + } + + const alert: AuditAlert = { + id: `${rule.id}:${event.actor}:${event.resource}:${nowMs}`, + ruleId: rule.id, + severity: rule.severity, + description: rule.description, + occurrences: 1, + firstSeenAt: timestamp, + lastSeenAt: timestamp, + actor: event.actor, + action: event.action, + resource: event.resource, + resourceId: event.resourceId, + ipAddress: event.ipAddress, + correlationId: event.correlationId, + details: event.details, + }; + + this.recent.unshift(alert); + if (this.recent.length > this.maxRecentAlerts) this.recent.length = this.maxRecentAlerts; + return alert; + } + + private async emit(alert: AuditAlert): Promise { + await Promise.all( + this.sinks.map((sink) => + sink.send(alert).catch((error: unknown) => { + logger.error({ error, sink: sink.name, alertId: alert.id }, 'Audit alert sink failed'); + }) + ) + ); + } + + /** Most recent alerts, newest first. */ + listRecentAlerts(limit = 50): AuditAlert[] { + return this.recent.slice(0, limit); + } + + /** Counts of retained alerts grouped by severity. */ + alertCounts(): Record { + const counts: Record = { info: 0, low: 0, medium: 0, high: 0, critical: 0 }; + for (const alert of this.recent) counts[alert.severity] += 1; + return counts; + } +} + +/** Application-wide alerter, wired to the audit service in `services/auditService.ts`. */ +export const auditAlerter = new AuditAlerter({ + sinks: process.env.AUDIT_ALERT_WEBHOOK_URL + ? [loggerAlertSink, webhookAlertSink(process.env.AUDIT_ALERT_WEBHOOK_URL)] + : [loggerAlertSink], +}); diff --git a/backend/src/audit/compliance-report.ts b/backend/src/audit/compliance-report.ts new file mode 100644 index 00000000..1dbb5089 --- /dev/null +++ b/backend/src/audit/compliance-report.ts @@ -0,0 +1,290 @@ +/** + * Compliance reporting over the audit log — Issue #396 + * + * Acceptance criterion: *"Compliance reporting (SOC2, PCI-DSS relevant + * fields)."* + * + * Auditors do not ask for a log dump, they ask "show me evidence for control + * CC6.1 in this window". This module maps audit events onto the SOC2 Trust + * Services Criteria and the PCI-DSS v4 logging requirements, reports whether + * each control has supporting evidence, and flags records that are missing the + * fields PCI-DSS 10.3 requires. + */ + +import { checkPciDssFieldCompleteness, escapeCsvCell, normalizeTimestamp, type AuditEvent } from './event-schema.js'; + +export type ComplianceFramework = 'SOC2' | 'PCI-DSS'; + +export type ControlCoverage = 'satisfied' | 'gap'; + +export interface ComplianceControl { + framework: ComplianceFramework; + /** Official control identifier, e.g. `CC6.1` or `10.2.4`. */ + id: string; + title: string; + requirement: string; + matcher: (event: AuditEvent) => boolean; +} + +const text = (event: AuditEvent): string => + `${event.action} ${event.resource} ${event.requestPath ?? ''}`.toLowerCase(); + +const contains = (event: AuditEvent, ...needles: string[]): boolean => + needles.some((needle) => text(event).includes(needle)); + +/** + * Controls that audit logging is expected to evidence. The mapping is + * deliberately conservative: a control is only listed when the audit stream can + * genuinely produce evidence for it. + */ +export const COMPLIANCE_CONTROLS: ComplianceControl[] = [ + // ── SOC2 Trust Services Criteria ────────────────────────────────────────── + { + framework: 'SOC2', + id: 'CC6.1', + title: 'Logical and physical access controls', + requirement: 'Authentication activity is recorded for principals accessing the system.', + matcher: (event) => contains(event, 'auth', 'login', 'oauth', 'token', 'session'), + }, + { + framework: 'SOC2', + id: 'CC6.2', + title: 'Access provisioning and authorization', + requirement: 'Granting of access rights (roles, permissions, scopes) is recorded.', + matcher: (event) => contains(event, 'roles', 'permissions', 'scopes', 'api-keys', 'workspaces', 'teams'), + }, + { + framework: 'SOC2', + id: 'CC7.2', + title: 'Monitoring for anomalies', + requirement: 'System components and security events are monitored for anomalies.', + matcher: (event) => event.suspicious !== true && contains(event, 'admin', 'config', 'flags', 'security'), + }, + { + framework: 'SOC2', + id: 'CC7.3', + title: 'Evaluation of security events', + requirement: 'Security incidents and failed operations are evaluated and tracked.', + matcher: (event) => event.outcome === 'failure', + }, + { + framework: 'SOC2', + id: 'CC8.1', + title: 'Change management', + requirement: 'Changes to infrastructure, data and software are authorized and recorded.', + matcher: (event) => contains(event, 'migration', 'config', 'feature-flags', 'deploy', 'release'), + }, + { + framework: 'SOC2', + id: 'CC4.1', + title: 'Monitoring activities over audit evidence', + requirement: 'Audit evidence integrity is periodically verified.', + matcher: (event) => contains(event, 'audit', 'integrity', 'chain', 'anchor'), + }, + + // ── PCI-DSS v4.0 requirement 10 ─────────────────────────────────────────── + { + framework: 'PCI-DSS', + id: '10.2.1', + title: 'Access to cardholder data', + requirement: 'All individual user access to cardholder data is logged.', + matcher: (event) => contains(event, 'card', 'payment-methods', 'payments'), + }, + { + framework: 'PCI-DSS', + id: '10.2.2', + title: 'Actions by privileged users', + requirement: 'All actions taken by any individual with administrative access are logged.', + matcher: (event) => contains(event, 'admin', 'impersonate', 'merchants', 'config'), + }, + { + framework: 'PCI-DSS', + id: '10.2.3', + title: 'Access to audit logs', + requirement: 'Access to all audit logs is recorded.', + matcher: (event) => contains(event, 'audit') && contains(event, 'get', 'export', 'query', 'read'), + }, + { + framework: 'PCI-DSS', + id: '10.2.4', + title: 'Invalid access attempts', + requirement: 'Invalid logical access attempts are logged.', + matcher: (event) => event.outcome === 'failure' && contains(event, 'auth', 'login', 'token', '2fa', 'otp'), + }, + { + framework: 'PCI-DSS', + id: '10.2.5', + title: 'Authentication credential changes', + requirement: 'Changes to authentication credentials are logged.', + matcher: (event) => contains(event, 'password', '2fa', 'mfa', 'api-key', 'secret', 'credential', 'rotate'), + }, + { + framework: 'PCI-DSS', + id: '10.2.7', + title: 'Creation and deletion of system objects', + requirement: 'Creation and deletion of system-level objects is logged.', + matcher: (event) => contains(event, 'delete', 'create', 'purge', 'revoke'), + }, + { + framework: 'PCI-DSS', + id: '10.3.1', + title: 'Recorded audit fields', + requirement: 'Records contain user identification, event type, date/time, success/failure and origin.', + matcher: (event) => checkPciDssFieldCompleteness(event).complete, + }, + { + framework: 'PCI-DSS', + id: '10.3.2', + title: 'Log integrity protection', + requirement: 'Audit log files are protected from modification and backed by an integrity mechanism.', + matcher: (event) => contains(event, 'audit', 'integrity', 'hash', 'anchor', 'tamper'), + }, +]; + +export interface ControlEvidence { + id: string; + timestamp: string; + actor: string; + action: string; + resource: string; + outcome?: 'success' | 'failure'; +} + +export interface ControlReport { + framework: ComplianceFramework; + id: string; + title: string; + requirement: string; + coverage: ControlCoverage; + matchedEvents: number; + /** Up to five representative events, newest first, for the auditor. */ + evidence: ControlEvidence[]; +} + +export interface ComplianceReport { + generatedAt: string; + from?: string; + to?: string; + totalEvents: number; + controls: ControlReport[]; + summary: { + satisfied: number; + gaps: number; + byFramework: Record; + }; + fieldCompleteness: { + pciDssComplete: number; + missingFields: Record; + }; +} + +export interface ComplianceReportOptions { + frameworks?: ComplianceFramework[]; + from?: number | string | Date; + to?: number | string | Date; + evidenceLimit?: number; +} + +const TO_ISO = (value: number | string | Date | undefined): string | undefined => + value === undefined ? undefined : new Date(normalizeTimestamp(value)).toISOString(); + +/** + * Build a control-by-control compliance report for the given audit events. + * Events outside the `from`/`to` window are ignored. + */ +export function generateComplianceReport( + events: AuditEvent[], + options: ComplianceReportOptions = {} +): ComplianceReport { + const frameworks = options.frameworks ?? ['SOC2', 'PCI-DSS']; + const from = options.from === undefined ? undefined : normalizeTimestamp(options.from); + const to = options.to === undefined ? undefined : normalizeTimestamp(options.to); + const evidenceLimit = options.evidenceLimit ?? 5; + + const inWindow = events.filter( + (event) => + (from === undefined || event.timestamp >= from) && (to === undefined || event.timestamp <= to) + ); + + const attempted = inWindow.filter((event) => checkPciDssFieldCompleteness(event).complete).length; + + const missingFields: Record = {}; + for (const event of inWindow) { + for (const field of checkPciDssFieldCompleteness(event).missing) { + missingFields[field] = (missingFields[field] ?? 0) + 1; + } + } + + const controls: ControlReport[] = COMPLIANCE_CONTROLS.filter((control) => + frameworks.includes(control.framework) + ).map((control) => { + const matches = inWindow + .filter((event) => control.matcher(event)) + .sort((a, b) => b.timestamp - a.timestamp); + + return { + framework: control.framework, + id: control.id, + title: control.title, + requirement: control.requirement, + coverage: matches.length > 0 ? 'satisfied' : 'gap', + matchedEvents: matches.length, + evidence: matches.slice(0, evidenceLimit).map((event) => ({ + id: event.id ?? event.correlationId ?? `${event.timestamp}`, + timestamp: new Date(event.timestamp).toISOString(), + actor: event.actor, + action: event.action, + resource: event.resource, + outcome: event.outcome, + })), + }; + }); + + const summary = { + satisfied: controls.filter((control) => control.coverage === 'satisfied').length, + gaps: controls.filter((control) => control.coverage === 'gap').length, + byFramework: { + SOC2: summarizeFramework(controls, 'SOC2'), + 'PCI-DSS': summarizeFramework(controls, 'PCI-DSS'), + }, + }; + + return { + generatedAt: new Date().toISOString(), + from: TO_ISO(options.from), + to: TO_ISO(options.to), + totalEvents: inWindow.length, + controls, + summary, + fieldCompleteness: { pciDssComplete: attempted, missingFields }, + }; +} + +function summarizeFramework( + controls: ControlReport[], + framework: ComplianceFramework +): { satisfied: number; gaps: number } { + const scoped = controls.filter((control) => control.framework === framework); + return { + satisfied: scoped.filter((control) => control.coverage === 'satisfied').length, + gaps: scoped.filter((control) => control.coverage === 'gap').length, + }; +} + +/** Render a compliance report as CSV for auditor hand-off. */ +export function complianceReportToCsv(report: ComplianceReport): string { + const header = ['Framework', 'Control', 'Title', 'Coverage', 'Matched Events', 'Requirement'].map(escapeCsvCell).join(','); + const rows = report.controls.map((control) => + [ + control.framework, + control.id, + control.title, + control.coverage, + control.matchedEvents, + control.requirement, + ] + .map(escapeCsvCell) + .join(',') + ); + return [header, ...rows].join('\n'); +} diff --git a/backend/src/audit/event-schema.ts b/backend/src/audit/event-schema.ts new file mode 100644 index 00000000..cd4fb388 --- /dev/null +++ b/backend/src/audit/event-schema.ts @@ -0,0 +1,229 @@ +/** + * Canonical audit event schema — Issue #396 + * + * Every audit record must carry the four fields the issue requires (actor, + * action, resource, timestamp) plus a best-effort outcome, origin and + * correlation context. This module is the single place that: + * + * 1. defines that schema (`auditEventSchema`), + * 2. normalises untrusted input into it, and + * 3. defends the log against **log injection** and **CSV injection**. + * + * Log injection: a caller can smuggle `\n`, `\r` or ANSI escapes into a detail + * value and forge additional log lines, or break the NDJSON archive format. + * All free-text is therefore stripped of control characters and length-capped + * before it is hashed or persisted. + * + * CSV injection: spreadsheet software executes cells starting with `= + - @` + * (or tab/CR), so exported audit rows are prefixed with `'` to neutralise + * formulas. + */ + +import { z } from 'zod'; + +/** Hard caps keep untrusted fields from bloating storage (see issue #396 notes on storage cost). */ +export const AUDIT_LIMITS = { + actor: 256, + action: 128, + resource: 128, + resourceId: 256, + ipAddress: 64, + userAgent: 512, + correlationId: 128, + detailKeys: 64, + detailDepth: 4, + detailString: 1_024, + detailArray: 64, + outcome: 16, +} as const; + +// These patterns intentionally match control characters: stripping them is the +// whole point (they are what make log injection possible). +// eslint-disable-next-line no-control-regex +const CONTROL_CHARACTERS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g; +// eslint-disable-next-line no-control-regex +const ANSI_ESCAPES = /\u001B\[[0-9;]*[A-Za-z]/g; + +/** + * Strip characters that could forge log lines, corrupt NDJSON archives, or + * inject terminal escape sequences, then truncate to `maxLength`. + */ +export function sanitizeAuditText(value: string, maxLength: number = AUDIT_LIMITS.detailString): string { + const withoutEscapes = value.replace(ANSI_ESCAPES, ''); + return withoutEscapes + .replace(CONTROL_CHARACTERS, '') + .replace(/[\r\n]+/g, ' ') + .trim() + .slice(0, maxLength); +} + +/** Recursively sanitise an arbitrary value so it is safe to persist and hash. */ +export function sanitizeAuditValue(value: unknown, depth = 0): unknown { + if (value === null || value === undefined) return value; + if (typeof value === 'string') return sanitizeAuditText(value); + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + if (typeof value === 'boolean') return value; + if (value instanceof Date) return value.toISOString(); + if (typeof value === 'bigint') return value.toString(); + + if (depth >= AUDIT_LIMITS.detailDepth) return '[TRUNCATED_DEPTH]'; + + if (Array.isArray(value)) { + return value.slice(0, AUDIT_LIMITS.detailArray).map((item) => sanitizeAuditValue(item, depth + 1)); + } + + if (typeof value === 'object') { + const entries = Object.entries(value as Record).slice(0, AUDIT_LIMITS.detailKeys); + return Object.fromEntries( + entries.map(([key, nested]) => [sanitizeAuditText(key, 128), sanitizeAuditValue(nested, depth + 1)]) + ); + } + + // Functions, symbols, etc. are not serialisable. + return `[UNSERIALIZABLE:${typeof value}]`; +} + +/** Deep-sanitise a detail bag, preserving `undefined` when there is nothing to record. */ +export function sanitizeAuditDetails( + details?: Record +): Record | undefined { + if (!details) return undefined; + const sanitized = sanitizeAuditValue(details, 0); + return typeof sanitized === 'object' && sanitized !== null ? (sanitized as Record) : undefined; +} + +/** + * Escape a value for CSV output. Quoting alone does not stop spreadsheet + * formula execution, so leading formula characters are prefixed with `'`. + */ +export function escapeCsvCell(value: unknown): string { + const raw = value === null || value === undefined ? '' : String(value); + const neutralized = /^[=+\-@\t\r]/.test(raw) ? `'${raw}` : raw; + return `"${sanitizeAuditText(neutralized, 8_192).replace(/"/g, '""')}"`; +} + +const timestampSchema = z.union([z.number().int().nonnegative(), z.string(), z.date()]); + +/** The canonical, validated shape of an audit event. */ +export const auditEventSchema = z.object({ + id: z.string().max(128).optional(), + actor: z.string().min(1).max(AUDIT_LIMITS.actor), + action: z + .string() + .min(1) + .max(AUDIT_LIMITS.action) + .regex(/^[A-Za-z0-9._:\-/]+$/, 'action may only contain letters, digits and . _ : - /'), + resource: z.string().min(1).max(AUDIT_LIMITS.resource), + resourceId: z.string().max(AUDIT_LIMITS.resourceId).optional(), + timestamp: timestampSchema, + outcome: z.enum(['success', 'failure']).optional(), + ipAddress: z.string().max(AUDIT_LIMITS.ipAddress).optional(), + userAgent: z.string().max(AUDIT_LIMITS.userAgent).optional(), + requestMethod: z.string().max(16).optional(), + requestPath: z.string().max(2_048).optional(), + responseStatus: z.number().int().min(100).max(599).optional(), + correlationId: z.string().max(AUDIT_LIMITS.correlationId).optional(), + details: z.record(z.string(), z.unknown()).optional(), + suspicious: z.boolean().optional(), + flags: z.array(z.string().max(128)).max(32).optional(), +}); + +export type AuditEvent = Omit, 'timestamp'> & { + /** Always normalised to epoch milliseconds by {@link normalizeAuditEvent}. */ + timestamp: number; +}; + +/** Thrown when an audit event cannot be normalised into the canonical schema. */ +export class AuditEventValidationError extends Error { + constructor( + message: string, + readonly issues: z.ZodIssue[] = [] + ) { + super(message); + this.name = 'AuditEventValidationError'; + } +} + +export interface AuditEventInput { + id?: string; + actor?: string; + action: string; + resource: string; + resourceId?: string; + timestamp?: number | string | Date; + outcome?: 'success' | 'failure'; + ipAddress?: string; + userAgent?: string; + requestMethod?: string; + requestPath?: string; + responseStatus?: number; + correlationId?: string; + details?: Record; + suspicious?: boolean; + flags?: string[]; +} + +/** Normalise a timestamp into epoch milliseconds. */ +export function normalizeTimestamp(timestamp: number | string | Date | undefined): number { + if (timestamp === undefined) return Date.now(); + if (typeof timestamp === 'number') return timestamp; + if (timestamp instanceof Date) return timestamp.getTime(); + const parsed = Date.parse(timestamp); + return Number.isNaN(parsed) ? Date.now() : parsed; +} + +/** + * Sanitise then validate an event. Returns the canonical event with an epoch + * timestamp and fully sanitised free-text fields. + * + * @throws {AuditEventValidationError} when required fields are missing/invalid. + */ +export function normalizeAuditEvent(input: AuditEventInput): AuditEvent { + const candidate = { + id: input.id ? sanitizeAuditText(input.id, 128) : undefined, + actor: sanitizeAuditText(input.actor ?? 'system', AUDIT_LIMITS.actor) || 'system', + action: sanitizeAuditText(input.action ?? '', AUDIT_LIMITS.action), + resource: sanitizeAuditText(input.resource ?? '', AUDIT_LIMITS.resource), + resourceId: input.resourceId ? sanitizeAuditText(input.resourceId, AUDIT_LIMITS.resourceId) : undefined, + timestamp: normalizeTimestamp(input.timestamp), + outcome: input.outcome, + ipAddress: input.ipAddress ? sanitizeAuditText(input.ipAddress, AUDIT_LIMITS.ipAddress) : undefined, + userAgent: input.userAgent ? sanitizeAuditText(input.userAgent, AUDIT_LIMITS.userAgent) : undefined, + requestMethod: input.requestMethod ? sanitizeAuditText(input.requestMethod, 16) : undefined, + requestPath: input.requestPath ? sanitizeAuditText(input.requestPath, 2_048) : undefined, + responseStatus: input.responseStatus, + correlationId: input.correlationId + ? sanitizeAuditText(input.correlationId, AUDIT_LIMITS.correlationId) + : undefined, + details: sanitizeAuditDetails(input.details), + suspicious: input.suspicious, + flags: input.flags?.map((flag) => sanitizeAuditText(flag, 128)).slice(0, 32), + }; + + const result = auditEventSchema.safeParse(candidate); + if (!result.success) { + throw new AuditEventValidationError( + `Invalid audit event: ${result.error.issues.map((issue) => `${issue.path.join('.')} ${issue.message}`).join('; ')}`, + result.error.issues + ); + } + + return { ...result.data, timestamp: normalizeTimestamp(result.data.timestamp) }; +} + +/** + * Check whether an event carries every field PCI-DSS 10.3 expects to be + * recorded (user, event type, date/time, success/failure, origin, identity). + */ +export function checkPciDssFieldCompleteness(event: AuditEvent): { + complete: boolean; + missing: string[]; +} { + const missing: string[] = []; + if (!event.actor) missing.push('userIdentification'); + if (!event.action) missing.push('typeOfEvent'); + if (!event.timestamp) missing.push('dateAndTime'); + if (!event.outcome) missing.push('successOrFailure'); + if (!event.ipAddress) missing.push('originOfEvent'); + return { complete: missing.length === 0, missing }; +} diff --git a/backend/src/audit/retention.ts b/backend/src/audit/retention.ts new file mode 100644 index 00000000..ebc7a308 --- /dev/null +++ b/backend/src/audit/retention.ts @@ -0,0 +1,245 @@ +/** + * Audit log retention and archival — Issue #396 + * + * Acceptance criterion: *"Audit log retention with archival policy."* + * + * Audit data must survive the hot window without being dropped, while + * high-volume deployments must not pay to keep every event queryable forever + * (see the issue's "storage costs for high-volume events" edge case). + * + * The policy tiers entries into three buckets: + * + * hot → still queryable in the primary store + * archive → written to append-only NDJSON cold storage, then evicted + * purge → past the delete horizon, removed even from cold storage + * + * Archives are append-only: each file must not already exist (`wx` flag), is + * named after the timestamp range it covers, and carries a manifest with a + * SHA-256 of the payload plus the first/last chain hashes. That keeps the + * tamper-evidence property intact across the archival boundary — a verifier can + * resume a hash chain from the archived `lastHash` instead of failing at the + * first evicted entry. + */ + +import { createHash } from 'node:crypto'; +import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { logger } from '../utils/logger.js'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Entry shape needed to archive and re-verify a hash chain. */ +export interface ChainedAuditEntry { + id: string; + timestamp: number | string; + actor: string; + action: string; + resource: string; + previousHash: string; + hash: string; + [key: string]: unknown; +} + +export interface AuditRetentionPolicy { + /** Entries younger than this stay queryable. */ + retentionDays: number; + /** Entries older than this move to cold storage. */ + archiveAfterDays: number; + /** Entries older than this are purged from cold storage too. */ + deleteAfterDays: number; + /** Directory holding append-only NDJSON archives. */ + archiveDir: string; +} + +/** + * Defaults follow the SOC2/PCI-DSS expectation of keeping audit evidence for + * at least a year, with a longer cold-storage tail. + */ +export const DEFAULT_RETENTION_POLICY: AuditRetentionPolicy = { + retentionDays: Number(process.env.AUDIT_RETENTION_DAYS ?? 2555), + archiveAfterDays: Number(process.env.AUDIT_ARCHIVE_AFTER_DAYS ?? 2190), + deleteAfterDays: Number(process.env.AUDIT_DELETE_AFTER_DAYS ?? 3650), + archiveDir: process.env.AUDIT_ARCHIVE_DIR ?? join(process.cwd(), 'var', 'audit-archive'), +}; + +export type RetentionTier = 'hot' | 'archive' | 'purge'; + +/** Which tier an entry of the given age belongs to. */ +export function classifyAge(timestamp: number, now: number, policy: AuditRetentionPolicy): RetentionTier { + const ageDays = (now - timestamp) / DAY_MS; + if (ageDays >= policy.deleteAfterDays) return 'purge'; + if (ageDays >= policy.archiveAfterDays) return 'archive'; + return 'hot'; +} + +export interface RetentionPlan { + hot: T[]; + archive: T[]; + purge: T[]; +} + +/** Split entries into the tiers defined by the policy. */ +export function planRetention( + entries: T[], + now: number, + policy: AuditRetentionPolicy = DEFAULT_RETENTION_POLICY +): RetentionPlan { + const plan: RetentionPlan = { hot: [], archive: [], purge: [] }; + + for (const entry of entries) { + const timestamp = typeof entry.timestamp === 'number' ? entry.timestamp : Date.parse(entry.timestamp); + plan[classifyAge(Number.isNaN(timestamp) ? now : timestamp, now, policy)].push(entry); + } + + return plan; +} + +export interface ArchiveManifest { + id: string; + createdAt: string; + entryCount: number; + fromTimestamp: string; + toTimestamp: string; + /** Hash of the oldest archived entry's `previousHash`, i.e. the chain resume point. */ + firstPreviousHash: string; + /** Hash of the newest archived entry, i.e. the chain resume point after replay. */ + lastHash: string; + /** SHA-256 of the NDJSON payload, so archive corruption is detectable. */ + payloadSha256: string; + byteSize: number; +} + +/** Append-only, verifiable cold storage for archived audit entries. */ +export class AuditArchiveStore { + constructor(private readonly dir: string = DEFAULT_RETENTION_POLICY.archiveDir) {} + + private archivePath(id: string): string { + return join(this.dir, `${id}.ndjson`); + } + + private manifestPath(id: string): string { + return join(this.dir, `${id}.manifest.json`); + } + + /** + * Persist a batch of entries as one immutable archive file plus manifest. + * + * Writes are exclusive (`wx`): re-archiving the same identifier fails rather + * than overwriting evidence. + */ + async writeArchive(entries: T[], id?: string): Promise { + if (entries.length === 0) throw new Error('Cannot archive an empty entry set'); + + await mkdir(this.dir, { recursive: true }); + + const ordered = [...entries].sort((a, b) => toEpoch(a.timestamp) - toEpoch(b.timestamp)); + const first = ordered[0]!; + const last = ordered[ordered.length - 1]!; + const archiveId = id ?? `audit-${toEpoch(first.timestamp)}-${toEpoch(last.timestamp)}-${first.hash.slice(0, 8)}`; + + const payload = `${ordered.map((entry) => JSON.stringify(entry)).join('\n')}\n`; + const manifest: ArchiveManifest = { + id: archiveId, + createdAt: new Date().toISOString(), + entryCount: ordered.length, + fromTimestamp: new Date(toEpoch(first.timestamp)).toISOString(), + toTimestamp: new Date(toEpoch(last.timestamp)).toISOString(), + firstPreviousHash: first.previousHash, + lastHash: last.hash, + payloadSha256: createHash('sha256').update(payload).digest('hex'), + byteSize: Buffer.byteLength(payload, 'utf8'), + }; + + await writeFile(this.archivePath(archiveId), payload, { encoding: 'utf8', flag: 'wx' }); + await writeFile(this.manifestPath(archiveId), JSON.stringify(manifest, null, 2), { + encoding: 'utf8', + flag: 'wx', + }); + + logger.info({ archiveId, entryCount: ordered.length, byteSize: manifest.byteSize }, 'Audit entries archived'); + return manifest; + } + + /** All archives currently in cold storage, newest first. */ + async listArchives(): Promise { + try { + const files = await readdir(this.dir); + const manifests = await Promise.all( + files + .filter((file) => file.endsWith('.manifest.json')) + .map(async (file) => JSON.parse(await readFile(join(this.dir, file), 'utf8')) as ArchiveManifest) + ); + return manifests.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } + } + + /** Read an archive back, verifying its payload hash before returning entries. */ + async readArchive(id: string): Promise { + const manifest = JSON.parse(await readFile(this.manifestPath(id), 'utf8')) as ArchiveManifest; + const payload = await readFile(this.archivePath(id), 'utf8'); + + const actual = createHash('sha256').update(payload).digest('hex'); + if (actual !== manifest.payloadSha256) { + throw new Error(`Audit archive ${id} failed integrity check (payload hash mismatch)`); + } + + return payload + .split('\n') + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line) as T); + } +} + +export interface RetentionEnforcementResult { + archived: number; + purged: number; + hot: number; + archiveIds: string[]; +} + +/** + * Apply the retention policy: archive everything in the `archive` tier in a + * single batch, drop the `purge` tier, and report what stayed hot. + * + * Returns the entries that should remain in the primary store. + */ +export async function enforceRetention(input: { + entries: T[]; + now?: number; + policy?: AuditRetentionPolicy; + store?: AuditArchiveStore; +}): Promise<{ result: RetentionEnforcementResult; remaining: T[] }> { + const policy = input.policy ?? DEFAULT_RETENTION_POLICY; + const now = input.now ?? Date.now(); + const plan = planRetention(input.entries, now, policy); + + const archiveIds: string[] = []; + if (plan.archive.length > 0) { + const store = input.store ?? new AuditArchiveStore(policy.archiveDir); + const manifest = await store.writeArchive(plan.archive); + archiveIds.push(manifest.id); + } + + if (plan.purge.length > 0) { + logger.warn({ purged: plan.purge.length }, 'Audit entries purged past the delete horizon'); + } + + return { + result: { + archived: plan.archive.length, + purged: plan.purge.length, + hot: plan.hot.length, + archiveIds, + }, + remaining: plan.hot, + }; +} + +function toEpoch(timestamp: number | string): number { + const value = typeof timestamp === 'number' ? timestamp : Date.parse(timestamp); + return Number.isNaN(value) ? 0 : value; +} diff --git a/backend/src/routes/audit.ts b/backend/src/routes/audit.ts index 49c931e3..5b12ca0d 100644 --- a/backend/src/routes/audit.ts +++ b/backend/src/routes/audit.ts @@ -1,9 +1,13 @@ import { Router, Request, Response } from 'express'; import { asyncHandler } from '../middleware/errorHandler.js'; import { auditService } from '../services/auditService.js'; +import { AuditEventValidationError, normalizeAuditEvent } from '../audit/event-schema.js'; +import type { ComplianceFramework } from '../audit/compliance-report.js'; export const auditRouter = Router(); +const COMPLIANCE_FRAMEWORKS: ComplianceFramework[] = ['SOC2', 'PCI-DSS']; + auditRouter.post('/log', asyncHandler(async (req: Request, res: Response) => { const { userId, action, resource, resourceId, outcome, details, beforeState, afterState, ipAddress, userAgent, request, response } = req.body; @@ -17,6 +21,17 @@ auditRouter.post('/log', asyncHandler(async (req: Request, res: Response) => { return; } + // Validate against the canonical event schema before writing (issue #396). + try { + normalizeAuditEvent({ action, resource, resourceId, outcome, timestamp: Date.now() }); + } catch (error) { + if (error instanceof AuditEventValidationError) { + res.status(400).json({ error: error.message, issues: error.issues }); + return; + } + throw error; + } + const entry = await auditService.logAction({ userId, action, @@ -131,4 +146,72 @@ auditRouter.delete('/clear', asyncHandler(async (req: Request, res: Response) => const deleted = await auditService.clearOldEntries(); res.status(200).json({ deleted, message: 'Old entries cleared' }); +})); + +// ── Real-time critical-event alerts (issue #396) ─────────────────────────── + +auditRouter.get('/alerts', asyncHandler(async (req: Request, res: Response) => { + const limit = req.query.limit ? Number(req.query.limit) : 50; + res.status(200).json({ + alerts: auditService.getAlerts(limit), + counts: auditService.getAlertCounts(), + }); +})); + +// ── Compliance reporting (issue #396) ────────────────────────────────────── + +auditRouter.get('/compliance/report', asyncHandler(async (req: Request, res: Response) => { + const { framework, from, to, format } = req.query; + + const frameworks = framework + ? String(framework) + .split(',') + .map((value) => value.trim().toUpperCase()) + .filter((value): value is ComplianceFramework => + (COMPLIANCE_FRAMEWORKS as string[]).includes(value) + ) + : undefined; + + if (framework && frameworks && frameworks.length === 0) { + res.status(400).json({ error: `framework must be one of ${COMPLIANCE_FRAMEWORKS.join(', ')}` }); + return; + } + + const options = { + frameworks, + from: from ? Number(from) : undefined, + to: to ? Number(to) : undefined, + }; + + if (String(format).toLowerCase() === 'csv') { + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="audit-compliance-${Date.now()}.csv"`); + res.status(200).send(auditService.getComplianceReportCsv(options)); + return; + } + + res.status(200).json(auditService.getComplianceReport(options)); +})); + +// ── Retention / archival (issue #396) ────────────────────────────────────── + +auditRouter.get('/retention/tiers', asyncHandler(async (_req: Request, res: Response) => { + res.status(200).json({ + policy: auditService.getRetentionPolicy(), + tiers: auditService.getRetentionTiers(), + }); +})); + +auditRouter.get('/archives', asyncHandler(async (_req: Request, res: Response) => { + res.status(200).json({ archives: await auditService.listArchives() }); +})); + +auditRouter.post('/retention/archive', asyncHandler(async (req: Request, res: Response) => { + if (req.query.confirm !== 'true') { + res.status(400).json({ error: 'Add ?confirm=true to run the archival policy' }); + return; + } + + const result = await auditService.archiveOldEntries(); + res.status(200).json(result); })); \ No newline at end of file diff --git a/backend/src/services/auditService.ts b/backend/src/services/auditService.ts index f25a4a95..a08f8760 100644 --- a/backend/src/services/auditService.ts +++ b/backend/src/services/auditService.ts @@ -1,6 +1,31 @@ import { createHash } from 'node:crypto'; import { randomUUID as uuidv4 } from 'node:crypto'; +import { AuditAlerter, auditAlerter, type AuditAlert, type AuditSeverity } from '../audit/alerting.js'; +import { + complianceReportToCsv, + generateComplianceReport, + type ComplianceReport, + type ComplianceReportOptions, +} from '../audit/compliance-report.js'; +import { + escapeCsvCell, + normalizeAuditEvent, + sanitizeAuditDetails, + sanitizeAuditText, + type AuditEvent, +} from '../audit/event-schema.js'; +import { + AuditArchiveStore, + DEFAULT_RETENTION_POLICY, + enforceRetention, + planRetention, + type ArchiveManifest, + type AuditRetentionPolicy, + type ChainedAuditEntry, + type RetentionEnforcementResult, +} from '../audit/retention.js'; + /** Whether a sensitive operation succeeded or failed. */ export type AuditOutcome = 'success' | 'failure'; @@ -45,18 +70,47 @@ export interface RetentionPolicy { deleteAfterDays: number; } +export interface AuditServiceOptions { + policy?: Partial; + /** Disable real-time critical-event alerting (enabled by default). */ + alerting?: boolean; + alerter?: AuditAlerter; + archiveDir?: string; +} + export class AuditService { private entries: AuditEntry[] = []; private currentHash = '0000000000000000000000000000000000000000000000000000000000000000'; + /** + * Hash immediately preceding the oldest retained entry. Archival/eviction + * moves this forward so `verifyIntegrity()` keeps validating the retained + * chain instead of reporting a break at the first evicted entry (issue #396). + */ + private chainAnchor = '0000000000000000000000000000000000000000000000000000000000000000'; private retentionPolicy: RetentionPolicy = { retentionDays: 2555, archiveAfterDays: 2190, deleteAfterDays: 3650, }; + private readonly alerter: AuditAlerter; + private readonly alertingEnabled: boolean; + private archiveDir: string = DEFAULT_RETENTION_POLICY.archiveDir; + + constructor(options: Partial | AuditServiceOptions = {}) { + const isOptionsObject = + 'policy' in options || 'alerting' in options || 'alerter' in options || 'archiveDir' in options; - constructor(policy?: Partial) { - if (policy) { - this.retentionPolicy = { ...this.retentionPolicy, ...policy }; + if (isOptionsObject) { + const { policy, alerting, alerter, archiveDir } = options as AuditServiceOptions; + if (policy) this.retentionPolicy = { ...this.retentionPolicy, ...policy }; + if (archiveDir) this.archiveDir = archiveDir; + this.alerter = alerter ?? auditAlerter; + this.alertingEnabled = alerting ?? true; + } else { + // Backwards-compatible constructor: `new AuditService({ retentionDays })`. + this.retentionPolicy = { ...this.retentionPolicy, ...(options as Partial) }; + this.alerter = auditAlerter; + this.alertingEnabled = true; } } @@ -115,18 +169,20 @@ export class AuditService { const entry: Omit = { id, timestamp, - userId: params.userId, - action: params.action, - resource: params.resource, - resourceId: params.resourceId, + // Sanitised on write so untrusted values cannot forge log lines or bloat + // storage (issue #396 — log injection / high-volume storage). + userId: params.userId ? sanitizeAuditText(params.userId, 256) : undefined, + action: sanitizeAuditText(params.action, 128), + resource: sanitizeAuditText(params.resource, 128), + resourceId: params.resourceId ? sanitizeAuditText(params.resourceId, 256) : undefined, outcome, - details: params.details, - beforeState: params.beforeState, - afterState: params.afterState, - ipAddress: params.ipAddress, - userAgent: params.userAgent, - requestMethod: params.request?.method, - requestPath: params.request?.path, + details: sanitizeAuditDetails(params.details), + beforeState: sanitizeAuditDetails(params.beforeState), + afterState: sanitizeAuditDetails(params.afterState), + ipAddress: params.ipAddress ? sanitizeAuditText(params.ipAddress, 64) : undefined, + userAgent: params.userAgent ? sanitizeAuditText(params.userAgent, 512) : undefined, + requestMethod: params.request?.method ? sanitizeAuditText(params.request.method, 16) : undefined, + requestPath: params.request?.path ? sanitizeAuditText(params.request.path, 2048) : undefined, requestBody: this.sanitizeRequestBody(params.request?.body), responseStatus: params.response?.status, previousHash: this.currentHash, @@ -134,29 +190,76 @@ export class AuditService { const hash = this.generateEntryHash(entry); const fullEntry: AuditEntry = { ...entry, hash }; - + this.entries.push(fullEntry); this.currentHash = hash; + // Real-time alerting for critical events (issue #396). Sink failures are + // contained inside the alerter so auditing can never break the request. + if (this.alertingEnabled) { + await this.alerter.dispatch(this.toAuditEvent(fullEntry)).catch(() => undefined); + } + return fullEntry; } + /** Adapt a stored entry to the canonical audit event shape used by alerting/compliance. */ + private toAuditEvent(entry: AuditEntry): AuditEvent { + try { + return normalizeAuditEvent({ + id: entry.id, + actor: entry.userId ?? 'system', + action: entry.action, + resource: entry.resource, + resourceId: entry.resourceId, + timestamp: entry.timestamp, + outcome: entry.outcome, + ipAddress: entry.ipAddress, + userAgent: entry.userAgent, + requestMethod: entry.requestMethod, + requestPath: entry.requestPath, + responseStatus: entry.responseStatus, + details: entry.details, + suspicious: entry.suspicious, + flags: entry.flags, + }); + } catch { + return { + id: entry.id, + actor: sanitizeAuditText(entry.userId ?? 'system', 256), + action: sanitizeAuditText(entry.action, 128), + resource: sanitizeAuditText(entry.resource, 128), + resourceId: entry.resourceId, + timestamp: entry.timestamp, + outcome: entry.outcome, + ipAddress: entry.ipAddress, + userAgent: entry.userAgent, + requestMethod: entry.requestMethod, + requestPath: entry.requestPath, + responseStatus: entry.responseStatus, + details: entry.details, + suspicious: entry.suspicious, + flags: entry.flags, + }; + } + } + private sanitizeRequestBody(body?: unknown): unknown { if (!body) return undefined; if (typeof body !== 'object') return body; - - const sanitized = { ...body as Record }; + + const sanitized = { ...(body as Record) }; const sensitiveFields = [ 'password', 'token', 'apiKey', 'secret', 'creditCard', 'ssn', 'documentNumber', 'fileContent', 'dateOfBirth', ]; - + for (const field of sensitiveFields) { if (field in sanitized) { sanitized[field] = '[REDACTED]'; } } - + return sanitized; } @@ -174,7 +277,7 @@ export class AuditService { const total = filtered.length; const offset = query.offset || 0; const limit = query.limit || 50; - + filtered = filtered.sort((a, b) => b.timestamp - a.timestamp); filtered = filtered.slice(offset, offset + limit); @@ -186,25 +289,25 @@ export class AuditService { } async verifyIntegrity(): Promise<{ valid: boolean; brokenAt?: string }> { - let expectedHash = '0000000000000000000000000000000000000000000000000000000000000000'; - + let expectedHash = this.chainAnchor; + for (const entry of this.entries) { if (entry.previousHash !== expectedHash) { return { valid: false, brokenAt: entry.id }; } - + const computedHash = this.generateEntryHash(entry); if (computedHash !== entry.hash) { return { valid: false, brokenAt: entry.id }; } - + expectedHash = entry.hash; } - + if (this.currentHash !== expectedHash) { return { valid: false, brokenAt: this.entries[this.entries.length - 1]?.id }; } - + return { valid: true }; } @@ -213,6 +316,16 @@ export class AuditService { if (entry) { entry.suspicious = true; entry.flags = reasons; + // Flagging is itself a critical event, so it is alerted on. + if (this.alertingEnabled) { + await this.alerter + .dispatch({ + ...this.toAuditEvent(entry), + action: 'audit.suspicious.flag', + outcome: 'failure', + }) + .catch(() => undefined); + } } return entry; } @@ -222,26 +335,30 @@ export class AuditService { 'ID', 'Timestamp', 'User ID', 'Action', 'Resource', 'Resource ID', 'Outcome', 'IP Address', 'Request Method', 'Request Path', 'Response Status', 'Previous Hash', 'Hash', 'Suspicious', 'Flags' - ].join(','); - - const rows = this.entries.map((entry) => [ - entry.id, - new Date(entry.timestamp).toISOString(), - entry.userId || '', - entry.action, - entry.resource, - entry.resourceId || '', - entry.outcome || '', - entry.ipAddress || '', - entry.requestMethod || '', - entry.requestPath || '', - entry.responseStatus || '', - entry.previousHash, - entry.hash, - entry.suspicious ? 'YES' : 'NO', - (entry.flags || []).join(';'), - ].map((v) => `"${String(v).replace(/"/g, '""')}"`).join(',')); - + ].map(escapeCsvCell).join(','); + + const rows = this.entries.map((entry) => + [ + entry.id, + new Date(entry.timestamp).toISOString(), + entry.userId || '', + entry.action, + entry.resource, + entry.resourceId || '', + entry.outcome || '', + entry.ipAddress || '', + entry.requestMethod || '', + entry.requestPath || '', + entry.responseStatus ?? '', + entry.previousHash, + entry.hash, + entry.suspicious ? 'YES' : 'NO', + (entry.flags || []).join(';'), + ] + .map(escapeCsvCell) + .join(',') + ); + return [headers, ...rows].join('\n'); } @@ -259,6 +376,10 @@ export class AuditService { this.retentionPolicy = { ...this.retentionPolicy, ...policy }; } + getRetentionPolicy(): RetentionPolicy { + return { ...this.retentionPolicy }; + } + async getRetentionStats(): Promise<{ totalEntries: number; byResource: Record; @@ -267,15 +388,15 @@ export class AuditService { }> { const byResource: Record = {}; let suspiciousCount = 0; - + for (const entry of this.entries) { byResource[entry.resource] = (byResource[entry.resource] || 0) + 1; if (entry.suspicious) suspiciousCount++; } - + const timestamps = this.entries.map((e) => e.timestamp); timestamps.sort((a, b) => a - b); - + return { totalEntries: this.entries.length, byResource, @@ -294,11 +415,89 @@ export class AuditService { async clearOldEntries(): Promise { const cutoff = Date.now() - (this.retentionPolicy.deleteAfterDays * 24 * 60 * 60 * 1000); const toDelete = this.entries.filter((e) => e.timestamp < cutoff); - + this.entries = this.entries.filter((e) => e.timestamp >= cutoff); - + this.advanceChainAnchor(toDelete); + return toDelete.length; } + + // ── Issue #396 additions ──────────────────────────────────────────────── + + /** Alerts raised for critical events, newest first. */ + getAlerts(limit = 50): AuditAlert[] { + return this.alerter.listRecentAlerts(limit); + } + + /** Retained alert totals grouped by severity. */ + getAlertCounts(): Record { + return this.alerter.alertCounts(); + } + + /** SOC2 / PCI-DSS control-by-control report over the retained audit entries. */ + getComplianceReport(options: ComplianceReportOptions = {}): ComplianceReport { + return generateComplianceReport(this.entries.map((entry) => this.toAuditEvent(entry)), options); + } + + /** The compliance report rendered as CSV for auditor hand-off. */ + getComplianceReportCsv(options: ComplianceReportOptions = {}): string { + return complianceReportToCsv(this.getComplianceReport(options)); + } + + private asRetentionPolicy(): AuditRetentionPolicy { + return { + retentionDays: this.retentionPolicy.retentionDays, + archiveAfterDays: this.retentionPolicy.archiveAfterDays, + deleteAfterDays: this.retentionPolicy.deleteAfterDays, + archiveDir: this.archiveDir, + }; + } + + /** How many entries sit in each retention tier right now. */ + getRetentionTiers(now = Date.now()): { hot: number; archive: number; purge: number } { + const plan = planRetention(this.entries as unknown as ChainedAuditEntry[], now, this.asRetentionPolicy()); + return { hot: plan.hot.length, archive: plan.archive.length, purge: plan.purge.length }; + } + + /** + * Apply the archival policy: write entries past `archiveAfterDays` to + * append-only cold storage, drop entries past `deleteAfterDays`, and keep the + * rest hot. Surviving entries are re-anchored so integrity verification still + * passes (issue #396). + */ + async archiveOldEntries(now = Date.now()): Promise { + const policy = this.asRetentionPolicy(); + const store = new AuditArchiveStore(policy.archiveDir); + const { result, remaining } = await enforceRetention({ + entries: this.entries as unknown as ChainedAuditEntry[], + now, + policy, + store, + }); + + const keptIds = new Set(remaining.map((entry) => entry.id)); + const evicted = this.entries.filter((entry) => !keptIds.has(entry.id)); + this.entries = this.entries.filter((entry) => keptIds.has(entry.id)); + this.advanceChainAnchor(evicted); + + return { ...result, manifests: await store.listArchives() }; + } + + /** Archives currently held in cold storage. */ + async listArchives(): Promise { + return new AuditArchiveStore(this.archiveDir).listArchives(); + } + + /** + * Move the verify-anchor past evicted entries so the retained chain stays + * verifiable. The oldest retained entry's `previousHash` is by definition the + * resume point; when nothing is retained the anchor becomes the current head + * so an emptied chain still verifies. + */ + private advanceChainAnchor(evicted: AuditEntry[]): void { + if (evicted.length === 0) return; + this.chainAnchor = this.entries.length > 0 ? this.entries[0]!.previousHash : this.currentHash; + } } -export const auditService = new AuditService(); \ No newline at end of file +export const auditService = new AuditService();