Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 66 additions & 12 deletions src/structs/sensitive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,19 +121,73 @@ export function withRedactedBranch(
// eslint-disable-next-line jsdoc/require-jsdoc
function* redactBranch(failures: Iterable<Failure>): Iterable<Failure> {
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 {
const childKey = Object.keys(ancestor).find(
(key) => ancestor[key] === child,
);
if (childKey === undefined) {
break;
}
branch[ancestorIndex] = { ...ancestor, [childKey]: childSanitized };
}
}

yield { ...failure, branch };
}
}

Expand Down
51 changes: 51 additions & 0 deletions test/validation/sensitive/invalid-sibling-ancestor-deep.ts
Original file line number Diff line number Diff line change
@@ -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',
],
},
];
38 changes: 38 additions & 0 deletions test/validation/sensitive/invalid-sibling-ancestor.ts
Original file line number Diff line number Diff line change
@@ -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',
],
},
];
Loading