/** * Structured JSON logger with a hard redaction pass. * * Two separate concerns: * - secrets (tokens, keys, Authorization headers) are ALWAYS redacted; * - PII (customer e-mail, name, message bodies) is redacted unless LOG_PII=true, * which should only ever be enabled temporarily while debugging. */ export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; const LEVELS: Record = { debug: 10, info: 20, warn: 30, error: 40 }; const SECRET_KEY_PATTERN = /(token|secret|password|passwd|api[_-]?key|authorization|consumer_key|consumer_secret|cookie|credential|private[_-]?key)/i; const PII_KEY_PATTERN = /(email|phone|content|question|answer|text|address|first_name|last_name)/i; const EMAIL_PATTERN = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g; const BEARER_PATTERN = /Bearer\s+[A-Za-z0-9._\-+/=]+/gi; const WOO_KEY_PATTERN = /\b(ck|cs)_[A-Za-z0-9]{8,}\b/g; const QUERY_SECRET_PATTERN = /([?&](?:consumer_key|consumer_secret|token|api_key)=)[^&\s]+/gi; export interface LoggerOptions { level: LogLevel; logPii: boolean; } let options: LoggerOptions = { level: 'info', logPii: false }; export function configureLogger(next: Partial): void { options = { ...options, ...next }; } /** Redact secret-looking substrings inside a free-form string. */ export function redactString(input: string, logPii = options.logPii): string { let out = input .replace(BEARER_PATTERN, 'Bearer [REDACTED]') .replace(WOO_KEY_PATTERN, '[REDACTED_WOO_KEY]') .replace(QUERY_SECRET_PATTERN, '$1[REDACTED]'); if (!logPii) { out = out.replace(EMAIL_PATTERN, '[REDACTED_EMAIL]'); } return out; } /** Mask an e-mail for audit records: `jan.kowalski@example.com` -> `j***@example.com`. */ export function maskEmail(email: string): string { const at = email.indexOf('@'); if (at <= 0) return '[REDACTED]'; const local = email.slice(0, at); const domain = email.slice(at + 1); return `${local[0]}***@${domain}`; } /** * Deep-redact an arbitrary value for logging. Secret keys become `[REDACTED]` * unconditionally; PII keys are masked unless PII logging is switched on. */ export function redact(value: unknown, logPii = options.logPii, depth = 0): unknown { if (depth > 8) return '[TRUNCATED_DEPTH]'; if (value === null || value === undefined) return value; if (typeof value === 'string') { const s = redactString(value, logPii); return s.length > 2000 ? `${s.slice(0, 2000)}…[TRUNCATED]` : s; } if (typeof value === 'number' || typeof value === 'boolean') return value; if (Array.isArray(value)) { return value.slice(0, 50).map((v) => redact(v, logPii, depth + 1)); } if (value instanceof Error) { return { name: value.name, message: redactString(value.message, logPii) }; } if (typeof value === 'object') { const out: Record = {}; for (const [k, v] of Object.entries(value as Record)) { if (SECRET_KEY_PATTERN.test(k)) { out[k] = '[REDACTED]'; } else if (!logPii && PII_KEY_PATTERN.test(k)) { out[k] = typeof v === 'string' && v.includes('@') ? maskEmail(v) : '[REDACTED_PII]'; } else { out[k] = redact(v, logPii, depth + 1); } } return out; } return '[UNSERIALIZABLE]'; } function emit(level: LogLevel, msg: string, ctx?: Record): void { if (LEVELS[level] < LEVELS[options.level]) return; const line = { ts: new Date().toISOString(), level, msg: redactString(msg), ...(ctx ? { ctx: redact(ctx) } : {}), }; const serialized = JSON.stringify(line); if (level === 'error' || level === 'warn') process.stderr.write(`${serialized}\n`); else process.stdout.write(`${serialized}\n`); } export const logger = { debug: (msg: string, ctx?: Record) => emit('debug', msg, ctx), info: (msg: string, ctx?: Record) => emit('info', msg, ctx), warn: (msg: string, ctx?: Record) => emit('warn', msg, ctx), error: (msg: string, ctx?: Record) => emit('error', msg, ctx), };