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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions src/struct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,19 @@ export class Struct<Type = unknown, Schema = unknown> {
} 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,
);
}
Comment on lines +74 to +85

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is necessary to forward the brand (marker) inside structs when we are re-constructing them.

Type-wise, that does not change anything, those are just plain symbols that are used as markers for us to detect later if a re-constructed struct was banded as sensitive (we could use this same logic for other kind of decorators in the future too).

}

/**
Expand Down
206 changes: 206 additions & 0 deletions src/structs/sensitive.ts
Original file line number Diff line number Diff line change
@@ -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';

export 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<PropertyKey, unknown>,
keys: string[],
): Record<PropertyKey, unknown> {
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<Type, Schema>(
struct: Struct<Type, Schema>,
): Struct<Type, Schema> {
// eslint-disable-next-line jsdoc/require-jsdoc
function* redact(failures: Iterable<Failure>): Iterable<Failure> {
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: failure.branch.map(() => SENSITIVE_REDACTED),
};
}
}

return new Struct({
...struct,
validator(value, context): ReturnType<Struct['validator']> {
return redact(struct.validator(value, context));
},
refiner(value: Type, context): ReturnType<Struct['refiner']> {
return redact(struct.refiner(value, context));
},
*entries(value: unknown, context: Context): ReturnType<Struct['entries']> {
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<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;
}
return redactKeys(parentObj, sensitiveKeys);
}),
};
}
}

return new Struct({
...struct,
validator(value, context): ReturnType<Struct['validator']> {
return redactBranch(struct.validator(value, context));
},
refiner(value, context): ReturnType<Struct['refiner']> {
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<Struct['entries']> {
for (const entry of struct.entries(value, context)) {
const [fieldKey, fieldValue, fieldStruct] = entry;
yield [
fieldKey,
fieldValue,
// `AnyStruct` is `Struct<any, any>`, while the tuple narrows only to
// `Struct<any> | Struct<never>` (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<Type, Schema>(
struct: Struct<Type, Schema>,
): Struct<Type, Schema> {
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;
}
84 changes: 81 additions & 3 deletions src/structs/types.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<Type, Schema extends ObjectSchema>(
base: Struct<Type, Schema>,
schema: Schema,
): Struct<Type, Schema> {
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),
Comment thread
cursor[bot] marked this conversation as resolved.
);

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<any> |
// Struct<never>`. `AnyStruct` is `Struct<any, any>`, 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.
*
Expand Down Expand Up @@ -448,7 +511,7 @@ export function object<Schema extends ObjectSchema>(
): any {
const knowns = schema ? Object.keys(schema) : [];
const Never = never();
return new Struct({
const base: Struct<ObjectType<Schema>, Schema | null> = new Struct({
type: 'object',
schema: schema ?? null,
*entries(value) {
Expand Down Expand Up @@ -482,6 +545,19 @@ export function object<Schema extends ObjectSchema>(
return isObject(value) ? { ...value } : value;
},
});

if (!schema) {
return base;
}

// `base` is typed as `Struct<unknown, Schema | null>` 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<ObjectType<Schema>, Schema>,
schema,
);
}

/**
Expand Down Expand Up @@ -678,7 +754,7 @@ export function type<Schema extends ObjectSchema>(
schema: Schema,
): Struct<ObjectType<Schema>, Schema> {
const keys = Object.keys(schema);
return new Struct({
const base: Struct<ObjectType<Schema>, Schema> = new Struct({
type: 'type',
schema,
*entries(value) {
Expand All @@ -703,6 +779,8 @@ export function type<Schema extends ObjectSchema>(
return isObject(value) ? { ...value } : value;
},
});

return withSensitiveEntries(base, schema);
}

/**
Expand Down
12 changes: 12 additions & 0 deletions test/typings/sensitive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { assert, object, sensitive, string } from '../../src';
import { test } from '../index.test';

test<string>((value) => {
assert(value, sensitive(string()));
return value;
});

test<{ key: string }>((value) => {
assert(value, object({ key: sensitive(string()) }));
return value;
});
13 changes: 13 additions & 0 deletions test/validation/sensitive/invalid-nested.ts
Original file line number Diff line number Diff line change
@@ -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: ['***', '***'],
},
];
Loading
Loading