diff --git a/src/lib/redact.spec.ts b/src/lib/redact.spec.ts index 918f9963..84093c35 100644 --- a/src/lib/redact.spec.ts +++ b/src/lib/redact.spec.ts @@ -119,6 +119,72 @@ describe('redactDeep', () => { }); }); + it('redacts string arrays under secret keys', () => { + const input = { + cookies: ['session=123', 'auth=456'], + tokens: ['tok_1', 'tok_2'], + secrets: ['s_alpha', 's_beta'], + nestedList: [['nested_secret']], + }; + expect(redactDeep(input)).toEqual({ + cookies: [REDACTED, REDACTED], + tokens: [REDACTED, REDACTED], + secrets: [REDACTED, REDACTED], + nestedList: [['nested_secret']], + }); + }); + + it('redacts plural and hyphenated secret key variants', () => { + const input = { + cookies: 'c_val', + 'set-cookies': ['c_1', 'c_2'], + passwords: ['p1', 'p2'], + secrets: 's_val', + tokens: 't_val', + apikeys: 'k_val', + 'api-keys': ['k_1', 'k_2'], + accessTokens: ['at_1'], + }; + expect(redactDeep(input)).toEqual({ + cookies: REDACTED, + 'set-cookies': [REDACTED, REDACTED], + passwords: [REDACTED, REDACTED], + secrets: REDACTED, + tokens: REDACTED, + apikeys: REDACTED, + 'api-keys': [REDACTED, REDACTED], + accessTokens: [REDACTED], + }); + }); + + it('redacts secrets on objects with a null prototype', () => { + const nullProto = Object.assign(Object.create(null), { + password: 'hunter2', + nested: Object.assign(Object.create(null), { apiKey: 'sk-secret' }), + publicData: 'hello', + }); + expect(redactDeep(nullProto)).toEqual({ + password: REDACTED, + nested: { apiKey: REDACTED }, + publicData: 'hello', + }); + }); + + it('preserves non-secret sibling fields in object arrays under secret keys', () => { + const input = { + credentials: [ + { username: 'alice', password: 'pw1' }, + { username: 'bob', password: 'pw2' }, + ], + }; + expect(redactDeep(input)).toEqual({ + credentials: [ + { username: 'alice', password: REDACTED }, + { username: 'bob', password: REDACTED }, + ], + }); + }); + it('never mutates the input', () => { const input = { password: 'hunter2' }; redactDeep(input); diff --git a/src/lib/redact.ts b/src/lib/redact.ts index e7de38b1..e995726a 100644 --- a/src/lib/redact.ts +++ b/src/lib/redact.ts @@ -48,10 +48,14 @@ export const REDACTED = '[REDACTED]'; */ const SECRET_KEY_SUFFIXES = [ 'password', + 'passwords', 'secret', + 'secrets', 'apikey', + 'apikeys', 'authorization', 'cookie', + 'cookies', 'credential', 'credentials', ]; @@ -67,13 +71,21 @@ const SECRET_KEY_SUFFIXES = [ */ const SECRET_TOKEN_KEYS = new Set([ 'token', + 'tokens', 'apitoken', + 'apitokens', 'authtoken', + 'authtokens', 'accesstoken', + 'accesstokens', 'refreshtoken', + 'refreshtokens', 'bearertoken', + 'bearertokens', 'idtoken', + 'idtokens', 'sessiontoken', + 'sessiontokens', ]); /** Lowercase and strip every non-alphanumeric character for key matching. */ @@ -87,6 +99,14 @@ function isSecretKey(key: string): boolean { return SECRET_KEY_SUFFIXES.some(suffix => normalized.endsWith(suffix)); } +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + /** * A conservative check for a secret-shaped string VALUE regardless of its * key name — a bearer token embedded in server/error prose (e.g. inside a @@ -114,10 +134,29 @@ function redactSecretLookingSubstrings(value: string): string { return value.replace(BEARER_TOKEN_RE, `Bearer ${REDACTED}`); } +/** + * Recursively mask values residing directly or within arrays under a matched secret key. + * String leaves become {@link REDACTED}; nested arrays are walked to mask their string + * elements; objects are processed via {@link redactDeep} so that non-secret sibling + * fields (e.g. `username` in a credentials object) are preserved. + */ +function redactSecretValue(val: unknown): unknown { + if (typeof val === 'string') { + return REDACTED; + } + if (Array.isArray(val)) { + return val.map(item => redactSecretValue(item)); + } + if (isPlainObject(val)) { + return redactDeep(val); + } + return val; +} + /** * Deep-walk `value`, replacing: - * - the string value of any object key matching {@link isSecretKey}, at any - * depth, arrays included, with {@link REDACTED}; + * - string values (and arrays of string values) of any object key matching + * {@link isSecretKey}, at any depth, with {@link REDACTED}; * - a `Bearer ` substring inside any other string, wherever it * appears (key name irrelevant). * @@ -132,14 +171,14 @@ export function redactDeep(value: T): T { if (typeof value === 'string') { return redactSecretLookingSubstrings(value) as unknown as T; } - if (value !== null && typeof value === 'object' && value.constructor === Object) { + if (isPlainObject(value)) { const result: Record = {}; - for (const [key, v] of Object.entries(value as Record)) { - if (VERBATIM_KEYS.has(key) && v !== null && typeof v === 'object' && !Array.isArray(v)) { + for (const [key, v] of Object.entries(value)) { + if (VERBATIM_KEYS.has(key) && isPlainObject(v)) { result[key] = v; continue; } - result[key] = isSecretKey(key) && typeof v === 'string' ? REDACTED : redactDeep(v); + result[key] = isSecretKey(key) ? redactSecretValue(v) : redactDeep(v); } return result as unknown as T; }