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