From 2a040a50e9136688d2c69afede173b9087336b0c Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 2 Jul 2026 14:38:39 +0200 Subject: [PATCH 01/15] feat: add sensitive struct --- CHANGELOG.md | 4 + src/index.ts | 1 + src/structs/sensitive.ts | 166 ++++++++++++++++++ src/structs/types.ts | 80 ++++++++- test/typings/sensitive.ts | 12 ++ test/validation/sensitive/invalid-nested.ts | 13 ++ .../sensitive/invalid-sibling-type.ts | 18 ++ test/validation/sensitive/invalid-sibling.ts | 18 ++ test/validation/sensitive/invalid.ts | 13 ++ test/validation/sensitive/valid.ts | 5 + 10 files changed, 327 insertions(+), 3 deletions(-) create mode 100644 src/structs/sensitive.ts create mode 100644 test/typings/sensitive.ts create mode 100644 test/validation/sensitive/invalid-nested.ts create mode 100644 test/validation/sensitive/invalid-sibling-type.ts create mode 100644 test/validation/sensitive/invalid-sibling.ts create mode 100644 test/validation/sensitive/invalid.ts create mode 100644 test/validation/sensitive/valid.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aa2f751..eacca5d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `sensitive` struct and `SENSITIVE_REDACTED` constant for redacting secret values from validation errors ([#41](https://github.com/MetaMask/superstruct/pull/41)) + ## [3.3.0] ### Added diff --git a/src/index.ts b/src/index.ts index 27441a8e..91e553cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ export * from './error.js'; export * from './struct.js'; export * from './structs/coercions.js'; export * from './structs/refinements.js'; +export { SENSITIVE_REDACTED, sensitive } from './structs/sensitive.js'; export * from './structs/types.js'; export * from './structs/utilities.js'; export type { diff --git a/src/structs/sensitive.ts b/src/structs/sensitive.ts new file mode 100644 index 00000000..aed990ef --- /dev/null +++ b/src/structs/sensitive.ts @@ -0,0 +1,166 @@ +import type { Context } from '../struct.js'; +import { Struct } from '../struct.js'; +import type { Failure } from '../error.js'; +import type { AnyStruct } from '../utils.js'; + +export const SENSITIVE_REDACTED = '***'; + +// Tracks which struct instances were created by `sensitive()`. Using a `WeakSet` +// avoids mutating the struct object itself and does not prevent garbage +// collection when a struct goes out of scope. +export const sensitiveStructs = new WeakSet(); + +/** + * Return a shallow copy of `sourceObj` with each key in `keys` replaced by + * the redaction placeholder. + * + * @param sourceObj - The source object to copy and redact. + * @param keys - The property names to redact. + * @returns A shallow copy with the specified keys replaced. + */ +function redactKeys( + sourceObj: Record, + keys: string[], +): Record { + const redacted = { ...sourceObj }; + for (const key of keys) { + if (key in redacted) { + redacted[key] = SENSITIVE_REDACTED; + } + } + return redacted; +} + +/** + * Wrap a struct so that every failure it emits has any occurrence of + * `parentObj` in `failure.branch` replaced with a sanitised copy where + * `sensitiveKeys` are redacted. This prevents the parent object (which holds + * secret field values) from leaking through sibling-field failures. + * + * The wrapping propagates recursively through `entries` so that failures from + * deeply nested sibling structs are covered too. + * + * @param struct - The struct whose failures should be patched. + * @param parentObj - The parent-object reference to look for in branch arrays. + * @param sensitiveKeys - Keys to redact from `parentObj` when it appears. + * @returns The wrapped struct. + */ +export function withRedactedBranch( + struct: AnyStruct, + parentObj: unknown, + sensitiveKeys: string[], +): AnyStruct { + 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 || + typeof parentObj !== 'object' || + parentObj === null + ) { + return branchItem; + } + return redactKeys( + parentObj as Record, + sensitiveKeys, + ); + }), + }; + } + } + + // `as unknown as AnyStruct` is necessary because the `Struct` constructor + // infers `Type = unknown` when the generics cannot be resolved from the + // spread of an `AnyStruct`, producing `Struct` which is not + // directly assignable to `Struct` (`AnyStruct`) without the cast. + return new Struct({ + ...struct, + validator(value, context): ReturnType { + return redactBranch(struct.validator(value, context)); + }, + refiner(value, context): ReturnType { + return redactBranch(struct.refiner(value, context)); + }, + // Propagate branch redaction recursively so that failures originating from + // any depth inside a sibling struct are also sanitised. + *entries(value: unknown, context: Context): ReturnType { + for (const entry of struct.entries(value, context)) { + const [fieldKey, fieldValue, fieldStruct] = entry; + yield [ + fieldKey, + fieldValue, + // `AnyStruct` is `Struct`, while the tuple narrows only to + // `Struct | Struct` (second generic left as `unknown`). + // The cast is safe because both forms represent untyped structs at runtime. + withRedactedBranch( + fieldStruct as AnyStruct, + parentObj, + sensitiveKeys, + ), + ]; + } + }, + }) as unknown as AnyStruct; +} + +/** + * Wrap a struct so that any validation failure redacts the actual value from + * the error message, `StructError.value`, and `StructError.branch`. Use this + * for fields that hold secrets to prevent + * sensitive material from leaking into error logs or external services. + * + * When composed with the `object()` or `type()` structs, sibling-field + * failures will also have the parent object's sensitive keys redacted from + * their branch. + * + * @example + * ```ts + * const MyStruct = object({ secret: sensitive(string()) }); + * assert({ secret: 123 }, MyStruct); + * // throws: At path: secret -- Expected a value of type `string`, + * // but received: `***` + * ``` + * + * @param struct - The struct to wrap. + * @returns The wrapped struct with identical validation logic but redacted + * failures. + */ +export function sensitive( + struct: Struct, +): Struct { + function* redact(failures: Iterable): Iterable { + for (const failure of failures) { + yield { + ...failure, + value: SENSITIVE_REDACTED, + message: `Expected a value of type \`${struct.type}\`, but received: \`${SENSITIVE_REDACTED}\``, + branch: failure.branch.map(() => SENSITIVE_REDACTED), + }; + } + } + + // `as unknown as Struct` is necessary because the `Struct` + // constructor loses the generic `Type` parameter when the result is stored in + // a variable (it infers `Struct` from the spread), so we must + // reassert the type we know is correct. + const wrapped = new Struct({ + ...struct, + validator(value, context): ReturnType { + return redact(struct.validator(value, context)); + }, + refiner(value, context): ReturnType { + return redact(struct.refiner(value as Type, context)); + }, + }) as unknown as Struct; + + // Register the wrapped struct so that `object()` and `type()` can detect + // which schema keys are sensitive and patch sibling-field failures accordingly. + sensitiveStructs.add(wrapped as AnyStruct); + return wrapped; +} diff --git a/src/structs/types.ts b/src/structs/types.ts index 26c9e01e..9476478a 100644 --- a/src/structs/types.ts +++ b/src/structs/types.ts @@ -1,4 +1,4 @@ -import type { Infer } from '../struct.js'; +import type { Context, Infer } from '../struct.js'; import { ExactOptionalStruct, Struct } from '../struct.js'; import type { ObjectSchema, @@ -8,8 +8,64 @@ import type { UnionToIntersection, } from '../utils.js'; import { print, run, isObject } from '../utils.js'; +import { sensitiveStructs, withRedactedBranch } from './sensitive.js'; import { define } from './utilities.js'; +/** + * Wrap a base struct so that entry-level failures from sibling fields have the + * parent object's sensitive keys redacted from their branch. If `schema` + * contains no sensitive-marked fields, `base` is returned as-is to avoid the + * overhead of wrapping every entry. + * + * Used by both `object()` and `type()` to share the sensitive-entry wrapping + * logic without duplication. + * + * @param base - The base struct to potentially wrap. + * @param schema - The object schema to inspect for sensitive fields. + * @returns The wrapped struct, or `base` unchanged when no sensitive fields + * are present. + */ +function withSensitiveEntries( + base: Struct, + schema: Schema, +): Struct { + const sensitiveKeys = Object.keys(schema).filter( + // `noUncheckedIndexedAccess` makes `schema[key]` return `AnyStruct | + // undefined`. After the `!== undefined` guard, TypeScript still treats the + // second `schema[key]` access as potentially undefined (it does not + // re-narrow repeated index reads), so the cast to `AnyStruct` is required. + (key) => + schema[key] !== undefined && + sensitiveStructs.has(schema[key] as AnyStruct), + ); + + if (sensitiveKeys.length === 0) { + return base; + } + + // Spreading `base` into a new `Struct` loses the `Type` generic — the + // constructor infers `Struct` from the spread. We only + // override `entries` and keep all other hooks unchanged, so the runtime + // behaviour is identical to `Struct`. + return new Struct({ + ...base, + *entries(value: unknown, context: Context) { + for (const entry of base.entries(value, context)) { + const [fieldKey, fieldValue, fieldStruct] = entry; + // The entries tuple types the third element as `Struct | + // Struct`. `AnyStruct` is `Struct`, which differs + // only in the second generic. The cast is safe because both forms + // represent untyped structs at runtime. + yield [ + fieldKey, + fieldValue, + withRedactedBranch(fieldStruct as AnyStruct, value, sensitiveKeys), + ]; + } + }, + }) as unknown as Struct; +} + /** * Ensure that any value passes validation. * @@ -448,7 +504,7 @@ export function object( ): any { const knowns = schema ? Object.keys(schema) : []; const Never = never(); - return new Struct({ + const base = new Struct({ type: 'object', schema: schema ?? null, *entries(value) { @@ -482,6 +538,16 @@ export function object( return isObject(value) ? { ...value } : value; }, }); + + if (!schema) { + return base; + } + + // `base` is typed as `Struct` because the `Struct` + // constructor receives `schema ?? null`. At this point `schema` is defined, + // so the schema slot is actually `Schema`. The cast strips the `| null` so + // `withSensitiveEntries` can accept it. + return withSensitiveEntries(base as unknown as Struct, schema); } /** @@ -678,7 +744,7 @@ export function type( schema: Schema, ): Struct, Schema> { const keys = Object.keys(schema); - return new Struct({ + const base = new Struct({ type: 'type', schema, *entries(value) { @@ -703,6 +769,14 @@ export function type( return isObject(value) ? { ...value } : value; }, }); + + // `new Struct()` infers `Type = unknown` from the spread, even though the + // struct validates `ObjectType` values. The cast asserts the correct + // type so that `withSensitiveEntries` propagates it through its return value. + return withSensitiveEntries( + base as unknown as Struct, Schema>, + schema, + ); } /** diff --git a/test/typings/sensitive.ts b/test/typings/sensitive.ts new file mode 100644 index 00000000..3c985c2b --- /dev/null +++ b/test/typings/sensitive.ts @@ -0,0 +1,12 @@ +import { assert, object, sensitive, string } from '../../src'; +import { test } from '../index.test'; + +test((value) => { + assert(value, sensitive(string())); + return value; +}); + +test<{ key: string }>((value) => { + assert(value, object({ key: sensitive(string()) })); + return value; +}); diff --git a/test/validation/sensitive/invalid-nested.ts b/test/validation/sensitive/invalid-nested.ts new file mode 100644 index 00000000..3c574b12 --- /dev/null +++ b/test/validation/sensitive/invalid-nested.ts @@ -0,0 +1,13 @@ +import { object, sensitive, string } from '../../../src'; + +export const Struct = object({ key: sensitive(string()) }); +export const data = { key: 123 }; +export const failures = [ + { + value: '***', + type: 'string', + refinement: undefined, + path: ['key'], + branch: ['***', '***'], + }, +]; diff --git a/test/validation/sensitive/invalid-sibling-type.ts b/test/validation/sensitive/invalid-sibling-type.ts new file mode 100644 index 00000000..8ad8e034 --- /dev/null +++ b/test/validation/sensitive/invalid-sibling-type.ts @@ -0,0 +1,18 @@ +import { literal, sensitive, string, type } from '../../../src'; + +export const Struct = type({ + secret: sensitive(string()), + tag: literal('ok'), +}); + +export const data = { secret: 'raw-secret', tag: 'bad' }; + +export const failures = [ + { + value: 'bad', + type: 'literal', + refinement: undefined, + path: ['tag'], + branch: [{ secret: '***', tag: 'bad' }, 'bad'], + }, +]; diff --git a/test/validation/sensitive/invalid-sibling.ts b/test/validation/sensitive/invalid-sibling.ts new file mode 100644 index 00000000..fae44823 --- /dev/null +++ b/test/validation/sensitive/invalid-sibling.ts @@ -0,0 +1,18 @@ +import { literal, object, sensitive, string } from '../../../src'; + +export const Struct = object({ + secret: sensitive(string()), + tag: literal('ok'), +}); + +export const data = { secret: 'raw-secret', tag: 'bad' }; + +export const failures = [ + { + value: 'bad', + type: 'literal', + refinement: undefined, + path: ['tag'], + branch: [{ secret: '***', tag: 'bad' }, 'bad'], + }, +]; diff --git a/test/validation/sensitive/invalid.ts b/test/validation/sensitive/invalid.ts new file mode 100644 index 00000000..6f9bcb11 --- /dev/null +++ b/test/validation/sensitive/invalid.ts @@ -0,0 +1,13 @@ +import { sensitive, string } from '../../../src'; + +export const Struct = sensitive(string()); +export const data = 123; +export const failures = [ + { + value: '***', + type: 'string', + refinement: undefined, + path: [], + branch: ['***'], + }, +]; diff --git a/test/validation/sensitive/valid.ts b/test/validation/sensitive/valid.ts new file mode 100644 index 00000000..3fe08628 --- /dev/null +++ b/test/validation/sensitive/valid.ts @@ -0,0 +1,5 @@ +import { sensitive, string } from '../../../src'; + +export const Struct = sensitive(string()); +export const data = 'hello'; +export const output = 'hello'; From 1282fabc9a46488dd566274c7434778c7ef0f97b Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 2 Jul 2026 14:53:42 +0200 Subject: [PATCH 02/15] chore: lint --- src/structs/sensitive.ts | 5 +++-- src/structs/types.ts | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/structs/sensitive.ts b/src/structs/sensitive.ts index aed990ef..fe1f4739 100644 --- a/src/structs/sensitive.ts +++ b/src/structs/sensitive.ts @@ -1,6 +1,6 @@ +import type { Failure } from '../error.js'; import type { Context } from '../struct.js'; import { Struct } from '../struct.js'; -import type { Failure } from '../error.js'; import type { AnyStruct } from '../utils.js'; export const SENSITIVE_REDACTED = '***'; @@ -50,6 +50,7 @@ export function withRedactedBranch( parentObj: unknown, sensitiveKeys: string[], ): AnyStruct { + // eslint-disable-next-line jsdoc/require-jsdoc function* redactBranch(failures: Iterable): Iterable { for (const failure of failures) { yield { @@ -126,7 +127,6 @@ export function withRedactedBranch( * // throws: At path: secret -- Expected a value of type `string`, * // but received: `***` * ``` - * * @param struct - The struct to wrap. * @returns The wrapped struct with identical validation logic but redacted * failures. @@ -134,6 +134,7 @@ export function withRedactedBranch( export function sensitive( struct: Struct, ): Struct { + // eslint-disable-next-line jsdoc/require-jsdoc function* redact(failures: Iterable): Iterable { for (const failure of failures) { yield { diff --git a/src/structs/types.ts b/src/structs/types.ts index 9476478a..4e7259df 100644 --- a/src/structs/types.ts +++ b/src/structs/types.ts @@ -547,7 +547,10 @@ export function object( // constructor receives `schema ?? null`. At this point `schema` is defined, // so the schema slot is actually `Schema`. The cast strips the `| null` so // `withSensitiveEntries` can accept it. - return withSensitiveEntries(base as unknown as Struct, schema); + return withSensitiveEntries( + base as unknown as Struct, + schema, + ); } /** From d5a25294333b439c66ddb6e461b7d465212ee48e Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 2 Jul 2026 15:04:40 +0200 Subject: [PATCH 03/15] chore: fix encapslutation of sensitiveStructs --- src/structs/sensitive.ts | 12 +++++++++++- src/structs/types.ts | 5 ++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/structs/sensitive.ts b/src/structs/sensitive.ts index fe1f4739..afe3075e 100644 --- a/src/structs/sensitive.ts +++ b/src/structs/sensitive.ts @@ -8,7 +8,17 @@ export const SENSITIVE_REDACTED = '***'; // Tracks which struct instances were created by `sensitive()`. Using a `WeakSet` // avoids mutating the struct object itself and does not prevent garbage // collection when a struct goes out of scope. -export const sensitiveStructs = new WeakSet(); +const sensitiveStructs = new WeakSet(); + +/** + * Check whether a struct was created by `sensitive()`. + * + * @param struct - The struct to check. + * @returns `true` if the struct was wrapped with `sensitive()`. + */ +export function isSensitiveStruct(struct: AnyStruct): boolean { + return sensitiveStructs.has(struct); +} /** * Return a shallow copy of `sourceObj` with each key in `keys` replaced by diff --git a/src/structs/types.ts b/src/structs/types.ts index 4e7259df..5fa7ee05 100644 --- a/src/structs/types.ts +++ b/src/structs/types.ts @@ -8,7 +8,7 @@ import type { UnionToIntersection, } from '../utils.js'; import { print, run, isObject } from '../utils.js'; -import { sensitiveStructs, withRedactedBranch } from './sensitive.js'; +import { isSensitiveStruct, withRedactedBranch } from './sensitive.js'; import { define } from './utilities.js'; /** @@ -35,8 +35,7 @@ function withSensitiveEntries( // second `schema[key]` access as potentially undefined (it does not // re-narrow repeated index reads), so the cast to `AnyStruct` is required. (key) => - schema[key] !== undefined && - sensitiveStructs.has(schema[key] as AnyStruct), + schema[key] !== undefined && isSensitiveStruct(schema[key] as AnyStruct), ); if (sensitiveKeys.length === 0) { From 33e4bd053c821ac61b82c5359a6d91b3a4e8237e Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 3 Jul 2026 10:49:24 +0200 Subject: [PATCH 04/15] refactor: remove unneeded type casts --- src/structs/sensitive.ts | 16 ++++------------ src/structs/types.ts | 17 +++++------------ 2 files changed, 9 insertions(+), 24 deletions(-) diff --git a/src/structs/sensitive.ts b/src/structs/sensitive.ts index afe3075e..77355dc7 100644 --- a/src/structs/sensitive.ts +++ b/src/structs/sensitive.ts @@ -86,10 +86,6 @@ export function withRedactedBranch( } } - // `as unknown as AnyStruct` is necessary because the `Struct` constructor - // infers `Type = unknown` when the generics cannot be resolved from the - // spread of an `AnyStruct`, producing `Struct` which is not - // directly assignable to `Struct` (`AnyStruct`) without the cast. return new Struct({ ...struct, validator(value, context): ReturnType { @@ -117,7 +113,7 @@ export function withRedactedBranch( ]; } }, - }) as unknown as AnyStruct; + }); } /** @@ -156,19 +152,15 @@ export function sensitive( } } - // `as unknown as Struct` is necessary because the `Struct` - // constructor loses the generic `Type` parameter when the result is stored in - // a variable (it infers `Struct` from the spread), so we must - // reassert the type we know is correct. const wrapped = new Struct({ ...struct, validator(value, context): ReturnType { return redact(struct.validator(value, context)); }, - refiner(value, context): ReturnType { - return redact(struct.refiner(value as Type, context)); + refiner(value: Type, context): ReturnType { + return redact(struct.refiner(value, context)); }, - }) as unknown as Struct; + }); // Register the wrapped struct so that `object()` and `type()` can detect // which schema keys are sensitive and patch sibling-field failures accordingly. diff --git a/src/structs/types.ts b/src/structs/types.ts index 5fa7ee05..d419782c 100644 --- a/src/structs/types.ts +++ b/src/structs/types.ts @@ -42,10 +42,6 @@ function withSensitiveEntries( return base; } - // Spreading `base` into a new `Struct` loses the `Type` generic — the - // constructor infers `Struct` from the spread. We only - // override `entries` and keep all other hooks unchanged, so the runtime - // behaviour is identical to `Struct`. return new Struct({ ...base, *entries(value: unknown, context: Context) { @@ -62,7 +58,7 @@ function withSensitiveEntries( ]; } }, - }) as unknown as Struct; + }); } /** @@ -503,7 +499,7 @@ export function object( ): any { const knowns = schema ? Object.keys(schema) : []; const Never = never(); - const base = new Struct({ + const base: Struct, Schema | null> = new Struct({ type: 'object', schema: schema ?? null, *entries(value) { @@ -547,7 +543,7 @@ export function object( // so the schema slot is actually `Schema`. The cast strips the `| null` so // `withSensitiveEntries` can accept it. return withSensitiveEntries( - base as unknown as Struct, + base as Struct, Schema>, schema, ); } @@ -746,7 +742,7 @@ export function type( schema: Schema, ): Struct, Schema> { const keys = Object.keys(schema); - const base = new Struct({ + const base: Struct, Schema> = new Struct({ type: 'type', schema, *entries(value) { @@ -775,10 +771,7 @@ export function type( // `new Struct()` infers `Type = unknown` from the spread, even though the // struct validates `ObjectType` values. The cast asserts the correct // type so that `withSensitiveEntries` propagates it through its return value. - return withSensitiveEntries( - base as unknown as Struct, Schema>, - schema, - ); + return withSensitiveEntries(base, schema); } /** From 2e1b5698c97e28d7d0e279bada4a5a162183e458 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 3 Jul 2026 11:25:01 +0200 Subject: [PATCH 05/15] chore: cosmetic --- src/structs/types.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/structs/types.ts b/src/structs/types.ts index d419782c..2f1376f8 100644 --- a/src/structs/types.ts +++ b/src/structs/types.ts @@ -768,9 +768,6 @@ export function type( }, }); - // `new Struct()` infers `Type = unknown` from the spread, even though the - // struct validates `ObjectType` values. The cast asserts the correct - // type so that `withSensitiveEntries` propagates it through its return value. return withSensitiveEntries(base, schema); } From 8eb318350dc4f43dcba0527282d0bc6a7317ddda Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 3 Jul 2026 11:25:32 +0200 Subject: [PATCH 06/15] fix: use brand/symbol to detect senstive wrapped structs --- src/struct.ts | 13 +++++++++ src/structs/sensitive.ts | 27 ++++++++++++------- .../invalid-sibling-exactoptional.ts | 18 +++++++++++++ .../sensitive/invalid-sibling-optional.ts | 18 +++++++++++++ .../type/invalid-exact-optional-value.ts | 21 +++++++++++++++ 5 files changed, 87 insertions(+), 10 deletions(-) create mode 100644 test/validation/sensitive/invalid-sibling-exactoptional.ts create mode 100644 test/validation/sensitive/invalid-sibling-optional.ts create mode 100644 test/validation/type/invalid-exact-optional-value.ts diff --git a/src/struct.ts b/src/struct.ts index 3b5a3fc5..a62fbe97 100644 --- a/src/struct.ts +++ b/src/struct.ts @@ -70,6 +70,19 @@ export class Struct { } else { this.refiner = () => []; } + + // Copy Symbol-keyed properties from `props` so that brands set on an inner + // struct (e.g. the sensitive marker) are automatically inherited by any + // wrapper that spreads the struct into a new constructor call. + for (const sym of Object.getOwnPropertySymbols(props)) { + Object.defineProperty( + this, + sym, + // No need to check for undefined, since we are iterating over + // `Object.getOwnPropertySymbols`. + Object.getOwnPropertyDescriptor(props, sym) as PropertyDescriptor, + ); + } } /** diff --git a/src/structs/sensitive.ts b/src/structs/sensitive.ts index 77355dc7..a61c1829 100644 --- a/src/structs/sensitive.ts +++ b/src/structs/sensitive.ts @@ -5,19 +5,20 @@ import type { AnyStruct } from '../utils.js'; export const SENSITIVE_REDACTED = '***'; -// Tracks which struct instances were created by `sensitive()`. Using a `WeakSet` -// avoids mutating the struct object itself and does not prevent garbage -// collection when a struct goes out of scope. -const sensitiveStructs = new WeakSet(); +// Unique brand set on every struct created by `sensitive()`. Because the +// `Struct` constructor copies Symbol-keyed properties from its `props` spread, +// any wrapper built with `new Struct({ ...sensitiveStruct, ... })` inherits +// the brand automatically — no manual tracking required. +const SENSITIVE_BRAND: unique symbol = Symbol('sensitive'); /** - * Check whether a struct was created by `sensitive()`. + * Check whether a struct was created by `sensitive()`, or wraps one. * * @param struct - The struct to check. - * @returns `true` if the struct was wrapped with `sensitive()`. + * @returns `true` if the struct carries the sensitive brand. */ export function isSensitiveStruct(struct: AnyStruct): boolean { - return sensitiveStructs.has(struct); + return Object.prototype.hasOwnProperty.call(struct, SENSITIVE_BRAND); } /** @@ -162,8 +163,14 @@ export function sensitive( }, }); - // Register the wrapped struct so that `object()` and `type()` can detect - // which schema keys are sensitive and patch sibling-field failures accordingly. - sensitiveStructs.add(wrapped as AnyStruct); + // Brand the wrapped struct. The `Struct` constructor copies Symbol-keyed + // properties on spread, so any wrapper (optional, nullable, exactOptional, + // deprecated, ...) automatically inherits this brand without needing changes. + Object.defineProperty(wrapped, SENSITIVE_BRAND, { + value: true, + enumerable: true, + configurable: true, + writable: false, + }); return wrapped; } diff --git a/test/validation/sensitive/invalid-sibling-exactoptional.ts b/test/validation/sensitive/invalid-sibling-exactoptional.ts new file mode 100644 index 00000000..84873049 --- /dev/null +++ b/test/validation/sensitive/invalid-sibling-exactoptional.ts @@ -0,0 +1,18 @@ +import { exactOptional, literal, object, sensitive, string } from '../../../src'; + +export const Struct = object({ + secret: exactOptional(sensitive(string())), + tag: literal('ok'), +}); + +export const data = { secret: 'raw-secret', tag: 'bad' }; + +export const failures = [ + { + value: 'bad', + type: 'literal', + refinement: undefined, + path: ['tag'], + branch: [{ secret: '***', tag: 'bad' }, 'bad'], + }, +]; diff --git a/test/validation/sensitive/invalid-sibling-optional.ts b/test/validation/sensitive/invalid-sibling-optional.ts new file mode 100644 index 00000000..d5b2c199 --- /dev/null +++ b/test/validation/sensitive/invalid-sibling-optional.ts @@ -0,0 +1,18 @@ +import { literal, object, optional, sensitive, string } from '../../../src'; + +export const Struct = object({ + secret: optional(sensitive(string())), + tag: literal('ok'), +}); + +export const data = { secret: 'raw-secret', tag: 'bad' }; + +export const failures = [ + { + value: 'bad', + type: 'literal', + refinement: undefined, + path: ['tag'], + branch: [{ secret: '***', tag: 'bad' }, 'bad'], + }, +]; diff --git a/test/validation/type/invalid-exact-optional-value.ts b/test/validation/type/invalid-exact-optional-value.ts new file mode 100644 index 00000000..8e1a2847 --- /dev/null +++ b/test/validation/type/invalid-exact-optional-value.ts @@ -0,0 +1,21 @@ +import { type, exactOptional, string, number } from '../../../src'; + +export const Struct = type({ + name: string(), + age: exactOptional(number()), +}); + +export const data = { + name: 'john', + age: '42', +}; + +export const failures = [ + { + value: '42', + type: 'number', + refinement: undefined, + path: ['age'], + branch: [data, data.age], + }, +]; From ab42b6e381be1464690d012f180b1c0790c90d1e Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 3 Jul 2026 12:18:21 +0200 Subject: [PATCH 07/15] fix: fix invalid redacting for sibling branches with coercions --- src/structs/types.ts | 14 ++++++++++- .../sensitive/invalid-sibling-coerced.ts | 23 +++++++++++++++++++ .../invalid-sibling-exactoptional.ts | 8 ++++++- 3 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 test/validation/sensitive/invalid-sibling-coerced.ts diff --git a/src/structs/types.ts b/src/structs/types.ts index 2f1376f8..f0c6ceb7 100644 --- a/src/structs/types.ts +++ b/src/structs/types.ts @@ -45,6 +45,14 @@ function withSensitiveEntries( return new Struct({ ...base, *entries(value: unknown, context: Context) { + // `run` initialises `branch` before coercion runs, so `value` here may + // be a freshly-created coerced copy (e.g. `object()`'s `{ ...value }`) + // while `context.branch[last]` still holds the original reference that + // ends up in failure branches. Using the branch reference ensures the + // `===` check inside `withRedactedBranch` matches when coercion is active. + // Falls back to `value` if branch is somehow empty (entries called outside + // of `run`). + const parentInBranch = context.branch[context.branch.length - 1] ?? value; for (const entry of base.entries(value, context)) { const [fieldKey, fieldValue, fieldStruct] = entry; // The entries tuple types the third element as `Struct | @@ -54,7 +62,11 @@ function withSensitiveEntries( yield [ fieldKey, fieldValue, - withRedactedBranch(fieldStruct as AnyStruct, value, sensitiveKeys), + withRedactedBranch( + fieldStruct as AnyStruct, + parentInBranch, + sensitiveKeys, + ), ]; } }, diff --git a/test/validation/sensitive/invalid-sibling-coerced.ts b/test/validation/sensitive/invalid-sibling-coerced.ts new file mode 100644 index 00000000..bdcb84c1 --- /dev/null +++ b/test/validation/sensitive/invalid-sibling-coerced.ts @@ -0,0 +1,23 @@ +import { literal, object, sensitive, string } from '../../../src'; + +// Validates that sibling-field branch redaction works when coercion is active +// (create / mask / validate with coerce). `run` builds `branch` before calling +// the struct coercer, so the parent reference in `branch` can differ from the +// coerced `value` received by `entries`. The fix reads from `context.branch`. +export const Struct = object({ + secret: sensitive(string()), + tag: literal('ok'), +}); + +export const create = true; +export const data = { secret: 'raw-secret', tag: 'bad' }; + +export const failures = [ + { + value: 'bad', + type: 'literal', + refinement: undefined, + path: ['tag'], + branch: [{ secret: '***', tag: 'bad' }, 'bad'], + }, +]; diff --git a/test/validation/sensitive/invalid-sibling-exactoptional.ts b/test/validation/sensitive/invalid-sibling-exactoptional.ts index 84873049..ee14f6b0 100644 --- a/test/validation/sensitive/invalid-sibling-exactoptional.ts +++ b/test/validation/sensitive/invalid-sibling-exactoptional.ts @@ -1,4 +1,10 @@ -import { exactOptional, literal, object, sensitive, string } from '../../../src'; +import { + exactOptional, + literal, + object, + sensitive, + string, +} from '../../../src'; export const Struct = object({ secret: exactOptional(sensitive(string())), From d4a4c284db1d42e3fdec0bc546a8dcb66670ab73 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 3 Jul 2026 12:35:36 +0200 Subject: [PATCH 08/15] fix: prevent unique symbols with different lib versions --- src/structs/sensitive.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/structs/sensitive.ts b/src/structs/sensitive.ts index a61c1829..afb580ec 100644 --- a/src/structs/sensitive.ts +++ b/src/structs/sensitive.ts @@ -5,11 +5,12 @@ import type { AnyStruct } from '../utils.js'; export const SENSITIVE_REDACTED = '***'; -// Unique brand set on every struct created by `sensitive()`. Because the -// `Struct` constructor copies Symbol-keyed properties from its `props` spread, -// any wrapper built with `new Struct({ ...sensitiveStruct, ... })` inherits -// the brand automatically — no manual tracking required. -const SENSITIVE_BRAND: unique symbol = Symbol('sensitive'); +// Global-registry Symbol so the brand is shared across multiple instances of +// this library loaded in the same runtime (e.g. npm dedup failure). Compare +// with `ExactOptionalBrand`, which uses a plain string for the same reason. +// Using Symbol.for() (not Symbol()) means isSensitiveStruct() works even when +// `sensitive()` and `object()` come from different copies of this package. +const SENSITIVE_BRAND = Symbol.for('superstruct.sensitive'); /** * Check whether a struct was created by `sensitive()`, or wraps one. From 0c96621923f0444c96900b9dc5934de951042122 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 3 Jul 2026 12:40:30 +0200 Subject: [PATCH 09/15] test: add test with entire sensitive object --- .../sensitive/invalid-outer-object.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 test/validation/sensitive/invalid-outer-object.ts diff --git a/test/validation/sensitive/invalid-outer-object.ts b/test/validation/sensitive/invalid-outer-object.ts new file mode 100644 index 00000000..68201d3b --- /dev/null +++ b/test/validation/sensitive/invalid-outer-object.ts @@ -0,0 +1,17 @@ +import { object, sensitive, string } from '../../../src'; + +// Validates that `sensitive()` can wrap an entire `object()` struct. +// When the outer value fails (not an object at all), the failure is redacted. +export const Struct = sensitive(object({ key: string() })); + +export const data = 'not-an-object'; + +export const failures = [ + { + value: '***', + type: 'object', + refinement: undefined, + path: [], + branch: ['***'], + }, +]; From 3525ddd562514c50cb45fea365e77364c77e4f97 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 3 Jul 2026 12:42:12 +0200 Subject: [PATCH 10/15] chore: simplify changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eacca5d6..bda2846e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `sensitive` struct and `SENSITIVE_REDACTED` constant for redacting secret values from validation errors ([#41](https://github.com/MetaMask/superstruct/pull/41)) +- Add `sensitive` struct for redacting secret values from validation errors ([#41](https://github.com/MetaMask/superstruct/pull/41)) ## [3.3.0] From d83507cf5fdf89c4a684af3256fabfa29ef6f1f3 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Tue, 7 Jul 2026 17:19:17 +0200 Subject: [PATCH 11/15] refactor: use isPlainObject to avoid type-cast --- src/structs/sensitive.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/structs/sensitive.ts b/src/structs/sensitive.ts index afb580ec..121ca9e3 100644 --- a/src/structs/sensitive.ts +++ b/src/structs/sensitive.ts @@ -1,6 +1,7 @@ import type { Failure } from '../error.js'; import type { Context } from '../struct.js'; import { Struct } from '../struct.js'; +import { isPlainObject } from '../utils.js'; import type { AnyStruct } from '../utils.js'; export const SENSITIVE_REDACTED = '***'; @@ -72,17 +73,10 @@ export function withRedactedBranch( // 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 || - typeof parentObj !== 'object' || - parentObj === null - ) { + if (branchItem !== parentObj || !isPlainObject(parentObj)) { return branchItem; } - return redactKeys( - parentObj as Record, - sensitiveKeys, - ); + return redactKeys(parentObj, sensitiveKeys); }), }; } From a45349ae2234af4d5ba02ed084e70e17371b957d Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Tue, 7 Jul 2026 21:37:48 +0200 Subject: [PATCH 12/15] fix: redact nested failures too --- src/structs/sensitive.ts | 67 +++++++++++++------ .../sensitive/invalid-outer-object-deep.ts | 40 +++++++++++ .../invalid-outer-object-subfield.ts | 18 +++++ 3 files changed, 104 insertions(+), 21 deletions(-) create mode 100644 test/validation/sensitive/invalid-outer-object-deep.ts create mode 100644 test/validation/sensitive/invalid-outer-object-subfield.ts diff --git a/src/structs/sensitive.ts b/src/structs/sensitive.ts index 121ca9e3..942f5b03 100644 --- a/src/structs/sensitive.ts +++ b/src/structs/sensitive.ts @@ -44,6 +44,51 @@ function redactKeys( return redacted; } +/** + * Wrap a struct so that every failure it (and any of its entries) emits is + * fully redacted: `value` and every item in `branch` are replaced with + * `SENSITIVE_REDACTED`. Field structs yielded by `entries` are wrapped + * recursively so that nested object failures are covered too. + * + * This is the internal workhorse for `sensitive()`. It deliberately does NOT + * stamp the `SENSITIVE_BRAND` so that only the user-facing wrapper is + * detectable via `isSensitiveStruct()`. + * + * @param struct - The struct to wrap. + * @returns The wrapped struct with identical validation logic but fully + * redacted failures. + */ +function wrapWithRedaction( + struct: Struct, +): Struct { + // eslint-disable-next-line jsdoc/require-jsdoc + function* redact(failures: Iterable): Iterable { + for (const failure of failures) { + yield { + ...failure, + value: SENSITIVE_REDACTED, + message: `Expected a value of type \`${struct.type}\`, but received: \`${SENSITIVE_REDACTED}\``, + branch: failure.branch.map(() => SENSITIVE_REDACTED), + }; + } + } + + return new Struct({ + ...struct, + validator(value, context): ReturnType { + return redact(struct.validator(value, context)); + }, + refiner(value: Type, context): ReturnType { + return redact(struct.refiner(value, context)); + }, + *entries(value: unknown, context: Context): ReturnType { + for (const [key, val, fieldStruct] of struct.entries(value, context)) { + yield [key, val, wrapWithRedaction(fieldStruct as AnyStruct)]; + } + }, + }); +} + /** * Wrap a struct so that every failure it emits has any occurrence of * `parentObj` in `failure.branch` replaced with a sanitised copy where @@ -136,27 +181,7 @@ export function withRedactedBranch( export function sensitive( struct: Struct, ): Struct { - // eslint-disable-next-line jsdoc/require-jsdoc - function* redact(failures: Iterable): Iterable { - for (const failure of failures) { - yield { - ...failure, - value: SENSITIVE_REDACTED, - message: `Expected a value of type \`${struct.type}\`, but received: \`${SENSITIVE_REDACTED}\``, - branch: failure.branch.map(() => SENSITIVE_REDACTED), - }; - } - } - - const wrapped = new Struct({ - ...struct, - validator(value, context): ReturnType { - return redact(struct.validator(value, context)); - }, - refiner(value: Type, context): ReturnType { - return redact(struct.refiner(value, context)); - }, - }); + const wrapped = wrapWithRedaction(struct); // Brand the wrapped struct. The `Struct` constructor copies Symbol-keyed // properties on spread, so any wrapper (optional, nullable, exactOptional, diff --git a/test/validation/sensitive/invalid-outer-object-deep.ts b/test/validation/sensitive/invalid-outer-object-deep.ts new file mode 100644 index 00000000..86b08cea --- /dev/null +++ b/test/validation/sensitive/invalid-outer-object-deep.ts @@ -0,0 +1,40 @@ +import { number, object, sensitive, string } from '../../../src'; + +// Validates that `sensitive()` on the outer object redacts failures at every +// depth: a top-level field (`a`), and two fields inside a nested object +// (`nested.b`, `nested.c`). +export const Struct = sensitive( + object({ + a: string(), + nested: object({ + b: number(), + c: string(), + }), + }), +); + +export const data = { a: 123, nested: { b: 'not-a-number', c: 456 } }; + +export const failures = [ + { + value: '***', + type: 'string', + refinement: undefined, + path: ['a'], + branch: ['***', '***'], + }, + { + value: '***', + type: 'number', + refinement: undefined, + path: ['nested', 'b'], + branch: ['***', '***', '***'], + }, + { + value: '***', + type: 'string', + refinement: undefined, + path: ['nested', 'c'], + branch: ['***', '***', '***'], + }, +]; diff --git a/test/validation/sensitive/invalid-outer-object-subfield.ts b/test/validation/sensitive/invalid-outer-object-subfield.ts new file mode 100644 index 00000000..09a49ea1 --- /dev/null +++ b/test/validation/sensitive/invalid-outer-object-subfield.ts @@ -0,0 +1,18 @@ +import { object, sensitive, string } from '../../../src'; + +// Validates that when the outer `object()` is wrapped in `sensitive()` and a +// sub-field fails, both the failing value and the parent object in the branch +// are fully redacted. +export const Struct = sensitive(object({ key: string() })); + +export const data = { key: 123 }; + +export const failures = [ + { + value: '***', + type: 'string', + refinement: undefined, + path: ['key'], + branch: ['***', '***'], + }, +]; From 3f457bc5728b8b272b3d0288b23e42bf40e57524 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Tue, 7 Jul 2026 21:58:25 +0200 Subject: [PATCH 13/15] fix: keep refiner context (not custom message though) --- src/structs/sensitive.ts | 12 +++++++++++- test/validation/sensitive/invalid-refined.ts | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 test/validation/sensitive/invalid-refined.ts diff --git a/src/structs/sensitive.ts b/src/structs/sensitive.ts index 942f5b03..c2b92537 100644 --- a/src/structs/sensitive.ts +++ b/src/structs/sensitive.ts @@ -67,7 +67,17 @@ function wrapWithRedaction( yield { ...failure, value: SENSITIVE_REDACTED, - message: `Expected a value of type \`${struct.type}\`, but received: \`${SENSITIVE_REDACTED}\``, + // We cannot safely preserve `failure.message` even for refiner + // failures: a refiner that returns `false` gets a default message from + // `toFailure` that embeds the raw value. There is no field on `Failure` + // that distinguishes a custom refiner string from that generated + // default, so preserving the original message risks leaking the + // sensitive value. We rebuild the template from scratch, mirroring + // `toFailure`'s own default, and include the refinement name when + // present so callers can still tell which constraint failed. + message: `Expected a value of type \`${struct.type}\`${ + failure.refinement ? ` with refinement \`${failure.refinement}\`` : '' + }, but received: \`${SENSITIVE_REDACTED}\``, branch: failure.branch.map(() => SENSITIVE_REDACTED), }; } diff --git a/test/validation/sensitive/invalid-refined.ts b/test/validation/sensitive/invalid-refined.ts new file mode 100644 index 00000000..94ef9def --- /dev/null +++ b/test/validation/sensitive/invalid-refined.ts @@ -0,0 +1,17 @@ +import { refine, sensitive, string } from '../../../src'; + +// Validates that a refiner failure preserves the refinement name in the +// message while still fully redacting `value` and `branch`. +export const Struct = sensitive(refine(string(), 'nonempty', (v) => v.length > 0)); + +export const data = ''; + +export const failures = [ + { + value: '***', + type: 'string', + refinement: 'nonempty', + path: [], + branch: ['***'], + }, +]; From 80cb01bddeedbc76c39e9a7a7611c485186ffbe4 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Tue, 7 Jul 2026 22:01:33 +0200 Subject: [PATCH 14/15] chore: lint --- test/validation/sensitive/invalid-refined.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/validation/sensitive/invalid-refined.ts b/test/validation/sensitive/invalid-refined.ts index 94ef9def..111fd542 100644 --- a/test/validation/sensitive/invalid-refined.ts +++ b/test/validation/sensitive/invalid-refined.ts @@ -2,7 +2,9 @@ import { refine, sensitive, string } from '../../../src'; // Validates that a refiner failure preserves the refinement name in the // message while still fully redacting `value` and `branch`. -export const Struct = sensitive(refine(string(), 'nonempty', (v) => v.length > 0)); +export const Struct = sensitive( + refine(string(), 'nonempty', (value) => value.length > 0), +); export const data = ''; From 37ec5203354e33b845dbecdfb12dfcd6a43036d4 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 8 Jul 2026 11:08:19 +0200 Subject: [PATCH 15/15] fix: fix sensitive for non-plain object --- src/structs/sensitive.ts | 8 +++---- .../sensitive/invalid-non-plain-object.ts | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 test/validation/sensitive/invalid-non-plain-object.ts diff --git a/src/structs/sensitive.ts b/src/structs/sensitive.ts index c2b92537..30c2db2c 100644 --- a/src/structs/sensitive.ts +++ b/src/structs/sensitive.ts @@ -1,7 +1,7 @@ import type { Failure } from '../error.js'; import type { Context } from '../struct.js'; import { Struct } from '../struct.js'; -import { isPlainObject } from '../utils.js'; +import { isObject } from '../utils.js'; import type { AnyStruct } from '../utils.js'; export const SENSITIVE_REDACTED = '***'; @@ -32,9 +32,9 @@ export function isSensitiveStruct(struct: AnyStruct): boolean { * @returns A shallow copy with the specified keys replaced. */ function redactKeys( - sourceObj: Record, + sourceObj: Record, keys: string[], -): Record { +): Record { const redacted = { ...sourceObj }; for (const key of keys) { if (key in redacted) { @@ -128,7 +128,7 @@ export function withRedactedBranch( // 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 || !isPlainObject(parentObj)) { + if (branchItem !== parentObj || !isObject(parentObj)) { return branchItem; } return redactKeys(parentObj, sensitiveKeys); diff --git a/test/validation/sensitive/invalid-non-plain-object.ts b/test/validation/sensitive/invalid-non-plain-object.ts new file mode 100644 index 00000000..1f8bc91e --- /dev/null +++ b/test/validation/sensitive/invalid-non-plain-object.ts @@ -0,0 +1,24 @@ +import { literal, type, sensitive, string } from '../../../src'; + +export const Struct = type({ + secret: sensitive(string()), + tag: literal('ok'), +}); + +class Payload { + secret = 'raw-secret'; + + tag = 'bad'; +} + +export const data = new Payload(); + +export const failures = [ + { + value: 'bad', + type: 'literal', + refinement: undefined, + path: ['tag'], + branch: [{ secret: '***', tag: 'bad' }, 'bad'], + }, +];