diff --git a/docs/SENSITIVE_FIELD_REDACTION.md b/docs/SENSITIVE_FIELD_REDACTION.md new file mode 100644 index 00000000..c5029d7d --- /dev/null +++ b/docs/SENSITIVE_FIELD_REDACTION.md @@ -0,0 +1,25 @@ +# 🔒 Sensitive Field Redaction Policy & Logging Sanitation + +This document defines the centralized logging redaction policies implemented across the NotifyChain platform (Issue #691). + +--- + +## 1. Redaction Rules & Scope + +All log messages and metadata emitted via `logger` are automatically sanitized before reaching any transport (Console, JSON log streams, File, CloudWatch, Datadog): + +| Category | Target Patterns | Sanitized Representation | +|---|---|---| +| **Sensitive Keys** | `password`, `secret`, `authorization`, `auth_token`, `bearer`, `api_key`, `private_key`, `secret_key`, `webhook_url` | `[REDACTED]` | +| **Bearer Tokens** | `Bearer ` in headers or messages | `Bearer [REDACTED]` | +| **Discord Webhooks** | `https://discord.com/api/webhooks//` | `https://discord.com/api/webhooks/[REDACTED_WEBHOOK_URL]` | +| **Stellar Secret Keys** | `S[A-Z2-7]{55}` (StrKey Ed25519) | `S[REDACTED_STELLAR_SECRET_KEY]` | +| **Error Stacks & Causes** | Error message, stack trace, and nested causal chains | Regex-sanitized | + +--- + +## 2. Implementation & Unit Tests + +* Redaction engine: [`listener/src/utils/redact.ts`](../listener/src/utils/redact.ts) +* Logger integration: [`listener/src/utils/logger.ts`](../listener/src/utils/logger.ts) +* Verification suite: [`listener/src/utils/redact.test.ts`](../listener/src/utils/redact.test.ts) diff --git a/listener/src/utils/logger.ts b/listener/src/utils/logger.ts index 53f7f2c9..1c60e3d5 100644 --- a/listener/src/utils/logger.ts +++ b/listener/src/utils/logger.ts @@ -79,19 +79,18 @@ export function formatError(error: unknown): FormattedError | string { return String(error); } -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- +import { redactSensitiveData, redactString } from './redact'; function formatMeta(meta: LogContext): LogContext { if (!('error' in meta) || meta.error === undefined) { - return meta; + return redactSensitiveData(meta) as LogContext; } - return { + const formatted = { ...meta, error: formatError(meta.error), }; + return redactSensitiveData(formatted) as LogContext; } function logWithMeta( @@ -99,10 +98,11 @@ function logWithMeta( message: string, meta?: LogContext ): void { + const sanitizedMessage = redactString(message); if (meta && Object.keys(meta).length > 0) { - baseLogger[level](message, formatMeta(meta)); + baseLogger[level](sanitizedMessage, formatMeta(meta)); } else { - baseLogger[level](message); + baseLogger[level](sanitizedMessage); } } diff --git a/listener/src/utils/redact.test.ts b/listener/src/utils/redact.test.ts new file mode 100644 index 00000000..971ff4ab --- /dev/null +++ b/listener/src/utils/redact.test.ts @@ -0,0 +1,94 @@ +import { + isSensitiveKey, + redactSensitiveData, + redactString, + REDACTED_PLACEHOLDER, +} from './redact'; + +describe('Sensitive Field Redaction (Issue #691)', () => { + describe('isSensitiveKey', () => { + test('identifies sensitive key names regardless of casing', () => { + expect(isSensitiveKey('password')).toBe(true); + expect(isSensitiveKey('PASSWORD')).toBe(true); + expect(isSensitiveKey('userSecret')).toBe(true); + expect(isSensitiveKey('authorization')).toBe(true); + expect(isSensitiveKey('bearer_token')).toBe(true); + expect(isSensitiveKey('api_key')).toBe(true); + expect(isSensitiveKey('apiKey')).toBe(true); + expect(isSensitiveKey('webhook_url')).toBe(true); + expect(isSensitiveKey('stellar_secret_key')).toBe(true); + }); + + test('ignores non-sensitive key names', () => { + expect(isSensitiveKey('username')).toBe(false); + expect(isSensitiveKey('contractAddress')).toBe(false); + expect(isSensitiveKey('ledgerSequence')).toBe(false); + expect(isSensitiveKey('eventId')).toBe(false); + }); + }); + + describe('redactString', () => { + test('redacts bearer tokens in string headers', () => { + const input = 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xyz'; + const output = redactString(input); + expect(output).toBe('Authorization: Bearer [REDACTED]'); + }); + + test('redacts Discord webhook URLs', () => { + const input = 'Sending notification to https://discord.com/api/webhooks/123456789/AbCdEfG_SecretToken'; + const output = redactString(input); + expect(output).toBe('Sending notification to https://discord.com/api/webhooks/[REDACTED_WEBHOOK_URL]'); + }); + + test('redacts Stellar secret keys', () => { + const secret = 'SCZANGBA5YHTNYVVV4C3U252E2B6P6IRKD45DCAHSKV2U2B6P6IRKD45'; + const input = `Sign transaction using ${secret}`; + const output = redactString(input); + expect(output).toContain('S[REDACTED_STELLAR_SECRET_KEY]'); + expect(output).not.toContain(secret); + }); + }); + + describe('redactSensitiveData', () => { + test('redacts sensitive fields in nested objects', () => { + const rawPayload = { + userId: 'user-001', + credentials: { + apiKey: 'super-secret-key-12345', + password: 'my-plaintext-password', + }, + webhookUrl: 'https://discord.com/api/webhooks/999/secret_token', + status: 'active', + }; + + const sanitized = redactSensitiveData(rawPayload) as typeof rawPayload; + + expect(sanitized.userId).toBe('user-001'); + expect(sanitized.status).toBe('active'); + expect(sanitized.credentials.apiKey).toBe(REDACTED_PLACEHOLDER); + expect(sanitized.credentials.password).toBe(REDACTED_PLACEHOLDER); + expect(sanitized.webhookUrl).toBe(REDACTED_PLACEHOLDER); + }); + + test('redacts sensitive information in Error instances and stack traces', () => { + const secret = 'SCZANGBA5YHTNYVVV4C3U252E2B6P6IRKD45DCAHSKV2U2B6P6IRKD45'; + const err = new Error(`Connection failed with key ${secret}`); + + const sanitized = redactSensitiveData(err) as Record; + + expect(sanitized.name).toBe('Error'); + expect(sanitized.message).not.toContain(secret); + expect(sanitized.message).toContain('S[REDACTED_STELLAR_SECRET_KEY]'); + if (sanitized.stack) { + expect(sanitized.stack).not.toContain(secret); + } + }); + + test('handles circular references gracefully without crashing', () => { + const circular: Record = { name: 'cyclic' }; + circular.self = circular; + + expect(() => redactSensitiveData(circular)).not.toThrow(); + }); + }); +}); diff --git a/listener/src/utils/redact.ts b/listener/src/utils/redact.ts new file mode 100644 index 00000000..5bf78100 --- /dev/null +++ b/listener/src/utils/redact.ts @@ -0,0 +1,111 @@ +/** + * Sensitive Field Redaction Utility (Issue #691) + * + * Centralized redaction policy for credentials, webhook URLs, bearer tokens, + * private keys, and authorization headers before emission to log transports. + */ + +export const REDACTED_PLACEHOLDER = '[REDACTED]'; + +// Sensitive key names (case-insensitive substring match) +export const SENSITIVE_KEY_PATTERNS = [ + /password/i, + /secret/i, + /authorization/i, + /auth_token/i, + /bearer/i, + /api[_-]?key/i, + /private[_-]?key/i, + /secret[_-]?key/i, + /webhook[_-]?url/i, + /access[_-]?token/i, + /refresh[_-]?token/i, +]; + +// Regex patterns to redact sensitive values embedded inside strings +export const SENSITIVE_VALUE_REGEXES = [ + // Bearer tokens: Bearer + { regex: /Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, replacement: 'Bearer [REDACTED]' }, + // Discord Webhook URLs: https://discord.com/api/webhooks// + { + regex: /https:\/\/(?:discord|discordapp)\.com\/api\/webhooks\/\d+\/[A-Za-z0-9_-]+/gi, + replacement: 'https://discord.com/api/webhooks/[REDACTED_WEBHOOK_URL]', + }, + // Stellar secret keys (starts with S, 56 uppercase chars Base32) + { regex: /\bS[A-Z2-7]{55}\b/g, replacement: 'S[REDACTED_STELLAR_SECRET_KEY]' }, + // Embedded basic auth credentials in URLs: http(s)://user:pass@host + { regex: /https?:\/\/[^/:]+:([^/@]+)@/gi, replacement: 'https://[REDACTED_AUTH]@' }, +]; + +/** + * Checks whether a given object key is considered sensitive. + */ +export function isSensitiveKey(key: string): boolean { + return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(key)); +} + +/** + * Redacts known sensitive patterns inside arbitrary strings. + */ +export function redactString(str: string): string { + let result = str; + for (const { regex, replacement } of SENSITIVE_VALUE_REGEXES) { + result = result.replace(regex, replacement); + } + return result; +} + +/** + * Recursively redacts sensitive keys and values from arbitrary objects/data. + */ +export function redactSensitiveData(data: unknown, seen = new WeakSet()): unknown { + if (data === null || data === undefined) { + return data; + } + + if (typeof data === 'string') { + return redactString(data); + } + + if (typeof data === 'number' || typeof data === 'boolean') { + return data; + } + + if (typeof data === 'object') { + // Avoid cyclic references + if (seen.has(data)) { + return '[Circular Reference]'; + } + seen.add(data); + + if (Array.isArray(data)) { + return data.map((item) => redactSensitiveData(item, seen)); + } + + if (data instanceof Error) { + const copy: Record = { + name: data.name, + message: redactString(data.message), + }; + if (data.stack) { + copy.stack = redactString(data.stack); + } + if ('cause' in data && data.cause !== undefined) { + copy.cause = redactSensitiveData(data.cause, seen); + } + return copy; + } + + const redactedObj: Record = {}; + for (const [key, value] of Object.entries(data)) { + if (isSensitiveKey(key)) { + redactedObj[key] = REDACTED_PLACEHOLDER; + } else { + redactedObj[key] = redactSensitiveData(value, seen); + } + } + return redactedObj; + } + + return data; +}