forked from ianstormtaylor/superstruct
-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add sensitive struct
#41
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ccharly
wants to merge
15
commits into
main
Choose a base branch
from
cc/feat/superstruct-sensitive
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
2a040a5
feat: add sensitive struct
ccharly 1282fab
chore: lint
ccharly d5a2529
chore: fix encapslutation of sensitiveStructs
ccharly 33e4bd0
refactor: remove unneeded type casts
ccharly 2e1b569
chore: cosmetic
ccharly 8eb3183
fix: use brand/symbol to detect senstive wrapped structs
ccharly ab42b6e
fix: fix invalid redacting for sibling branches with coercions
ccharly d4a4c28
fix: prevent unique symbols with different lib versions
ccharly 0c96621
test: add test with entire sensitive object
ccharly 3525ddd
chore: simplify changelog
ccharly d83507c
refactor: use isPlainObject to avoid type-cast
ccharly a45349a
fix: redact nested failures too
ccharly 3f457bc
fix: keep refiner context (not custom message though)
ccharly 80cb01b
chore: lint
ccharly 37ec520
fix: fix sensitive for non-plain object
ccharly File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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: ['***', '***'], | ||
| }, | ||
| ]; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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).