/** * Redaction and shrinking for anything copied into `audit_logs.request`. * * This matters more here than in a typical audit log. `main.ts` raises the JSON * body ceiling to 100MB so contract signing can post a signature AND a company * stamp as base64 in one request. Copying a body like that verbatim would put * both a credential-grade artefact and a 100MB blob into the audit table, on * the write path of every audited endpoint. */ const REDACTED = '[REDACTED]'; /** * Substring-matched against lower-cased key names, so `newPassword`, * `otpCode` and `x-authorization` are all caught without enumerating variants. * * `signature` and `stamp` are here because contract signing posts both as * base64 — they are simultaneously the largest and the most sensitive fields * this API accepts. */ const SENSITIVE_KEY_PATTERNS = [ 'password', 'otp', 'token', 'secret', 'pin', 'authorization', 'signature', 'stamp', 'apikey', 'api_key', 'credential', 'ssn', ]; /** Serialized `request` ceiling. Beyond this the payload is dropped for a marker. */ const MAX_REQUEST_BYTES = 64 * 1024; /** Depth guard: deep nesting is never worth the recursion cost here. */ const MAX_DEPTH = 6; /** Long strings (base64 blobs) are truncated rather than stored whole. */ const MAX_STRING_LENGTH = 2_000; function isSensitiveKey(key: string): boolean { const lower = key.toLowerCase(); return SENSITIVE_KEY_PATTERNS.some((pattern) => lower.includes(pattern)); } /** * Multer file shape, reduced to a descriptor. The buffer is never stored — * Postgres is the wrong home for file bytes, and `audit_logs` doubly so. */ function isMulterFile(value: unknown): boolean { if (typeof value !== 'object' || value === null) return false; const candidate = value as Record; return ( typeof candidate.originalname === 'string' && (typeof candidate.mimetype === 'string' || typeof candidate.size === 'number') ); } function describeFile(value: Record): Record { return { __file: true, originalName: value.originalname ?? null, mimeType: value.mimetype ?? null, size: typeof value.size === 'number' ? value.size : null, fieldName: value.fieldname ?? null, }; } function sanitizeValue(value: unknown, depth: number): unknown { if (value === null || value === undefined) return value ?? null; if (typeof value === 'string') { return value.length > MAX_STRING_LENGTH ? `${value.slice(0, MAX_STRING_LENGTH)}…[truncated ${value.length} chars]` : value; } if (typeof value === 'number' || typeof value === 'boolean') return value; if (value instanceof Date) return value.toISOString(); // Buffers are file bytes by definition — never persisted, only described. if (Buffer.isBuffer(value)) return { __buffer: true, size: value.length }; if (depth >= MAX_DEPTH) return '[MAX_DEPTH]'; if (Array.isArray(value)) { // Cap array length: bulk endpoints post large collections. const capped = value.slice(0, 50).map((item) => sanitizeValue(item, depth + 1)); if (value.length > 50) capped.push(`…[${value.length - 50} more items]`); return capped; } if (typeof value === 'object') { if (isMulterFile(value)) return describeFile(value as Record); const out: Record = {}; for (const [key, nested] of Object.entries(value as Record)) { out[key] = isSensitiveKey(key) ? REDACTED : sanitizeValue(nested, depth + 1); } return out; } // Functions, symbols and anything else are not audit data. return null; } /** * Sanitize a request body (or query object) for storage. * * Returns null when there is nothing worth keeping, so empty bodies do not * occupy jsonb rows. */ export function sanitizeRequestPayload( body: unknown, files?: unknown, ): Record | null { const payload: Record = {}; if (body && typeof body === 'object' && Object.keys(body).length > 0) { const sanitizedBody = sanitizeValue(body, 0); if (sanitizedBody && typeof sanitizedBody === 'object') { Object.assign(payload, sanitizedBody as Record); } } // Multer puts uploads on `req.files`, outside `req.body`, so they are folded // in explicitly — otherwise a pure-upload request records an empty payload. if (files) { const sanitizedFiles = sanitizeValue(files, 0); if ( sanitizedFiles && (Array.isArray(sanitizedFiles) || typeof sanitizedFiles === 'object') ) { const hasEntries = Array.isArray(sanitizedFiles) ? sanitizedFiles.length > 0 : Object.keys(sanitizedFiles as object).length > 0; if (hasEntries) payload.__uploads = sanitizedFiles; } } if (Object.keys(payload).length === 0) return null; // Final size guard. A body can stay under every per-field cap and still be // enormous in aggregate, so the serialized form is measured before storing. const serialized = JSON.stringify(payload); if (serialized && Buffer.byteLength(serialized, 'utf8') > MAX_REQUEST_BYTES) { return { __truncated: true, reason: 'Payload exceeded the audit size limit', bytes: Buffer.byteLength(serialized, 'utf8'), keys: Object.keys(payload).slice(0, 50), }; } return payload; } /** * Rebuild a URL with sensitive query values redacted. * * `url` is stored with its full query string, and query strings are a common * place for one-time tokens and signed links, so the same deny-list that * protects the body is applied to the query. */ export function redactUrlQuery(url: string): string { const queryIndex = url.indexOf('?'); if (queryIndex === -1) return url; const path = url.slice(0, queryIndex); const query = url.slice(queryIndex + 1); if (!query) return path; const redacted = query .split('&') .map((pair) => { const eq = pair.indexOf('='); if (eq === -1) return pair; const key = pair.slice(0, eq); // Keys arrive percent-encoded; decode before matching so `api%2Dkey` // is not treated as harmless. let decodedKey = key; try { decodedKey = decodeURIComponent(key); } catch { /* malformed encoding — fall back to the raw key */ } return isSensitiveKey(decodedKey) ? `${key}=${REDACTED}` : pair; }) .join('&'); return `${path}?${redacted}`; }