Skip to content

Commit 2a040a5

Browse files
committed
feat: add sensitive struct
1 parent 4cb082d commit 2a040a5

10 files changed

Lines changed: 327 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Add `sensitive` struct and `SENSITIVE_REDACTED` constant for redacting secret values from validation errors ([#41](https://github.com/MetaMask/superstruct/pull/41))
13+
1014
## [3.3.0]
1115

1216
### Added

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export * from './error.js';
22
export * from './struct.js';
33
export * from './structs/coercions.js';
44
export * from './structs/refinements.js';
5+
export { SENSITIVE_REDACTED, sensitive } from './structs/sensitive.js';
56
export * from './structs/types.js';
67
export * from './structs/utilities.js';
78
export type {

src/structs/sensitive.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import type { Context } from '../struct.js';
2+
import { Struct } from '../struct.js';
3+
import type { Failure } from '../error.js';
4+
import type { AnyStruct } from '../utils.js';
5+
6+
export const SENSITIVE_REDACTED = '***';
7+
8+
// Tracks which struct instances were created by `sensitive()`. Using a `WeakSet`
9+
// avoids mutating the struct object itself and does not prevent garbage
10+
// collection when a struct goes out of scope.
11+
export const sensitiveStructs = new WeakSet<AnyStruct>();
12+
13+
/**
14+
* Return a shallow copy of `sourceObj` with each key in `keys` replaced by
15+
* the redaction placeholder.
16+
*
17+
* @param sourceObj - The source object to copy and redact.
18+
* @param keys - The property names to redact.
19+
* @returns A shallow copy with the specified keys replaced.
20+
*/
21+
function redactKeys(
22+
sourceObj: Record<string, unknown>,
23+
keys: string[],
24+
): Record<string, unknown> {
25+
const redacted = { ...sourceObj };
26+
for (const key of keys) {
27+
if (key in redacted) {
28+
redacted[key] = SENSITIVE_REDACTED;
29+
}
30+
}
31+
return redacted;
32+
}
33+
34+
/**
35+
* Wrap a struct so that every failure it emits has any occurrence of
36+
* `parentObj` in `failure.branch` replaced with a sanitised copy where
37+
* `sensitiveKeys` are redacted. This prevents the parent object (which holds
38+
* secret field values) from leaking through sibling-field failures.
39+
*
40+
* The wrapping propagates recursively through `entries` so that failures from
41+
* deeply nested sibling structs are covered too.
42+
*
43+
* @param struct - The struct whose failures should be patched.
44+
* @param parentObj - The parent-object reference to look for in branch arrays.
45+
* @param sensitiveKeys - Keys to redact from `parentObj` when it appears.
46+
* @returns The wrapped struct.
47+
*/
48+
export function withRedactedBranch(
49+
struct: AnyStruct,
50+
parentObj: unknown,
51+
sensitiveKeys: string[],
52+
): AnyStruct {
53+
function* redactBranch(failures: Iterable<Failure>): Iterable<Failure> {
54+
for (const failure of failures) {
55+
yield {
56+
...failure,
57+
// Replace the parent object in the branch with a sanitised copy.
58+
// Reference equality (`===`) is intentional: we only want to touch
59+
// this specific parent, not an unrelated object at a different depth
60+
// that might happen to have the same shape.
61+
branch: failure.branch.map((branchItem) => {
62+
if (
63+
branchItem !== parentObj ||
64+
typeof parentObj !== 'object' ||
65+
parentObj === null
66+
) {
67+
return branchItem;
68+
}
69+
return redactKeys(
70+
parentObj as Record<string, unknown>,
71+
sensitiveKeys,
72+
);
73+
}),
74+
};
75+
}
76+
}
77+
78+
// `as unknown as AnyStruct` is necessary because the `Struct` constructor
79+
// infers `Type = unknown` when the generics cannot be resolved from the
80+
// spread of an `AnyStruct`, producing `Struct<unknown, ...>` which is not
81+
// directly assignable to `Struct<any, ...>` (`AnyStruct`) without the cast.
82+
return new Struct({
83+
...struct,
84+
validator(value, context): ReturnType<Struct['validator']> {
85+
return redactBranch(struct.validator(value, context));
86+
},
87+
refiner(value, context): ReturnType<Struct['refiner']> {
88+
return redactBranch(struct.refiner(value, context));
89+
},
90+
// Propagate branch redaction recursively so that failures originating from
91+
// any depth inside a sibling struct are also sanitised.
92+
*entries(value: unknown, context: Context): ReturnType<Struct['entries']> {
93+
for (const entry of struct.entries(value, context)) {
94+
const [fieldKey, fieldValue, fieldStruct] = entry;
95+
yield [
96+
fieldKey,
97+
fieldValue,
98+
// `AnyStruct` is `Struct<any, any>`, while the tuple narrows only to
99+
// `Struct<any> | Struct<never>` (second generic left as `unknown`).
100+
// The cast is safe because both forms represent untyped structs at runtime.
101+
withRedactedBranch(
102+
fieldStruct as AnyStruct,
103+
parentObj,
104+
sensitiveKeys,
105+
),
106+
];
107+
}
108+
},
109+
}) as unknown as AnyStruct;
110+
}
111+
112+
/**
113+
* Wrap a struct so that any validation failure redacts the actual value from
114+
* the error message, `StructError.value`, and `StructError.branch`. Use this
115+
* for fields that hold secrets to prevent
116+
* sensitive material from leaking into error logs or external services.
117+
*
118+
* When composed with the `object()` or `type()` structs, sibling-field
119+
* failures will also have the parent object's sensitive keys redacted from
120+
* their branch.
121+
*
122+
* @example
123+
* ```ts
124+
* const MyStruct = object({ secret: sensitive(string()) });
125+
* assert({ secret: 123 }, MyStruct);
126+
* // throws: At path: secret -- Expected a value of type `string`,
127+
* // but received: `***`
128+
* ```
129+
*
130+
* @param struct - The struct to wrap.
131+
* @returns The wrapped struct with identical validation logic but redacted
132+
* failures.
133+
*/
134+
export function sensitive<Type, Schema>(
135+
struct: Struct<Type, Schema>,
136+
): Struct<Type, Schema> {
137+
function* redact(failures: Iterable<Failure>): Iterable<Failure> {
138+
for (const failure of failures) {
139+
yield {
140+
...failure,
141+
value: SENSITIVE_REDACTED,
142+
message: `Expected a value of type \`${struct.type}\`, but received: \`${SENSITIVE_REDACTED}\``,
143+
branch: failure.branch.map(() => SENSITIVE_REDACTED),
144+
};
145+
}
146+
}
147+
148+
// `as unknown as Struct<Type, Schema>` is necessary because the `Struct`
149+
// constructor loses the generic `Type` parameter when the result is stored in
150+
// a variable (it infers `Struct<unknown, Schema>` from the spread), so we must
151+
// reassert the type we know is correct.
152+
const wrapped = new Struct({
153+
...struct,
154+
validator(value, context): ReturnType<Struct['validator']> {
155+
return redact(struct.validator(value, context));
156+
},
157+
refiner(value, context): ReturnType<Struct['refiner']> {
158+
return redact(struct.refiner(value as Type, context));
159+
},
160+
}) as unknown as Struct<Type, Schema>;
161+
162+
// Register the wrapped struct so that `object()` and `type()` can detect
163+
// which schema keys are sensitive and patch sibling-field failures accordingly.
164+
sensitiveStructs.add(wrapped as AnyStruct);
165+
return wrapped;
166+
}

src/structs/types.ts

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Infer } from '../struct.js';
1+
import type { Context, Infer } from '../struct.js';
22
import { ExactOptionalStruct, Struct } from '../struct.js';
33
import type {
44
ObjectSchema,
@@ -8,8 +8,64 @@ import type {
88
UnionToIntersection,
99
} from '../utils.js';
1010
import { print, run, isObject } from '../utils.js';
11+
import { sensitiveStructs, withRedactedBranch } from './sensitive.js';
1112
import { define } from './utilities.js';
1213

14+
/**
15+
* Wrap a base struct so that entry-level failures from sibling fields have the
16+
* parent object's sensitive keys redacted from their branch. If `schema`
17+
* contains no sensitive-marked fields, `base` is returned as-is to avoid the
18+
* overhead of wrapping every entry.
19+
*
20+
* Used by both `object()` and `type()` to share the sensitive-entry wrapping
21+
* logic without duplication.
22+
*
23+
* @param base - The base struct to potentially wrap.
24+
* @param schema - The object schema to inspect for sensitive fields.
25+
* @returns The wrapped struct, or `base` unchanged when no sensitive fields
26+
* are present.
27+
*/
28+
function withSensitiveEntries<Type, Schema extends ObjectSchema>(
29+
base: Struct<Type, Schema>,
30+
schema: Schema,
31+
): Struct<Type, Schema> {
32+
const sensitiveKeys = Object.keys(schema).filter(
33+
// `noUncheckedIndexedAccess` makes `schema[key]` return `AnyStruct |
34+
// undefined`. After the `!== undefined` guard, TypeScript still treats the
35+
// second `schema[key]` access as potentially undefined (it does not
36+
// re-narrow repeated index reads), so the cast to `AnyStruct` is required.
37+
(key) =>
38+
schema[key] !== undefined &&
39+
sensitiveStructs.has(schema[key] as AnyStruct),
40+
);
41+
42+
if (sensitiveKeys.length === 0) {
43+
return base;
44+
}
45+
46+
// Spreading `base` into a new `Struct` loses the `Type` generic — the
47+
// constructor infers `Struct<unknown, Schema>` from the spread. We only
48+
// override `entries` and keep all other hooks unchanged, so the runtime
49+
// behaviour is identical to `Struct<Type, Schema>`.
50+
return new Struct({
51+
...base,
52+
*entries(value: unknown, context: Context) {
53+
for (const entry of base.entries(value, context)) {
54+
const [fieldKey, fieldValue, fieldStruct] = entry;
55+
// The entries tuple types the third element as `Struct<any> |
56+
// Struct<never>`. `AnyStruct` is `Struct<any, any>`, which differs
57+
// only in the second generic. The cast is safe because both forms
58+
// represent untyped structs at runtime.
59+
yield [
60+
fieldKey,
61+
fieldValue,
62+
withRedactedBranch(fieldStruct as AnyStruct, value, sensitiveKeys),
63+
];
64+
}
65+
},
66+
}) as unknown as Struct<Type, Schema>;
67+
}
68+
1369
/**
1470
* Ensure that any value passes validation.
1571
*
@@ -448,7 +504,7 @@ export function object<Schema extends ObjectSchema>(
448504
): any {
449505
const knowns = schema ? Object.keys(schema) : [];
450506
const Never = never();
451-
return new Struct({
507+
const base = new Struct({
452508
type: 'object',
453509
schema: schema ?? null,
454510
*entries(value) {
@@ -482,6 +538,16 @@ export function object<Schema extends ObjectSchema>(
482538
return isObject(value) ? { ...value } : value;
483539
},
484540
});
541+
542+
if (!schema) {
543+
return base;
544+
}
545+
546+
// `base` is typed as `Struct<unknown, Schema | null>` because the `Struct`
547+
// constructor receives `schema ?? null`. At this point `schema` is defined,
548+
// so the schema slot is actually `Schema`. The cast strips the `| null` so
549+
// `withSensitiveEntries` can accept it.
550+
return withSensitiveEntries(base as unknown as Struct<unknown, Schema>, schema);
485551
}
486552

487553
/**
@@ -678,7 +744,7 @@ export function type<Schema extends ObjectSchema>(
678744
schema: Schema,
679745
): Struct<ObjectType<Schema>, Schema> {
680746
const keys = Object.keys(schema);
681-
return new Struct({
747+
const base = new Struct({
682748
type: 'type',
683749
schema,
684750
*entries(value) {
@@ -703,6 +769,14 @@ export function type<Schema extends ObjectSchema>(
703769
return isObject(value) ? { ...value } : value;
704770
},
705771
});
772+
773+
// `new Struct()` infers `Type = unknown` from the spread, even though the
774+
// struct validates `ObjectType<Schema>` values. The cast asserts the correct
775+
// type so that `withSensitiveEntries` propagates it through its return value.
776+
return withSensitiveEntries(
777+
base as unknown as Struct<ObjectType<Schema>, Schema>,
778+
schema,
779+
);
706780
}
707781

708782
/**

test/typings/sensitive.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { assert, object, sensitive, string } from '../../src';
2+
import { test } from '../index.test';
3+
4+
test<string>((value) => {
5+
assert(value, sensitive(string()));
6+
return value;
7+
});
8+
9+
test<{ key: string }>((value) => {
10+
assert(value, object({ key: sensitive(string()) }));
11+
return value;
12+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { object, sensitive, string } from '../../../src';
2+
3+
export const Struct = object({ key: sensitive(string()) });
4+
export const data = { key: 123 };
5+
export const failures = [
6+
{
7+
value: '***',
8+
type: 'string',
9+
refinement: undefined,
10+
path: ['key'],
11+
branch: ['***', '***'],
12+
},
13+
];
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { literal, sensitive, string, type } from '../../../src';
2+
3+
export const Struct = type({
4+
secret: sensitive(string()),
5+
tag: literal('ok'),
6+
});
7+
8+
export const data = { secret: 'raw-secret', tag: 'bad' };
9+
10+
export const failures = [
11+
{
12+
value: 'bad',
13+
type: 'literal',
14+
refinement: undefined,
15+
path: ['tag'],
16+
branch: [{ secret: '***', tag: 'bad' }, 'bad'],
17+
},
18+
];
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { literal, object, sensitive, string } from '../../../src';
2+
3+
export const Struct = object({
4+
secret: sensitive(string()),
5+
tag: literal('ok'),
6+
});
7+
8+
export const data = { secret: 'raw-secret', tag: 'bad' };
9+
10+
export const failures = [
11+
{
12+
value: 'bad',
13+
type: 'literal',
14+
refinement: undefined,
15+
path: ['tag'],
16+
branch: [{ secret: '***', tag: 'bad' }, 'bad'],
17+
},
18+
];
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { sensitive, string } from '../../../src';
2+
3+
export const Struct = sensitive(string());
4+
export const data = 123;
5+
export const failures = [
6+
{
7+
value: '***',
8+
type: 'string',
9+
refinement: undefined,
10+
path: [],
11+
branch: ['***'],
12+
},
13+
];

test/validation/sensitive/valid.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { sensitive, string } from '../../../src';
2+
3+
export const Struct = sensitive(string());
4+
export const data = 'hello';
5+
export const output = 'hello';

0 commit comments

Comments
 (0)