| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108 |
- /**
- * 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<LogLevel, number> = { 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<LoggerOptions>): 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<string, unknown> = {};
- for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
- 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<string, unknown>): 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<string, unknown>) => emit('debug', msg, ctx),
- info: (msg: string, ctx?: Record<string, unknown>) => emit('info', msg, ctx),
- warn: (msg: string, ctx?: Record<string, unknown>) => emit('warn', msg, ctx),
- error: (msg: string, ctx?: Record<string, unknown>) => emit('error', msg, ctx),
- };
|