diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aa2f751..bda2846e 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 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..250c2b5a 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 * from './structs/sensitive.js'; export * from './structs/types.js'; export * from './structs/utilities.js'; export type { 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 new file mode 100644 index 00000000..a29c2609 --- /dev/null +++ b/src/structs/sensitive.ts @@ -0,0 +1,206 @@ +import type { Failure } from '../error.js'; +import type { Context } from '../struct.js'; +import { Struct } from '../struct.js'; +import { isObject } from '../utils.js'; +import type { AnyStruct } from '../utils.js'; + +const SENSITIVE_REDACTED = '***'; + +// 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. + * + * @param struct - The struct to check. + * @returns `true` if the struct carries the sensitive brand. + */ +export function isSensitiveStruct(struct: AnyStruct): boolean { + return Object.prototype.hasOwnProperty.call(struct, SENSITIVE_BRAND); +} + +/** + * 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 (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, + // 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: new Array(failure.branch.length).fill(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 + * `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 { + // 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; + } + return redactKeys(parentObj, sensitiveKeys); + }), + }; + } + } + + 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, + ), + ]; + } + }, + }); +} + +/** + * 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 { + const wrapped = wrapWithRedaction(struct); + + // 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/src/structs/types.ts b/src/structs/types.ts index 26c9e01e..f0c6ceb7 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,71 @@ import type { UnionToIntersection, } from '../utils.js'; import { print, run, isObject } from '../utils.js'; +import { isSensitiveStruct, 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 && isSensitiveStruct(schema[key] as AnyStruct), + ); + + if (sensitiveKeys.length === 0) { + return base; + } + + 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 | + // 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, + parentInBranch, + sensitiveKeys, + ), + ]; + } + }, + }); +} + /** * Ensure that any value passes validation. * @@ -448,7 +511,7 @@ export function object( ): any { const knowns = schema ? Object.keys(schema) : []; const Never = never(); - return new Struct({ + const base: Struct, Schema | null> = new Struct({ type: 'object', schema: schema ?? null, *entries(value) { @@ -482,6 +545,19 @@ 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 Struct, Schema>, + schema, + ); } /** @@ -678,7 +754,7 @@ export function type( schema: Schema, ): Struct, Schema> { const keys = Object.keys(schema); - return new Struct({ + const base: Struct, Schema> = new Struct({ type: 'type', schema, *entries(value) { @@ -703,6 +779,8 @@ export function type( return isObject(value) ? { ...value } : value; }, }); + + return withSensitiveEntries(base, 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-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'], + }, +]; 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: ['***', '***'], + }, +]; 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: ['***'], + }, +]; diff --git a/test/validation/sensitive/invalid-refined.ts b/test/validation/sensitive/invalid-refined.ts new file mode 100644 index 00000000..111fd542 --- /dev/null +++ b/test/validation/sensitive/invalid-refined.ts @@ -0,0 +1,19 @@ +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', (value) => value.length > 0), +); + +export const data = ''; + +export const failures = [ + { + value: '***', + type: 'string', + refinement: 'nonempty', + path: [], + branch: ['***'], + }, +]; 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 new file mode 100644 index 00000000..ee14f6b0 --- /dev/null +++ b/test/validation/sensitive/invalid-sibling-exactoptional.ts @@ -0,0 +1,24 @@ +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/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'; 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], + }, +];