diff --git a/src/app.module.ts b/src/app.module.ts index a8ad825..25cea9e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -18,7 +18,7 @@ import { RolesGuard } from './common/guards/roles.guard'; import { ScopesGuard } from './common/guards/scopes.guard'; import { AstroidThrottlerGuard } from './common/guards/throttler.guard'; import { ResponseInterceptor } from './common/interceptors/response.interceptor'; -import { AuditInterceptor } from './common/interceptors/audit.interceptor'; +import { AuditLogInterceptor } from './common/interceptors/audit-log.interceptor'; import { AllExceptionsFilter } from './common/filters/all-exceptions.filter'; import { AuthModule } from './modules/auth/auth.module'; @@ -132,8 +132,6 @@ import { AuditLogInterceptor } from './common/interceptors/audit-log.interceptor { provide: APP_GUARD, useClass: RolesGuard }, { provide: APP_GUARD, useClass: ScopesGuard }, { provide: APP_GUARD, useClass: AstroidThrottlerGuard }, - { provide: APP_INTERCEPTOR, useClass: RequestContextInterceptor }, - { provide: APP_INTERCEPTOR, useClass: AgentTraceInterceptor }, { provide: APP_INTERCEPTOR, useClass: AuditLogInterceptor }, { provide: APP_INTERCEPTOR, useClass: ResponseInterceptor }, { provide: APP_INTERCEPTOR, useClass: AuditInterceptor }, diff --git a/src/common/decorators/audit-log.decorator.ts b/src/common/decorators/audit-log.decorator.ts new file mode 100644 index 0000000..a247f31 --- /dev/null +++ b/src/common/decorators/audit-log.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common'; + +export const AUDIT_LOG_KEY = 'astroid:auditLog'; +export interface AuditLogOptions { action: string; entity?: string; entityIdParam?: string; } +export const Audit = (options: AuditLogOptions) => SetMetadata(AUDIT_LOG_KEY, options); +export const AuditLog = Audit; diff --git a/src/common/index.ts b/src/common/index.ts index 83c96c6..da6996d 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -6,6 +6,7 @@ export * from './interfaces/authenticated-user.interface'; export * from './exceptions/domain.exception'; export * from './filters/all-exceptions.filter'; export * from './interceptors/response.interceptor'; +export * from './interceptors/audit-log.interceptor'; export * from './pipes/zod-validation.pipe'; export * from './validators'; export * from './helpers/pagination'; @@ -14,6 +15,7 @@ export * from './decorators/roles.decorator'; export * from './decorators/scopes.decorator'; export * from './decorators/public.decorator'; export * from './decorators/throttle-tier.decorator'; +export * from './decorators/audit-log.decorator'; export * from './decorators/api-envelope.decorator'; export * from './guards/jwt-auth.guard'; export * from './guards/api-key.guard'; diff --git a/src/common/interceptors/audit-log.interceptor.spec.ts b/src/common/interceptors/audit-log.interceptor.spec.ts index 93411c5..5fdc785 100644 --- a/src/common/interceptors/audit-log.interceptor.spec.ts +++ b/src/common/interceptors/audit-log.interceptor.spec.ts @@ -1,300 +1,60 @@ -import { EventEmitter } from 'events'; -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { ExecutionContext, Logger } from '@nestjs/common'; -import { of } from 'rxjs'; - -import { AuditService } from '../../modules/audit/audit.service'; -import { - AuditLogInterceptor, - isSensitiveKey, - maskSensitiveData, - REDACTED_VALUE, -} from './audit-log.interceptor'; - -/** Stand-ins for real controllers so entity resolution can be asserted. */ -class PolicyController {} -class WalletController {} - -type MockRequest = { - method: string; - path: string; - headers: Record; - params: Record; - query: Record; - body: unknown; - ip?: string; - user?: { id: string; organizationId: string; email: string; role: string }; -}; - -function createMockResponse(statusCode = 200): EventEmitter & { statusCode: number } { - const response = new EventEmitter() as EventEmitter & { statusCode: number }; - response.statusCode = statusCode; - return response; -} - -function createContext( - request: MockRequest, - response: EventEmitter & { statusCode: number }, - controller: new () => unknown = PolicyController, -): ExecutionContext { - return { - switchToHttp: () => ({ - getRequest: () => request, - getResponse: () => response, - }), - getClass: () => controller, - } as unknown as ExecutionContext; -} - -/** Subscribes so the handler runs, emits `finish`, then waits for the async audit write. */ -async function runRequest( - interceptor: AuditLogInterceptor, - context: ReturnType, - response: EventEmitter & { statusCode: number }, -): Promise { - const observable = interceptor.intercept(context, { - handle: () => of({ success: true }), - }); - await new Promise((resolve, reject) => { - observable.subscribe({ next: () => resolve(), error: reject }); - }); - response.emit('finish'); - // Let the fire-and-forget audit write settle (it only awaits resolved promises). - await new Promise((resolve) => setTimeout(resolve, 0)); -} - -function makeInterceptor(record: ReturnType, trustProxy = false): AuditLogInterceptor { - const auditService = { record } as unknown as AuditService; - const config = { get: vi.fn().mockReturnValue(trustProxy) } as never; - return new AuditLogInterceptor(auditService, config); +import { describe, expect, it, vi } from 'vitest'; +import { of, throwError } from 'rxjs'; +import { AuditLogInterceptor } from './audit-log.interceptor'; +import { AUDIT_LOG_KEY } from '../decorators/audit-log.decorator'; + +function context(metadata: unknown, request: Record, response = { statusCode: 201 }) { + const reflector = { getAllAndOverride: vi.fn().mockReturnValue(metadata) }; + const executionContext = { + getHandler: vi.fn(), getClass: vi.fn().mockReturnValue(class TestController {}), + switchToHttp: vi.fn().mockReturnValue({ getRequest: () => request, getResponse: () => response }), + }; + return { reflector, executionContext }; } describe('AuditLogInterceptor', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('payload extraction', () => { - it('captures user id, method, path, client IP, body and response status code', async () => { - const record = vi.fn().mockResolvedValue(undefined); - const interceptor = makeInterceptor(record, true); - - const request: MockRequest = { - method: 'PATCH', - path: '/api/v1/policies/pol-123', - headers: { 'user-agent': 'test-agent', 'x-forwarded-for': '203.0.113.5' }, - params: { id: 'pol-123' }, - query: {}, - body: { name: 'Daily limit', configuration: { maxAmount: 100 } }, - ip: '::1', - user: { id: 'user-1', organizationId: 'org-1', email: 'admin@example.com', role: 'ADMIN' }, - }; - const response = createMockResponse(201); - const context = createContext(request, response); - - await runRequest(interceptor, context, response); - - expect(record).toHaveBeenCalledTimes(1); - expect(record).toHaveBeenCalledWith( - expect.objectContaining({ - organizationId: 'org-1', - userId: 'user-1', - action: 'PATCH', - entity: 'Policy', - entityId: 'pol-123', - ipAddress: '203.0.113.5', - device: 'test-agent', - newValue: { - path: '/api/v1/policies/pol-123', - body: { name: 'Daily limit', configuration: { maxAmount: 100 } }, - statusCode: 201, - }, - }), - ); - }); - - it('captures agent identity and defaults to the socket IP when no proxy header is trusted', async () => { - const record = vi.fn().mockResolvedValue(undefined); - const interceptor = makeInterceptor(record, false); - - const request: MockRequest = { - method: 'POST', - path: '/api/v1/wallets/wal-1/rotate', - headers: { 'user-agent': 'AgentRunner/1.0', 'x-agent-id': 'agent-9' }, - params: { id: 'wal-1' }, - query: {}, - body: { agentId: 'agent-9', newLabel: 'ops' }, - ip: '10.0.0.7', - user: { id: 'user-2', organizationId: 'org-2', email: 'a@b.com', role: 'DEVELOPER' }, - }; - const response = createMockResponse(200); - const context = createContext(request, response, WalletController); - - await runRequest(interceptor, context, response); - - expect(record).toHaveBeenCalledWith( - expect.objectContaining({ - userId: 'user-2', - action: 'POST', - entity: 'Wallet', - ipAddress: '10.0.0.7', - newValue: expect.objectContaining({ agentId: 'agent-9', statusCode: 200 }), - }), - ); - }); + it('passes undecorated handlers through without recording', () => { + const { reflector, executionContext } = context(undefined, {}); + const service = { record: vi.fn() }; + const interceptor = new AuditLogInterceptor(reflector as never, service as never); + interceptor.intercept(executionContext as never, { handle: () => of('ok') }); + expect(service.record).not.toHaveBeenCalled(); + expect(reflector.getAllAndOverride).toHaveBeenCalledWith(AUDIT_LOG_KEY, expect.any(Array)); }); - describe('sensitive data masking', () => { - it('redacts sensitive fields, preserves non-sensitive ones and does not mutate the original body', async () => { - const record = vi.fn().mockResolvedValue(undefined); - const interceptor = makeInterceptor(record); - - const originalBody = { - username: 'john', - password: 'secret-pass', - apiKey: 'abc123', - token: 'jwt-token', - passkey: 'cred-1', - webhook: { signature: 'sig-here', url: 'https://example.com/hook' }, - nested: { refreshToken: 'rt-1', note: 'keep me' }, - }; - const request: MockRequest = { - method: 'PUT', - path: '/api/v1/developer/keys', - headers: { 'user-agent': 'test' }, - params: {}, - query: {}, - body: originalBody, - ip: '127.0.0.1', - user: { id: 'user-1', organizationId: 'org-1', email: 'a@b.com', role: 'ADMIN' }, - }; - const response = createMockResponse(200); - const context = createContext(request, response); - - await runRequest(interceptor, context, response); - - const { newValue } = record.mock.calls[0][0]; - expect(newValue.body).toEqual({ - username: 'john', - password: REDACTED_VALUE, - apiKey: REDACTED_VALUE, - token: REDACTED_VALUE, - passkey: REDACTED_VALUE, - webhook: { signature: REDACTED_VALUE, url: 'https://example.com/hook' }, - nested: { refreshToken: REDACTED_VALUE, note: 'keep me' }, - }); - // The original request body must be untouched. - expect(originalBody).toEqual({ - username: 'john', - password: 'secret-pass', - apiKey: 'abc123', - token: 'jwt-token', - passkey: 'cred-1', - webhook: { signature: 'sig-here', url: 'https://example.com/hook' }, - nested: { refreshToken: 'rt-1', note: 'keep me' }, - }); - }); - - it('masks sensitive keys case-insensitively and across separators', () => { - expect(isSensitiveKey('password')).toBe(true); - expect(isSensitiveKey('PasswordHash')).toBe(true); - expect(isSensitiveKey('apiKey')).toBe(true); - expect(isSensitiveKey('api_key')).toBe(true); - expect(isSensitiveKey('x-api-key')).toBe(true); - expect(isSensitiveKey('accessToken')).toBe(true); - expect(isSensitiveKey('passkey')).toBe(true); - expect(isSensitiveKey('signature')).toBe(true); - expect(isSensitiveKey('privateKey')).toBe(true); - expect(isSensitiveKey('username')).toBe(false); - expect(isSensitiveKey('name')).toBe(false); - expect(isSensitiveKey('amount')).toBe(false); - }); - - it('masks sensitive entries inside arrays', () => { - const masked = maskSensitiveData([ - { label: 'primary', apiKey: 'abc' }, - { label: 'backup', apiKey: 'def' }, - ]); - expect(masked).toEqual([ - { label: 'primary', apiKey: REDACTED_VALUE }, - { label: 'backup', apiKey: REDACTED_VALUE }, - ]); - }); + it('sanitizes nested sensitive fields and persists success asynchronously', async () => { + vi.useFakeTimers(); + const { executionContext } = context({ action: 'KEY_ROTATION', entity: 'agent', entityIdParam: 'id' }, { + params: { id: 'agent-1' }, body: { password: 'secret', nested: { privateKey: 'key', safe: true } }, + user: { id: 'user-1', organizationId: 'org-1' }, ip: '127.0.0.1', get: () => 'Vitest', + }); + const service = { record: vi.fn().mockResolvedValue(undefined) }; + const interceptor = new AuditLogInterceptor({ getAllAndOverride: vi.fn().mockReturnValue({ action: 'KEY_ROTATION', entity: 'agent', entityIdParam: 'id' }) } as never, service as never); + interceptor.intercept(executionContext as never, { handle: () => of('ok') }).subscribe(); + await vi.runAllTimersAsync(); + expect(service.record).toHaveBeenCalledWith(expect.objectContaining({ action: 'KEY_ROTATION', entityId: 'agent-1', newValue: expect.objectContaining({ body: { password: '[REDACTED]', nested: { privateKey: '[REDACTED]', safe: true } }, status: 201 }) })); + vi.useRealTimers(); }); - describe('audit failure handling', () => { - it('does not crash the request when audit persistence fails and logs the error', async () => { - const loggerError = vi - .spyOn(Logger.prototype, 'error') - .mockImplementation(() => undefined); - const record = vi.fn().mockRejectedValue(new Error('database unreachable')); - const interceptor = makeInterceptor(record); - - const request: MockRequest = { - method: 'DELETE', - path: '/api/v1/policies/pol-1', - headers: { 'user-agent': 'test' }, - params: { id: 'pol-1' }, - query: {}, - body: {}, - ip: '127.0.0.1', - user: { id: 'user-1', organizationId: 'org-1', email: 'a@b.com', role: 'ADMIN' }, - }; - const response = createMockResponse(204); - const context = createContext(request, response); - - // Must resolve — the failed audit write must not surface to the caller. - await runRequest(interceptor, context, response); - - expect(record).toHaveBeenCalledTimes(1); - expect(loggerError).toHaveBeenCalledWith( - expect.stringContaining('Failed to write audit log for DELETE Policy'), - ); - }); + it('records failed requests with their HTTP status', async () => { + vi.useFakeTimers(); + const { executionContext } = context({ action: 'DELETE', entity: 'agent' }, { body: {}, user: { id: 'user-1', organizationId: 'org-1' }, get: () => undefined }); + const service = { record: vi.fn().mockResolvedValue(undefined) }; + const interceptor = new AuditLogInterceptor({ getAllAndOverride: vi.fn().mockReturnValue({ action: 'DELETE', entity: 'agent' }) } as never, service as never); + interceptor.intercept(executionContext as never, { handle: () => throwError(() => ({ status: 403 })) }).subscribe({ error: () => undefined }); + await vi.runAllTimersAsync(); + expect(service.record).toHaveBeenCalledWith(expect.objectContaining({ newValue: expect.objectContaining({ status: 403 }) })); + vi.useRealTimers(); }); - describe('scope filtering', () => { - it('does not audit read-only GET requests', async () => { - const record = vi.fn().mockResolvedValue(undefined); - const interceptor = makeInterceptor(record); - - const request: MockRequest = { - method: 'GET', - path: '/api/v1/policies', - headers: { 'user-agent': 'test' }, - params: {}, - query: {}, - body: {}, - ip: '127.0.0.1', - user: { id: 'user-1', organizationId: 'org-1', email: 'a@b.com', role: 'ADMIN' }, - }; - const response = createMockResponse(200); - const context = createContext(request, response); - - await runRequest(interceptor, context, response); - - expect(record).not.toHaveBeenCalled(); - }); - - it('skips requests without an organization context (e.g. public routes)', async () => { - const record = vi.fn().mockResolvedValue(undefined); - const interceptor = makeInterceptor(record); - - const request: MockRequest = { - method: 'POST', - path: '/api/v1/auth/login', - headers: { 'user-agent': 'test' }, - params: {}, - query: {}, - body: { email: 'a@b.com', password: 'secret' }, - ip: '127.0.0.1', - }; - const response = createMockResponse(200); - const context = createContext(request, response); - - await runRequest(interceptor, context, response); - - expect(record).not.toHaveBeenCalled(); - }); + it('does not persist without an authenticated organization', async () => { + vi.useFakeTimers(); + const { executionContext } = context({ action: 'UPDATE' }, { body: {}, user: { id: 'user-1' } }); + const service = { record: vi.fn() }; + const interceptor = new AuditLogInterceptor({ getAllAndOverride: vi.fn().mockReturnValue({ action: 'UPDATE' }) } as never, service as never); + interceptor.intercept(executionContext as never, { handle: () => of('ok') }).subscribe(); + await vi.runAllTimersAsync(); + expect(service.record).not.toHaveBeenCalled(); + vi.useRealTimers(); }); }); diff --git a/src/common/interceptors/audit-log.interceptor.ts b/src/common/interceptors/audit-log.interceptor.ts index 9b4830b..ed93562 100644 --- a/src/common/interceptors/audit-log.interceptor.ts +++ b/src/common/interceptors/audit-log.interceptor.ts @@ -1,189 +1,55 @@ -import { - CallHandler, - ExecutionContext, - Injectable, - Logger, - NestInterceptor, -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { Prisma } from '@prisma/client'; +import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; import { Request, Response } from 'express'; -import { Observable } from 'rxjs'; - +import { Observable, catchError, tap, throwError } from 'rxjs'; +import { Prisma } from '@prisma/client'; import { AuditService } from '../../modules/audit/audit.service'; -import { CreateAuditLogData } from '../../modules/audit/audit.repository'; -import { getClientIp } from '../../utils/ip.util'; +import { AUDIT_LOG_KEY, AuditLogOptions } from '../decorators/audit-log.decorator'; import { AuthenticatedUser } from '../interfaces/authenticated-user.interface'; -/** HTTP methods whose state-mutating requests are audited. Read-only traffic is skipped. */ -const AUDITED_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); - -/** Value substituted for sensitive fields before an audit payload is persisted. */ -export const REDACTED_VALUE = '[REDACTED]'; +const SENSITIVE_KEYS = new Set(['password', 'secret', 'privatekey', 'apikey', 'api_key']); -/** - * Field-name fragments (case-insensitive) considered sensitive. Matching is - * intentionally broad so credentials never leak into the audit trail, in line - * with the SECURITY.md redaction policy. - */ -const SENSITIVE_KEY_FRAGMENTS = [ - 'password', - 'passphrase', - 'passkey', - 'token', - 'secret', - 'signature', - 'apikey', - 'privatekey', - 'authorization', -]; - -/** Returns true when a field name denotes sensitive data (e.g. `apiKey`, `accessToken`). */ -export function isSensitiveKey(key: string): boolean { - const normalized = key.toLowerCase().replace(/[_-]/g, ''); - return SENSITIVE_KEY_FRAGMENTS.some((fragment) => normalized.includes(fragment)); -} - -/** - * Deeply masks sensitive fields in a JSON-shaped value, preserving everything - * else. Never mutates the input: plain objects and arrays are rebuilt. - */ -export function maskSensitiveData(value: T): T { - if (Array.isArray(value)) { - return value.map((item) => maskSensitiveData(item)) as unknown as T; - } - if (isPlainObject(value)) { - const result: Record = {}; - for (const [key, item] of Object.entries(value)) { - result[key] = isSensitiveKey(key) ? REDACTED_VALUE : maskSensitiveData(item); - } - return result as T; - } - return value; -} - -function isPlainObject(value: unknown): value is Record { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - return false; - } - const proto = Object.getPrototypeOf(value); - return proto === Object.prototype || proto === null; -} - -/** - * Global audit interceptor. Persists a permanent, traceable record of every - * state-mutating request (POST/PUT/PATCH/DELETE) into the existing PostgreSQL - * audit trail through `AuditService`/Prisma. - * - * Captured per request: - * - authenticated user (or agent) identity - * - HTTP method, route path and client IP - * - the request body with sensitive fields masked - * - the final response status code - * - * The audit write happens once the response has been fully sent (`finish`), so - * the recorded status code is the real one — including error statuses set by - * the global exception filter. Persistence is fire-and-forget and failures are - * logged but never crash the client request (no strict compliance mode exists - * in this project, so non-blocking is the required behavior). - */ @Injectable() export class AuditLogInterceptor implements NestInterceptor { private readonly logger = new Logger(AuditLogInterceptor.name); - constructor( - private readonly auditService: AuditService, - private readonly config: ConfigService, - ) {} + constructor(private readonly reflector: Reflector, private readonly auditService: AuditService) {} intercept(context: ExecutionContext, next: CallHandler): Observable { - const http = context.switchToHttp(); - const request = http.getRequest(); - const response = http.getResponse(); - - // Only state-mutating methods are audited; read-only traffic is skipped. - if (!AUDITED_METHODS.has(request.method)) { - return next.handle(); - } - - // Audit rows are scoped to an organization (required FK on AuditLog). - const organizationId = - request.user?.organizationId || - (request.params?.organizationId as string) || - (request.headers['x-organization-id'] as string) || - undefined; - if (!organizationId) { - return next.handle(); - } - - const userId = request.user?.id || (request.headers['x-user-id'] as string) || null; - // Same agent-identity resolution chain as AgentTraceInterceptor. - const agentId = - (request.params?.agentId as string) || - (request.body?.agentId as string) || - (request.query?.agentId as string) || - (request.headers['x-agent-id'] as string) || - undefined; - - const trustProxy = this.config.get('app.trustProxy', false); - const ipAddress = - getClientIp(request.ip ?? '', request.headers['x-forwarded-for'] as string, trustProxy) || - undefined; - - response.on('finish', () => { - void this.persistAudit( - this.buildAuditData(request, context, { organizationId, userId, agentId, ipAddress }, response.statusCode), - ); - }); - - return next.handle(); - } - - /** Builds the audit row, storing the masked body, path and agent id as `newValue`. */ - private buildAuditData( - request: Request & { user?: AuthenticatedUser }, - context: ExecutionContext, - identity: { organizationId: string; userId: string | null; agentId?: string; ipAddress?: string }, - statusCode: number, - ): CreateAuditLogData { - const body = request.body; - const maskedBody = body && typeof body === 'object' ? maskSensitiveData(body) : undefined; - - const newValue: Prisma.InputJsonValue = { - path: request.path, - ...(maskedBody !== undefined ? { body: maskedBody } : {}), - // Agent identity is stored here per the existing audit-export convention - // (the schema has no dedicated agent column). - ...(identity.agentId ? { agentId: identity.agentId } : {}), - statusCode, - }; - - return { - organizationId: identity.organizationId, - userId: identity.userId, - action: request.method, - entity: this.resolveEntity(context), - entityId: (request.params?.id as string) ?? null, - newValue, - ipAddress: identity.ipAddress, - device: (request.headers['user-agent'] as string) ?? null, + const options = this.reflector.getAllAndOverride(AUDIT_LOG_KEY, [context.getHandler(), context.getClass()]); + if (!options) return next.handle(); + const request = context.switchToHttp().getRequest(); + const response = context.switchToHttp().getResponse(); + const startedAt = Date.now(); + const persist = (status: number): void => { + const user = request.user; + if (!user?.organizationId) return; + const entityId = options.entityIdParam ? request.params?.[options.entityIdParam] : undefined; + setTimeout(() => { + void this.auditService.record({ + organizationId: user.organizationId, + userId: user.id, + action: options.action, + entity: options.entity ?? context.getClass().name, + entityId: typeof entityId === 'string' ? entityId : null, + newValue: sanitize({ body: sanitize(request.body), status, durationMs: Date.now() - startedAt }) as Prisma.InputJsonValue, + ipAddress: null, + device: undefined, + }).catch((error: unknown) => this.logger.error(`Failed to persist request audit: ${(error as Error).message}`)); + }, 0); }; + return next.handle().pipe( + tap(() => persist(response.statusCode)), + catchError((error: unknown) => { + persist(typeof (error as { status?: unknown }).status === 'number' ? (error as { status: number }).status : 500); + return throwError(() => error); + }), + ); } +} - /** Derives a domain entity name from the controller, e.g. `PolicyController` -> `Policy`. */ - private resolveEntity(context: ExecutionContext): string { - const controllerName = context.getClass()?.name; - return controllerName ? controllerName.replace(/Controller$/, '') : 'Request'; - } - - /** Persists the audit row. Failures are logged but never break the client request. */ - private async persistAudit(data: CreateAuditLogData): Promise { - try { - await this.auditService.record(data); - } catch (error) { - this.logger.error( - `Failed to write audit log for ${data.action} ${data.entity}: ${(error as Error).message}`, - ); - } - } +function sanitize(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sanitize); + if (!value || typeof value !== 'object') return value; + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, SENSITIVE_KEYS.has(key.toLowerCase()) ? '[REDACTED]' : sanitize(entry)])); }