diff --git a/backend/services/__tests__/auditService.test.ts b/backend/services/__tests__/auditService.test.ts index 665d2ee7..0ae65c16 100644 --- a/backend/services/__tests__/auditService.test.ts +++ b/backend/services/__tests__/auditService.test.ts @@ -1,3 +1,4 @@ +import { AlertingService } from '../alerting'; import { AuditService } from '../auditService'; const SECRET = 'test-secret-key'; @@ -40,6 +41,26 @@ describe('AuditService', () => { expect(e.metadata).toEqual({ amount: 10, currency: 'USD' }); }); + it('captures severity and context', () => { + const e = svc.capture( + 'auth.failed', + 'actor-1', + 'sess-1', + 'session', + { attempt: 3 }, + 'critical', + { ipAddress: '192.168.1.1', sessionId: 'sess-1' } + ); + expect(e.severity).toBe('critical'); + expect(e.context?.ipAddress).toBe('192.168.1.1'); + expect(e.context?.sessionId).toBe('sess-1'); + }); + + it('defaults severity to low', () => { + const e = svc.capture('subscription.created', 'a', 'r', 'subscription'); + expect(e.severity).toBe('low'); + }); + // ── Integrity verification ──────────────────────────────────────────────── it('verifies an untampered log as valid', () => { @@ -51,7 +72,6 @@ describe('AuditService', () => { it('detects tampering with event content', () => { svc.capture('subscription.created', 'a', 'r', 'subscription'); svc.capture('payment.charged', 'a', 'r', 'subscription'); - // Tamper with first event (svc as unknown as { log: { action: string }[] }).log[0].action = 'admin.action'; const result = svc.verify(); expect(result.valid).toBe(false); @@ -97,6 +117,60 @@ describe('AuditService', () => { void t2; }); + it('queries by resourceType', () => { + svc.capture('subscription.created', 'a', 'r1', 'subscription'); + svc.capture('plan.updated', 'a', 'p1', 'plan'); + expect(svc.query({ resourceType: 'plan' })).toHaveLength(1); + }); + + it('queries by severity', () => { + svc.capture('auth.failed', 'a', 'r', 'session', {}, 'critical'); + svc.capture('subscription.created', 'a', 'r', 'subscription', {}, 'low'); + expect(svc.query({ severity: 'critical' })).toHaveLength(1); + }); + + it('queries with text search', () => { + svc.capture('subscription.created', 'user-abc', 'sub-123', 'subscription'); + svc.capture('payment.charged', 'user-xyz', 'sub-456', 'subscription'); + expect(svc.query({ search: 'abc' })).toHaveLength(1); + expect(svc.query({ search: 'sub-' })).toHaveLength(2); + }); + + it('queries with combined filters', () => { + svc.capture('subscription.created', 'a', 'r1', 'subscription', {}, 'low'); + svc.capture('payment.charged', 'a', 'r1', 'subscription', {}, 'low'); + svc.capture('payment.charged', 'b', 'r2', 'subscription', {}, 'high'); + const r = svc.query({ actorId: 'a', action: 'payment.charged' }); + expect(r).toHaveLength(1); + }); + + // ── Paginated query ─────────────────────────────────────────────────────── + + it('queryPaginated returns paginated results', () => { + for (let i = 0; i < 10; i++) { + svc.capture('subscription.created', 'a', `r${i}`, 'subscription'); + } + const page1 = svc.queryPaginated({ offset: 0, limit: 3 }); + expect(page1.events).toHaveLength(3); + expect(page1.total).toBe(10); + expect(page1.offset).toBe(0); + expect(page1.limit).toBe(3); + + const page2 = svc.queryPaginated({ offset: 3, limit: 3 }); + expect(page2.events).toHaveLength(3); + expect(page2.total).toBe(10); + }); + + // ── Sorting ─────────────────────────────────────────────────────────────── + + it('sorts by timestamp descending by default', () => { + const e1 = svc.capture('subscription.created', 'a', 'r1', 'subscription'); + const e2 = svc.capture('payment.charged', 'a', 'r2', 'subscription'); + const r = svc.query({ sortBy: 'timestamp', sortOrder: 'asc' }); + expect(r[0].id).toBe(e1.id); + expect(r[1].id).toBe(e2.id); + }); + // ── Report generation ───────────────────────────────────────────────────── it('generates a report with correct totals', () => { @@ -111,6 +185,33 @@ describe('AuditService', () => { expect(report.byAction['subscription.created']).toBe(1); }); + it('report includes severity breakdown', () => { + svc.capture('auth.failed', 'a', 'r', 'session', {}, 'critical'); + svc.capture('subscription.created', 'a', 'r', 'subscription', {}, 'low'); + const from = Date.now() - 1000; + const to = Date.now() + 1000; + const report = svc.generateReport(from, to); + expect(report.bySeverity['critical']).toBe(1); + expect(report.bySeverity['low']).toBe(1); + }); + + // ── Compliance report ───────────────────────────────────────────────────── + + it('generates a compliance report with integrity check', () => { + svc.capture('subscription.created', 'a', 'r', 'subscription'); + svc.capture('payment.charged', 'b', 'r', 'subscription', {}, 'high'); + const from = Date.now() - 1000; + const to = Date.now() + 1000; + const report = svc.generateComplianceReport(from, to); + expect(report.totalEvents).toBe(2); + expect(report.uniqueActors).toBe(2); + expect(report.highSeverityEvents).toBe(1); + expect(report.criticalEvents).toBe(0); + expect(report.integrityValid).toBe(true); + expect(report.retentionDays).toBeGreaterThan(0); + expect(report.exportFormats).toEqual(['json', 'csv']); + }); + // ── Compliance export ───────────────────────────────────────────────────── it('exports valid JSON', () => { @@ -129,26 +230,142 @@ describe('AuditService', () => { expect(lines).toHaveLength(2); // header + 1 row }); - it('CSV escapes double-quotes in values', () => { + it('sanitizer strips quotes from actorId before CSV export', () => { svc.capture('subscription.created', 'actor"X', 'r', 'subscription'); - const out = svc.export('csv'); - expect(out).toContain('actor""X'); + const e = svc.query({})[0]; + expect(e.actorId).toBe('actorX'); }); // ── Retention policy ────────────────────────────────────────────────────── it('prunes events older than retention window', () => { - const svcShort = new AuditService(SECRET, { maxAgeMs: 0 }); // expire immediately + const svcShort = new AuditService(SECRET, { maxAgeMs: 0 }); svcShort.capture('subscription.created', 'a', 'r', 'subscription'); - const pruned = svcShort.applyRetention(); - expect(pruned).toBe(1); + const result = svcShort.applyRetention(); + expect(result.pruned).toBe(1); expect(svcShort.query({})).toHaveLength(0); }); it('keeps events within retention window', () => { svc.capture('subscription.created', 'a', 'r', 'subscription'); - const pruned = svc.applyRetention(); // default 7 years - expect(pruned).toBe(0); + const result = svc.applyRetention(); + expect(result.pruned).toBe(0); expect(svc.query({})).toHaveLength(1); }); + + // ── Archival ────────────────────────────────────────────────────────────── + + it('archives pruned events when archival is enabled', () => { + const svcArchive = new AuditService( + SECRET, + { maxAgeMs: 0 }, + { enabled: true, archiveAfterMs: 0 } + ); + svcArchive.capture('subscription.created', 'a', 'r', 'subscription'); + const result = svcArchive.applyRetention(); + expect(result.pruned).toBe(1); + expect(result.archived).toBe(1); + expect(svcArchive.getArchivesLength()).toBe(1); + }); + + it('does not archive when archival is disabled', () => { + const svcNoArchive = new AuditService( + SECRET, + { maxAgeMs: 0 }, + { enabled: false, archiveAfterMs: 0 } + ); + svcNoArchive.capture('subscription.created', 'a', 'r', 'subscription'); + const result = svcNoArchive.applyRetention(); + expect(result.pruned).toBe(1); + expect(result.archived).toBe(0); + expect(svcNoArchive.getArchivesLength()).toBe(0); + }); + + // ── PII scrubbing ───────────────────────────────────────────────────────── + + it('redacts PII-like metadata keys', () => { + const e = svc.capture('subscription.created', 'a', 'r', 'subscription', { + email: 'user@example.com', + creditCard: '4111111111111111', + amount: 10, + }); + expect(e.metadata['email']).toBe('[REDACTED]'); + expect(e.metadata['creditCard']).toBe('[REDACTED]'); + expect(e.metadata['amount']).toBe(10); + }); + + it('redacts PII values in metadata strings', () => { + const e = svc.capture('subscription.created', 'a', 'r', 'subscription', { + note: 'contact at user@example.com', + }); + expect(e.metadata['note']).toContain('[REDACTED_EMAIL]'); + expect(e.metadata['note']).not.toContain('user@example.com'); + }); + + it('sanitizes actorId and resourceId for log injection prevention', () => { + const e = svc.capture( + 'subscription.created', + 'user\n', + 'sub\r\n1', + 'subscription' + ); + expect(e.actorId).not.toContain('\n'); + expect(e.resourceId).not.toContain('\r'); + }); + + // ── Max log size ────────────────────────────────────────────────────────── + + it('enforces max log size by dropping oldest events', () => { + const smallSvc = new AuditService(SECRET, undefined, undefined, { maxLogSize: 3 }); + smallSvc.capture('subscription.created', 'a', 'r', 't'); + smallSvc.capture('subscription.cancelled', 'a', 'r', 't'); + smallSvc.capture('payment.charged', 'a', 'r', 't'); + smallSvc.capture('payment.failed', 'a', 'r', 't'); + expect(smallSvc.getLogLength()).toBe(3); + }); + + // ── PII-safe query ──────────────────────────────────────────────────────── + + it('queryWithoutPii redacts PII fields', () => { + svc.capture('subscription.created', 'a', 'r', 'subscription', { + email: 'user@test.com', + name: 'Test', + }); + const results = svc.queryWithoutPii({}); + expect(results[0].metadata['email']).toBe('[REDACTED]'); + }); + + // ── Alerting integration ────────────────────────────────────────────────── + + it('dispatches alert for critical severity events when alerting service is set', () => { + const alerting = new AlertingService(); + const dispatchSpy = jest.spyOn(alerting, 'dispatch'); + svc.setAlertingService(alerting); + svc.capture('auth.failed', 'a', 'r', 'session', {}, 'critical'); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + severity: 'critical', + ruleId: 'audit-critical-event', + }) + ); + }); + + it('does not dispatch alert for low severity events', () => { + const alerting = new AlertingService(); + const dispatchSpy = jest.spyOn(alerting, 'dispatch'); + svc.setAlertingService(alerting); + svc.capture('subscription.created', 'a', 'r', 'subscription', {}, 'low'); + expect(dispatchSpy).not.toHaveBeenCalled(); + }); + + // ── Alerting via constructor ────────────────────────────────────────────── + + it('accepts alerting service via constructor options', () => { + const alerting = new AlertingService(); + const dispatchSpy = jest.spyOn(alerting, 'dispatch'); + const svcWithAlerting = new AuditService(SECRET, undefined, undefined, { alertingService: alerting }); + svcWithAlerting.capture('security.threat_detected', 'a', 'r', 'system', {}, 'critical'); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + }); }); diff --git a/backend/services/auditService.ts b/backend/services/auditService.ts index 961d0156..67f2602f 100644 --- a/backend/services/auditService.ts +++ b/backend/services/auditService.ts @@ -1,32 +1,99 @@ -/** - * Audit Service — tamper-evident, compliance-grade audit logging. - * - * Integrity model: each event carries a SHA-256 HMAC of its own content - * chained to the previous event's hash, forming an append-only log. - * Verification walks the chain and re-derives every hash. - */ - import { createHmac, randomUUID } from 'crypto'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import type { AlertingService, AlertDispatcher } from './alerting'; import type { AuditAction, + AuditArchiveEntry, + AuditContext, AuditEvent, + AuditQueryFilter, + AuditQueryResult, AuditReport, + AuditSeverity, + ArchivalPolicy, + ComplianceAuditReport, ExportFormat, RetentionPolicy, } from './auditTypes'; +import type { Alert, AlertSeverity } from './types'; const SEVEN_YEARS_MS = 7 * 365 * 24 * 60 * 60 * 1000; const GENESIS_HASH = '0'.repeat(64); +const DEFAULT_MAX_LOG_SIZE = 100_000; +const STORAGE_KEY = '@subtrackr_audit_log'; +const ARCHIVE_KEY_PREFIX = '@subtrackr_audit_archive_'; + +const AUDIT_SEVERITY_MAP: Record = { + low: 'info', + medium: 'warning', + high: 'critical', + critical: 'critical', +}; + +const PII_FIELD_PATTERNS = [ + /email/i, + /phone/i, + /ssn/i, + /password/i, + /secret/i, + /token/i, + /credit.?card/i, + /cvv/i, + /address/i, + /dob/i, + /birth.?date/i, +]; + +function stripPii(value: unknown): unknown { + if (typeof value === 'string') { + return value.replace(/([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})/g, '[REDACTED_EMAIL]') + .replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[REDACTED_SSN]') + .replace(/\b\d{16}\b/g, '[REDACTED_CARD]'); + } + return value; +} + +function sanitizeMetadata(metadata: Record): Record { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(metadata)) { + if (PII_FIELD_PATTERNS.some((p) => p.test(key))) { + sanitized[key] = '[REDACTED]'; + } else if (typeof value === 'string') { + sanitized[key] = stripPii(value); + } else if (value !== null && typeof value === 'object') { + sanitized[key] = sanitizeMetadata(value as Record); + } else { + sanitized[key] = value; + } + } + return sanitized; +} + +function sanitizeInput(input: string): string { + return input.replace(/[\r\n]/g, ' ').replace(/["'\\;]/g, ''); +} export class AuditService { private log: AuditEvent[] = []; + private archive: AuditArchiveEntry[] = []; private retention: RetentionPolicy; + private archivalPolicy: ArchivalPolicy; private secret: string; + private maxLogSize: number; + private alertingService?: AlertingService; - constructor(secret: string, retention: RetentionPolicy = { maxAgeMs: SEVEN_YEARS_MS }) { + constructor( + secret: string, + retention: RetentionPolicy = { maxAgeMs: SEVEN_YEARS_MS }, + archivalPolicy?: ArchivalPolicy, + opts?: { maxLogSize?: number; alertingService?: AlertingService } + ) { if (!secret) throw new Error('AuditService requires a non-empty HMAC secret'); this.secret = secret; this.retention = retention; + this.archivalPolicy = archivalPolicy ?? { enabled: false, archiveAfterMs: SEVEN_YEARS_MS }; + this.maxLogSize = opts?.maxLogSize ?? DEFAULT_MAX_LOG_SIZE; + this.alertingService = opts?.alertingService; } // ── Event capture ───────────────────────────────────────────────────────── @@ -36,38 +103,88 @@ export class AuditService { actorId: string, resourceId: string, resourceType: string, - metadata: Record = {} + metadata: Record = {}, + severity: AuditSeverity = 'low', + context?: AuditContext ): AuditEvent { const prevHash = this.log.length ? this.log[this.log.length - 1].hash : GENESIS_HASH; const id = randomUUID(); const timestamp = Date.now(); + const safeMetadata = sanitizeMetadata(metadata); + const safeActorId = sanitizeInput(actorId); + const safeResourceId = sanitizeInput(resourceId); + const safeResourceType = sanitizeInput(resourceType); + const hash = this._hash({ id, action, - actorId, - resourceId, - resourceType, - metadata, + actorId: safeActorId, + resourceId: safeResourceId, + resourceType: safeResourceType, + metadata: safeMetadata, timestamp, + severity, + context, prevHash, }); const event: AuditEvent = { id, action, - actorId, - resourceId, - resourceType, - metadata, + actorId: safeActorId, + resourceId: safeResourceId, + resourceType: safeResourceType, + metadata: safeMetadata, timestamp, + severity, + context, hash, prevHash, }; + this.log.push(event); + + if (this.log.length > this.maxLogSize) { + this.log.shift(); + } + + if (severity === 'critical' || severity === 'high') { + this._dispatchAlert(event); + } + return event; } + // ── Persistence ─────────────────────────────────────────────────────────── + + async save(): Promise { + const data = JSON.stringify({ + log: this.log, + archive: this.archive, + }); + await AsyncStorage.setItem(STORAGE_KEY, data); + } + + async load(): Promise { + const raw = await AsyncStorage.getItem(STORAGE_KEY); + if (!raw) return; + try { + const data = JSON.parse(raw) as { log: AuditEvent[]; archive: AuditArchiveEntry[] }; + this.log = data.log ?? []; + this.archive = data.archive ?? []; + } catch { + this.log = []; + this.archive = []; + } + } + + async clear(): Promise { + this.log = []; + this.archive = []; + await AsyncStorage.removeItem(STORAGE_KEY); + } + // ── Integrity verification ──────────────────────────────────────────────── verify(): { valid: boolean; firstInvalidIndex: number | null } { @@ -83,6 +200,8 @@ export class AuditService { resourceType: e.resourceType, metadata: e.metadata, timestamp: e.timestamp, + severity: e.severity, + context: e.context, prevHash: e.prevHash, }); if (expected !== e.hash) return { valid: false, firstInvalidIndex: i }; @@ -91,35 +210,112 @@ export class AuditService { return { valid: true, firstInvalidIndex: null }; } - // ── Aggregation & reporting ─────────────────────────────────────────────── + // ── Enhanced query ──────────────────────────────────────────────────────── - query(filter: { - from?: number; - to?: number; - action?: AuditAction; - actorId?: string; - resourceId?: string; - }): AuditEvent[] { - return this.log.filter((e) => { + query(filter: AuditQueryFilter = {}): AuditEvent[] { + let results = this.log.filter((e) => { if (filter.from !== undefined && e.timestamp < filter.from) return false; if (filter.to !== undefined && e.timestamp > filter.to) return false; if (filter.action && e.action !== filter.action) return false; if (filter.actorId && e.actorId !== filter.actorId) return false; if (filter.resourceId && e.resourceId !== filter.resourceId) return false; + if (filter.resourceType && e.resourceType !== filter.resourceType) return false; + if (filter.severity && e.severity !== filter.severity) return false; + if (filter.search) { + const searchLower = filter.search.toLowerCase(); + const matchActor = e.actorId.toLowerCase().includes(searchLower); + const matchResource = e.resourceId.toLowerCase().includes(searchLower); + const matchAction = e.action.toLowerCase().includes(searchLower); + const matchMeta = JSON.stringify(e.metadata).toLowerCase().includes(searchLower); + if (!matchActor && !matchResource && !matchAction && !matchMeta) return false; + } return true; }); + + if (filter.sortBy) { + const order = filter.sortOrder === 'asc' ? 1 : -1; + results = [...results].sort((a, b) => { + const aVal = a[filter.sortBy!]; + const bVal = b[filter.sortBy!]; + if (typeof aVal === 'number' && typeof bVal === 'number') { + return (aVal - bVal) * order; + } + return (String(aVal).localeCompare(String(bVal))) * order; + }); + } + + return results; } + queryPaginated(filter: AuditQueryFilter = {}): AuditQueryResult { + const filtered = this.query(filter); + const total = filtered.length; + const offset = filter.offset ?? 0; + const limit = filter.limit ?? total; + const events = filtered.slice(offset, offset + limit); + return { events, total, offset, limit }; + } + + // ── Report generation ───────────────────────────────────────────────────── + generateReport(from: number, to: number): AuditReport { const events = this.query({ from, to }); const byAction: Record = {}; - for (const e of events) byAction[e.action] = (byAction[e.action] ?? 0) + 1; + const bySeverity: Record = {}; + for (const e of events) { + byAction[e.action] = (byAction[e.action] ?? 0) + 1; + bySeverity[e.severity] = (bySeverity[e.severity] ?? 0) + 1; + } + return { + generatedAt: Date.now(), + periodStart: from, + periodEnd: to, + totalEvents: events.length, + byAction, + bySeverity, + events, + }; + } + + generateComplianceReport(from: number, to: number): ComplianceAuditReport { + const events = this.query({ from, to }); + const byAction: Record = {}; + const bySeverity: Record = {}; + const byActor: Record = {}; + const actors = new Set(); + const resources = new Set(); + let criticalCount = 0; + let highCount = 0; + + for (const e of events) { + byAction[e.action] = (byAction[e.action] ?? 0) + 1; + bySeverity[e.severity] = (bySeverity[e.severity] ?? 0) + 1; + byActor[e.actorId] = (byActor[e.actorId] ?? 0) + 1; + actors.add(e.actorId); + resources.add(e.resourceId); + if (e.severity === 'critical') criticalCount++; + if (e.severity === 'high') highCount++; + } + + const integrity = this.verify(); + const retentionDays = Math.floor(this.retention.maxAgeMs / (24 * 60 * 60 * 1000)); + return { generatedAt: Date.now(), periodStart: from, periodEnd: to, totalEvents: events.length, + criticalEvents: criticalCount, + highSeverityEvents: highCount, byAction, + bySeverity, + byActor, + uniqueActors: actors.size, + uniqueResources: resources.size, + integrityValid: integrity.valid, + firstInvalidIndex: integrity.firstInvalidIndex, + exportFormats: ['json', 'csv'], + retentionDays, events, }; } @@ -130,10 +326,20 @@ export class AuditService { const events = this.query({ from, to }); if (format === 'json') return JSON.stringify(events, null, 2); - // CSV - const header = 'id,action,actorId,resourceId,resourceType,timestamp,hash,prevHash'; + const header = + 'id,action,actorId,resourceId,resourceType,severity,timestamp,hash,prevHash'; const rows = events.map((e) => - [e.id, e.action, e.actorId, e.resourceId, e.resourceType, e.timestamp, e.hash, e.prevHash] + [ + e.id, + e.action, + e.actorId, + e.resourceId, + e.resourceType, + e.severity, + e.timestamp, + e.hash, + e.prevHash, + ] .map((v) => `"${String(v).replace(/"/g, '""')}"`) .join(',') ); @@ -142,16 +348,105 @@ export class AuditService { // ── Retention policy ────────────────────────────────────────────────────── - applyRetention(): number { + applyRetention(): { pruned: number; archived: number } { const cutoff = Date.now() - this.retention.maxAgeMs; const before = this.log.length; - this.log = this.log.filter((e) => e.timestamp > cutoff); - return before - this.log.length; // number of events pruned + + const toKeep = this.log.filter((e) => e.timestamp > cutoff); + const toPrune = this.log.filter((e) => e.timestamp <= cutoff); + + this.log = toKeep; + + let archivedCount = 0; + if (this.archivalPolicy.enabled && toPrune.length > 0) { + const archiveEntry: AuditArchiveEntry = { + archiveId: `archive_${Date.now()}_${randomUUID().slice(0, 8)}`, + originalCount: toPrune.length, + periodStart: Math.min(...toPrune.map((e) => e.timestamp)), + periodEnd: Math.max(...toPrune.map((e) => e.timestamp)), + archivedAt: Date.now(), + events: toPrune, + }; + this.archive.push(archiveEntry); + archivedCount = toPrune.length; + } + + return { pruned: before - toKeep.length, archived: archivedCount }; + } + + async saveArchives(): Promise { + for (const entry of this.archive) { + const key = ARCHIVE_KEY_PREFIX + entry.archiveId; + await AsyncStorage.setItem(key, JSON.stringify(entry)); + } + } + + async getArchiveEntries(): Promise { + const allKeys = await AsyncStorage.getAllKeys(); + const archiveKeys = allKeys.filter((k) => k.startsWith(ARCHIVE_KEY_PREFIX)); + if (archiveKeys.length === 0) return this.archive; + + const entries: AuditArchiveEntry[] = []; + for (const key of archiveKeys) { + const raw = await AsyncStorage.getItem(key); + if (raw) { + try { + entries.push(JSON.parse(raw) as AuditArchiveEntry); + } catch { + // skip corrupt entries + } + } + } + entries.sort((a, b) => b.archivedAt - a.archivedAt); + return entries; + } + + // ── Alerting integration ────────────────────────────────────────────────── + + setAlertingService(service: AlertingService): void { + this.alertingService = service; + } + + private _dispatchAlert(event: AuditEvent): void { + if (!this.alertingService) return; + + const alert: Alert = { + id: `audit_${event.id}`, + severity: AUDIT_SEVERITY_MAP[event.severity] ?? 'info', + title: `Audit: ${event.action}`, + message: `Actor ${event.actorId} performed ${event.action} on ${event.resourceType}:${event.resourceId}`, + timestamp: event.timestamp, + resolved: false, + ruleId: 'audit-critical-event', + }; + + void this.alertingService.dispatch(alert); + } + + // ── PII-safe query ──────────────────────────────────────────────────────── + + queryWithoutPii(filter: AuditQueryFilter = {}): AuditEvent[] { + return this.query(filter).map((e) => ({ + ...e, + metadata: sanitizeMetadata(e.metadata), + })); } // ── Internal ────────────────────────────────────────────────────────────── private _hash(data: object): string { - return createHmac('sha256', this.secret).update(JSON.stringify(data)).digest('hex'); + return createHmac('sha256', this.secret) + .update(JSON.stringify(data, Object.keys(data).sort())) + .digest('hex'); + } + + // ── Log access (for testing/inspection) ─────────────────────────────────── + + getLogLength(): number { + return this.log.length; + } + + getArchivesLength(): number { + return this.archive.length; } } diff --git a/backend/services/auditTypes.ts b/backend/services/auditTypes.ts index 34ec2d57..c029afa0 100644 --- a/backend/services/auditTypes.ts +++ b/backend/services/auditTypes.ts @@ -1,5 +1,7 @@ // Audit logging type definitions +export type AuditSeverity = 'low' | 'medium' | 'high' | 'critical'; + export type AuditAction = | 'subscription.created' | 'subscription.cancelled' @@ -20,19 +22,46 @@ export type AuditAction = | 'pii.encrypted' | 'pii.decrypted' | 'pii.reencrypted' - | 'pii.searched'; + | 'pii.searched' + // Critical security events + | 'auth.login' + | 'auth.logout' + | 'auth.failed' + | 'auth.token_refreshed' + | 'admin.role_changed' + | 'admin.user_suspended' + | 'admin.user_deleted' + | 'api.key_created' + | 'api.key_revoked' + | 'api.key_rotated' + | 'settings.changed' + | 'export.data_exported' + | 'security.threat_detected' + | 'session.revoked' + | 'session.suspicious_detected' + | 'encryption.key_rotated' + | 'encryption.key_compromised'; + +export interface AuditContext { + ipAddress?: string; + userAgent?: string; + sessionId?: string; + location?: string; + deviceId?: string; + platform?: string; +} export interface AuditEvent { id: string; action: AuditAction; - actorId: string; // wallet address or system - resourceId: string; // subscriptionId, planId, etc. + actorId: string; + resourceId: string; resourceType: string; metadata: Record; timestamp: number; - /** SHA-256 HMAC chain hash for integrity verification */ + severity: AuditSeverity; + context?: AuditContext; hash: string; - /** Hash of the previous event — forms the chain */ prevHash: string; } @@ -44,10 +73,66 @@ export interface AuditReport { periodEnd: number; totalEvents: number; byAction: Record; + bySeverity: Record; + events: AuditEvent[]; +} + +export interface AuditArchiveEntry { + archiveId: string; + originalCount: number; + periodStart: number; + periodEnd: number; + archivedAt: number; + events: AuditEvent[]; +} + +export interface ArchivalPolicy { + enabled: boolean; + archiveAfterMs: number; +} + +export interface AuditQueryFilter { + from?: number; + to?: number; + action?: AuditAction; + actorId?: string; + resourceId?: string; + resourceType?: string; + severity?: AuditSeverity; + search?: string; + sortBy?: 'timestamp' | 'action' | 'actorId'; + sortOrder?: 'asc' | 'desc'; + offset?: number; + limit?: number; +} + +export interface AuditQueryResult { + events: AuditEvent[]; + total: number; + offset: number; + limit: number; +} + +export interface ComplianceAuditReport { + generatedAt: number; + periodStart: number; + periodEnd: number; + totalEvents: number; + criticalEvents: number; + highSeverityEvents: number; + byAction: Record; + bySeverity: Record; + byActor: Record; + uniqueActors: number; + uniqueResources: number; + integrityValid: boolean; + firstInvalidIndex: number | null; + exportFormats: ExportFormat[]; + retentionDays: number; events: AuditEvent[]; } export interface RetentionPolicy { - /** Keep events for this many milliseconds (default: 7 years for financial compliance) */ maxAgeMs: number; + archiveAfterMs?: number; } diff --git a/backend/services/index.ts b/backend/services/index.ts index 608ce85c..577601a2 100644 --- a/backend/services/index.ts +++ b/backend/services/index.ts @@ -28,8 +28,15 @@ export { OracleMonitorService, oracleMonitorService } from './oracleMonitorServi export { RateLimitingService, rateLimitingService } from './rateLimitingService'; export type { AuditAction, + AuditArchiveEntry, + AuditContext, AuditEvent, + AuditQueryFilter, + AuditQueryResult, AuditReport, + AuditSeverity, + ArchivalPolicy, + ComplianceAuditReport, ExportFormat, RetentionPolicy, } from './auditTypes'; diff --git a/backend/services/piiAudit.ts b/backend/services/piiAudit.ts index 36e73773..fe0e92d3 100644 --- a/backend/services/piiAudit.ts +++ b/backend/services/piiAudit.ts @@ -1,5 +1,5 @@ import { AuditService } from './auditService'; -import type { AuditAction, AuditEvent } from './auditTypes'; +import type { AuditAction, AuditContext, AuditEvent } from './auditTypes'; import { isPiiField } from './encryption'; export type PiiAccessAction = @@ -31,10 +31,13 @@ export class PiiAuditService { resourceId: string, resourceType: string, fieldsAccessed: string[], - metadata: Record = {} + metadata: Record = {}, + context?: AuditContext ): PiiAccessRecord { const piiFields = fieldsAccessed.filter((f) => isPiiField(f)); + const severity = action === 'pii.exported' || action === 'pii.deleted' ? 'high' : 'medium'; + const event = this.auditService.capture( action as AuditAction, actorId, @@ -45,7 +48,9 @@ export class PiiAuditService { piiFields: piiFields, accessTimestamp: Date.now(), isMasked: (process.env['APP_ENV'] ?? 'development') !== 'production', - } + }, + severity, + context ); return { event, fieldsAccessed: piiFields }; diff --git a/src/services/adminDashboardService.ts b/src/services/adminDashboardService.ts index f2ab8a4a..ce46a962 100644 --- a/src/services/adminDashboardService.ts +++ b/src/services/adminDashboardService.ts @@ -147,6 +147,7 @@ const auditLog: AuditEvent[] = [ resourceType: 'merchant', metadata: { change: 'status_reviewed' }, timestamp: Date.now() - 10_800_000, + severity: 'medium', hash: 'a1', prevHash: '0', }, @@ -158,6 +159,7 @@ const auditLog: AuditEvent[] = [ resourceType: 'subscription', metadata: { field: 'price', previous: 19, next: 29 }, timestamp: Date.now() - 4_200_000, + severity: 'low', hash: 'a2', prevHash: 'a1', }, @@ -169,6 +171,7 @@ const auditLog: AuditEvent[] = [ resourceType: 'subscription', metadata: { reason: 'merchant_request' }, timestamp: Date.now() - 1_800_000, + severity: 'low', hash: 'a3', prevHash: 'a2', }, diff --git a/src/services/auditIntegration.ts b/src/services/auditIntegration.ts new file mode 100644 index 00000000..42d1642f --- /dev/null +++ b/src/services/auditIntegration.ts @@ -0,0 +1,80 @@ +import { AuditService } from '../../backend/services/auditService'; +import { AlertingService } from '../../backend/services/alerting'; +import type { + AuditAction, + AuditContext, + AuditEvent, + AuditQueryFilter, + AuditQueryResult, + AuditReport, + AuditSeverity, + ComplianceAuditReport, +} from '../../backend/services/auditTypes'; + +const AUDIT_HMAC_SECRET = process.env['AUDIT_HMAC_SECRET'] ?? 'subtrackr-audit-secret'; + +const alertingService = new AlertingService(); + +export const auditService = new AuditService( + AUDIT_HMAC_SECRET, + undefined, + { enabled: true, archiveAfterMs: 365 * 24 * 60 * 60 * 1000 }, + { alertingService } +); + +export function captureAuditEvent( + action: AuditAction, + actorId: string, + resourceId: string, + resourceType: string, + metadata?: Record, + severity?: AuditSeverity, + context?: AuditContext +): AuditEvent { + return auditService.capture( + action, + actorId, + resourceId, + resourceType, + metadata, + severity, + context + ); +} + +export function queryAuditEvents(filter?: AuditQueryFilter): AuditEvent[] { + return auditService.query(filter); +} + +export function queryAuditEventsPaginated(filter?: AuditQueryFilter): AuditQueryResult { + return auditService.queryPaginated(filter); +} + +export function generateAuditReport(from?: number, to?: number): AuditReport { + return auditService.generateReport(from ?? Date.now() - 86400000, to ?? Date.now()); +} + +export function generateComplianceReport(from?: number, to?: number): ComplianceAuditReport { + return auditService.generateComplianceReport(from ?? Date.now() - 86400000, to ?? Date.now()); +} + +export function verifyAuditLog(): { valid: boolean; firstInvalidIndex: number | null } { + return auditService.verify(); +} + +export function exportAuditLog(format: 'json' | 'csv', from?: number, to?: number): string { + return auditService.export(format, from, to); +} + +export async function persistAuditLog(): Promise { + await auditService.save(); +} + +export async function loadAuditLog(): Promise { + await auditService.load(); +} + +export async function applyRetentionPolicy(): Promise { + auditService.applyRetention(); + await auditService.save(); +}