From d3af6ee39025a343454b68d812a606a248e33835 Mon Sep 17 00:00:00 2001 From: sandrawillow001-afk Date: Mon, 28 Sep 2026 03:23:25 +0000 Subject: [PATCH] feat(backend): audit logging for sensitive operations (#793) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record authentication, payment, admin, identity, and compliance requests in the tamper-evident audit log so every sensitive action captures who did what, from where, and with what result. - Add sensitiveAudit middleware that classifies sensitive paths and logs timestamp, user, action, resource, IP, user agent, and outcome on response finish, treating aborted connections as failures - Add a first-class outcome field to AuditService entries (derived from the response status when not supplied) and include it in hashing and CSV export - Mount the middleware for all API routes and allow outcome in POST /audit/log - Fix audit resource derivation (payments instead of v1) and remove stale compiled auditService.js that shadowed the TypeScript source in tests - Document the behaviour and add unit tests Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- backend/src/index.ts | 4 + .../__tests__/sensitiveAudit.test.ts | 155 +++++++++ backend/src/middleware/audit.ts | 20 +- backend/src/middleware/sensitiveAudit.ts | 148 ++++++++ backend/src/routes/audit.ts | 16 +- .../services/__tests__/auditService.test.ts | 79 +++++ backend/src/services/auditService.js | 316 ------------------ backend/src/services/auditService.ts | 20 +- docs/security/audit-logging.md | 71 ++++ 9 files changed, 501 insertions(+), 328 deletions(-) create mode 100644 backend/src/middleware/__tests__/sensitiveAudit.test.ts create mode 100644 backend/src/middleware/sensitiveAudit.ts create mode 100644 backend/src/services/__tests__/auditService.test.ts delete mode 100644 backend/src/services/auditService.js create mode 100644 docs/security/audit-logging.md diff --git a/backend/src/index.ts b/backend/src/index.ts index fdca8608..f75caf0f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -49,6 +49,7 @@ import { nfcRouter } from './routes/nfc.js'; import { cacheRouter } from './routes/cache.js'; import { ipAllowlistMiddleware, initIpAllowlist } from './middleware/ip-allowlist.js'; import { sessionMiddleware } from './middleware/session.js'; +import { auditSensitiveOperations } from './middleware/sensitiveAudit.js'; import { notificationsRouter } from './routes/notifications.js'; import { auditRouter } from './routes/audit.js'; import { hedgingRouter } from './routes/hedging.js'; @@ -220,6 +221,9 @@ const sandboxRateLimiter = tokenBucketRateLimit({ sandboxMode: env.NODE_ENV === 'sandbox' || env.NODE_ENV === 'development' }); +// Audit logging for sensitive operations — auth, payments, admin, identity (#793) +app.use(auditSensitiveOperations()); + app.use('/api/', versionMiddleware); const apiV1Router = express.Router(); diff --git a/backend/src/middleware/__tests__/sensitiveAudit.test.ts b/backend/src/middleware/__tests__/sensitiveAudit.test.ts new file mode 100644 index 00000000..9eb40fe2 --- /dev/null +++ b/backend/src/middleware/__tests__/sensitiveAudit.test.ts @@ -0,0 +1,155 @@ +/** + * Sensitive-operation audit middleware tests — Issue #793 + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Request, Response } from 'express'; +import { + auditSensitiveOperations, + classifySensitivePath, + isSensitiveOperation, + sensitiveAuditMiddleware, +} from '../sensitiveAudit.js'; +import { auditService } from '../../services/auditService.js'; + +vi.mock('../../services/auditService.js', () => ({ + auditService: { + logAction: vi.fn().mockResolvedValue({ id: 'mock-id' }), + }, +})); + +interface MockResponse { + statusCode: number; + writableFinished?: boolean; + on: (event: string, cb: () => void) => MockResponse; +} + +function makeRes(overrides: Partial = {}) { + const listeners: Record void> = {}; + const res: MockResponse = { + statusCode: 200, + writableFinished: true, + on: vi.fn((event: string, cb: () => void) => { + listeners[event] = cb; + return res; + }), + ...overrides, + }; + return { res: res as unknown as Response, listeners }; +} + +function makeReq(overrides: Partial> = {}) { + return { + method: 'POST', + path: '/api/v1/auth/login', + headers: { 'user-agent': 'vitest', 'x-user-id': 'user-42' }, + body: { password: 'super-secret' }, + params: {}, + query: {}, + ip: '203.0.113.7', + socket: { remoteAddress: '203.0.113.7' }, + ...overrides, + } as unknown as Request; +} + +describe('sensitive path classification', () => { + it('classifies auth, payments, admin, identity, and compliance paths', () => { + expect(classifySensitivePath('/api/v1/auth/login')).toBe('auth'); + expect(classifySensitivePath('/api/v1/payments/123')).toBe('payments'); + expect(classifySensitivePath('/api/v1/admin/users')).toBe('admin'); + expect(classifySensitivePath('/api/v1/kyb/status')).toBe('identity'); + expect(classifySensitivePath('/api/v1/compliance/report')).toBe('compliance'); + }); + + it('ignores non-sensitive paths', () => { + expect(classifySensitivePath('/api/v1/health')).toBeUndefined(); + expect(isSensitiveOperation('/api/v1/catalog/items')).toBe(false); + expect(isSensitiveOperation('/api/v1/payments')).toBe(true); + }); +}); + +describe('sensitiveAuditMiddleware', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('does not log non-sensitive requests', () => { + const { res } = makeRes(); + const next = vi.fn(); + + sensitiveAuditMiddleware()(makeReq({ path: '/api/v1/catalog/items' }), res, next); + + expect(next).toHaveBeenCalledOnce(); + expect(res.on).not.toHaveBeenCalled(); + expect(auditService.logAction).not.toHaveBeenCalled(); + }); + + it('records timestamp, user, action, IP, and a success outcome on finish', () => { + const { res, listeners } = makeRes({ statusCode: 201 }); + const next = vi.fn(); + + sensitiveAuditMiddleware()(makeReq(), res, next); + expect(next).toHaveBeenCalledOnce(); + + listeners.finish(); + + expect(auditService.logAction).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-42', + action: 'auth.post', + resource: 'auth', + outcome: 'success', + ipAddress: '203.0.113.7', + response: { status: 201 }, + }), + ); + }); + + it('marks 4xx/5xx responses as failures', () => { + const { res, listeners } = makeRes({ statusCode: 401 }); + + sensitiveAuditMiddleware()(makeReq({ path: '/api/v1/payments/transfer' }), res, vi.fn()); + listeners.finish(); + + const call = vi.mocked(auditService.logAction).mock.calls[0][0]; + expect(call.outcome).toBe('failure'); + expect(call.resource).toBe('payments'); + }); + + it('treats aborted connections as failures', () => { + const { res, listeners } = makeRes({ writableFinished: false }); + + sensitiveAuditMiddleware()(makeReq({ path: '/api/v1/admin/flags' }), res, vi.fn()); + listeners.close(); + + expect(vi.mocked(auditService.logAction).mock.calls[0][0].outcome).toBe('failure'); + }); + + it('skips explicitly excluded paths', () => { + const { res } = makeRes(); + sensitiveAuditMiddleware({ excludePaths: ['/api/v1/auth/refresh'] })( + makeReq({ path: '/api/v1/auth/refresh' }), + res, + vi.fn(), + ); + + expect(res.on).not.toHaveBeenCalled(); + expect(auditService.logAction).not.toHaveBeenCalled(); + }); + + it('supports custom action and user mappers', () => { + const { res, listeners } = makeRes(); + sensitiveAuditMiddleware({ + actionMapper: (req, category) => `${category}:${req.method}`, + userIdResolver: () => 'custom-user', + })(makeReq({ path: '/api/v1/admin/flags' }), res, vi.fn()); + listeners.finish(); + + expect(auditService.logAction).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'custom-user', action: 'admin:POST' }), + ); + }); + + it('exports a default middleware alias', () => { + expect(auditSensitiveOperations).toBe(sensitiveAuditMiddleware); + }); +}); diff --git a/backend/src/middleware/audit.ts b/backend/src/middleware/audit.ts index 60d45838..7519d3e0 100644 --- a/backend/src/middleware/audit.ts +++ b/backend/src/middleware/audit.ts @@ -7,6 +7,20 @@ export interface AuditMiddlewareOptions { resourceMapper?: (req: Request) => string; } +/** + * Derive a stable resource name from a request path, skipping the API version + * segment so `/api/v1/payments` becomes `payments` rather than `v1`. + */ +export function deriveAuditResource(req: Request): string { + const segments = req.baseUrl ? req.baseUrl.split('/') : req.path.split('/'); + const filtered = segments.filter(Boolean); + if (filtered.length === 0) return 'root'; + + const versionIndex = filtered.findIndex((segment) => /^v\d+$/.test(segment)); + const resourceIndex = versionIndex >= 0 ? versionIndex + 1 : 1; + return filtered[resourceIndex] ?? filtered[filtered.length - 1] ?? 'root'; +} + /** * Express middleware that records user and system operations to the tamper-evident audit log. */ @@ -39,9 +53,7 @@ export function auditMiddleware(options: AuditMiddlewareOptions = {}) { ? options.actionMapper(req) : `${req.method} ${req.path}`; - const resource = options.resourceMapper - ? options.resourceMapper(req) - : req.baseUrl || req.path.split('/')[2] || 'root'; + const resource = options.resourceMapper ? options.resourceMapper(req) : deriveAuditResource(req); // Capture request body (sanitization happens inside auditService.logAction) const requestBody = req.body; @@ -60,7 +72,7 @@ export function auditMiddleware(options: AuditMiddlewareOptions = {}) { }, }, ipAddress: req.ip || req.socket.remoteAddress, - userAgent: req.headers['user-agent'], + userAgent: typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : undefined, request: { method: req.method, path: req.path, diff --git a/backend/src/middleware/sensitiveAudit.ts b/backend/src/middleware/sensitiveAudit.ts new file mode 100644 index 00000000..3ead79bc --- /dev/null +++ b/backend/src/middleware/sensitiveAudit.ts @@ -0,0 +1,148 @@ +/** + * Sensitive-operation audit logging — Issue #793 + * + * Records every request that touches a sensitive area (authentication, + * payments, administration, identity, compliance) to the tamper-evident + * {@link auditService}. Each entry captures the timestamp, acting user, + * action, IP address, and outcome required by the issue. + * + * Unlike the generic `auditMiddleware`, this middleware only logs a curated + * set of sensitive route prefixes so the audit log stays focused and cheap. + */ + +import type { NextFunction, Request, Response } from 'express'; +import { auditService, type AuditOutcome } from '../services/auditService.js'; + +export type SensitiveCategory = 'auth' | 'payments' | 'admin' | 'identity' | 'compliance'; + +/** Route fragments that identify a sensitive operation. */ +const SENSITIVE_PATTERNS: Array<{ category: SensitiveCategory; pattern: RegExp }> = [ + { + category: 'auth', + pattern: /(^|\/)(auth|login|logout|register|signup|sign-up|password|reset|2fa|mfa|otp|sessions?|token|oauth)(\/|$)/i, + }, + { + category: 'payments', + pattern: + /(^|\/)(payments?|transfers?|payouts?|withdrawals?|refunds?|invoices?|escrow|subscriptions?|fiat-payments|payment-links|splits|allowances)(\/|$)/i, + }, + { + category: 'admin', + pattern: /(^|\/)(admin|users?|roles?|permissions?|api-keys?|secrets?|merchants?|flags?|config|impersonate)(\/|$)/i, + }, + { + category: 'identity', + pattern: /(^|\/)(kyb|kyc|verification|zk-identity|reputation)(\/|$)/i, + }, + { + category: 'compliance', + pattern: /(^|\/)(audit|compliance|security|gdpr|sanctions)(\/|$)/i, + }, +]; + +const DEFAULT_EXCLUDE_PATHS = ['/health', '/metrics', '/docs', '/api/v1/cold-start']; + +/** Classify a request path into a sensitive category, or `undefined` if it is not sensitive. */ +export function classifySensitivePath(path: string): SensitiveCategory | undefined { + for (const { category, pattern } of SENSITIVE_PATTERNS) { + if (pattern.test(path)) return category; + } + return undefined; +} + +/** True when the request path touches a sensitive area. */ +export function isSensitiveOperation(path: string): boolean { + return classifySensitivePath(path) !== undefined; +} + +export interface SensitiveAuditOptions { + /** Extra path prefixes to skip (in addition to the built-in defaults). */ + excludePaths?: string[]; + /** Override how the action label is derived. */ + actionMapper?: (req: Request, category: SensitiveCategory) => string; + /** Override how the acting user is resolved. */ + userIdResolver?: (req: Request) => string | undefined; +} + +function defaultUserResolver(req: Request): string | undefined { + const user = (req as Request & { user?: { id?: string; email?: string } }).user; + if (user?.id) return user.id; + if (user?.email) return user.email; + const header = req.headers['x-user-id']; + if (typeof header === 'string' && header) return header; + const apiKey = req.headers['x-api-key']; + if (typeof apiKey === 'string' && apiKey) return `api-key:${apiKey.slice(0, 8)}`; + return undefined; +} + +function defaultActionMapper(req: Request, category: SensitiveCategory): string { + return `${category}.${req.method.toLowerCase()}`; +} + +/** + * Express middleware factory that writes an audit entry once the response + * finishes (or the connection closes) for sensitive requests only. + */ +export function sensitiveAuditMiddleware(options: SensitiveAuditOptions = {}) { + const excludePaths = [...DEFAULT_EXCLUDE_PATHS, ...(options.excludePaths ?? [])]; + + return (req: Request, res: Response, next: NextFunction): void => { + const category = classifySensitivePath(req.path); + if (!category || excludePaths.some((prefix) => req.path.startsWith(prefix))) { + next(); + return; + } + + const startedAt = Date.now(); + let recorded = false; + + const record = (forcedOutcome?: AuditOutcome) => { + if (recorded) return; + recorded = true; + + const status = res.statusCode; + const outcome: AuditOutcome = forcedOutcome ?? (status >= 400 ? 'failure' : 'success'); + + void auditService + .logAction({ + userId: (options.userIdResolver ?? defaultUserResolver)(req), + action: (options.actionMapper ?? defaultActionMapper)(req, category), + resource: category, + resourceId: typeof req.params?.id === 'string' ? req.params.id : undefined, + outcome, + ipAddress: req.ip || req.socket?.remoteAddress, + userAgent: typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : undefined, + details: { + category, + outcome, + method: req.method, + path: req.path, + statusCode: status, + durationMs: Date.now() - startedAt, + }, + request: { + method: req.method, + path: req.path, + body: req.body, + }, + response: { + status, + }, + }) + .catch((error: unknown) => { + console.error('[sensitive-audit] Failed to write audit entry', error); + }); + }; + + res.on('finish', () => record()); + res.on('close', () => { + // Aborted responses never emit `finish`; treat them as failures. + if (!res.writableFinished) record('failure'); + }); + + next(); + }; +} + +export const auditSensitiveOperations = sensitiveAuditMiddleware; +export default sensitiveAuditMiddleware; diff --git a/backend/src/routes/audit.ts b/backend/src/routes/audit.ts index a46018c7..49c931e3 100644 --- a/backend/src/routes/audit.ts +++ b/backend/src/routes/audit.ts @@ -1,22 +1,28 @@ -import { Router, Request, Response, NextFunction } from 'express'; +import { Router, Request, Response } from 'express'; import { asyncHandler } from '../middleware/errorHandler.js'; -import { AuditService, auditService } from '../services/auditService.js'; +import { auditService } from '../services/auditService.js'; export const auditRouter = Router(); auditRouter.post('/log', asyncHandler(async (req: Request, res: Response) => { - const { userId, action, resource, resourceId, details, beforeState, afterState, ipAddress, userAgent, request, response } = req.body; + const { userId, action, resource, resourceId, outcome, details, beforeState, afterState, ipAddress, userAgent, request, response } = req.body; if (!action || !resource) { res.status(400).json({ error: 'Action and resource are required' }); return; } + if (outcome !== undefined && outcome !== 'success' && outcome !== 'failure') { + res.status(400).json({ error: "Outcome must be 'success' or 'failure'" }); + return; + } + const entry = await auditService.logAction({ userId, action, resource, resourceId, + outcome, details, beforeState, afterState, @@ -47,7 +53,7 @@ auditRouter.get('/entries', asyncHandler(async (req: Request, res: Response) => })); auditRouter.get('/entries/:id', asyncHandler(async (req: Request, res: Response) => { - const { id } = req.params; + const id = String(req.params.id); const entry = await auditService.getEntry(id); if (!entry) { @@ -64,7 +70,7 @@ auditRouter.get('/verify', asyncHandler(async (req: Request, res: Response) => { })); auditRouter.post('/flag/:id', asyncHandler(async (req: Request, res: Response) => { - const { id } = req.params; + const id = String(req.params.id); const { reasons } = req.body; if (!reasons || !Array.isArray(reasons)) { diff --git a/backend/src/services/__tests__/auditService.test.ts b/backend/src/services/__tests__/auditService.test.ts new file mode 100644 index 00000000..70bb616d --- /dev/null +++ b/backend/src/services/__tests__/auditService.test.ts @@ -0,0 +1,79 @@ +/** + * AuditService outcome behaviour tests — Issue #793 + */ +import { describe, expect, it } from 'vitest'; +import { AuditService } from '../auditService.js'; + +describe('AuditService outcome logging (#793)', () => { + it('derives a success outcome from a 2xx response status', async () => { + const service = new AuditService(); + const entry = await service.logAction({ + userId: 'u1', + action: 'auth.post', + resource: 'auth', + response: { status: 200 }, + }); + + expect(entry.outcome).toBe('success'); + expect(typeof entry.timestamp).toBe('number'); + }); + + it('derives a failure outcome from a 4xx/5xx response status', async () => { + const service = new AuditService(); + const entry = await service.logAction({ + action: 'payments.post', + resource: 'payments', + response: { status: 500 }, + }); + + expect(entry.outcome).toBe('failure'); + }); + + it('honours an explicit outcome over the response status', async () => { + const service = new AuditService(); + const entry = await service.logAction({ + action: 'auth.post', + resource: 'auth', + outcome: 'failure', + response: { status: 200 }, + }); + + expect(entry.outcome).toBe('failure'); + }); + + it('leaves the outcome undefined when neither outcome nor status is given', async () => { + const service = new AuditService(); + const entry = await service.logAction({ action: 'admin.get', resource: 'admin' }); + + expect(entry.outcome).toBeUndefined(); + }); + + it('redacts sensitive fields in the request body', async () => { + const service = new AuditService(); + const entry = await service.logAction({ + action: 'auth.post', + resource: 'auth', + request: { method: 'POST', path: '/api/v1/auth/login', body: { password: 'hunter2', apiKey: 'abc' } }, + }); + + expect(entry.requestBody).toEqual({ password: '[REDACTED]', apiKey: '[REDACTED]' }); + }); + + it('keeps the hash chain valid after logging outcomes', async () => { + const service = new AuditService(); + await service.logAction({ action: 'auth.post', resource: 'auth', response: { status: 200 } }); + await service.logAction({ action: 'auth.post', resource: 'auth', response: { status: 401 } }); + + await expect(service.verifyIntegrity()).resolves.toEqual({ valid: true }); + }); + + it('includes the Outcome column in CSV exports', async () => { + const service = new AuditService(); + await service.logAction({ action: 'auth.post', resource: 'auth', response: { status: 201 } }); + + const csv = await service.exportToCSV(); + const [header, row] = csv.split('\n'); + expect(header).toContain('Outcome'); + expect(row).toContain('success'); + }); +}); diff --git a/backend/src/services/auditService.js b/backend/src/services/auditService.js deleted file mode 100644 index 86452c52..00000000 --- a/backend/src/services/auditService.js +++ /dev/null @@ -1,316 +0,0 @@ -"use strict"; -var __assign = (this && this.__assign) || function () { - __assign = Object.assign || function(t) { - for (var s, i = 1, n = arguments.length; i < n; i++) { - s = arguments[i]; - for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) - t[p] = s[p]; - } - return t; - }; - return __assign.apply(this, arguments); -}; -var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { - function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } - return new (P || (P = Promise))(function (resolve, reject) { - function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } - function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } - function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } - step((generator = generator.apply(thisArg, _arguments || [])).next()); - }); -}; -var __generator = (this && this.__generator) || function (thisArg, body) { - var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); - return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; - function verb(n) { return function (v) { return step([n, v]); }; } - function step(op) { - if (f) throw new TypeError("Generator is already executing."); - while (g && (g = 0, op[0] && (_ = 0)), _) try { - if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; - if (y = 0, t) op = [op[0] & 2, t.value]; - switch (op[0]) { - case 0: case 1: t = op; break; - case 4: _.label++; return { value: op[1], done: false }; - case 5: _.label++; y = op[1]; op = [0]; continue; - case 7: op = _.ops.pop(); _.trys.pop(); continue; - default: - if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } - if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } - if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } - if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } - if (t[2]) _.ops.pop(); - _.trys.pop(); continue; - } - op = body.call(thisArg, _); - } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } - if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; - } -}; -var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) { - if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) { - if (ar || !(i in from)) { - if (!ar) ar = Array.prototype.slice.call(from, 0, i); - ar[i] = from[i]; - } - } - return to.concat(ar || Array.prototype.slice.call(from)); -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.auditService = exports.AuditService = void 0; -var node_crypto_1 = require("node:crypto"); -var node_crypto_2 = require("node:crypto"); -var AuditService = /** @class */ (function () { - function AuditService(policy) { - this.entries = []; - this.currentHash = '0000000000000000000000000000000000000000000000000000000000000000'; - this.retentionPolicy = { - retentionDays: 2555, - archiveAfterDays: 2190, - deleteAfterDays: 3650, - }; - if (policy) { - this.retentionPolicy = __assign(__assign({}, this.retentionPolicy), policy); - } - } - AuditService.prototype.computeHash = function (data) { - return (0, node_crypto_1.createHash)('sha256').update(data).digest('hex'); - }; - AuditService.prototype.generateEntryHash = function (entry) { - var data = [ - entry.id, - entry.timestamp, - entry.userId || '', - entry.action, - entry.resource, - entry.resourceId || '', - JSON.stringify(entry.details || {}), - JSON.stringify(entry.beforeState || {}), - JSON.stringify(entry.afterState || {}), - entry.ipAddress || '', - entry.requestMethod || '', - entry.requestPath || '', - entry.previousHash, - ].join('|'); - return this.computeHash(data); - }; - AuditService.prototype.logAction = function (params) { - return __awaiter(this, void 0, void 0, function () { - var id, timestamp, entry, hash, fullEntry; - var _a, _b, _c, _d; - return __generator(this, function (_e) { - id = (0, node_crypto_2.randomUUID)(); - timestamp = Date.now(); - entry = { - id: id, - timestamp: timestamp, - userId: params.userId, - action: params.action, - resource: params.resource, - resourceId: params.resourceId, - details: params.details, - beforeState: params.beforeState, - afterState: params.afterState, - ipAddress: params.ipAddress, - userAgent: params.userAgent, - requestMethod: (_a = params.request) === null || _a === void 0 ? void 0 : _a.method, - requestPath: (_b = params.request) === null || _b === void 0 ? void 0 : _b.path, - requestBody: this.sanitizeRequestBody((_c = params.request) === null || _c === void 0 ? void 0 : _c.body), - responseStatus: (_d = params.response) === null || _d === void 0 ? void 0 : _d.status, - previousHash: this.currentHash, - }; - hash = this.generateEntryHash(entry); - fullEntry = __assign(__assign({}, entry), { hash: hash }); - this.entries.push(fullEntry); - this.currentHash = hash; - return [2 /*return*/, fullEntry]; - }); - }); - }; - AuditService.prototype.sanitizeRequestBody = function (body) { - if (!body) - return undefined; - if (typeof body !== 'object') - return body; - var sanitized = __assign({}, body); - var sensitiveFields = ['password', 'token', 'apiKey', 'secret', 'creditCard', 'ssn']; - for (var _i = 0, sensitiveFields_1 = sensitiveFields; _i < sensitiveFields_1.length; _i++) { - var field = sensitiveFields_1[_i]; - if (field in sanitized) { - sanitized[field] = '[REDACTED]'; - } - } - return sanitized; - }; - AuditService.prototype.queryEntries = function (query) { - return __awaiter(this, void 0, void 0, function () { - var filtered, total, offset, limit; - return __generator(this, function (_a) { - filtered = this.entries.filter(function (entry) { - if (query.userId && entry.userId !== query.userId) - return false; - if (query.action && entry.action !== query.action) - return false; - if (query.resource && entry.resource !== query.resource) - return false; - if (query.suspicious !== undefined && entry.suspicious !== query.suspicious) - return false; - if (query.startDate && entry.timestamp < query.startDate) - return false; - if (query.endDate && entry.timestamp > query.endDate) - return false; - return true; - }); - total = filtered.length; - offset = query.offset || 0; - limit = query.limit || 50; - filtered = filtered.sort(function (a, b) { return b.timestamp - a.timestamp; }); - filtered = filtered.slice(offset, offset + limit); - return [2 /*return*/, { entries: filtered, total: total }]; - }); - }); - }; - AuditService.prototype.getEntry = function (id) { - return __awaiter(this, void 0, void 0, function () { - return __generator(this, function (_a) { - return [2 /*return*/, this.entries.find(function (entry) { return entry.id === id; })]; - }); - }); - }; - AuditService.prototype.verifyIntegrity = function () { - return __awaiter(this, void 0, void 0, function () { - var expectedHash, _i, _a, entry, computedHash; - var _b; - return __generator(this, function (_c) { - expectedHash = '0000000000000000000000000000000000000000000000000000000000000000'; - for (_i = 0, _a = this.entries; _i < _a.length; _i++) { - entry = _a[_i]; - if (entry.previousHash !== expectedHash) { - return [2 /*return*/, { valid: false, brokenAt: entry.id }]; - } - computedHash = this.generateEntryHash(entry); - if (computedHash !== entry.hash) { - return [2 /*return*/, { valid: false, brokenAt: entry.id }]; - } - expectedHash = entry.hash; - } - if (this.currentHash !== expectedHash) { - return [2 /*return*/, { valid: false, brokenAt: (_b = this.entries[this.entries.length - 1]) === null || _b === void 0 ? void 0 : _b.id }]; - } - return [2 /*return*/, { valid: true }]; - }); - }); - }; - AuditService.prototype.flagSuspicious = function (entryId, reasons) { - return __awaiter(this, void 0, void 0, function () { - var entry; - return __generator(this, function (_a) { - entry = this.entries.find(function (e) { return e.id === entryId; }); - if (entry) { - entry.suspicious = true; - entry.flags = reasons; - } - return [2 /*return*/, entry]; - }); - }); - }; - AuditService.prototype.exportToCSV = function () { - return __awaiter(this, void 0, void 0, function () { - var headers, rows; - return __generator(this, function (_a) { - headers = [ - 'ID', 'Timestamp', 'User ID', 'Action', 'Resource', 'Resource ID', - 'IP Address', 'Request Method', 'Request Path', 'Response Status', - 'Previous Hash', 'Hash', 'Suspicious', 'Flags' - ].join(','); - rows = this.entries.map(function (entry) { return [ - entry.id, - new Date(entry.timestamp).toISOString(), - entry.userId || '', - entry.action, - entry.resource, - entry.resourceId || '', - entry.ipAddress || '', - entry.requestMethod || '', - entry.requestPath || '', - entry.responseStatus || '', - entry.previousHash, - entry.hash, - entry.suspicious ? 'YES' : 'NO', - (entry.flags || []).join(';'), - ].map(function (v) { return "\"".concat(String(v).replace(/"/g, '""'), "\""); }).join(','); }); - return [2 /*return*/, __spreadArray([headers], rows, true).join('\n')]; - }); - }); - }; - AuditService.prototype.exportToJSON = function () { - return __awaiter(this, void 0, void 0, function () { - var _a, _b; - var _c; - return __generator(this, function (_d) { - switch (_d.label) { - case 0: - _b = (_a = JSON).stringify; - _c = { - exportedAt: Date.now(), - entryCount: this.entries.length, - retentionPolicy: this.retentionPolicy - }; - return [4 /*yield*/, this.verifyIntegrity()]; - case 1: return [2 /*return*/, _b.apply(_a, [(_c.integrity = _d.sent(), - _c.entries = this.entries, - _c), null, 2])]; - } - }); - }); - }; - AuditService.prototype.setRetentionPolicy = function (policy) { - this.retentionPolicy = __assign(__assign({}, this.retentionPolicy), policy); - }; - AuditService.prototype.getRetentionStats = function () { - return __awaiter(this, void 0, void 0, function () { - var byResource, suspiciousCount, _i, _a, entry, timestamps; - return __generator(this, function (_b) { - byResource = {}; - suspiciousCount = 0; - for (_i = 0, _a = this.entries; _i < _a.length; _i++) { - entry = _a[_i]; - byResource[entry.resource] = (byResource[entry.resource] || 0) + 1; - if (entry.suspicious) - suspiciousCount++; - } - timestamps = this.entries.map(function (e) { return e.timestamp; }); - timestamps.sort(function (a, b) { return a - b; }); - return [2 /*return*/, { - totalEntries: this.entries.length, - byResource: byResource, - suspiciousCount: suspiciousCount, - dateRange: { - oldest: timestamps[0] || 0, - newest: timestamps[timestamps.length - 1] || 0, - }, - }]; - }); - }); - }; - AuditService.prototype.getEntryCount = function () { - return __awaiter(this, void 0, void 0, function () { - return __generator(this, function (_a) { - return [2 /*return*/, this.entries.length]; - }); - }); - }; - AuditService.prototype.clearOldEntries = function () { - return __awaiter(this, void 0, void 0, function () { - var cutoff, toDelete; - return __generator(this, function (_a) { - cutoff = Date.now() - (this.retentionPolicy.deleteAfterDays * 24 * 60 * 60 * 1000); - toDelete = this.entries.filter(function (e) { return e.timestamp < cutoff; }); - this.entries = this.entries.filter(function (e) { return e.timestamp >= cutoff; }); - return [2 /*return*/, toDelete.length]; - }); - }); - }; - return AuditService; -}()); -exports.AuditService = AuditService; -exports.auditService = new AuditService(); diff --git a/backend/src/services/auditService.ts b/backend/src/services/auditService.ts index bca9c356..243ad09f 100644 --- a/backend/src/services/auditService.ts +++ b/backend/src/services/auditService.ts @@ -1,6 +1,9 @@ -import { createHash, randomUUID } from 'node:crypto'; +import { createHash } from 'node:crypto'; import { randomUUID as uuidv4 } from 'node:crypto'; +/** Whether a sensitive operation succeeded or failed. */ +export type AuditOutcome = 'success' | 'failure'; + export interface AuditEntry { id: string; timestamp: number; @@ -8,6 +11,8 @@ export interface AuditEntry { action: string; resource: string; resourceId?: string; + /** Result of the operation — issue #793 requires an explicit outcome. */ + outcome?: AuditOutcome; details?: Record; beforeState?: Record; afterState?: Record; @@ -67,6 +72,7 @@ export class AuditService { entry.action, entry.resource, entry.resourceId || '', + entry.outcome || '', JSON.stringify(entry.details || {}), JSON.stringify(entry.beforeState || {}), JSON.stringify(entry.afterState || {}), @@ -83,6 +89,8 @@ export class AuditService { action: string; resource: string; resourceId?: string; + /** Explicit outcome; derived from `response.status` when omitted. */ + outcome?: AuditOutcome; details?: Record; beforeState?: Record; afterState?: Record; @@ -99,7 +107,11 @@ export class AuditService { }): Promise { const id = uuidv4(); const timestamp = Date.now(); - + + const status = params.response?.status; + const outcome: AuditOutcome | undefined = + params.outcome ?? (typeof status === 'number' ? (status >= 400 ? 'failure' : 'success') : undefined); + const entry: Omit = { id, timestamp, @@ -107,6 +119,7 @@ export class AuditService { action: params.action, resource: params.resource, resourceId: params.resourceId, + outcome, details: params.details, beforeState: params.beforeState, afterState: params.afterState, @@ -204,7 +217,7 @@ export class AuditService { async exportToCSV(): Promise { const headers = [ 'ID', 'Timestamp', 'User ID', 'Action', 'Resource', 'Resource ID', - 'IP Address', 'Request Method', 'Request Path', 'Response Status', + 'Outcome', 'IP Address', 'Request Method', 'Request Path', 'Response Status', 'Previous Hash', 'Hash', 'Suspicious', 'Flags' ].join(','); @@ -215,6 +228,7 @@ export class AuditService { entry.action, entry.resource, entry.resourceId || '', + entry.outcome || '', entry.ipAddress || '', entry.requestMethod || '', entry.requestPath || '', diff --git a/docs/security/audit-logging.md b/docs/security/audit-logging.md new file mode 100644 index 00000000..32c9cc61 --- /dev/null +++ b/docs/security/audit-logging.md @@ -0,0 +1,71 @@ +# Sensitive Operation Audit Logging (#793) + +Every sensitive request — authentication, payments, administration, identity, +and compliance — is written to the tamper-evident audit log so security teams +can answer *who did what, when, from where, and with what result*. + +## What gets logged + +`backend/src/middleware/sensitiveAudit.ts` classifies request paths and, once +the response finishes, appends an entry through `auditService.logAction`: + +| Field | Source | +|-------|--------| +| `timestamp` | Epoch milliseconds captured when the entry is written | +| `userId` | Authenticated user, `x-user-id`, or an API-key fingerprint | +| `action` | `.` (e.g. `auth.post`, `payments.post`) | +| `resource` | Sensitive category (`auth`, `payments`, `admin`, `identity`, `compliance`) | +| `resourceId` | `req.params.id` when present | +| `outcome` | `success` for 2xx/3xx, `failure` for 4xx/5xx or aborted requests | +| `ipAddress` / `userAgent` | Request metadata | +| `details` | Category, method, path, status code, duration | + +### Categories + +| Category | Matches | +|----------|---------| +| `auth` | `/auth`, `/login`, `/register`, `/password`, `/2fa`, `/sessions`, `/oauth` | +| `payments` | `/payments`, `/transfers`, `/payouts`, `/withdrawals`, `/refunds`, `/invoices`, `/escrow`, `/subscriptions` | +| `admin` | `/admin`, `/users`, `/roles`, `/permissions`, `/api-keys`, `/secrets`, `/merchants` | +| `identity` | `/kyb`, `/kyc`, `/verification`, `/zk-identity` | +| `compliance` | `/audit`, `/compliance`, `/security`, `/gdpr` | + +## Wiring + +The middleware is mounted once, before the versioned routers, so it wraps every +API route: + +```ts +// backend/src/index.ts +import { auditSensitiveOperations } from './middleware/sensitiveAudit.js'; + +app.use(auditSensitiveOperations()); +``` + +Customise behaviour with `sensitiveAuditMiddleware(options)`: + +```ts +sensitiveAuditMiddleware({ + excludePaths: ['/api/v1/auth/refresh'], // noisy or non-sensitive routes + actionMapper: (req, category) => `${category}:${req.method}`, + userIdResolver: (req) => req.user?.id, +}); +``` + +## Outcome + +`AuditService.logAction` accepts an explicit `outcome`, but when it is omitted +the outcome is derived from `response.status` (`>= 400` is a failure). Aborted +connections are recorded as `failure` because they never emit `finish`. + +The outcome participates in the entry hash, so tampering with it breaks +`GET /api/v1/audit/verify`. + +## Querying + +- `GET /api/v1/audit/entries?resource=payments&limit=50` +- `POST /api/v1/audit/log` — accepts an optional `outcome` of `success` or `failure` +- `GET /api/v1/audit/export/csv` — includes an `Outcome` column + +Sensitive request bodies are sanitized before storage: `password`, `token`, +`apiKey`, `secret`, `creditCard`, and `ssn` are replaced with `[REDACTED]`.