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();
+}