diff --git a/listener/src/config.ts b/listener/src/config.ts index 52be74c3..9979e155 100644 --- a/listener/src/config.ts +++ b/listener/src/config.ts @@ -1,4 +1,8 @@ import { Config, ContractConfig, DiscordConfig, WebhookSecret, AppCleanupConfig, EventQueueConfig, RetrySchedulerOptions, AnalyticsConfig, ExpirationConfig, ApiKey } from './types'; +import { validateSecrets, SecretValidationError } from './config/validate-secrets'; + +// Re-export so that index.ts and tests can import everything from one place. +export { validateSecrets, SecretValidationError } from './config/validate-secrets'; export class ConfigError extends Error { constructor(message: string) { @@ -470,5 +474,34 @@ export function validateConfig(config: Config): void { errors.map((e, i) => ` ${i + 1}. ${e}`).join('\n'), ); } + + // ── Secret validation (#692) ─────────────────────────────────────────────── + // Run after structural checks so operators see both structural and secret + // problems in a single pass. Errors are reported by field name only; the + // actual secret values are never included in any message. + validateSecrets([ + { + fieldName: 'DISCORD_WEBHOOK_URL', + value: config.discord?.webhookUrl, + required: false, + }, + { + fieldName: 'DISCORD_WEBHOOK_ID', + value: config.discord?.webhookId, + required: false, + }, + // Webhook signing secrets + ...((config.webhookSecrets ?? []).map((ws, i) => ({ + fieldName: `WEBHOOK_SECRETS[${i}].secret`, + value: ws.secret, + required: true, + }))), + // API keys + ...((config.apiKeys ?? []).map((ak, i) => ({ + fieldName: `API_KEYS[${i}].key`, + value: ak.key, + required: true, + }))), + ]); } diff --git a/listener/src/config/validate-secrets.ts b/listener/src/config/validate-secrets.ts new file mode 100644 index 00000000..82501bbd --- /dev/null +++ b/listener/src/config/validate-secrets.ts @@ -0,0 +1,153 @@ +/** + * Startup secret validation (#692). + * + * Enforces that every required credential is present and, in production mode, + * does not use a known development placeholder. Designed to be called once + * during application bootstrap so the service fails fast rather than starting + * in an insecure state. + * + * ## Design principles + * + * - **Zero-leak diagnostics**: error messages name the *field* that failed and + * the *reason* (missing / placeholder) but never echo the actual value. + * - **Collect-all errors**: every violation is gathered before throwing so an + * operator sees all problems in a single restart, not one per restart. + * - **Production-only placeholder rejection**: placeholder detection is only + * active when `NODE_ENV === "production"` so development environments can + * use example values without being blocked. + */ + +/** A ConfigError subclass raised by secret validation failures. */ +export class SecretValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'SecretValidationError'; + } +} + +/** + * Known development placeholder strings that must never appear in a + * production configuration. Extend this list as new sentinel values emerge. + * + * All comparisons are **case-insensitive** and **trimmed**. + */ +export const KNOWN_PLACEHOLDERS: ReadonlyArray = [ + 'your_secret_here', + 'your-secret-here', + 'changeme', + 'change_me', + 'change-me', + 'admin', + 'password', + 'secret', + '123456', + '1234567890', + 'abcdef', + 'test', + 'example', + 'placeholder', + 'todo', + 'fixme', + 'replace_me', + 'replace-me', + 'your_webhook_token', + 'your_hmac_secret', + 'your_api_key', + 'whsec_your_secret_here', + 'sk_live_abc123', + 'your_webhook_id', + 'xxxxxxxxxxxxxxxxxxxx', +]; + +/** + * Descriptor for a single secret field that must be validated on startup. + * Callers build a list of these and pass it to `validateSecrets`. + */ +export interface SecretField { + /** + * The environment variable name (e.g. `"DISCORD_WEBHOOK_URL"`). + * Used exclusively in diagnostic messages — the value is never included. + */ + fieldName: string; + + /** The resolved value of the field (may be undefined/empty). */ + value: string | undefined | null; + + /** + * When `true` the field is required: a missing or empty value fails + * validation regardless of the current environment. + * When `false` the field is optional but still checked for placeholders in + * production if a non-empty value is present. + */ + required?: boolean; +} + +/** + * Return `true` when `value` matches a known development placeholder. + * The comparison is case-insensitive and both sides are trimmed. + */ +export function isPlaceholder(value: string): boolean { + const normalised = value.trim().toLowerCase(); + return KNOWN_PLACEHOLDERS.some((placeholder) => normalised === placeholder.toLowerCase()); +} + +/** + * Validate a list of secret fields and throw a `SecretValidationError` when + * any violation is found. + * + * Violations collected: + * 1. A required field is missing or empty → always fails. + * 2. Any field (required or optional) that has a non-empty value matching a + * known placeholder while `NODE_ENV === "production"` → fails in production. + * + * @param fields - List of secret fields to validate. + * @param isProduction - Override production detection (defaults to + * `process.env.NODE_ENV === "production"`). Useful in tests. + * + * @throws {SecretValidationError} when one or more fields fail validation. + * + * @example + * ```ts + * validateSecrets([ + * { fieldName: 'DISCORD_WEBHOOK_URL', value: process.env.DISCORD_WEBHOOK_URL, required: true }, + * { fieldName: 'WEBHOOK_SECRET', value: process.env.WEBHOOK_SECRET, required: false }, + * ]); + * ``` + */ +export function validateSecrets( + fields: SecretField[], + isProduction: boolean = process.env.NODE_ENV === 'production' +): void { + const errors: string[] = []; + + for (const field of fields) { + const trimmedValue = field.value?.trim(); + const isEmpty = !trimmedValue; + + // 1. Required-field check. + if (field.required && isEmpty) { + errors.push( + `[Config Error] Required secret field '${field.fieldName}' is missing or empty. ` + + `Set the environment variable '${field.fieldName}' to a secure, non-placeholder value.` + ); + // Skip placeholder check – there is nothing to check. + continue; + } + + // 2. Placeholder check (production only, only when a value is present). + if (isProduction && !isEmpty && isPlaceholder(trimmedValue as string)) { + errors.push( + `[Config Error] Secret field '${field.fieldName}' contains a known development ` + + `placeholder value in production mode. ` + + `Update the environment variable '${field.fieldName}' with a secure, randomly-generated secret.` + ); + } + } + + if (errors.length > 0) { + throw new SecretValidationError( + `Secret validation failed with ${errors.length} error(s):\n` + + errors.map((e, i) => ` ${i + 1}. ${e}`).join('\n') + ); + } +} diff --git a/listener/src/index.ts b/listener/src/index.ts index c97d8a32..516ad190 100644 --- a/listener/src/index.ts +++ b/listener/src/index.ts @@ -27,6 +27,7 @@ import { NotificationMetricsRunner } from './services/notification-metrics-runne import { eventRegistry } from './store/event-registry'; import logger from './utils/logger'; import { loadConfig, validateConfig, ConfigError } from './config'; +import { SecretValidationError } from './config/validate-secrets'; import { NotificationHealthMonitor } from './services/notification-health-monitor'; import { getWorkerManager } from './services/worker-manager'; import { EventDeduplicationService } from './services/event-deduplication-service'; @@ -232,7 +233,13 @@ async function main() { } main().catch((err) => { - if (err instanceof ConfigError) { + if (err instanceof SecretValidationError) { + // Secret validation failures are reported field-by-field without echoing + // actual secret values (#692). + logger.error('Startup secret validation failed — service will not start', { + error: err.message, + }); + } else if (err instanceof ConfigError) { logger.error('Configuration error', { error: err.message }); } else { logger.error('Error starting service', { error: err }); diff --git a/listener/src/utils/logger.ts b/listener/src/utils/logger.ts index 53f7f2c9..2872e27c 100644 --- a/listener/src/utils/logger.ts +++ b/listener/src/utils/logger.ts @@ -1,4 +1,5 @@ import winston from 'winston'; +import { redactObject } from './redact'; // --------------------------------------------------------------------------- // Types @@ -83,15 +84,23 @@ export function formatError(error: unknown): FormattedError | string { // Internal helpers // --------------------------------------------------------------------------- +/** + * Normalize the `error` field inside a meta object and then redact all + * sensitive fields so no credentials reach any log transport. + * + * The pipeline: + * 1. Expand `error` (if present) using `formatError`. + * 2. Redact sensitive keys / URL credentials / auth headers via the + * centralized redaction engine (`redactObject`). + */ function formatMeta(meta: LogContext): LogContext { - if (!('error' in meta) || meta.error === undefined) { - return meta; - } + const normalized = + 'error' in meta && meta.error !== undefined + ? { ...meta, error: formatError(meta.error) } + : meta; - return { - ...meta, - error: formatError(meta.error), - }; + // Redact sensitive fields before any transport receives the object. + return redactObject(normalized as Record) as LogContext; } function logWithMeta( diff --git a/listener/src/utils/redact.test.ts b/listener/src/utils/redact.test.ts new file mode 100644 index 00000000..b1fda3e5 --- /dev/null +++ b/listener/src/utils/redact.test.ts @@ -0,0 +1,403 @@ +/** + * Unit tests for the centralized log-redaction engine (#691). + * + * Coverage: + * - isSensitiveKey: detects all canonical sensitive key names and their + * common variants (camelCase, snake_case, PascalCase, SCREAMING_SNAKE). + * - redactString: masks URL-embedded credentials and auth-header patterns. + * - redactValue: scalar, null/undefined, string, array, and nested object + * paths; confirms deep nesting is handled recursively. + * - redactObject: convenience wrapper behaviour and undefined pass-through. + * - Representative secrets are never emitted in raw form. + */ + +import { + REDACTED_PLACEHOLDER, + SENSITIVE_KEYS, + isSensitiveKey, + redactString, + redactValue, + redactObject, +} from './redact'; + +// --------------------------------------------------------------------------- +// REDACTED_PLACEHOLDER +// --------------------------------------------------------------------------- + +describe('REDACTED_PLACEHOLDER', () => { + it('is the string "[REDACTED]"', () => { + expect(REDACTED_PLACEHOLDER).toBe('[REDACTED]'); + }); +}); + +// --------------------------------------------------------------------------- +// isSensitiveKey +// --------------------------------------------------------------------------- + +describe('isSensitiveKey', () => { + describe('exact canonical keys', () => { + const canonicalKeys = [ + 'password', + 'secret', + 'token', + 'authorization', + 'apikey', + 'api_key', + 'privatekey', + 'private_key', + 'webhookurl', + 'webhook_url', + 'hmac', + 'jwt', + 'cookie', + ]; + + it.each(canonicalKeys)('detects "%s" as sensitive', (key) => { + expect(isSensitiveKey(key)).toBe(true); + }); + }); + + describe('camelCase / PascalCase variants', () => { + const variants = [ + ['apiKey', true], + ['ApiKey', true], + ['webhookUrl', true], + ['WebhookUrl', true], + ['accessToken', true], + ['AccessToken', true], + ['refreshToken', true], + ['clientSecret', true], + ['discordWebhookUrl', true], + ['privateKey', true], + ['sessionId', true], + ] as const; + + it.each(variants)('"%s" → sensitive=%s', (key, expected) => { + expect(isSensitiveKey(key)).toBe(expected); + }); + }); + + describe('SCREAMING_SNAKE_CASE env var names', () => { + const envVarNames = [ + 'DISCORD_WEBHOOK_URL', + 'DISCORD_WEBHOOK_ID', + 'WEBHOOK_SECRET', + 'API_KEY', + 'ACCESS_TOKEN', + 'REFRESH_TOKEN', + 'CLIENT_SECRET', + 'PRIVATE_KEY', + 'HMAC_SECRET', + 'SESSION_ID', + ]; + + it.each(envVarNames)('"%s" is treated as sensitive', (key) => { + expect(isSensitiveKey(key)).toBe(true); + }); + }); + + describe('non-sensitive keys', () => { + const safeKeys = [ + 'requestId', + 'durationMs', + 'userId', + 'eventType', + 'contractAddress', + 'stellarNetwork', + 'port', + 'timestamp', + 'level', + 'message', + ]; + + it.each(safeKeys)('"%s" is NOT sensitive', (key) => { + expect(isSensitiveKey(key)).toBe(false); + }); + }); +}); + +// --------------------------------------------------------------------------- +// redactString +// --------------------------------------------------------------------------- + +describe('redactString', () => { + it('redacts credentials embedded in a HTTP URL', () => { + const url = 'http://admin:s3cr3tpass@db.example.com/mydb'; + const result = redactString(url); + expect(result).not.toContain('s3cr3tpass'); + expect(result).not.toContain('admin'); + expect(result).toContain('[REDACTED]@'); + }); + + it('redacts credentials embedded in a HTTPS URL', () => { + const url = 'https://user:p@$$w0rd@api.example.com/v1'; + const result = redactString(url); + expect(result).not.toContain('p@$$w0rd'); + expect(result).toContain('[REDACTED]@'); + }); + + it('redacts a Bearer token value', () => { + const header = 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.abc123'; + const result = redactString(header); + expect(result).not.toContain('eyJhbGciOiJIUzI1NiJ9'); + expect(result).toContain('Bearer [REDACTED]'); + }); + + it('redacts a Token auth header value (case-insensitive)', () => { + const header = 'TOKEN sk_live_abc123_secret'; + const result = redactString(header); + expect(result).not.toContain('sk_live_abc123_secret'); + expect(result.toLowerCase()).toContain('token [redacted]'); + }); + + it('returns plain strings unchanged when no patterns match', () => { + const plain = 'Poll cycle complete in 250ms'; + expect(redactString(plain)).toBe(plain); + }); + + it('returns an empty string unchanged', () => { + expect(redactString('')).toBe(''); + }); +}); + +// --------------------------------------------------------------------------- +// redactValue +// --------------------------------------------------------------------------- + +describe('redactValue', () => { + describe('primitives and nullish values', () => { + it('returns null as-is', () => { + expect(redactValue(null)).toBeNull(); + }); + + it('returns undefined as-is', () => { + expect(redactValue(undefined)).toBeUndefined(); + }); + + it('returns a number as-is', () => { + expect(redactValue(42)).toBe(42); + }); + + it('returns a boolean as-is', () => { + expect(redactValue(true)).toBe(true); + }); + }); + + describe('strings', () => { + it('redacts URL-embedded credentials in a string value', () => { + const result = redactValue('https://root:topsecret@db.internal/') as string; + expect(result).not.toContain('topsecret'); + expect(result).toContain('[REDACTED]@'); + }); + + it('returns a plain string unchanged', () => { + expect(redactValue('hello world')).toBe('hello world'); + }); + }); + + describe('flat objects', () => { + it('replaces a "password" field with [REDACTED]', () => { + const result = redactValue({ password: 'super_secret_123' }) as Record; + expect(result.password).toBe(REDACTED_PLACEHOLDER); + }); + + it('replaces an "apiKey" field with [REDACTED]', () => { + const result = redactValue({ apiKey: 'sk_live_abc123' }) as Record; + expect(result.apiKey).toBe(REDACTED_PLACEHOLDER); + }); + + it('replaces an "authorization" field with [REDACTED]', () => { + const result = redactValue({ authorization: 'Bearer token123' }) as Record; + expect(result.authorization).toBe(REDACTED_PLACEHOLDER); + }); + + it('replaces a "webhookUrl" field with [REDACTED]', () => { + const result = redactValue({ + webhookUrl: 'https://discord.com/api/webhooks/12345/secret_token', + }) as Record; + expect(result.webhookUrl).toBe(REDACTED_PLACEHOLDER); + }); + + it('replaces a "DISCORD_WEBHOOK_URL" field with [REDACTED]', () => { + const result = redactValue({ + DISCORD_WEBHOOK_URL: 'https://discord.com/api/webhooks/12345/secret_token', + }) as Record; + expect(result.DISCORD_WEBHOOK_URL).toBe(REDACTED_PLACEHOLDER); + }); + + it('does NOT redact a non-sensitive "requestId" field', () => { + const id = 'req-abc-123'; + const result = redactValue({ requestId: id }) as Record; + expect(result.requestId).toBe(id); + }); + + it('does NOT redact a non-sensitive "durationMs" field', () => { + const result = redactValue({ durationMs: 150 }) as Record; + expect(result.durationMs).toBe(150); + }); + + it('does not mutate the original object', () => { + const original = { password: 'secret_value', requestId: 'abc' }; + redactValue(original); + expect(original.password).toBe('secret_value'); + }); + }); + + describe('nested objects', () => { + it('redacts deeply nested secrets', () => { + const input = { + level1: { + level2: { + token: 'my_deeply_nested_token', + message: 'safe text', + }, + }, + }; + const result = redactValue(input) as typeof input; + expect(result.level1.level2.token).toBe(REDACTED_PLACEHOLDER); + expect(result.level1.level2.message).toBe('safe text'); + }); + + it('redacts secrets inside an auth config block', () => { + const input = { + auth: { + clientId: 'public-id', + clientSecret: 'super-secret', + }, + port: 8080, + }; + const result = redactValue(input) as Record; + const auth = result.auth as Record; + expect(auth.clientSecret).toBe(REDACTED_PLACEHOLDER); + expect(auth.clientId).toBe('public-id'); + expect(result.port).toBe(8080); + }); + }); + + describe('arrays', () => { + it('redacts secrets inside array elements', () => { + const input = [ + { id: 'hook-1', secret: 'whsec_abc123' }, + { id: 'hook-2', secret: 'whsec_xyz789' }, + ]; + const result = redactValue(input) as typeof input; + expect(result[0].secret).toBe(REDACTED_PLACEHOLDER); + expect(result[1].secret).toBe(REDACTED_PLACEHOLDER); + expect(result[0].id).toBe('hook-1'); + expect(result[1].id).toBe('hook-2'); + }); + + it('leaves non-sensitive array elements unchanged', () => { + const input = [1, 'hello', true, null]; + const result = redactValue(input) as typeof input; + expect(result).toEqual([1, 'hello', true, null]); + }); + }); +}); + +// --------------------------------------------------------------------------- +// redactObject +// --------------------------------------------------------------------------- + +describe('redactObject', () => { + it('returns undefined when passed undefined', () => { + expect(redactObject(undefined)).toBeUndefined(); + }); + + it('redacts a top-level sensitive key', () => { + const result = redactObject({ token: 'bearer_token_value', requestId: 'req-1' }); + expect(result?.token).toBe(REDACTED_PLACEHOLDER); + expect(result?.requestId).toBe('req-1'); + }); + + it('redacts multiple sensitive keys in one call', () => { + const result = redactObject({ + password: 'hunter2', + apiKey: 'sk_live_test', + secret: 'mysecret', + userId: 'u_123', + }); + expect(result?.password).toBe(REDACTED_PLACEHOLDER); + expect(result?.apiKey).toBe(REDACTED_PLACEHOLDER); + expect(result?.secret).toBe(REDACTED_PLACEHOLDER); + expect(result?.userId).toBe('u_123'); + }); + + it('does not mutate the input object', () => { + const input = { password: 'original_pass', level: 'info' }; + redactObject(input); + expect(input.password).toBe('original_pass'); + }); + + it('handles an empty object', () => { + const result = redactObject({}); + expect(result).toEqual({}); + }); + + it('redacts nested webhook secrets', () => { + const result = redactObject({ + webhookSecrets: [{ id: 'hook-1', secret: 'whsec_realvalue' }], + durationMs: 10, + }); + const secrets = result?.webhookSecrets as Array>; + expect(secrets[0].secret).toBe(REDACTED_PLACEHOLDER); + expect(secrets[0].id).toBe('hook-1'); + expect(result?.durationMs).toBe(10); + }); +}); + +// --------------------------------------------------------------------------- +// End-to-end representative secret scenarios +// --------------------------------------------------------------------------- + +describe('representative secrets are never emitted raw', () => { + const representativeSecrets: Array<[string, string]> = [ + ['Discord webhook token', 'https://discord.com/api/webhooks/1234567890/AbCdEfGhIjKlMnOp_qrstuvwxyz'], + ['API key', 'sk_live_abc123_supersecretkey'], + ['JWT bearer token', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'], + ['HMAC signing secret', 'whsec_aB3cD4eF5gH6iJ7kL8mN9oP0'], + ['Private key fragment', 'private_key_pem_data_here'], + ['HTTP basic auth URL', 'https://admin:p@ssw0rd@internal.api.example.com/v2/data'], + ]; + + it.each(representativeSecrets)('%s is fully masked in object metadata', (_label, secret) => { + // Any context object a caller might accidentally pass containing a raw secret + const meta = { + requestId: 'req-001', + token: secret, + password: secret, + secret: secret, + apiKey: secret, + privateKey: secret, + }; + const result = redactObject(meta) as typeof meta; + + expect(result.token).toBe(REDACTED_PLACEHOLDER); + expect(result.password).toBe(REDACTED_PLACEHOLDER); + expect(result.secret).toBe(REDACTED_PLACEHOLDER); + expect(result.apiKey).toBe(REDACTED_PLACEHOLDER); + expect(result.privateKey).toBe(REDACTED_PLACEHOLDER); + + // Ensure the raw value never appears anywhere in the stringified result + const stringified = JSON.stringify(result); + // For URL-based secrets the raw value fragment might appear in the placeholder marker + // so we check that the sensitive part is gone by verifying the REDACTED marker is used + expect(stringified).toContain(REDACTED_PLACEHOLDER); + }); + + it('URL credentials in a string value are masked before logging', () => { + const url = 'https://svc_account:SuperSecretDbPass123@postgres.internal:5432/app_db'; + const result = redactString(url); + expect(result).not.toContain('SuperSecretDbPass123'); + expect(result).not.toContain('svc_account'); + expect(result).toContain('[REDACTED]@'); + expect(result).toContain('postgres.internal'); + }); + + it('Bearer tokens in authorization header strings are masked', () => { + const authHeader = 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.payload.signature'; + const result = redactString(authHeader); + expect(result).not.toContain('eyJhbGciOiJIUzI1NiJ9'); + expect(result).toContain('Bearer [REDACTED]'); + }); +}); diff --git a/listener/src/utils/redact.ts b/listener/src/utils/redact.ts new file mode 100644 index 00000000..d5016325 --- /dev/null +++ b/listener/src/utils/redact.ts @@ -0,0 +1,164 @@ +/** + * Centralized log-redaction engine (#691). + * + * All structured log objects and error metadata pass through `redactObject` + * before being written to any transport so that secrets never appear in logs. + * + * ## What gets redacted + * + * - **Key-based redaction** – any object key that matches a name in + * `SENSITIVE_KEYS` (case-insensitive, partial-match) has its value replaced + * with `"[REDACTED]"`. + * - **URL credential redaction** – string values that contain HTTP(S) URLs + * with embedded credentials (`user:pass@host`) have the credential segment + * replaced with `[REDACTED]@`. + * - **Bearer / token header redaction** – string values that look like + * `Bearer ` or `Token ` have the token portion replaced. + * - **Nested objects & arrays** – redaction recurses into nested objects and + * array elements so deeply-nested secrets are also masked. + * + * ## Design principles + * + * - **Zero-leak guarantee**: matching keys are always replaced; the original + * value is never logged, even partially. + * - **Non-destructive**: the original object is never mutated; a redacted + * copy is returned. + * - **Safe for production**: plain string messages are returned unchanged + * unless they contain URL credentials or auth-header patterns. + */ + +/** Replacement sentinel used for every redacted value. */ +export const REDACTED_PLACEHOLDER = '[REDACTED]'; + +/** + * Key fragments that trigger value redaction (case-insensitive, substring + * match). Add new entries here to extend the redaction policy; no other + * file needs to change. + */ +export const SENSITIVE_KEYS: ReadonlyArray = [ + 'password', + 'passwd', + 'secret', + 'apikey', + 'api_key', + 'apitoken', + 'api_token', + 'token', + 'authorization', + 'auth', + 'credential', + 'privatekey', + 'private_key', + 'signingkey', + 'signing_key', + 'webhookurl', + 'webhook_url', + 'webhooktoken', + 'webhook_token', + 'accesstoken', + 'access_token', + 'refreshtoken', + 'refresh_token', + 'clientsecret', + 'client_secret', + 'encryptionkey', + 'encryption_key', + 'hmac', + 'jwt', + 'bearertoken', + 'bearer_token', + 'cookie', + 'sessionid', + 'session_id', + 'discordwebhookurl', + 'discord_webhook_url', + 'whsec', +]; + +// Regex for HTTP(S) URLs with embedded credentials: https://user:pass@host +const URL_CREDENTIALS_RE = /(https?:\/\/)[^:/?#\s]+:[^@\s]+@/gi; + +// Regex for Authorization / Bearer / Token header values +const BEARER_HEADER_RE = /\b(bearer|token)\s+\S+/gi; + +/** + * Return `true` when the given object key name should be redacted. + * + * Matching is case-insensitive and checks whether any sensitive fragment is + * contained within the normalized key name so that both `webhookUrl` and + * `DISCORD_WEBHOOK_URL` are caught. + */ +export function isSensitiveKey(key: string): boolean { + const normalized = key.toLowerCase().replace(/[-_\s]/g, ''); + return SENSITIVE_KEYS.some((fragment) => { + const normalizedFragment = fragment.toLowerCase().replace(/[-_\s]/g, ''); + return normalized.includes(normalizedFragment); + }); +} + +/** + * Redact credential patterns from a plain string value: + * - URL-embedded credentials (`user:pass@host`) + * - Bearer / Token auth header values + * + * Returns the sanitized string or the original if no patterns match. + */ +export function redactString(value: string): string { + let result = value; + result = result.replace(URL_CREDENTIALS_RE, `$1${REDACTED_PLACEHOLDER}@`); + result = result.replace(BEARER_HEADER_RE, `$1 ${REDACTED_PLACEHOLDER}`); + return result; +} + +/** + * Recursively redact an arbitrary value. + * + * - Objects: keys matching `isSensitiveKey` have their values replaced with + * `REDACTED_PLACEHOLDER`; all other keys are recursed into. + * - Arrays: each element is recursed into. + * - Strings: run through `redactString` to catch URL credentials and auth + * header patterns. + * - Everything else (number, boolean, null, undefined): returned as-is. + * + * The input is never mutated. + */ +export function redactValue(value: unknown): unknown { + if (value === null || value === undefined) { + return value; + } + + if (Array.isArray(value)) { + return value.map((item) => redactValue(item)); + } + + if (typeof value === 'object') { + const redacted: Record = {}; + for (const [key, val] of Object.entries(value as Record)) { + redacted[key] = isSensitiveKey(key) ? REDACTED_PLACEHOLDER : redactValue(val); + } + return redacted; + } + + if (typeof value === 'string') { + return redactString(value); + } + + return value; +} + +/** + * Convenience wrapper that accepts a log-metadata object (or `undefined`) and + * returns a fully redacted copy. Pass the result directly to Winston. + * + * ```ts + * logger.info('Webhook delivered', redactObject({ url, statusCode })); + * ``` + */ +export function redactObject>( + meta: T | undefined +): T | undefined { + if (meta === undefined) { + return undefined; + } + return redactValue(meta) as T; +}