From 94370f0f6dc46b3f699e8ccea2356cc4da7eecca Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 15 Jul 2026 22:05:07 +0200 Subject: [PATCH 1/5] fix: fix deep sensitive redaction --- src/structs/sensitive.ts | 75 ++++++++++++++++--- .../invalid-sibling-ancestor-deep.ts | 51 +++++++++++++ .../sensitive/invalid-sibling-ancestor.ts | 38 ++++++++++ 3 files changed, 152 insertions(+), 12 deletions(-) create mode 100644 test/validation/sensitive/invalid-sibling-ancestor-deep.ts create mode 100644 test/validation/sensitive/invalid-sibling-ancestor.ts diff --git a/src/structs/sensitive.ts b/src/structs/sensitive.ts index a29c2609..1641c94b 100644 --- a/src/structs/sensitive.ts +++ b/src/structs/sensitive.ts @@ -121,19 +121,70 @@ export function withRedactedBranch( // eslint-disable-next-line jsdoc/require-jsdoc function* redactBranch(failures: Iterable): Iterable { for (const failure of failures) { - yield { - ...failure, - // Replace the parent object in the branch with a sanitised copy. - // Reference equality (`===`) is intentional: we only want to touch - // this specific parent, not an unrelated object at a different depth - // that might happen to have the same shape. - branch: failure.branch.map((branchItem) => { - if (branchItem !== parentObj || !isObject(parentObj)) { - return branchItem; + if (!isObject(parentObj)) { + yield failure; + continue; + } + + // Locate this specific parent. If it isn't in the branch (e.g. the + // failure came from a different scope), pass it through. + const parentIndex = failure.branch.indexOf(parentObj); + if (parentIndex === -1) { + yield failure; + continue; + } + + // Sanitize the parent entry. + const branch = [...failure.branch]; + branch[parentIndex] = redactKeys(parentObj, sensitiveKeys); + + // Walk backward through every ancestor in the branch. Each ancestor + // holds a direct reference to the child below it - without this step, + // outer objects would still expose the unredacted child through their + // own properties (e.g. root.wrapper.secret would remain visible + // even after wrapper was sanitised at branch[1]). + // + // For each level we use the original child reference to find which + // property key points to it (via ===), then replace that entry with + // the already-sanitised child from the patched branch. + for ( + let ancestorIndex = parentIndex - 1; + ancestorIndex >= 0; + ancestorIndex-- + ) { + const ancestor = branch[ancestorIndex]; + + // No reference to update in this case. + if (!isObject(ancestor)) { + break; + } + + // Keep the original child reference, so we can detect it. + const child = failure.branch[ancestorIndex + 1]; + + // The already-sanitised child from the patched branch. + const childSanitized = branch[ancestorIndex + 1]; + + if (Array.isArray(ancestor)) { + const childIndex = ancestor.indexOf(child); + if (childIndex === -1) { + break; } - return redactKeys(parentObj, sensitiveKeys); - }), - }; + const copy = [...ancestor]; + copy[childIndex] = childSanitized; + branch[ancestorIndex] = copy; + } else { + const childKey = Object.keys(ancestor).find( + (key) => ancestor[key] === child, + ); + if (childKey === undefined) { + break; + } + branch[ancestorIndex] = { ...ancestor, [childKey]: childSanitized }; + } + } + + yield { ...failure, branch }; } } diff --git a/test/validation/sensitive/invalid-sibling-ancestor-deep.ts b/test/validation/sensitive/invalid-sibling-ancestor-deep.ts new file mode 100644 index 00000000..28911cab --- /dev/null +++ b/test/validation/sensitive/invalid-sibling-ancestor-deep.ts @@ -0,0 +1,51 @@ +import { literal, object, sensitive, string } from '../../../src'; + +// Three levels of nesting: RootStruct > WrapperStruct > AccountStruct. +// A sibling failure inside AccountStruct must redact sensitive data from +// ALL branch entries — including both intermediate and root ancestors. +const SecretStruct = object({ + secret: sensitive(string()), + encoding: literal('hex'), +}); + +const WrapperStruct = object({ + account: SecretStruct, + tag: literal('ok'), +}); + +export const Struct = object({ + wrapper: WrapperStruct, + id: literal('root'), +}); + +export const data = { + wrapper: { + account: { secret: 'super-secret', encoding: 'invalid-encoding' }, + tag: 'ok', + }, + id: 'root', +}; + +export const failures = [ + { + value: 'invalid-encoding', + type: 'literal', + refinement: undefined, + path: ['wrapper', 'account', 'encoding'], + branch: [ + { + wrapper: { + account: { secret: '***', encoding: 'invalid-encoding' }, + tag: 'ok', + }, + id: 'root', + }, + { + account: { secret: '***', encoding: 'invalid-encoding' }, + tag: 'ok', + }, + { secret: '***', encoding: 'invalid-encoding' }, + 'invalid-encoding', + ], + }, +]; diff --git a/test/validation/sensitive/invalid-sibling-ancestor.ts b/test/validation/sensitive/invalid-sibling-ancestor.ts new file mode 100644 index 00000000..1a614128 --- /dev/null +++ b/test/validation/sensitive/invalid-sibling-ancestor.ts @@ -0,0 +1,38 @@ +import { literal, object, sensitive, string } from '../../../src'; + +// When a sensitive() field lives inside a nested object(), a sibling-field +// failure must redact sensitive data from ALL branch entries — including +// ancestor objects that hold a reference to the inner (parent) object. +// Without the fix, branch[0] (the outer wrapper) still exposes secret +// via its `account` property. +const SecretStruct = object({ + secret: sensitive(string()), + encoding: literal('hex'), +}); + +export const Struct = object({ + account: SecretStruct, + tag: literal('ok'), +}); + +export const data = { + account: { secret: 'super-secret', encoding: 'invalid-encoding' }, + tag: 'ok', +}; + +export const failures = [ + { + value: 'invalid-encoding', + type: 'literal', + refinement: undefined, + path: ['account', 'encoding'], + branch: [ + { + account: { secret: '***', encoding: 'invalid-encoding' }, + tag: 'ok', + }, + { secret: '***', encoding: 'invalid-encoding' }, + 'invalid-encoding', + ], + }, +]; From 5e9e9de810b939e7180d769e0589a7080e44a57a Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 16 Jul 2026 11:13:35 +0200 Subject: [PATCH 2/5] chore: comment --- src/structs/sensitive.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/structs/sensitive.ts b/src/structs/sensitive.ts index 1641c94b..8884c238 100644 --- a/src/structs/sensitive.ts +++ b/src/structs/sensitive.ts @@ -165,6 +165,9 @@ export function withRedactedBranch( // The already-sanitised child from the patched branch. const childSanitized = branch[ancestorIndex + 1]; + // If we find the (original) child (not redacted) in one of the ancestor's + // properties/entries, we replace it with the redacted version (re-using the same + // sanitized child from the patched branch). if (Array.isArray(ancestor)) { const childIndex = ancestor.indexOf(child); if (childIndex === -1) { From 63cd19951c8c26dfb85a74bdbd10da6c908c01d2 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 16 Jul 2026 15:15:07 +0200 Subject: [PATCH 3/5] test: add missing test for arrays --- .../invalid-sibling-ancestor-array.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 test/validation/sensitive/invalid-sibling-ancestor-array.ts diff --git a/test/validation/sensitive/invalid-sibling-ancestor-array.ts b/test/validation/sensitive/invalid-sibling-ancestor-array.ts new file mode 100644 index 00000000..3d92995e --- /dev/null +++ b/test/validation/sensitive/invalid-sibling-ancestor-array.ts @@ -0,0 +1,38 @@ +import { array, literal, object, sensitive, string } from '../../../src'; + +// When a sensitive-entries object lives inside an array field, the array +// itself appears as an ancestor in the branch. The backward walk must handle +// this by copying the array and replacing the sanitised element, rather than +// treating it as a plain object with property keys. +const SecretStruct = object({ + secret: sensitive(string()), + encoding: literal('hex'), +}); + +export const Struct = object({ + items: array(SecretStruct), + tag: literal('ok'), +}); + +export const data = { + items: [{ secret: 'super-secret', encoding: 'invalid-encoding' }], + tag: 'ok', +}; + +export const failures = [ + { + value: 'invalid-encoding', + type: 'literal', + refinement: undefined, + path: ['items', 0, 'encoding'], + branch: [ + { + items: [{ secret: '***', encoding: 'invalid-encoding' }], + tag: 'ok', + }, + [{ secret: '***', encoding: 'invalid-encoding' }], + { secret: '***', encoding: 'invalid-encoding' }, + 'invalid-encoding', + ], + }, +]; From c86b4f4c75a83b46e34724e2f8fdc7a80552f998 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 16 Jul 2026 15:28:43 +0200 Subject: [PATCH 4/5] fix: fix map case --- src/structs/sensitive.ts | 16 ++++++++++ .../sensitive/invalid-sibling-ancestor-map.ts | 29 +++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 test/validation/sensitive/invalid-sibling-ancestor-map.ts diff --git a/src/structs/sensitive.ts b/src/structs/sensitive.ts index 8884c238..2e7b5b6b 100644 --- a/src/structs/sensitive.ts +++ b/src/structs/sensitive.ts @@ -176,6 +176,22 @@ export function withRedactedBranch( const copy = [...ancestor]; copy[childIndex] = childSanitized; branch[ancestorIndex] = copy; + } else if (ancestor instanceof Map) { + // Map entries are not enumerable via Object.keys, so we iterate the + // Map itself to find which key holds the original child reference. + let childKey: unknown; + for (const [entryKey, entryValue] of ancestor) { + if (entryValue === child) { + childKey = entryKey; + break; + } + } + if (childKey === undefined) { + break; + } + const copy = new Map(ancestor); + copy.set(childKey, childSanitized); + branch[ancestorIndex] = copy; } else { const childKey = Object.keys(ancestor).find( (key) => ancestor[key] === child, diff --git a/test/validation/sensitive/invalid-sibling-ancestor-map.ts b/test/validation/sensitive/invalid-sibling-ancestor-map.ts new file mode 100644 index 00000000..bb293fa9 --- /dev/null +++ b/test/validation/sensitive/invalid-sibling-ancestor-map.ts @@ -0,0 +1,29 @@ +import { literal, map, object, sensitive, string } from '../../../src'; + +// When a sensitive-entries object lives inside a Map, the Map itself appears +// as an ancestor in the branch. Object.keys() returns [] for Maps, so the +// backward walk must handle this case by iterating the Map's entries directly. +const SecretStruct = object({ + secret: sensitive(string()), + tag: literal('ok'), +}); + +export const Struct = map(string(), SecretStruct); + +export const data = new Map([ + ['key', { secret: 'super-secret', tag: 'INVALID' }], +]); + +export const failures = [ + { + value: 'INVALID', + type: 'literal', + refinement: undefined, + path: ['key', 'tag'], + branch: [ + new Map([['key', { secret: '***', tag: 'INVALID' }]]), + { secret: '***', tag: 'INVALID' }, + 'INVALID', + ], + }, +]; From 7ae1968f27d0080aa2ccd6888833b6ac1e1b164a Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 16 Jul 2026 16:57:47 +0200 Subject: [PATCH 5/5] fix: allow undefined map keys --- src/structs/sensitive.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/structs/sensitive.ts b/src/structs/sensitive.ts index 2e7b5b6b..6887b94e 100644 --- a/src/structs/sensitive.ts +++ b/src/structs/sensitive.ts @@ -177,16 +177,17 @@ export function withRedactedBranch( copy[childIndex] = childSanitized; branch[ancestorIndex] = copy; } else if (ancestor instanceof Map) { - // Map entries are not enumerable via Object.keys, so we iterate the - // Map itself to find which key holds the original child reference. + // Use a flag instead of relying on `undefined` since that could be a valid key in a `Map`. + let childFound = false; let childKey: unknown; for (const [entryKey, entryValue] of ancestor) { if (entryValue === child) { childKey = entryKey; + childFound = true; break; } } - if (childKey === undefined) { + if (!childFound) { break; } const copy = new Map(ancestor);