Skip to content

Commit 8d3c281

Browse files
committed
fix: fix deep sensitive redaction
1 parent 7a55df7 commit 8d3c281

3 files changed

Lines changed: 152 additions & 12 deletions

File tree

src/structs/sensitive.ts

Lines changed: 63 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -121,19 +121,70 @@ export function withRedactedBranch(
121121
// eslint-disable-next-line jsdoc/require-jsdoc
122122
function* redactBranch(failures: Iterable<Failure>): Iterable<Failure> {
123123
for (const failure of failures) {
124-
yield {
125-
...failure,
126-
// Replace the parent object in the branch with a sanitised copy.
127-
// Reference equality (`===`) is intentional: we only want to touch
128-
// this specific parent, not an unrelated object at a different depth
129-
// that might happen to have the same shape.
130-
branch: failure.branch.map((branchItem) => {
131-
if (branchItem !== parentObj || !isObject(parentObj)) {
132-
return branchItem;
124+
if (!isObject(parentObj)) {
125+
yield failure;
126+
continue;
127+
}
128+
129+
// Locate this specific parent by identity. If it isn't in the branch
130+
// (e.g. the failure came from a different scope), pass it through.
131+
const parentIndex = failure.branch.indexOf(parentObj);
132+
if (parentIndex === -1) {
133+
yield failure;
134+
continue;
135+
}
136+
137+
// Sanitize the parent entry.
138+
const branch = [...failure.branch];
139+
branch[parentIndex] = redactKeys(parentObj, sensitiveKeys);
140+
141+
// Walk backward through every ancestor in the branch. Each ancestor
142+
// holds a direct reference to the child below it - without this step,
143+
// outer objects would still expose the unredacted child through their
144+
// own properties (e.g. root.wrapper.secret would remain visible
145+
// even after wrapper was sanitised at branch[1]).
146+
//
147+
// For each level we use the original child reference to find which
148+
// property key points to it (via ===), then replace that entry with
149+
// the already-sanitised child from the patched branch.
150+
for (
151+
let ancestorIndex = parentIndex - 1;
152+
ancestorIndex >= 0;
153+
ancestorIndex--
154+
) {
155+
const ancestor = branch[ancestorIndex];
156+
157+
// No reference to update in this case.
158+
if (!isObject(ancestor)) {
159+
break;
160+
}
161+
162+
// Keep the original child reference, so we can detect it.
163+
const child = failure.branch[ancestorIndex + 1];
164+
165+
// The already-sanitised child from the patched branch.
166+
const childSanitized = branch[ancestorIndex + 1];
167+
168+
if (Array.isArray(ancestor)) {
169+
const childIndex = ancestor.indexOf(child);
170+
if (childIndex === -1) {
171+
break;
133172
}
134-
return redactKeys(parentObj, sensitiveKeys);
135-
}),
136-
};
173+
const copy = [...ancestor];
174+
copy[childIndex] = childSanitized;
175+
branch[ancestorIndex] = copy;
176+
} else {
177+
const childKey = Object.keys(ancestor).find(
178+
(key) => ancestor[key] === child,
179+
);
180+
if (childKey === undefined) {
181+
break;
182+
}
183+
branch[ancestorIndex] = { ...ancestor, [childKey]: childSanitized };
184+
}
185+
}
186+
187+
yield { ...failure, branch };
137188
}
138189
}
139190

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { literal, object, sensitive, string } from '../../../src';
2+
3+
// Three levels of nesting: RootStruct > WrapperStruct > AccountStruct.
4+
// A sibling failure inside AccountStruct must redact sensitive data from
5+
// ALL branch entries — including both intermediate and root ancestors.
6+
const SecretStruct = object({
7+
secret: sensitive(string()),
8+
encoding: literal('hex'),
9+
});
10+
11+
const WrapperStruct = object({
12+
account: SecretStruct,
13+
tag: literal('ok'),
14+
});
15+
16+
export const Struct = object({
17+
wrapper: WrapperStruct,
18+
id: literal('root'),
19+
});
20+
21+
export const data = {
22+
wrapper: {
23+
account: { secret: 'super-secret', encoding: 'invalid-encoding' },
24+
tag: 'ok',
25+
},
26+
id: 'root',
27+
};
28+
29+
export const failures = [
30+
{
31+
value: 'invalid-encoding',
32+
type: 'literal',
33+
refinement: undefined,
34+
path: ['wrapper', 'account', 'encoding'],
35+
branch: [
36+
{
37+
wrapper: {
38+
account: { secret: '***', encoding: 'invalid-encoding' },
39+
tag: 'ok',
40+
},
41+
id: 'root',
42+
},
43+
{
44+
account: { secret: '***', encoding: 'invalid-encoding' },
45+
tag: 'ok',
46+
},
47+
{ secret: '***', encoding: 'invalid-encoding' },
48+
'invalid-encoding',
49+
],
50+
},
51+
];
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { literal, object, sensitive, string } from '../../../src';
2+
3+
// When a sensitive() field lives inside a nested object(), a sibling-field
4+
// failure must redact sensitive data from ALL branch entries — including
5+
// ancestor objects that hold a reference to the inner (parent) object.
6+
// Without the fix, branch[0] (the outer wrapper) still exposes secret
7+
// via its `account` property.
8+
const SecretStruct = object({
9+
secret: sensitive(string()),
10+
encoding: literal('hex'),
11+
});
12+
13+
export const Struct = object({
14+
account: SecretStruct,
15+
tag: literal('ok'),
16+
});
17+
18+
export const data = {
19+
account: { secret: 'super-secret', encoding: 'invalid-encoding' },
20+
tag: 'ok',
21+
};
22+
23+
export const failures = [
24+
{
25+
value: 'invalid-encoding',
26+
type: 'literal',
27+
refinement: undefined,
28+
path: ['account', 'encoding'],
29+
branch: [
30+
{
31+
account: { secret: '***', encoding: 'invalid-encoding' },
32+
tag: 'ok',
33+
},
34+
{ secret: '***', encoding: 'invalid-encoding' },
35+
'invalid-encoding',
36+
],
37+
},
38+
];

0 commit comments

Comments
 (0)