diff --git a/src/structs/sensitive.ts b/src/structs/sensitive.ts index a29c2609..6887b94e 100644 --- a/src/structs/sensitive.ts +++ b/src/structs/sensitive.ts @@ -121,19 +121,90 @@ 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 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) { + break; } - return redactKeys(parentObj, sensitiveKeys); - }), - }; + const copy = [...ancestor]; + copy[childIndex] = childSanitized; + branch[ancestorIndex] = copy; + } else if (ancestor instanceof Map) { + // 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 (!childFound) { + break; + } + const copy = new Map(ancestor); + copy.set(childKey, 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-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', + ], + }, +]; 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-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', + ], + }, +]; 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', + ], + }, +];