From 6509a7c6b37b4c567dc86f02742ac03e60fd5a6c Mon Sep 17 00:00:00 2001 From: sandrawillow001-afk Date: Tue, 29 Sep 2026 00:24:22 +0000 Subject: [PATCH] feat(audit): tamper-evident logging, alerting, retention and compliance reports (#396) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit evidence was only half-covered: the HTTP audit path had a hash chain and query API, but nothing alerted on critical events in real time, nothing enforced an archival policy, no compliance view existed, and untrusted detail values went into the log verbatim. - event-schema: canonical actor/action/resource/timestamp schema with zod validation, plus sanitisers that strip CR/LF, control characters and ANSI escapes and neutralise CSV formula injection on export. - alerting: declarative critical-event rules with severity thresholds, sliding window de-duplication and pluggable sinks (log + optional webhook). - retention: three-tier policy (hot/archive/purge) writing append-only NDJSON archives with manifests and payload hashes; a chain anchor keeps verifyIntegrity() valid after eviction. - compliance-report: SOC2 TSC and PCI-DSS v4 requirement 10 control mapping with per-control evidence and PCI-DSS 10.3 field completeness. - routes: GET /audit/alerts, /audit/compliance/report (JSON or CSV), /audit/retention/tiers, /audit/archives, POST /audit/retention/archive, and schema validation on POST /audit/log. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- backend/src/audit/__tests__/alerting.test.ts | 199 ++++++++++++ .../audit/__tests__/auditService-396.test.ts | 199 ++++++++++++ .../audit/__tests__/compliance-report.test.ts | 135 ++++++++ .../src/audit/__tests__/event-schema.test.ts | 144 +++++++++ backend/src/audit/__tests__/retention.test.ts | 165 ++++++++++ backend/src/audit/alerting.ts | 304 ++++++++++++++++++ backend/src/audit/compliance-report.ts | 290 +++++++++++++++++ backend/src/audit/event-schema.ts | 229 +++++++++++++ backend/src/audit/retention.ts | 245 ++++++++++++++ backend/src/routes/audit.ts | 83 +++++ backend/src/services/auditService.ts | 303 ++++++++++++++--- 11 files changed, 2244 insertions(+), 52 deletions(-) create mode 100644 backend/src/audit/__tests__/alerting.test.ts create mode 100644 backend/src/audit/__tests__/auditService-396.test.ts create mode 100644 backend/src/audit/__tests__/compliance-report.test.ts create mode 100644 backend/src/audit/__tests__/event-schema.test.ts create mode 100644 backend/src/audit/__tests__/retention.test.ts create mode 100644 backend/src/audit/alerting.ts create mode 100644 backend/src/audit/compliance-report.ts create mode 100644 backend/src/audit/event-schema.ts create mode 100644 backend/src/audit/retention.ts 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();