Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down
155 changes: 155 additions & 0 deletions backend/src/middleware/__tests__/sensitiveAudit.test.ts
Original file line number Diff line number Diff line change
@@ -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<MockResponse> = {}) {
const listeners: Record<string, () => 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<Record<string, unknown>> = {}) {
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);
});
});
20 changes: 16 additions & 4 deletions backend/src/middleware/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down
148 changes: 148 additions & 0 deletions backend/src/middleware/sensitiveAudit.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading