diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 593f7640c..3ad848f58 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -233,7 +233,7 @@ }, "packages/keyring-utils/src/superstruct.ts": { "@typescript-eslint/no-explicit-any": { - "count": 7 + "count": 5 } }, "packages/keyring-utils/src/types.test.ts": { diff --git a/eslint.config.mjs b/eslint.config.mjs index 4d49bdaa8..af67e3825 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -138,6 +138,36 @@ const config = createConfig([ }, }, + // @metamask/keyring-api + { + files: ['packages/keyring-api/src/**/*.ts'], + rules: { + // Prevent importing superstruct's `object` directly. The keyring-utils + // wrapper must be used instead: it supports `exactOptional()` fields and, + // critically, redacts sensitive() field values from sibling-failure + // branches so secrets never appear in error metadata. + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: '@metamask/superstruct', + importNames: ['object'], + message: + "Import object() from '@metamask/keyring-utils' instead — it supports exactOptional() and sensitive() field redaction.", + }, + { + name: '@metamask/superstruct', + importNames: ['exactOptional'], + message: + "Import exactOptional() from '@metamask/keyring-utils' instead — mixing superstruct's exactOptional with keyring-utils' object() breaks ExactOptionalize and makes optional fields appear required.", + }, + ], + }, + ], + }, + }, + // @metamask/keyring-eth-trezor { files: ['packages/keyring-eth-trezor/src/**/*.ts'], diff --git a/packages/keyring-api/CHANGELOG.md b/packages/keyring-api/CHANGELOG.md index 307e2c9f5..10dfea06d 100644 --- a/packages/keyring-api/CHANGELOG.md +++ b/packages/keyring-api/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Use `sensitive(string())` for `privateKey` in `PrivateKeyExportedAccountStruct` to prevent raw private keys from leaking into validation error messages ([#577](https://github.com/MetaMask/accounts/pull/577)) + +### Changed + +- Migrate all `object()` usages from `@metamask/superstruct` to `@metamask/keyring-utils` ([#577](https://github.com/MetaMask/accounts/pull/577)) + - This ensures `sensitive()` field redaction and `exactOptional()` support are always active. + ## [23.3.0] ### Added diff --git a/packages/keyring-api/src/api/account-options.ts b/packages/keyring-api/src/api/account-options.ts index a8fbbeb26..ed57b06a6 100644 --- a/packages/keyring-api/src/api/account-options.ts +++ b/packages/keyring-api/src/api/account-options.ts @@ -1,11 +1,15 @@ -import { exactOptional, selectiveUnion, type } from '@metamask/keyring-utils'; +import { + exactOptional, + object, + selectiveUnion, + type, +} from '@metamask/keyring-utils'; import type { Infer } from '@metamask/superstruct'; import { boolean, intersection, literal, number, - object, record, string, } from '@metamask/superstruct'; diff --git a/packages/keyring-api/src/api/address.ts b/packages/keyring-api/src/api/address.ts index e5b08710d..960b03659 100644 --- a/packages/keyring-api/src/api/address.ts +++ b/packages/keyring-api/src/api/address.ts @@ -1,5 +1,5 @@ +import { object } from '@metamask/keyring-utils'; import type { Infer } from '@metamask/superstruct'; -import { object } from '@metamask/superstruct'; import { CaipAccountIdStruct } from '@metamask/utils'; /** diff --git a/packages/keyring-api/src/api/discovery.ts b/packages/keyring-api/src/api/discovery.ts index 1ed0ccf1a..8d221fcfe 100644 --- a/packages/keyring-api/src/api/discovery.ts +++ b/packages/keyring-api/src/api/discovery.ts @@ -1,5 +1,6 @@ +import { object } from '@metamask/keyring-utils'; import type { Infer } from '@metamask/superstruct'; -import { array, literal, object } from '@metamask/superstruct'; +import { array, literal } from '@metamask/superstruct'; import { CaipChainIdStruct } from './caip'; import { DerivationPathStruct } from './derivation'; diff --git a/packages/keyring-api/src/v2/api/create-account/bip44.ts b/packages/keyring-api/src/v2/api/create-account/bip44.ts index 4723235c3..026e32129 100644 --- a/packages/keyring-api/src/v2/api/create-account/bip44.ts +++ b/packages/keyring-api/src/v2/api/create-account/bip44.ts @@ -1,4 +1,5 @@ -import { literal, number, object, string } from '@metamask/superstruct'; +import { object } from '@metamask/keyring-utils'; +import { literal, number, string } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; import { DerivationPathStruct } from '../../../api/derivation'; diff --git a/packages/keyring-api/src/v2/api/create-account/private-key.ts b/packages/keyring-api/src/v2/api/create-account/private-key.ts index 967f47a3f..fcea4db93 100644 --- a/packages/keyring-api/src/v2/api/create-account/private-key.ts +++ b/packages/keyring-api/src/v2/api/create-account/private-key.ts @@ -1,4 +1,5 @@ -import { exactOptional, literal, object, string } from '@metamask/superstruct'; +import { exactOptional, object } from '@metamask/keyring-utils'; +import { literal, string } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; import { KeyringAccountTypeStruct } from '../../../api/account'; diff --git a/packages/keyring-api/src/v2/api/export-account/private-key.test.ts b/packages/keyring-api/src/v2/api/export-account/private-key.test.ts new file mode 100644 index 000000000..e3c674f1e --- /dev/null +++ b/packages/keyring-api/src/v2/api/export-account/private-key.test.ts @@ -0,0 +1,61 @@ +import { SENSITIVE_REDACTED } from '@metamask/keyring-utils'; +import type { StructError } from '@metamask/superstruct'; +import { assert } from '@metamask/superstruct'; + +import { PrivateKeyExportedAccountStruct } from './private-key'; + +const RAW_PRIVATE_KEY = + '0xdeadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678'; + +describe('PrivateKeyExportedAccountStruct', () => { + it('accepts a valid exported account', () => { + expect(() => + assert( + { + type: 'private-key', + privateKey: RAW_PRIVATE_KEY, + encoding: 'hexadecimal', + }, + PrivateKeyExportedAccountStruct, + ), + ).not.toThrow(); + }); + + it('redacts the private key value from the error when `privateKey` is invalid', () => { + let error: StructError | undefined; + try { + assert( + { type: 'private-key', privateKey: 123, encoding: 'hexadecimal' }, + PrivateKeyExportedAccountStruct, + ); + } catch (caughtError) { + error = caughtError as StructError; + } + expect(error?.value).toBe(SENSITIVE_REDACTED); + expect(error?.message).toContain(SENSITIVE_REDACTED); + expect(error?.message).not.toContain('123'); + }); + + it('redacts the private key from `branch` when a sibling field fails', () => { + let error: StructError | undefined; + try { + assert( + { + type: 'private-key', + privateKey: RAW_PRIVATE_KEY, + encoding: 'invalid-encoding', + }, + PrivateKeyExportedAccountStruct, + ); + } catch (caughtError) { + error = caughtError as StructError; + } + expect(error?.message).toContain('encoding'); + const allBranchItems = (error?.failures() ?? []).flatMap( + (failure) => failure.branch, + ); + expect(allBranchItems).not.toContainEqual( + expect.objectContaining({ privateKey: RAW_PRIVATE_KEY }), + ); + }); +}); diff --git a/packages/keyring-api/src/v2/api/export-account/private-key.ts b/packages/keyring-api/src/v2/api/export-account/private-key.ts index 566820c73..29dffafe4 100644 --- a/packages/keyring-api/src/v2/api/export-account/private-key.ts +++ b/packages/keyring-api/src/v2/api/export-account/private-key.ts @@ -1,4 +1,5 @@ -import { literal, object, string } from '@metamask/superstruct'; +import { object, sensitive } from '@metamask/keyring-utils'; +import { literal, string } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; import { PrivateKeyEncodingStruct } from '../private-key'; @@ -14,7 +15,7 @@ export const PrivateKeyExportedAccountStruct = object({ /** * The private key of the exported account. */ - privateKey: string(), + privateKey: sensitive(string()), /** * The encoding of the exported private key. */ diff --git a/packages/keyring-api/src/v2/api/keyring-capabilities.ts b/packages/keyring-api/src/v2/api/keyring-capabilities.ts index 9e1ae61a8..d6eb9fed1 100644 --- a/packages/keyring-api/src/v2/api/keyring-capabilities.ts +++ b/packages/keyring-api/src/v2/api/keyring-capabilities.ts @@ -1,11 +1,5 @@ -import { - array, - boolean, - exactOptional, - nonempty, - object, - partial, -} from '@metamask/superstruct'; +import { exactOptional, object } from '@metamask/keyring-utils'; +import { array, boolean, nonempty, partial } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; import { CaipChainIdStruct } from '../../api/caip'; diff --git a/packages/keyring-api/src/v2/api/private-key.ts b/packages/keyring-api/src/v2/api/private-key.ts index 97b657058..5474f7e25 100644 --- a/packages/keyring-api/src/v2/api/private-key.ts +++ b/packages/keyring-api/src/v2/api/private-key.ts @@ -1,4 +1,5 @@ -import { enums, exactOptional, object } from '@metamask/superstruct'; +import { exactOptional, object } from '@metamask/keyring-utils'; +import { enums } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; import { KeyringAccountTypeStruct } from '../../api/account'; diff --git a/packages/keyring-utils/CHANGELOG.md b/packages/keyring-utils/CHANGELOG.md index acf035cf1..bae5ddcf8 100644 --- a/packages/keyring-utils/CHANGELOG.md +++ b/packages/keyring-utils/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `sensitive()` superstruct wrapper to redact sensitive values from validation errors ([#577](https://github.com/MetaMask/accounts/pull/577)) + - Wrapping a struct with `sensitive()` replaces the value, message, and branch in any `StructError` with `'***'` to prevent secrets (private keys, mnemonics) from leaking into logs or external services. +- Update `object()` to automatically redact sensitive sibling fields from `StructError.branch` when the schema contains `sensitive()`-wrapped fields ([#577](https://github.com/MetaMask/accounts/pull/577)) + ## [3.3.1] ### Fixed diff --git a/packages/keyring-utils/src/superstruct.test.ts b/packages/keyring-utils/src/superstruct.test.ts index 6a15911ee..f5ac21a0a 100644 --- a/packages/keyring-utils/src/superstruct.test.ts +++ b/packages/keyring-utils/src/superstruct.test.ts @@ -1,4 +1,4 @@ -import type { Struct } from '@metamask/superstruct'; +import type { Struct, StructError } from '@metamask/superstruct'; import { assert, coerce, @@ -7,12 +7,163 @@ import { literal, max, number, + refine, string, union, } from '@metamask/superstruct'; import { isPlainObject } from '@metamask/utils'; -import { exactOptional, object, strictMask, selectiveUnion, type } from '.'; +import { + exactOptional, + object, + sensitive, + SENSITIVE_REDACTED, + strictMask, + selectiveUnion, + type, +} from '.'; + +describe('sensitive', () => { + const RAW_SECRET = '0xdeadbeef1234567890abcdef'; + + it('accepts a valid value', () => { + expect(is(RAW_SECRET, sensitive(string()))).toBe(true); + }); + + it('rejects an invalid value', () => { + expect(is(123, sensitive(string()))).toBe(false); + }); + + it('redacts the value in the error message', () => { + expect(() => assert(123, sensitive(string()))).toThrow(SENSITIVE_REDACTED); + }); + + it('does not expose the raw value in the error message', () => { + let error: StructError | undefined; + try { + assert(RAW_SECRET, sensitive(refine(string(), 'hex', () => false))); + } catch (caughtError) { + error = caughtError as StructError; + } + expect(error?.message).toContain(SENSITIVE_REDACTED); + expect(error?.message).not.toContain(RAW_SECRET); + }); + + it('redacts error.value', () => { + let error: StructError | undefined; + try { + assert(123, sensitive(string())); + } catch (caughtError) { + error = caughtError as StructError; + } + expect(error?.value).toBe(SENSITIVE_REDACTED); + }); + + it('does not expose the raw value in error.branch', () => { + let error: StructError | undefined; + try { + assert(123, sensitive(string())); + } catch (caughtError) { + error = caughtError as StructError; + } + expect(error?.branch).not.toContain(123); + }); + + it('redacts the value when used inside an object struct', () => { + const struct = object({ privateKey: sensitive(string()) }); + let error: StructError | undefined; + try { + assert({ privateKey: 123 }, struct); + } catch (caughtError) { + error = caughtError as StructError; + } + expect(error?.message).toContain(SENSITIVE_REDACTED); + expect(error?.message).not.toContain('123'); + }); + + it('redacts refinement failures', () => { + const hexString = sensitive( + refine(string(), 'hex', (str) => str.startsWith('0x') || 'not hex'), + ); + let error: StructError | undefined; + try { + assert('not-a-hex-string', hexString); + } catch (caughtError) { + error = caughtError as StructError; + } + expect(error?.message).toContain(SENSITIVE_REDACTED); + expect(error?.message).not.toContain('not-a-hex-string'); + expect(error?.value).toBe(SENSITIVE_REDACTED); + }); + + it('does not alter coercion behaviour', () => { + const struct = sensitive(string()); + expect(is(RAW_SECRET, struct)).toBe(true); + }); + + describe('branch redaction for sibling fields via object()', () => { + const struct = object({ + privateKey: sensitive(string()), + encoding: literal('hex'), + }); + + it('redacts the sensitive key from branch[0] when a sibling field fails', () => { + let error: StructError | undefined; + try { + assert({ privateKey: RAW_SECRET, encoding: 'invalid' }, struct); + } catch (caughtError) { + error = caughtError as StructError; + } + // The error is about `encoding`, not `privateKey`. + expect(error?.message).toContain('encoding'); + // But the parent object at branch[0] must not expose the raw secret. + const parentInBranch = error?.branch[0] as Record; + expect(parentInBranch?.privateKey).toBe(SENSITIVE_REDACTED); + expect(parentInBranch?.privateKey).not.toBe(RAW_SECRET); + }); + + it('redacts the sensitive key from every failure returned by error.failures()', () => { + let error: StructError | undefined; + try { + assert({ privateKey: RAW_SECRET, encoding: 'invalid' }, struct); + } catch (caughtError) { + error = caughtError as StructError; + } + // Collect every object appearing in any failure branch and assert that + // none of them expose the raw private key. + const allBranchItems = (error?.failures() ?? []).flatMap( + (failure) => failure.branch, + ); + expect(allBranchItems).not.toContainEqual( + expect.objectContaining({ privateKey: RAW_SECRET }), + ); + }); + + it('redacts the sensitive key from branch when a deeply nested sibling field fails', () => { + const nestedStruct = object({ + privateKey: sensitive(string()), + // `meta` is a sibling whose inner field will fail validation. + meta: object({ tag: literal('valid') }), + }); + + let error: StructError | undefined; + try { + assert( + { privateKey: RAW_SECRET, meta: { tag: 'invalid' } }, + nestedStruct, + ); + } catch (caughtError) { + error = caughtError as StructError; + } + // The error path leads into the nested `meta.tag` field. + expect(error?.path).toContain('tag'); + // The top-level parent at branch[0] must still have the key redacted. + const parentInBranch = error?.branch[0] as Record; + expect(parentInBranch?.privateKey).toBe(SENSITIVE_REDACTED); + expect(parentInBranch?.privateKey).not.toBe(RAW_SECRET); + }); + }); +}); describe('exactOptional', () => { const simpleStruct = object({ diff --git a/packages/keyring-utils/src/superstruct.ts b/packages/keyring-utils/src/superstruct.ts index 9bed59bfb..bbcada2af 100644 --- a/packages/keyring-utils/src/superstruct.ts +++ b/packages/keyring-utils/src/superstruct.ts @@ -5,14 +5,15 @@ import { type as stType, } from '@metamask/superstruct'; import type { - Infer, + AnyStruct, Context, + Failure, + Infer, ObjectSchema, OmitBy, Optionalize, PickBy, Simplify, - AnyStruct, } from '@metamask/superstruct'; import type { Equals } from './types'; @@ -27,6 +28,7 @@ export type ExactOptionalTag = { /** * Exclude type `Type` from the properties of `Obj`. * + * @example * ```ts * type Foo = { a: string | null; b: number }; * type Bar = ExcludeType; @@ -40,10 +42,11 @@ export type ExcludeType = { /** * Make optional all properties that have the `ExactOptionalTag` type. * + * @example * ```ts - * type Foo = { a: string | ExactOptionalTag; b: number}; + * type Foo = { a: string | ExactOptionalTag; b: number }; * type Bar = ExactOptionalize; - * // Bar = { a?: string; b: number} + * // Bar = { a?: string; b: number } * ``` */ export type ExactOptionalize = OmitBy< @@ -59,9 +62,190 @@ export type ObjectType = Simplify< ExactOptionalize }>> >; +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(); + +/** + * Return a shallow copy of `sourceObj` with each key in `sensitiveKeys` + * replaced by the redaction placeholder. + * + * @param sourceObj - The source object to copy and redact. + * @param sensitiveKeys - The property names to redact. + * @returns A shallow copy with the specified keys replaced. + */ +function redactKeys( + sourceObj: Record, + sensitiveKeys: string[], +): Record { + const redacted = { ...sourceObj }; + for (const key of sensitiveKeys) { + 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. + * + * How the branch override works: superstruct's internal `toFailure` assembles + * each `Failure` as `{ value, branch, ...result, message }`. Because the + * spread of `result` comes *after* the defaults derived from context, returning + * a failure object that carries a sanitised `branch` property causes it to win + * over the raw branch that superstruct would otherwise inject from context. + * + * @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. + */ +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; + // The superstruct `entries` type allows `AnyStruct | AnyStruct[]`, but + // in practice `object()` never yields an array - it is a type-level + // guard for future or exotic struct types. + // istanbul ignore next + if (Array.isArray(fieldStruct)) { + yield entry; + } else { + yield [ + fieldKey, + fieldValue, + // `fieldStruct` is typed as `Struct | Struct` + // by TypeScript's entries tuple inference, which does not match + // `AnyStruct` (`Struct`) exactly. The cast is safe because both + // shapes represent untyped structs and behave identically 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 (private keys, mnemonics, passwords) to prevent + * sensitive material from leaking into error logs or external services. + * + * When composed with the `object()` exported from this module, sibling-field + * failures will also have the parent object's sensitive keys redacted from + * their branch. This does **not** apply when using superstruct's own `object()` + * directly, since that function is unaware of the sensitive marker. + * + * @example + * ```ts + * const MyStruct = object({ privateKey: sensitive(string()) }); + * assert({ privateKey: 123 }, MyStruct); + * // throws: At path: privateKey -- 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()` can detect which schema keys + // are sensitive and patch sibling-field failures accordingly. + sensitiveStructs.add(wrapped as AnyStruct); + return wrapped; +} + /** * Change the return type of a superstruct's `object` function to support - * exact optional properties. + * exact optional properties. When the schema contains fields wrapped with + * `sensitive()`, the returned struct also patches every sibling-field failure + * so that the parent object in `failure.branch` has the sensitive keys + * redacted, including failures from deeply nested fields inside siblings. * * @param schema - The object schema. * @returns A struct representing an object with a known set of properties. @@ -69,7 +253,53 @@ export type ObjectType = Simplify< export function object( schema: Schema, ): Struct, Schema> { - return stObject(schema) as any; + // `as unknown as` is required because our `ObjectType` differs from + // superstruct's own `ObjectType` (it supports `ExactOptional`), and the two + // types are not directly comparable without an intermediate `unknown` cast. + const base = stObject(schema) as unknown as Struct< + ObjectType, + Schema + >; + + // `schema[key]` can theoretically be `undefined` when the compiler has + // `noUncheckedIndexedAccess` enabled, even though `Object.keys` only returns + // keys that exist in the object. The explicit guard makes this safe. + const sensitiveKeys = Object.keys(schema).filter((key) => { + const fieldStruct = schema[key]; + return fieldStruct !== undefined && sensitiveStructs.has(fieldStruct); + }); + + // No sensitive fields - return the base struct directly to avoid the + // overhead of wrapping every entry. + if (sensitiveKeys.length === 0) { + return base; + } + + // Wrap each entry's struct so that failures from any field carry a sanitised + // copy of the parent object in their branch rather than the raw one that + // contains secret values. + return new Struct({ + ...base, + *entries(value: unknown, context: Context): ReturnType { + for (const entry of base.entries(value, context)) { + const [fieldKey, fieldValue, fieldStruct] = entry; + // See comment in `withRedactedBranch.entries` - array structs are a + // type-system possibility that `object()` never produces at runtime. + // istanbul ignore next + if (Array.isArray(fieldStruct)) { + yield entry; + } else { + yield [ + fieldKey, + fieldValue, + // Same inference mismatch as in `withRedactedBranch` - see that + // function's `entries` block for the explanation of this cast. + withRedactedBranch(fieldStruct as AnyStruct, value, sensitiveKeys), + ]; + } + } + }, + }) as unknown as Struct, Schema>; } /** @@ -83,7 +313,8 @@ export function object( export function type( schema: Schema, ): Struct, Schema> { - return stType(schema) as any; + // See comment in `object()` above for why this cast is necessary. + return stType(schema) as unknown as Struct, Schema>; } /** @@ -103,6 +334,7 @@ function hasOptional(ctx: Context): boolean { * Augment a struct to allow exact-optional values. Exact-optional values can * be omitted but cannot be `undefined`. * + * @example * ```ts * const foo = object({ bar: exactOptional(string()) }); * type Foo = Infer;