logger.ts 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
  1. /**
  2. * Structured JSON logger with a hard redaction pass.
  3. *
  4. * Two separate concerns:
  5. * - secrets (tokens, keys, Authorization headers) are ALWAYS redacted;
  6. * - PII (customer e-mail, name, message bodies) is redacted unless LOG_PII=true,
  7. * which should only ever be enabled temporarily while debugging.
  8. */
  9. export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
  10. const LEVELS: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 };
  11. const SECRET_KEY_PATTERN =
  12. /(token|secret|password|passwd|api[_-]?key|authorization|consumer_key|consumer_secret|cookie|credential|private[_-]?key)/i;
  13. const PII_KEY_PATTERN = /(email|phone|content|question|answer|text|address|first_name|last_name)/i;
  14. const EMAIL_PATTERN = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
  15. const BEARER_PATTERN = /Bearer\s+[A-Za-z0-9._\-+/=]+/gi;
  16. const WOO_KEY_PATTERN = /\b(ck|cs)_[A-Za-z0-9]{8,}\b/g;
  17. const QUERY_SECRET_PATTERN = /([?&](?:consumer_key|consumer_secret|token|api_key)=)[^&\s]+/gi;
  18. export interface LoggerOptions {
  19. level: LogLevel;
  20. logPii: boolean;
  21. }
  22. let options: LoggerOptions = { level: 'info', logPii: false };
  23. export function configureLogger(next: Partial<LoggerOptions>): void {
  24. options = { ...options, ...next };
  25. }
  26. /** Redact secret-looking substrings inside a free-form string. */
  27. export function redactString(input: string, logPii = options.logPii): string {
  28. let out = input
  29. .replace(BEARER_PATTERN, 'Bearer [REDACTED]')
  30. .replace(WOO_KEY_PATTERN, '[REDACTED_WOO_KEY]')
  31. .replace(QUERY_SECRET_PATTERN, '$1[REDACTED]');
  32. if (!logPii) {
  33. out = out.replace(EMAIL_PATTERN, '[REDACTED_EMAIL]');
  34. }
  35. return out;
  36. }
  37. /** Mask an e-mail for audit records: `jan.kowalski@example.com` -> `j***@example.com`. */
  38. export function maskEmail(email: string): string {
  39. const at = email.indexOf('@');
  40. if (at <= 0) return '[REDACTED]';
  41. const local = email.slice(0, at);
  42. const domain = email.slice(at + 1);
  43. return `${local[0]}***@${domain}`;
  44. }
  45. /**
  46. * Deep-redact an arbitrary value for logging. Secret keys become `[REDACTED]`
  47. * unconditionally; PII keys are masked unless PII logging is switched on.
  48. */
  49. export function redact(value: unknown, logPii = options.logPii, depth = 0): unknown {
  50. if (depth > 8) return '[TRUNCATED_DEPTH]';
  51. if (value === null || value === undefined) return value;
  52. if (typeof value === 'string') {
  53. const s = redactString(value, logPii);
  54. return s.length > 2000 ? `${s.slice(0, 2000)}…[TRUNCATED]` : s;
  55. }
  56. if (typeof value === 'number' || typeof value === 'boolean') return value;
  57. if (Array.isArray(value)) {
  58. return value.slice(0, 50).map((v) => redact(v, logPii, depth + 1));
  59. }
  60. if (value instanceof Error) {
  61. return { name: value.name, message: redactString(value.message, logPii) };
  62. }
  63. if (typeof value === 'object') {
  64. const out: Record<string, unknown> = {};
  65. for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
  66. if (SECRET_KEY_PATTERN.test(k)) {
  67. out[k] = '[REDACTED]';
  68. } else if (!logPii && PII_KEY_PATTERN.test(k)) {
  69. out[k] = typeof v === 'string' && v.includes('@') ? maskEmail(v) : '[REDACTED_PII]';
  70. } else {
  71. out[k] = redact(v, logPii, depth + 1);
  72. }
  73. }
  74. return out;
  75. }
  76. return '[UNSERIALIZABLE]';
  77. }
  78. function emit(level: LogLevel, msg: string, ctx?: Record<string, unknown>): void {
  79. if (LEVELS[level] < LEVELS[options.level]) return;
  80. const line = {
  81. ts: new Date().toISOString(),
  82. level,
  83. msg: redactString(msg),
  84. ...(ctx ? { ctx: redact(ctx) } : {}),
  85. };
  86. const serialized = JSON.stringify(line);
  87. if (level === 'error' || level === 'warn') process.stderr.write(`${serialized}\n`);
  88. else process.stdout.write(`${serialized}\n`);
  89. }
  90. export const logger = {
  91. debug: (msg: string, ctx?: Record<string, unknown>) => emit('debug', msg, ctx),
  92. info: (msg: string, ctx?: Record<string, unknown>) => emit('info', msg, ctx),
  93. warn: (msg: string, ctx?: Record<string, unknown>) => emit('warn', msg, ctx),
  94. error: (msg: string, ctx?: Record<string, unknown>) => emit('error', msg, ctx),
  95. };