diff --git a/.changeset/bugsnag-integration.md b/.changeset/bugsnag-integration.md deleted file mode 100644 index 138c221..0000000 --- a/.changeset/bugsnag-integration.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@platform-sec/sensitive-string-bugsnag': minor ---- - -A Bugsnag integration, over the shared `@platform-sec/sensitive-string-scrub` layer. Everything -below was measured on Node 22.15.0 against `@bugsnag/js` 8.10.0, driving the real SDK to a server -on loopback and asserting on the bytes that left the process, with a companion case requiring each -position to leak once the scrubbing is removed. - -`bugsnag` composes `onError`, `onBreadcrumb` and `onSession`, and separately unions the resolved -denylist into `redactedKeys`. Both, because neither covers what the other does: `redactedKeys` -defaults to the single key `password` and applies under exactly four payload paths, so `context`, -`user`, `featureFlags`, `app` and the exception itself are the callbacks' work alone. Bugsnag's -callback contract is to mutate the event it hands over and return a boolean, so the package projects -the event's regions into a snapshot, scrubs that, and writes back through the SDK's own setters. -Every write is an assignment, never a write through something the caller still holds: measured, -`event.originalError` is the caller's own error and a `Breadcrumb` stores the metadata object -`leaveBreadcrumb` was given by reference. - -A callback you supply runs *before* the scrubber, which is the opposite of the Sentry package. -Bugsnag has no second seam, so holding the last position it allows is the only way a callback of -yours cannot add raw data behind the scrubber's back. - -The seam inventory is a type-level exhaustiveness check against the SDK's own config keys, so a -fourth `on*` option in a future release fails `pnpm typecheck` with its name in the failure rather -than opening quietly. diff --git a/.changeset/core-audit-context.md b/.changeset/core-audit-context.md deleted file mode 100644 index 11c4ced..0000000 --- a/.changeset/core-audit-context.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -'@platform-sec/sensitive-string-core': minor -'@platform-sec/sensitive-string-testing': minor -'@platform-sec/sensitive-string-prisma': patch -'@platform-sec/sensitive-string-typeorm': patch ---- - -Hand `auditReveal` the context the operation ran under. - -The hook was called with the value and the operation and nothing else, so a sink could not tell which -unit of work a reveal belonged to. That is not a gap anyone works around cheaply: `sensitive-prisma` -tags each unwrap with the model, the operation and the argument path, and `sensitive-typeorm` tags -each column write with the table and the column, and both had to install their per-value context as -the ambient one around every single reveal so a sink could recover the tags from -`getCurrentRevealContext()` inside its own hook. One `AsyncLocalStorage` entry per sensitive value, -paid to move three strings. - -`auditReveal(value, details, context)` now carries it. The context is the one passed to the call, or -the ambient one when the call passed none, which is the same context the policy's -`checkRevealAllowed` was asked to authorize, so an audit record and an authorization decision are -comparable after the fact. It is not always the context the hook was installed on: a reveal under a -`createChildContext()` is audited under the child, which is exactly what makes an integration's tags -reachable. Both packages now reveal plainly, and the entry went with the workaround. - -The argument is typed as `AuditContext`: `name`, `reason` and `tags`, and no `auditReveal`, since the -sink reading it is that hook. The narrowing is in the type and not in a copy, so a sink may compare -the context by identity and an audited reveal allocates nothing; `tags` is the caller's own object, -shared by every reveal under the context, so a sink copies out what it means to keep rather than -writing to it. - -A hook written with two parameters is unaffected, in types and at runtime. Code that calls a -context's `auditReveal` itself must pass the third argument; the only such caller in this repository -was the worker handoff's hook forwarder, which now passes the receiving context through so a -forwarded sink sees the work the sender named. - -In `sensitive-string-testing`, `AuditCall` gains `context` and `AuditEntry.contextName` is read from -what core reports rather than from the log's own name, so an entry made under a nested child is -recorded under the child. The recorded context is a one-level copy: a record pointing at the live -mock context would close a cycle through `calls` that `JSON.stringify` could not walk. Both helpers -degrade rather than throw when a caller forwards the hook with two arguments, recording the mock's -own name; they run inside the reveal gate, where a `TypeError` would surface as a failing reveal in -somebody's suite rather than as a missing tag. diff --git a/.changeset/core-bson-refusal.md b/.changeset/core-bson-refusal.md deleted file mode 100644 index 2b94933..0000000 --- a/.changeset/core-bson-refusal.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@platform-sec/sensitive-string-core': minor ---- - -Refuse BSON serialization instead of silently corrupting the document. - -`SensitiveString` now has a `toBSON()` that throws `SensitiveBsonError`. This is a behavior change -for any application that persists an unrevealed instance to MongoDB today: that write starts -throwing. Throwing is the point, and what it replaces is worse. - -Measured against `bson` 7.3.2 on Node 22.15.0. BSON honours `toBSON()` on a value and ignores -`toJSON()`, and the class had neither, so the encoder fell back to the instance's own enumerable -properties. Those are `klass` and `label`. A write of a classified token stored -`{ klass: 'credential', label: 'token' }` and dropped the value, with nothing thrown and nothing -logged, and the column looked populated. The same husk is produced for every value of a given class -and label, so a filter built from one instance matched a document written from another, which is an -authentication bypass rather than a storage bug. The refusal covers the top level of a document, a -subdocument and an array, and therefore every write path, every filter path, both bulk paths and the -raw driver, since all of them serialise through the same encoder. - -`toJSON()` still redacts and still does not throw, because logging must not throw and a redacted log -line is useful. BSON is a persistence and matching format, where an unrevealed value has no useful -degraded outcome. Nothing but a BSON encoder calls `toBSON()`, so no other path pays for it. - -Reveal at the write boundary, where the access is audited: - -```ts -await users.updateOne({ _id }, { $set: { apiKey: token.reveal({ name: 'rotate-key' }) } }); -``` - -Two alternatives were rejected. Returning the redaction marker keeps the corruption and makes every -classified path match the same constant, which is the same bypass with a different value in it. -Revealing under the ambient context makes writes work everywhere and quietly ships secrets into -filters, aggregation stages and every log line built from a command. diff --git a/.changeset/core-classification-memo.md b/.changeset/core-classification-memo.md deleted file mode 100644 index 82bec95..0000000 --- a/.changeset/core-classification-memo.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -'@platform-sec/sensitive-string-core': patch ---- - -Reuse the compiled form of a classification spec instead of rebuilding it per call. - -`classifyField` compiles the spec it is handed, which means building a Map, validating every class -and recompiling every rule's `RegExp`, and it did that on every call. Over a payload of five keys it -measured 4339ns against 281ns for the same work through a compiled classification held across the -loop. The gap is the whole reason `compileClassification` is exported, and it is not a gap a caller -should have to know about to avoid. - -`compileClassification` now memoizes per spec object in a `WeakMap`. On its own it went from 769ns -to 17ns, `classifyField` on one key from 868ns to 66ns, and over five keys from 4339ns to 281ns. The -entry is held only as long as the spec object is, so a spec built per request is collected with it. - -`compileClassification` now also refuses a specification that is not an object, with -`Invalid classification specification: expected an object, received string.` A primitive reached -`Object.entries(spec.fields ?? {})`, compiled to a classification that matched nothing and reported -that to nobody, so a caller who passed the wrong argument got an answer instead of an error. The -check sits ahead of the memo because `WeakMap` refuses a primitive key and would otherwise report -the mistake as `Invalid value used as weak map key`, which names an implementation detail rather -than what the caller did. - -One consequence worth stating, since it is observable. Reuse goes by object identity, so the same -spec object hands back the same compiled form, two equal specs compile separately, and a spec -mutated after it has been classified with keeps the form it compiled to the first time. A -`ClassificationSpec` is `readonly` throughout its type, so mutating one was never supported, but the -old behaviour would have picked the change up and the new one does not. All three cases are covered -by tests. diff --git a/.changeset/core-destination-binding.md b/.changeset/core-destination-binding.md deleted file mode 100644 index 2e7ead7..0000000 --- a/.changeset/core-destination-binding.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -'@platform-sec/sensitive-string-core': minor -'@platform-sec/sensitive-string-zod': patch ---- - -Add destination binding: a value can say which sinks and hosts it may be revealed toward, and the -raw-value gate checks the reveal context against it. - -```ts -const registryPassword = new SensitiveString(pw, 'credential', 'registry', { - destinations: { hosts: ['registry-1.docker.io'] }, -}); - -registryPassword.reveal(); // DestinationRestrictedError -registryPassword.reveal({ name: 'push', tags: { host: 'registry-1.docker.io.attacker.net' } }); - // DestinationRestrictedError -registryPassword.reveal({ name: 'push', tags: { sink: 'http', host: 'registry-1.docker.io' } }); - // the password -``` - -Every other check in the gate answers a question about the value or about the process. None asked -where the plaintext was about to go, so a credential could be wrapped at entry, revealed at exactly -the call site the design says, audited, and still written to the wrong host. The adoption corpus -recorded that seven times, twice as the same substring host comparison from unrelated maintainers. - -The destination is the context's `tags.sink` and `tags.host`, which are the tags the integrations -already write on every reveal: the HTTP clients set both from the request URL per attempt, the data -layer sets the sink to its driver, and `sendToExternal` sets it to the sink it was given. So a bound -value works unchanged through `sensitive-axios`, `sensitive-fetch` and the rest, and refuses through -them when the URL the program computed points somewhere else. A context that states no destination -refuses, on the allowlist rule the environment restrictions follow; there is no denylist. - -Every operation that hands plaintext out is checked, `hash()`, `mask()`, `map()`, `serialize()` and -`toWorkerPayload()` included, so serializing a bound value is itself a reveal toward wherever the -payload goes. `equals()` is not checked: a comparison sends the value nowhere. Derived values carry -the binding, a value derived from two bound values carries both, and bindings cross the wire and the -worker handoff as data that can only tighten. `zSensitiveDeserialize()` declares the wire field so an -object schema does not strip it. - -A host entry is what `new URL(url).host` spells, compared case-insensitively, with the port when it -is not the scheme's default; `*.example.com` matches at least one label below and not the domain -itself, with a port as part of the suffix. A URL, a path, a userinfo, a bare `*`, a misplaced -wildcard, or any spelling the URL parser would change, a Unicode domain or a stated default port, -is refused at construction, because an entry that can never equal a host tag sits in the list -matching nothing. `sensitive-fetch`'s standalone `sensitiveHeaders` helper takes no URL and writes -no host, so a host-bound value refuses through it unless the caller's context names the host. - -Three additions on the same mechanism. `principals` is a third literal list, matched against -`tags.principal`, whatever identity the application tags its reveals with, so a credential fetched -for one caller and handed out of a shared cache to another refuses. A `proxy` tag is read the other -way round: absent means the request goes to its host directly, present means a second machine -receives the bytes first and has to match the same `hosts` list; `sensitive-http` tags it when a -client resolves one. And `releasedBy: Promise` holds every exposing read with -`UnreleasedSensitiveValueError` (code `unreleased-value`, `status` `'pending'` or `'refused'`) until -the promise resolves `true`, for good when it resolves anything else or rejects: a credential whose -recipient is trusted only after an asynchronous check, an SSH host key against a known-hosts store, -is constructed with the check in flight and cannot be sent until it says so. A plain boolean is -refused at construction, `equals()` is not held, and the promise never crosses the wire because an -unreleased value cannot be serialized and a released one has nothing to carry. - -`DestinationRestrictedError` extends `SensitiveStringError` with the code `destination-restricted` -and names the tag that refused, what the context stated, and the allowed list, none of which is any -part of the value. A process that binds nothing pays a length check on an empty frozen array. diff --git a/.changeset/core-deterministic-redaction.md b/.changeset/core-deterministic-redaction.md deleted file mode 100644 index ffb8929..0000000 --- a/.changeset/core-deterministic-redaction.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -'@platform-sec/sensitive-string-core': minor -'@platform-sec/sensitive-string-llm': patch -'@platform-sec/sensitive-string-scrub': patch ---- - -`deterministicRedaction()`: a redacted rendering that tells two values apart. - -ROADMAP 6.1. Three log lines saying `[REDACTED:sensitive]` may be one subject or three, and the -difference is usually the question being asked. Registering this policy replaces the constant with -`[REDACTED:sensitive:5f2a91c0b74e]`, a stable tag derived from the value, so the lines that share a -subject look like they do. It is opt-in through `registerPolicy` and changes nothing for anyone who -does not register it. - -It changes what redacted output means, which is why it is a decision with a threat model behind it -rather than a formatting option. Redacted output has until now carried no information at all; -under this policy it carries a keyed fingerprint. Anyone holding the key and a guess can confirm the -guess against a log line, and anyone holding neither still learns which lines concern the same -subject, which is the feature and is still disclosure. - -The tag is an HMAC over `deriveKeyedToken`, and there is no unkeyed mode. That primitive was built -for this feature and for the LLM package's entity handles, and its rules apply here unchanged: a -16-byte key floor and a 12-hex-character tag floor. Both override the design sketch, which -defaulted to a plain SHA-256 with an optional HMAC and an eight-character tag. An email address -under an unkeyed digest is a wordlist lookup, and a redacted log is exactly where an offline attack -happens, so the factory refuses without a key instead of defaulting to the dangerous version. The -sketch's `deterministicRedactors` presets are gone with it, since all three were unkeyed. Eight -characters is 32 bits, where a one-in-a-thousand collision arrives at about 3,000 distinct values, -and a collision merges two subjects into one in a log, where it reads as a fact. - -`salt` became `scope`: a domain component mixed in ahead of the value on top of a mandatory key, -rather than the sketch's alternative to one. It keeps the use case the sketch wanted it for, -bounding correlation to a session or a tenant, and drops the reading that a salt is a substitute -for a key, which it is not, because it sits in the same row as the digest. - -`credential` is refused. A password or an API key is guessable by construction, so a stable tag -turns every log line into an oracle for confirming a guess against, for anyone who later obtains -the key. The sketch's own "When NOT to Use" section says so; this makes it the behaviour. The -refusal is hard-coded rather than read from an advisory flag, because every existing flag answers a -different question, and it is escapable through `createDeterministicRedactor`, the bare -`(value) => string` primitive, which is one line that shows up in review. - -`deterministicRedaction` starts from the class policy currently registered and replaces only -`redact`, so `requireConstantTimeEquals`, `allowMask`, `checkRevealAllowed`, the advisory flags and -every deadline rule survive a registration that would otherwise drop whichever one a hand-written -policy forgot to name. It also mixes the class into the derivation, the way `entityHandle` does, so -one string classified two ways gets two tags. - -One registration covers `toString()`, `toJSON()`, `inspect()`, `redactInStructure`, `redactCopy`, -`safeStringify`, a `mask()` that could not meet its floor, and a `serialize()` payload caught by a -redaction walk, which renders the same tag as the instance it came from. `expectRedacted` in -`sensitive-testing` needed no change, because it has always read the expected rendering off the -instance rather than assuming `[REDACTED:class]`; that claim is now covered by a test rather than -by a comment. - -That reach is worth naming twice over. It includes output bound for somewhere else: the -error tracking and APM integrations scrub through `redactCopy`, so an instance in a Sentry event, a -Bugsnag report, a Rollbar item or a Datadog span carries the tag. And the guards that refuse to send -a redaction onward learn a policy's marker by redacting two probes and keeping the answer when both -agree, which a value-dependent policy defeats by construction, so `SensitivityPolicy` gains an -optional `markerPattern` and this policy declares one. Without it a custom `prefix` would have -silently blinded `assertNoMarker` in the HTTP clients. The marker recognizers in `llm` and `scrub` -had a 64-character inner bound that the longest tag overran, and now allow 96. - -The key is not the only way to confirm a guess, which the design's threat model did not say. Anyone -who can get a value of their own choosing redacted into output they can read has an oracle: submit a -guess, read its tag, compare it with a tag from the log. HMAC-SHA256 is a PRF, so this does not -recover the key; it does reduce "an attacker who holds the key" to "an attacker who can reach the -service". - -Where redaction runs has two consequences, both deliberate and both worth knowing. It is not a -reveal, so it fires no audit hook, charges no reveal budget, and keeps working on an expired or -revoked value: a dead value still emits its tag, which is the same disclosure the live one made and -no new one. And it runs on every rendering with no cache, because a cache keyed by value is a table -of plaintexts with the lifetime of the process. Measured on Node 22.15.0, a redaction costs about -20 ns under the constant policy and about 2.6 µs under this one, so the sketch's "negligible -overhead" holds for a log line and not for a hot loop. diff --git a/.changeset/core-development-mode.md b/.changeset/core-development-mode.md deleted file mode 100644 index 0aeae91..0000000 --- a/.changeset/core-development-mode.md +++ /dev/null @@ -1,102 +0,0 @@ ---- -'@platform-sec/sensitive-string-core': minor ---- - -Add development mode: what a value's own history was, for the engineer debugging it. - -A reveal that fails, or one that should never have happened, says very little on its own. The stack -names the caller of `reveal()` and nothing else: not where the value came from, not how often it has -already been read, not which of the four `SensitiveString`s in scope this one is. `setDevMode()` -turns on recording of exactly that, `getDevModeConfig()` reads the resolved configuration back, and -`withDevMode(config, fn)` installs one for the length of a call, which is what a test wants. - -```ts -setDevMode({ - enabled: true, - captureStackTraces: true, - captureCreationStack: true, - warnOnMissingContext: true, - warnOnRepeatedReveal: { threshold: 5, window: 60_000 }, - logReveals: { enabled: true, showPartialValue: true }, -}); -``` - -The history is also readable, through a new `__devInfo` getter: `createdAt`, `creationStack`, -`revealCount`, `revealTimestamps` and `lastRevealStack`. It is `undefined` when development mode is -off rather than a missing property, so debugging code written against it compiles and runs in -production. Records live in a `WeakMap` keyed by the instance, so a value's history is collected -with the value, and a record is only kept for a value constructed while development mode was on. - -Everything is off until it is turned on. What a process that never calls `setDevMode` pays is one -property read in the constructor and one in the raw-value gate. - -What counts as an exposure is exactly what an audit hook sees, because the recording sits in the -same gate, immediately after the audit call: `reveal()`, `mask()`, `serialize()`, `map()` and the -string, regex and template operations count, and each is reported under its own operation name. -`equals()` does not, because a comparison reads the value without disclosing it, and neither does -redaction. A read the gate refused, or one an audit hook stopped by throwing, is not counted: a -denied reveal is not a reveal. - -Five decisions differ from the design document, all in the same direction. - -`showPartialValue` prints a prefix of the raw value, which is a real disclosure and the reason the -flag is off by default and capped at eight characters. It is bounded a second time, by the same -floor on hidden characters that `mask()` enforces for the class: four for `sensitive`, eight for -`credential`. A ten-character credential shows two characters rather than the four asked for, and an -eight-character one shows nothing at all rather than most of itself. Counts are code points, so a -prefix never ends inside a surrogate pair. This is still a wider disclosure than `mask()` allows, -which refuses `credential` outright, and the README says so. - -Repeated reveals are counted per value in fixed windows rather than a sliding one, and the warning -fires as the threshold is crossed and then stays quiet for the rest of the window. The design's -sketch warns on every reveal past the threshold, which floods the console for exactly the runaway -loop the feature exists to find; one line per window is the useful form of the same signal. It also -answers the design's open question about timestamp memory in the process: the window needs two -numbers, so `revealTimestamps` is free to be a plain 64-entry ring, which is what bounds a value -revealed in a loop for an hour to a fixed amount of memory. `revealCount` stays exact. - -Captured stacks are trimmed by file rather than by matching the string `sensitive-core` in a frame, -which would also match an application directory of that name and would match nothing at all once a -frame has been rewritten by a bundler. This module reads its own file out of a stack at load time, -drops its own frames, and then drops the contiguous run of frames belonging to whichever module -called into it, which is the raw-value gate. A build that inlines the two into one file leaves -nothing after the filter, and the unfiltered frames are kept instead. - -An error thrown by a configured logger is swallowed. Without that, a broken logger turns a -successful reveal into a failed one, after the audit hook has already recorded it as successful, and -only in development: the one environment where the difference would be mistaken for a bug in the -caller. - -One item from the design is deliberately absent. Its open questions propose that `setDevMode` warn -when the configuration changes while `SensitiveString`s already exist, and nothing can answer that -question: records live in a `WeakMap`, so whether any instance is still alive is exactly what cannot -be observed. The nearest implementable version, a flag set the first time anything is recorded, -would fire on every `withDevMode` call in a test suite, which is the use case the neighbouring open -question adds `withDevMode` to support. - -The design's `<1%` overhead tests are `src/__benchmarks__/dev-mode.bench.ts` rather than assertions, -for the reason the timing analysis is not part of `pnpm test`: a wall clock verdict depends on how -busy the machine is and must not gate a release. Each group holds a development-mode-off control, -and the gap between it and the guarded path is the whole production price of the feature. The bound -on memory is asserted, because that one is deterministic. - -Measured on this machine at Node 22.15.0, with the harness floor subtracted: a reveal costs 195ns -with development mode off and 187ns with it on over a value it never recorded, which is the same -figure and is what the guard is worth. Recording one costs 334ns, the repeated-reveal window 578ns, -logging 629ns, a partial rendering 1949ns, and capturing the stack 214us, which is the whole of the -gap and is why the two capture options are separate flags. That last figure is inflated by the -runner's source-map-aware `Error.prepareStackTrace`: the same capture in a plain Node process -measures 13.5us. Construction is 6.8x with recording on and 795x with the creation stack. - -The benchmark hooks are worth one warning. Vitest hands a benchmark's third argument to the -tinybench `Bench` constructor rather than to the `Task`, so `beforeAll` is accepted by the type -checker through a non-literal and then silently ignored; the first version of this file used it, -installed no configuration at all, and reported capturing a creation stack as cheaper than capturing -none. `setup` and `teardown` are the hooks that run. The same file also revealed a value built -before development mode was on, which has no record, so the group measured the guard six times over -rather than the recording; that case is now a labelled task of its own. - -58 cases across `src/dev-mode.test.ts` and `src/__tests__/dev-mode-security.test.ts`, including -canaries that the full value reaches no log line under every option at once, that it is in nothing -`__devInfo` exposes, and that every redacted rendering, the wire format included, is byte for byte -what it was with development mode off. diff --git a/.changeset/core-environment-restrictions.md b/.changeset/core-environment-restrictions.md deleted file mode 100644 index 8fadb03..0000000 --- a/.changeset/core-environment-restrictions.md +++ /dev/null @@ -1,104 +0,0 @@ ---- -'@platform-sec/sensitive-string-core': minor -'@platform-sec/sensitive-string-zod': patch ---- - -Add environment restrictions: a value can say which deployment environment, region or datacenter it -may be read in, and the check runs in the raw-value gate rather than at the application layer. - -```ts -setEnvironmentInfo({ environment: process.env.NODE_ENV, region: await instanceRegion() }); -lockEnvironmentInfo(); - -const euCustomer = new SensitiveString(pii, 'sensitive', 'eu_pii', { - restrictions: { allowedRegions: ['eu-*'] }, -}); - -euCustomer.reveal(); // in us-east-1: throws EnvironmentRestrictedError -``` - -Six declarative lists, `allowed` and `denied` over environment, region and datacenter, plus a -`custom` predicate asked last and handed the frozen environment. Every field that is present has to -pass, so a name on both lists is denied. Region entries take a trailing `*` as a prefix and nothing -else; a `*` anywhere but the last character is refused at construction, because `*-east-1` matched -as a literal is a denylist that matches nothing. - -The check sits in the gate the TTL, revocation and reveal-budget checks sit in, which is what rule -R2 requires and what the design sketch did not do: the sketch put it at the top of `reveal()`, where -`secret.map(v => v).reveal()` walks past it. Every exposing read refuses, and so does `equals()`, -which R2 names explicitly: a value that may not be read in this region must not still be able to -answer whether a guess matches it here. Redaction and metadata keep working, because a value that -cannot be used still has to be safe to log. It is decided after the deadline and before the reveal -budget, so a read in the wrong region costs nothing and a revoked or expired value still reports the -condition that invites the right response. - -`renew()` checks it too, and needs its own check because it reads nothing and so does not pass the -gate at all. Revocation already had that check for the same reason; the reason transfers, because -extending the life of a value this deployment may not use is using it. Expiry is the one that does -not transfer: reviving a lapsed deadline is what `renew()` is for. - -Restrictions are read at each access, not at construction, and derived values share the state, so a -substring is restricted exactly as its parent is and a value derived from two restricted values -carries both. That makes a derivative at least as restricted as each source, which is R2's -combination rule with nothing left to decide: an intersection is what sharing already gives. - -Declarative restrictions cross the wire, as rule R3's default requires, and like the `revoked` flag -they can only tighten: a deserialized value starts with none and there is no spelling of "no -restriction". The wire field is a list, because a value derived from two restricted values carries -two, and carrying only the first would silently drop the other. A payload entry with a key this -receiver does not know is rejected rather than ignored, since enforcing part of a restriction and -reporting nothing about the rest is the failure the feature exists to prevent. The zod -`zSensitiveDeserialize()` schema declares the field for the same reason it declares `revoked`: an -object schema strips what it does not declare, and stripping this one turns a value restricted to -the EU into one readable anywhere. - -Four places the sketch had to change, all of them the same shape: it chose to read silence as -permission, and silence is where this feature fails. - -- **A custom restriction refuses to cross.** The sketch skipped it on serialize. A function has no - wire form, so skipping it hands the receiver a value it believes it has fully checked while the - half that named the actual condition is gone. `serialize()` now refuses such a value before - reading it, with `UnserializableRestrictionError`, and `toWorkerPayload()` refuses it with - `UnshareableConstraintError` alongside a reveal budget, which is the error for a constraint that - cannot cross an isolate. The serialize refusal is deliberately not an - `EnvironmentRestrictedError`: that one means "not here, try where the value allows" and a caller - may sensibly retry elsewhere on it, while this one means "not anywhere", and a caller who cannot - tell them apart retries in every region forever. -- **An empty list is refused at construction.** The sketch specified that `allowedRegions: []` - allows everything. Read literally it says the opposite, and a security control must not pick one - of those silently. A caller whose configuration came back empty finds out at the call that wrote - it. A restriction set that expresses nothing at all is refused for the same reason. -- **A field the environment does not state is refused by an allowlist.** The sketch skipped both - kinds of list, which makes `allowedRegions: ['eu-*']` inert on any deployment that never sets a - region, which is the deployment most likely to be the wrong one. An allowlist is the statement - "only here", and a process that cannot say where it is has not shown that it is here. A denylist - still passes: "not there" concludes nothing from silence, and refusing would break every laptop - and CI runner. The first read of a restricted value before `setEnvironmentInfo()` warns once, so - the quiet half of that asymmetry is audible. -- **A custom function that answers with anything but `true` denies.** A throw denies, for the reason - a revocation registry that throws does, and an `async` function returns a Promise, which is truthy - and would otherwise allow every read it was written to refuse. - -`EnvironmentRestrictedError` extends `SensitiveStringError` with the code `environment-restricted`, -`EnvironmentLockedError` with `environment-locked`, and `UnserializableRestrictionError` with -`unserializable-restriction`, so all three are catchable as library refusals and greppable in a log. The error names the restriction that refused, the environment's current -value for it, and the configured lists. All of that is configuration and none of it is any part of -the protected value. - -The environment itself is process-global state under rule R6: one `Symbol.for()` singleton so -duplicate installs of core declare one environment, with a test that reads it back through that key, -and documentation whose first paragraph says it defends against a deploy to the wrong region rather -than against a caller who wants the value. `lockEnvironmentInfo()` freezes it for the rest of the -process, and `resetEnvironmentInfo()` clears both it and the lock for test isolation, which is the -honest statement of what the lock is worth. Every field is copied and frozen when it is declared, so -neither a caller mutating the object it passed nor a custom restriction reading it can move the -environment after the fact, and the same applies to the lists a value is constructed with. - -A process that restricts nothing pays a length check on an empty frozen array, on the same shape the -reveal budget uses. - -69 cases across two files. The security file drives the two tables the TTL, reveal-budget and -revocation suites drive, so an operation added later that forgets the gate fails there rather than -shipping as a bypass: 35 that reach the raw value, each refused in the wrong region and leaking -nothing in the message, the stack or the inspection, and 24 that derive a new protected value, each -inheriting the restriction of the value it came from. diff --git a/.changeset/core-equals-length-timing.md b/.changeset/core-equals-length-timing.md deleted file mode 100644 index f2783e1..0000000 --- a/.changeset/core-equals-length-timing.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -'@platform-sec/sensitive-string-core': patch ---- - -`equals()` no longer leaks the stored value's length through timing. - -The constant-time path compared lengths first and returned early when they differed, under two -comments calling that leak unavoidable. It is not unavoidable, and the early return was measurable: -a comparand of the wrong length answered before any byte was examined, so an attacker who can time -the call learns the length of the credential by finding the length that costs more. A length is not -the secret, but it bounds the search for one, and this was the one call site the library advertises -as safe to hand a credential to without thinking about it. - -Both operands are now hashed to a fixed width before they are compared, so `timingSafeEqual` always -receives two 32-byte digests and there is no length to check and no path to choose between. The -per-process key it hashes under is ephemeral and never exported: it exists to make a digest of a -guessable value worthless if it were ever observed, not to be agreed with anything. - -What remains is the HMAC's own cost, which grows with its input one 64-byte block at a time, over -the stored value as well as over the comparand. Measured independently, stored values from 8 to 256 -bytes were indistinguishable within the timing analysis's noise floor; 1024 bytes cost about 460ns -more than 8 and 4096 about 2.5us more. A credential-sized value has its length hidden to the -resolution anyone has measured. A value hundreds of bytes long has it hidden at block granularity, -which is a weaker promise than "no dependence" and is what the source comments now say. - -Comparing a wrong-length comparand against a zero-filled buffer of its own length is the more common -repair and was not used. It moves the branch rather than removing it, since only the mismatch path -allocates and fills that buffer, so that path becomes the slower one and the signal survives with -its sign inverted. - -This costs real time. Measured on Node 22.15 with 32-character operands, a `credential` comparison -went from about 130ns to about 5us, dominated by two `createHmac` calls. That is once per -authentication event, against a password verification that is four orders of magnitude slower by -design. A caller matching one credential against many wants `hash()` and an index rather than a -linear scan of comparisons, which was already true before this change. - -No API changed and no behaviour changed other than the timing: the same comparisons answer the same -way. The timing analysis run by `pnpm test:timing` now measures the direction an attacker actually -controls, holding the comparand fixed and varying the stored length, which is the case the previous -version of that test documented as expected to leak. diff --git a/.changeset/core-external-sink-and-derivation.md b/.changeset/core-external-sink-and-derivation.md deleted file mode 100644 index 21bc37f..0000000 --- a/.changeset/core-external-sink-and-derivation.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -'@platform-sec/sensitive-string-core': minor ---- - -Write `allowSendToExternal` explicitly on both default policies, and add the keyed derivation two -features want. - -`advisory.allowSendToExternal` has been declared on `SensitivityAdvisory` since the beginning and set -by neither `DEFAULT_POLICIES` entry, so it read `undefined` on every value in a stock installation. -The two obvious ways to test a flag, `if (flag)` and `if (flag !== false)`, therefore disagreed on -every value in the library, and the first reader of the field would have picked one of them by -accident. Both classes now say `false` out loud. That is behaviourally identical once `undefined` is -defined as a refusal, which is how `@platform-sec/sensitive-string-llm` reads it, and it is -completely different to somebody reviewing a policy: an absent field looks like an oversight, which -until now it was. `sensitive` starts closed rather than permissive because the class is deliberately -coarse and covers a customer email as readily as an internal note; a caller who wants either in a -prompt registers a policy that says so, in one place, in code that shows up in review. This is the -last piece of the roadmap's Phase 0. - -`deriveKeyedToken` is `HMAC-SHA256(key, domain[0] || 0x00 || ... || value)`, hex, truncated. It lives -here rather than in a consumer because two features want the same primitive and a truncated keyed -HMAC over a low-entropy value has exactly one correct shape: deterministic redaction correlates a -value across log lines, and an LLM entity handle stands in for a value inside a prompt. Both derive a -short stable token from something an attacker can guess, and both publish the result where the -attacker can see it, which is why there is no unkeyed mode and no way to configure one. An email -address under a plain SHA-256 is recoverable from a wordlist in seconds. - -Three refusals, each of which is a correctness guard as much as a security one. A token shorter than -12 hex characters: 12 is 48 bits, where a one-in-a-million collision arrives at roughly 24,000 -distinct values, while 8 is 32 bits, where a one-in-a-thousand collision arrives at about 3,000 and a -collision silently merges two subjects into one. A key under 16 bytes, which is an unkeyed hash with -extra steps because the key space is enumerable alongside the wordlist the value came from. And a -domain component containing the NUL separator, without which `['a', 'bc']` and `['ab', 'c']` would -hash identically and two domains would quietly share a token space. - -The module reads no `SensitiveString`, authorizes nothing and audits nothing: a caller reveals through -the ordinary gate and hands the result over, so the audit record comes from the reveal rather than -from a second, quieter path to the same value. - -`assertKeyedDerivationOptions` raises every one of those refusals without deriving anything, for the -caller who has to read a protected value first. Without it a bad key or length is caught after the -read, which leaves an audit record for a reveal that produced nothing and gives an auditor -reconciling reveals against derived tokens one that does not balance. The raw-value gate already -applies this rule in the other direction, refusing a denied read before the audit hook on the grounds -that a denied read is not a reveal; this is the same rule one step later. diff --git a/.changeset/core-guard-path-cost.md b/.changeset/core-guard-path-cost.md deleted file mode 100644 index f4a6a75..0000000 --- a/.changeset/core-guard-path-cost.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -'@platform-sec/sensitive-string-core': patch ---- - -Stop paying for a clock read, an ISO rendering and a Set on paths that do not need them. - -Three costs found by the benchmark suite and attributed against the primitives underneath them, all -measured on this machine at Node 22.15.0 with the harness overhead subtracted. - -`#expiredDeadline` read `Date.now()` before looking at whether any state carried a deadline, so a -value that cannot expire paid for a clock read on every reveal, comparison, transform, mask and -serialize. `Date.now()` measured 82ns here, against 141ns for the whole of `reveal()`: the gate spent -more on asking the time than on everything else together. The loop now reads the clock lazily, at -most once, and only when a state actually has a deadline. `reveal()` on a value with no deadline -went from 141ns to 40ns, and `equals()` on a `sensitive` value from 160ns to 75ns. - -`serialize()` called `new Date(deadline).toISOString()` on every call, which measured 1060ns on its -own, more than the rest of the method put together. The rendering is memoized by the deadline it -renders, so a renewal misses and re-renders on its own, and the memo is discarded once it passes 256 -entries so a service with many distinct deadlines cannot grow it without bound. A deadline is a -number that already travels on the wire in exactly this form, so nothing secret is held there. -`serialize()` on an expiring value went from 1393ns to 153ns. - -Deriving from a single source rebuilt the state list through a `Set` and an `Array.from`, when the -source's own list is already what the derivative needs. `#states` is assigned once and never -mutated, and the state objects are shared between derivatives by design, so the single-source case -now reuses it. `substring()` went from 273ns to 96ns and `concat()` from 359ns to 259ns. - -No behaviour changes. The expiry gate, the wire format and the deadline a derivative inherits are -all unchanged, and the suite that covers them is unchanged too. Two cases were added to the wire -format tests: that a renewed value serializes its new deadline rather than the one it first -rendered, and that more distinct deadlines than the memo holds each serialize correctly across two -passes. Both fail against a memo keyed wrongly. diff --git a/.changeset/core-hash-transformation.md b/.changeset/core-hash-transformation.md deleted file mode 100644 index 8511f82..0000000 --- a/.changeset/core-hash-transformation.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -'@platform-sec/sensitive-string-core': minor ---- - -`hash()` and `hmac()`: a one-way digest of a protected value, as a protected value. - -ROADMAP 1.2, the `core` half. The alternative was `reveal()` followed by handing the raw value to -`crypto.createHash`, which is a full reveal recorded as one and a plain string afterwards, for a -lookup key that never needed the value in the first place. Both methods read through the ordinary -gate, so the class policy, the deadline, the reveal budget and any revocation all apply, and the -audit record says `operation: 'hash'` or `'hmac'` with the algorithm and encoding on it. - -`advisory.allowHash` has been declared on `SensitivityAdvisory` since the beginning and read by -nothing. This is what reads it. `undefined` allows, which is the opposite of how the LLM package -reads `allowSendToExternal` and is deliberate: hashing is the operation the library wants used in -place of holding a value, so the flag exists to forbid it for a class where even a digest is too -much, not to require permission for the safe path. - -The result is `credential` whatever the source class was, and it is a derived value: it carries the -deadline, the reveal budget and the revocation of what it came from, through the same `#derived` -path as `substring` and `map`, and the three enumerated security suites now name `hash` and `hmac` -alongside them. That is the R2 decision this feature had to make, and it goes the way it does -because a fast digest of a guessable value is that value behind a lookup table. Ten billion Social -Security numbers hash in under a minute on a laptop, so a digest that started a fresh budget would -be a way to spend one reveal and keep a recoverable copy of the value forever. - -`hmac()` is the version to reach for when the value is guessable, and it is why the sketch's -salt-only API was not enough: a salt stops precomputation and nothing else, because it sits in the -same row as the digest. The key floor is 16 bytes, the constant `deriveKeyedToken` already applies, -since a key an attacker can enumerate alongside the value is an unkeyed hash that reads as a keyed -one in review. A `SensitiveString` key is read through its own gate and the result carries the -constraints of the value and the key both, which is the one place in this class where an argument is -read before the receiver: a key below the floor produces no digest, and refusing after the receiver -had been read would charge an exposure for nothing. - -MD5 and SHA-1 are refused by name and with a reason rather than by absence from a list, since a -caller asking for one has usually copied it from code that predates the break. SHA-3 is checked -against `getHashes()` before use, because a FIPS build can be without it. A salt is prepended, never -appended, which is what makes the digest reproducible by anything else computing -`sha256(salt || value)`, and an empty salt is refused rather than read as no salt at all: it -produces exactly the unsalted digest and would report as `salted: true` in the audit record. - -Development mode names a digest as a digest. `[SensitiveString] Hash of 'api_key' without context` -rather than `Reveal of`, because a line telling a developer their value was handed out when it was -not is worse than no line. - -What is deliberately absent is password storage. `hash('sha256')` is fast by construction, which is -the one property a stored password must not have, and the functions that are not fast need a native -module. Those are in `@platform-sec/sensitive-string-kdf`. diff --git a/.changeset/core-mask-url-strategy.md b/.changeset/core-mask-url-strategy.md deleted file mode 100644 index c2838b5..0000000 --- a/.changeset/core-mask-url-strategy.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@platform-sec/sensitive-string-core': minor ---- - -Add the `url` mask strategy: keep the scheme, host, port and path of a URL, and hide the userinfo, -every query value and the fragment. - -```ts -new SensitiveString('https://x-access-token:ghp_abcdef123456@github.com/org/repo.git', 'sensitive').mask({ - strategy: 'url', -}); -// 'https://***@github.com/org/repo.git' -``` - -Two corpus records wanted this rendering and could not get it: a clone message should keep which -repository it names, and a custom policy cannot do it because `validateRedactFunction` refuses a -`redact` that is the identity on its non-URL canaries. The hidden parts are written at a fixed width -so the rendering does not disclose a credential's length, and the rendering is the parsed URL written -back, so it is normalized. The floor applies as to every strategy: a URL with nothing to hide hides -nothing and redacts in full, which is deliberate. A caller who marked a URL sensitive and wants its -bare form shown has marked the wrong thing; wrap the credential and keep the URL. diff --git a/.changeset/core-memory-hygiene.md b/.changeset/core-memory-hygiene.md deleted file mode 100644 index 0606154..0000000 --- a/.changeset/core-memory-hygiene.md +++ /dev/null @@ -1,109 +0,0 @@ ---- -'@platform-sec/sensitive-string-core': minor ---- - -Add memory hygiene: `clear()` to destroy a value at a point the caller chooses, a bulk clear for a -shutdown or incident path, and optional instance freezing. - -```ts -token.clear(); -token.isCleared; // true -token.clearedAt; // Date -token.toString(); // '[REDACTED:credential]', unchanged -token.reveal(); // throws ClearedSensitiveValueError - -setMemoryHygiene({ trackInstances: true, freezeInstances: true }); -clearAllSensitiveValues(); // returns how many it destroyed -``` - -**`clear()` drops the library's reference and nothing else.** It is not zeroing. JavaScript cannot -overwrite the backing store of a string it holds a reference to, and there is no primitive that -would let it. Whether the library's was the last reference is a property of the calling code, which -is why the documentation leads with the two shapes rather than with the API: a credential decrypted -into a local and cleared after use really does leave the heap, and a route that wraps -`req.body.token` while the handler keeps the parsed body gains nothing, because `req.body.token` and -the instance's value are the same string object. No integration fixes the second shape, and it is -worth being exact about why: a body has to be parsed into strings before any field can be -classified, and `wrapSensitiveByPath` returns primitives by reference, so the wrapped value holds -the string the pre-wrap body holds. - -The check lives in the raw-value gate, as rule R2 requires, and goes first among the lifecycle -checks. Ahead of revocation as well as expiry, and not because a destruction outranks a revocation: -it is the only one of the three that reads a plain field, so asking a `revocationRegistry` about a -value this process already destroyed would let a registry outage decide which refusal a destroyed -value reports. `renew()` takes the same check first too, ahead of its own `derived` refusal, so a -derivative of a cleared origin now reports `ClearedSensitiveValueError` rather than -`RenewalNotAllowedError`. - -Redaction survives a clear, because logging a sensitive value must never throw: an error handler -doing `logger.error({ err, token })` on a cleared token would otherwise raise a second error from -inside the first and lose the diagnostic. `clear()` snapshots the redacted rendering from the live -value immediately before dropping it. That snapshot is not decoration: under a -deterministic-redaction policy, every cleared value in the process would otherwise redact to the -HMAC of the empty string, and one shared tag across unrelated values is a false correlation in an -audit log, which is worse than no correlation. Its cost is that a policy re-registered after a clear -does not change what a cleared value renders. - -`length` and `isEmpty()` refuse instead of answering from the empty string. `0` and `true` are wrong -answers rather than degraded ones, and a caller branching on `if (!token.length)` would read a -destroyed credential as an absent one. The length is deliberately not snapshotted alongside the -rendering: the rendering is retained because a copy of it is already in a log file, and no -equivalent argument covers the length. - -Derived values are refused, not released, and the distinction is the finding the roadmap sketch did -not have. Clearing an origin refuses everything descended from it, because they share the state -object the clear writes; clearing a derivative refuses only that derivative. The traffic also runs -the other way: a derivative of 13 characters or more is a V8 `SlicedString` pinning the origin's -entire backing store, and a `concat()` result is a cons string pinning both operands, so the -origin's own `clear()` releases nothing at all until the derivatives are cleared too. Measured end -to end in the retention suite. That is what makes `trackInstances` more than a convenience: -derivatives are usually short-lived locals whose holder a caller cannot name, and walking the -tracked set is the only way to break a pin like that. Derivations are not flattened to avoid the -pin: the three idioms that actually work cost between 20x and 88x the derivation itself, on the path -every string method routes through, and each makes its own transient plaintext copy while it runs. - -No policy is consulted. Clearing reduces exposure, so there is no confidentiality outcome a denial -protects, and the same caller could drop the reference by letting it go out of scope. It is audited -after the fact with `{ operation: 'clear', propagated }`, and a sink that throws is swallowed, as -`revoke()` already does: a sweep in a `SIGTERM` handler must not stop halfway because one sink is -down, which is when a sink is most likely to be down. A policy whose `redact` throws does not -prevent a clear either; the instance falls back to the class's default marker. - -Both switches are off by default and read at construction, so they belong beside `registerPolicy` in -the entry module. `trackInstances` costs about 202 bytes per instance, roughly 87% on top of the -instance itself, which a service wrapping a few dozen credentials at boot will never notice and a -service wrapping a value per request will. `freezeInstances` costs about 51 ns per construction and -blocks four real tamper paths, two of which the roadmap did not mention: an own `toJSON` replaces -redaction outright on the path most likely to reach a log aggregator, and a `klass` rewrite -downgrades a credential to a class with different redaction, permitted partial queries, permitted -masking and no constant-time comparison. It is off by default because it is a breaking change for -any consumer that decorates an instance, not because of the 51 ns. It does not touch `#private` -fields, so `clear()` works on a frozen instance, and it does not freeze the lifecycle: `renew()` -still moves a frozen value's deadline, because the deadline lives in a state object the instance -references rather than on the instance. - -The tracked set is a `WeakRef` set pruned by a `FinalizationRegistry`, so tracking never keeps alive -the thing it exists to destroy, and its size is a lagging indicator with no bound worth promising: -a prune pass that calls `deref()` keeps every target it touches alive to the end of the turn, and -finalizer callbacks were measured needing two GC cycles and two macrotask turns before a set of -5,005 entries fell to 5. `clearAllSensitiveValues()` therefore clears what it can reach and does not -claim the set is empty afterwards. - -A cleared value cannot be serialized: the wire format is read through the gate, so there is nothing -to send, and no field a cleared state could cross on. The registry and both switches are per -isolate, like every other process-global here. - -69 cases across three suites. The security file drives the two enumerating tables the TTL and -revocation suites established, so an operation added later that forgets the gate fails there rather -than shipping as a bypass: 36 that reach the raw value, each refused and leaking nothing in the -message or the stack, and 24 that derive a new protected value, each inheriting the origin's cleared -state. One of them is the guarantee that would be easiest to break quietly: a cleared value, and a -derivative that only inherited the clear, through `redactInStructure`, `safeStringify`, `safeLog`, -`containsSensitive`, `sensitiveClassesIn` and `redactCopy`, none of which may throw. - -Eight cases measure the collector rather than behaviour, and all eight live under `pnpm test:timing` -with `--expose-gc`: five search a heap snapshot for a canary held nowhere else, and three check that -tracking never keeps an instance alive, that the finalizer prunes the set, and that a swept instance -is not counted twice. They belong there rather than in the functional suite because `pnpm test` has -no `global.gc`, and a test guarded by `typeof global.gc` that returns early reports a pass for a -measurement it never took. `describe.skipIf` reports a skip instead. diff --git a/.changeset/core-pino-wildcard-marker.md b/.changeset/core-pino-wildcard-marker.md deleted file mode 100644 index e6a0dae..0000000 --- a/.changeset/core-pino-wildcard-marker.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@platform-sec/sensitive-string-core': patch ---- - -Documents one case where pino loses the redaction marker, and the one-line remedy. - -No code changed. This is a behaviour of pino that a user of this library will otherwise meet without -warning, and the README's pino section now describes it where someone configuring pino will read it. - -With a wildcard entry in pino's `redact.paths`, a wrapped value logged as a direct property of the -merging argument serializes as `[unable to serialize, circular reference is too complex to analyze]` -rather than as its marker. Three conditions have to hold at once and dropping any one avoids it: a -wildcard in the path list, since a literal list is unaffected at any depth; a class instance carrying -an ES private field; and that instance at the top level of the log object, since one level down or -inside an array renders correctly. - -Nothing is disclosed. The value does not appear in either case. What is lost is the marker, so a line -that should say a credential was present instead says the serializer gave up, and the diagnostic this -library exists to preserve is exactly the thing that goes. - -It is not fixable from inside the library. The same failure reproduces with any class carrying a -`#private` field and no `toJSON` at all, and does not reproduce with the identical class without the -private field; `Date` and plain objects with a `toJSON` are unaffected. So it is not caused by -`toJSON`, and `#value` is what makes a wrapped value unreadable by reflection, which is not a -property to trade away for this. - -The remedy is a `formatters.log` that converts the payload before pino's redaction machinery sees it, -and it leaves pino's own path redaction working alongside the marker. - -The top-level condition sounds like it should reduce this to nothing, and does not: `logger.info(data, -msg)` is the ordinary pino idiom and puts every field of `data` at exactly that level. It was found -in two real applications that both reach it that way through their own logging wrappers. - -Measured against pino 10.3.1. diff --git a/.changeset/core-redaction-marker.md b/.changeset/core-redaction-marker.md deleted file mode 100644 index e29d6e4..0000000 --- a/.changeset/core-redaction-marker.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@platform-sec/sensitive-string-core': minor -'@platform-sec/sensitive-string-http': patch -'@platform-sec/sensitive-string-secrets': patch -'@platform-sec/sensitive-string-typeorm': patch -'@platform-sec/sensitive-string-drizzle': patch ---- - -`containsRedactionMarker()` and `redactionMatchers()` in core, replacing four separate answers to -the same question: given a string about to be written to a column, sent in a request body or stored -in a secret, does it carry the text a `SensitiveString` renders as? `sensitive-http` derived its -answer from the policy registry, `sensitive-typeorm` and `sensitive-drizzle` each shipped a narrower -copy of that derivation, and `sensitive-secrets/write` used a hardcoded `[REDACTED:`. Core owns the -format, so the detector lives there and the four call it. - -Two gaps close, both in the database packages and both security-relevant. - -**`markerPattern` was ignored.** Core declares it on `SensitivityPolicy` for a policy whose redaction -varies with the value, `deterministicRedaction()` sets it, and core's own documentation for the field -names the http guard as its consumer. The two copies written for a database did not read it, so a -deployment using deterministic redaction had a marker check in front of its columns that could not -recognise its own markers. - -**A marker for an unregistered class went unrecognised.** Both copies enumerated the registry and -looked for `[REDACTED:${klass}]` per class. For a reader that is the ordinary case rather than an -edge one: the column was written by an older release, or by a service that registered a class this -process does not have. The detector matches the `[REDACTED:` prefix, which covers those, and also -catches a marker that a composed transformer altered without destroying. - -`policyRegistryVersion()` is new and exported. The memo is keyed on it rather than on the set of -registered class names, because an `overwrite` re-registration replaces a policy's `redact` while -leaving the names identical, and both copies kept answering from the previous policy until something -else changed. - -`sensitive-typeorm` and `sensitive-drizzle` no longer export `redactionMarkers` or -`resetMarkerCache`; they re-export core's `redactionMatchers` and `resetRedactionMarkerCache` -instead. `redactionMatchers()` returns `(string | RegExp)[]` rather than a `Set`, since a -`markerPattern` is a pattern. diff --git a/.changeset/core-redaction-observer.md b/.changeset/core-redaction-observer.md deleted file mode 100644 index 13155cf..0000000 --- a/.changeset/core-redaction-observer.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -'@platform-sec/sensitive-string-core': minor ---- - -Add `observeRedactions()`, a process-wide subscription to every rendering of a redaction marker. - -```ts -const stop = observeRedactions(({ klass, label, surface }) => { - redactions.inc({ klass, label: label ?? '', surface }); -}); -``` - -A reveal was always audited. A redaction was recorded nowhere, and it is the number that says what -the library prevented: every time `toString()`, `toJSON()` or the inspect hook rendered a marker, -some caller was about to write the plain string somewhere and the marker went instead. The -rendering runs on the `'safe'` read, which fires no audit hook, charges no budget and consults no -policy, and that stays so: a log line must not need authorization. Observation sits beside the -rendering rather than inside the gate, after the rendering succeeds, so a policy whose `redact` -throws reports nothing, because nothing was written. - -The event carries the class, the label and the surface, frozen, and never the value or its -rendering. Under a deterministic-redaction policy the marker is a pseudonym of the value, and a -metric dimension is the wrong place for one. The four surfaces separate the readings an operator -has to make differently: - -- `'toString'` is a coercion: a template literal, concatenation, `String()`. It is the surface T13 - describes, where a rendering may be a prevented log line or a broken outbound request, and the - event cannot tell the two apart. A count here is meaningful only once the HTTP guard is installed. -- `'toJSON'` is `JSON.stringify()`, which a JSON logger and a JSON request body both reach. -- `'inspect'` is `util.inspect()` and `console.log()`. -- `'structure'` is a walker: `redactInStructure`, `redactCopy`, `safeStringify`, `safeLog`, the - winston format and the pino serializer. This is the one surface that is unambiguously a sink that - installed redaction, and a `serialize()` result a walker replaced reports here too, with no label, - since the label in a wire shape is data off the wire and an event carrying it would let whoever - built the payload choose a metric dimension. - -The walkers render through the class's own path now rather than through `String(value)`. That is -what gives them their own surface, and it closes a gap on the way: an own `toString` on an unfrozen -instance, or a subclass override, used to decide what `redactInStructure` and `redactCopy` wrote. A -value a walker was asked to clean comes out clean whatever its `toString` says; the coercion itself -is still the instance's own business, which is what `freezeInstances` exists for. - -An observer that throws is warned about once through `process.emitWarning` and its later throws are -swallowed, because a redaction is a log line and a log line must never throw. An observer's own -redactions are not observed, so a dashboard that logs a wrapped value inside its observer does not -recurse. The list is replaced rather than mutated on subscribe and unsubscribe, so a notification in -flight sees the list it started with. The registry is process-global under rule R6 and keyed on -`Symbol.for()`, so a second installed copy of core shares it; it protects a dashboard against a -missing subscription, not a secret against a hostile caller, since no event ever carried one. - -What an unobserved process pays is one array-length read per rendering. Priced on Node 22.15.0 the -way the reveal counter was, two copies of `dist` with the read commented out of one, two million -`toString()` calls per round, twelve rounds interleaved, median taken: across three runs the delta -was 0.0, 0.7 and 1.5 ns on a rendering of 17 to 26 ns, inside the run-to-run spread. The observed -path adds one frozen object and one call per observer; `redaction.bench.ts` carries the ratio -between a no-op observer and a counting one, which is the exporter's cost rather than the library's. - -34 cases across two suites. The security file asserts the event shape on every surface and through -every walker, that a renderer deriving its marker from the value leaves nothing of it in the event, -that a throwing, mutating or returning observer changes no rendering, that an observer's own -redactions and mid-notification subscriptions do not reach the notification in flight, and that an -own or subclass `toString` no longer reaches a walker. Nine mutations were run against the -mechanism list, each caught by at least one named test. diff --git a/.changeset/core-reveal-budget.md b/.changeset/core-reveal-budget.md deleted file mode 100644 index a470721..0000000 --- a/.changeset/core-reveal-budget.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -'@platform-sec/sensitive-string-core': minor ---- - -Add reveal counting and rate limiting: a token that can only be read once, and a way to notice one -being read in a loop. - -A bootstrap token or a reset link is meant to be used once, and nothing enforced that. A secret read -thousands of times a second is a retry storm or a caching layer that stopped caching, and nothing -noticed. `maxReveals` is the lifetime limit, `revealWindow` and `maxRevealsPerWindow` are a sliding -window, and both are optional fields on the same `SensitiveStringOptions` the TTL options live on. - -```ts -const bootstrap = new SensitiveString(key, 'credential', 'bootstrap', { maxReveals: 1 }); - -bootstrap.reveal(); // works -bootstrap.revealsRemaining; // 0 -bootstrap.isExhausted(); // true -bootstrap.reveal(); // throws RevealLimitExceededError - -const apiKey = new SensitiveString(key, 'credential', 'api_key', { - revealWindow: 60_000, - maxRevealsPerWindow: 100, -}); -// throws RevealRateLimitError, whose `retryAfter` says how long to wait -``` - -The two errors are separate because a caller recovers from them differently: one waits, the other -fetches a new secret. When both would refuse, the lifetime limit is reported, being the condition -waiting will not clear. - -The check lives in the raw-value gate, so `mask()`, `serialize()`, `map()` and every string, regex -and template operation are charged alongside `reveal()`, one per call. `equals()` is not charged and -is not refused by a spent budget: a comparison reads the value without disclosing it, and it is the -operation to reach for in place of a reveal, so a stored credential stays usable for the only thing -it is stored to do. Redaction and metadata are not charged either, so a spent value is still safe to -log. - -Derived values share the counter rather than copying it, which is what makes the limit mean -anything: `secret.substring(0).reveal()` throws, because the substring spent the budget and shares -it. A value built from two constrained sources is charged against both, reports the highest count -and the smallest remainder, and is exhausted as soon as either source is. `revealCount` is kept for -every value, whether or not it limits anything, since returning zero forever for an unlimited value -would be a lie in an API declared to return a number. - -Counters do not cross the wire. `serialize()` charges the sender, `deserialize()` starts a fresh -count with no limits, and a payload claiming `maxReveals` for itself is ignored rather than obeyed. -Nothing resets a count, in test builds or otherwise: a control the process can switch off is not a -control, and a test wanting a fresh budget constructs a fresh value. - -Five decisions differ from the design document. - -The charge happens after the policy and audit hooks, where the design incremented before both. The -gate already refuses ahead of the hooks because a denied read is not a reveal; the same rule one step -later says a read an audit sink stopped by throwing is not one either, so a broken sink cannot -quietly spend a single-use token. The consequence is documented rather than defended: an audit hook -that re-enters the gate to read the same value sees the pre-charge count. Reading a value from an -audit hook already recursed without bound before this feature existed, and `RevealContext.auditReveal` -now carries the same warning `checkRevealAllowed` does. - -`revealWindow` and `maxRevealsPerWindow` are a construction error apart. The design guarded the rate -limit with `if (window && limit)`, so either alone silently configured nothing and `revealWindow: 0` -disabled the limit rather than tightening it, which is the truthiness defect the TTL work found in -`ttl: 0` pointing the other way. `maxRevealsPerWindow: 0` is rejected outright: it describes a value -that can never be exposed, which `maxReveals: 0` says without ambiguity, and it would raise an error -whose `retryAfter` no waiting could satisfy. - -There is no `__resetForTesting()`. The design proposed one gated on `NODE_ENV`, which is a string any -code in the process can assign. - -The window is a ring with a head index rather than an array rebuilt by `filter` on every check. The -design's version allocates per reveal and stays as large as the burst that filled it; amortized -compaction bounds the backing array at twice the limit however long the value lives, and the bound is -asserted over 10,000 charges rather than argued. - -`#value` is not cleared when a value is exhausted. The design recommended it. It would break -`equals()` and `toString()`, both of which stay available on purpose, and it would not do what it -sounds like: dropping a reference to a JavaScript string does not erase the string, which the threat -model already lists as out of scope. Memory hygiene is its own roadmap item. - -`src/__benchmarks__/reveal-budget.bench.ts` measures what this costs, as ratios within each group -rather than as absolute figures, because the machine it ran on was not idle and four runs disagreed -about the nanoseconds while agreeing about the shape. Against the unbudgeted control on the cleanest -run, at Node 22.15.0: a lifetime limit is within 2% of the control, which is to say indistinguishable -from it, since the limit is one comparison. A sliding window costs 25% and both limits together 27%, -which is the timestamp push and the prune. Two budgeted sources cost 11%, being the same work over a -second state. Redaction, comparison and construction do not move, because none of them is charged. A -refusal is roughly 40x a successful reveal, which is the error's stack capture and is not on anyone's -hot path. - -That group cannot price the counter, because its control pays for it too. Measured separately, by -timing two million reveals against a build with the charge removed: a reveal on an unbudgeted value -costs about 20ns and about 26ns with the counter, so the counter is roughly 6ns. Inlining the walk -for the single-state case, which is every value that was not built by `concat`, measured the same, so -that is the increment rather than the call. It is what an honest `revealCount` costs on a value that -configures nothing, and the alternative is an API declared to return a number that returns zero -forever. Both figures are a tight loop rather than the benchmark harness, whose per-call floor is -several times the whole operation. - -66 cases across `src/reveal-budget.test.ts` and `src/__tests__/reveal-budget-security.test.ts`. The -security file drives two tables the way the TTL suite does, so an operation added later that forgets -the gate fails there rather than shipping as a bypass: 31 operations that expose the raw value, each -asserted to refuse an exhausted value and a full window and to leak nothing in the message, the -stack, or the inspection, and 22 that derive a new protected value, each asserted to share the -parent's spent budget. diff --git a/.changeset/core-revocation.md b/.changeset/core-revocation.md deleted file mode 100644 index 286443a..0000000 --- a/.changeset/core-revocation.md +++ /dev/null @@ -1,104 +0,0 @@ ---- -'@platform-sec/sensitive-string-core': minor -'@platform-sec/sensitive-string-zod': patch ---- - -Add revocation: a way to stop a value being read after it is rotated or compromised, including -instances a caller no longer holds a reference to. - -After a rotation the old secret is still in memory, still held by whatever closed over it, and still -works. `revoke()` ends one value. `revokeByLabel()` and `revokeByPattern()` end every value in the -process built under a label, without needing a reference to any of them. A `revocationRegistry` -passed at construction is consulted on every read, which is where a store-backed list belongs. - -```ts -apiKey.revoke('Rotated 2026-08-01'); -apiKey.reveal(); // throws RevokedSensitiveValueError -apiKey.isRevoked; // true -apiKey.revocationReason; // 'Rotated 2026-08-01' - -revokeByLabel('api_key', 'Emergency rotation'); -revokeByPattern(/^session:/, 'User logged out'); -``` - -There is no way back. No `unrevoke`, no reset, no options object and no wire payload that lifts one. -`pruneRevocations()` shrinks the process-global lists, and it cannot resurrect anything that has -already been refused: a value latches its own revocation the first time it observes one, so pruning -only ever drops entries nothing has matched yet. That is also the rule for when pruning is safe, and -the only condition under which it is. - -The check lives in the raw-value gate, as rule R2 requires and as the design's sketch did not: the -sketch put it at the top of `reveal()`, where `secret.map(v => v).reveal()` walks past it. Every -exposing read refuses, and so does `equals()`, which R2 names explicitly: a comparison reads the -value, and a rotated credential must not still be able to answer whether a guess matches it. -Redaction and metadata keep working, because a value that can no longer be used still has to be safe -to log. `renew()` refuses too, though it reads nothing: a lifecycle control that keeps working on a -revoked value is one somebody will eventually mistake for the value working. - -Revocation runs along derivation edges in both directions, which is the decision R2 leaves to each -feature. A derivative is revoked by its sources, and revoking a derivative revokes its sources and -their other derivatives. That is the opposite of `renew()`, which refuses on a derivative, and the -reason for the asymmetry is which way each fails: refusing to extend a lifetime fails closed, -refusing to end one does not. An incident responder holding a substring, a `matchGroups()` result or -a template result has no way to know it is derived, and refusing them would leave the credential -alive. It is also the honest reading of what a derivative is: it carries its sources' material, so a -leaked substring is a leaked secret. - -Label and pattern revocation match the label a value was **constructed** with, and a derived value -is matched against its sources' labels rather than the one the derivation gave it. Revoking -`api_key` reaches every substring, match group and template built from that value; revoking the -combined label `a/b` that `concat()` produced matches nothing, because no value was ever constructed -under it. - -When both a revocation and a deadline would refuse, the revocation is reported. `ExpiredSensitiveValueError` -invites a renewal and a retry, and a revoked value must never look renewable. - -Nothing may refuse a revocation. No policy hook is consulted, and the audit record is reported after -the fact with anything the sink throws swallowed, which is the mirror image of `renew()`: that one -audits first so a sink *can* stop it. A second `revoke()` on an already-revoked value changes -nothing and reports nothing, keeping the first reason and timestamp, because a revocation is a fact -that already happened. - -Three things the design sketch had to change. `RevocationRegistry` ships as the interface the option -accepts rather than a class to subclass, so a store-backed registry in another package satisfies it -structurally; the in-process implementation is `RevocationList`. A registry that throws denies the -read, as specified, and so does one that answers with anything but a boolean: an `async isRevoked` -returns a Promise, which is truthy and not `true`, so reading it either way silently revokes -everything or nothing. And `revoke()` does not clear the value, against the design's recommendation: -it would make `length` lie about a value that is still there, and it cannot actually erase a string -from the V8 heap, which is memory hygiene's problem rather than revocation's. - -`revokeByPattern` rejects a pattern that applies an unbounded quantifier to a group that can match -the same input more than one way. A stored pattern is tested against every label a value carries, on -a thread nothing can interrupt, so `(a+)+` in the list is a process that hangs on the first read -after somebody revokes something. The heuristic is deliberately strict and rejects some safe -patterns; the message says what to rewrite. Patterns are also copied out of the caller's RegExp with -`g` and `y` dropped, which would otherwise make a stored pattern match every other time it was -consulted. - -Instance revocation crosses the wire as a flag that can only tighten. `serialize()` never writes it, -because the gate refuses to read a revoked value at all; `deserialize()` honours it, and rejects -`revoked: false` rather than reading it as permission. The zod `zSensitiveDeserialize()` schema -declares both fields, because an object schema strips what it does not declare, and stripping these -would turn a payload that says "do not use this" into one that says nothing. - -The process-global lists follow rule R6: one `Symbol.for()` singleton so duplicate installs of core -share it, with a test that reads it back through that key, and documentation saying it defends -against a stale secret and a misconfiguration rather than against code already running in the -process. `pruneRevocations` is the only way to shrink them, in test builds or otherwise, which is -what a test uses to isolate itself. - -The cost to a process that revokes nothing is a generation counter. A value that has looked once -compares one integer instead of searching the label map and walking the pattern list, and both that -comparison and the registry guard are inline, so the common read reaches no call at all. Measured -against a build with the check removed, on Node 22.15.0: roughly 2 to 4ns on a read of about 20ns, -close enough to the machine's own drift between rounds that it takes the paired measurement to see. -Before the guards were inlined the same measurement showed 5 to 24ns. What the counter is avoiding -is in the benchmark: against 200 revoked labels and 20 patterns, a value reading for the first time, -which is the one case that has to search, is 2.5x slower than one with no label to look up. - -84 cases. The security file drives the two tables the TTL and reveal-budget suites do, so an -operation added later that forgets the gate fails there rather than shipping as a bypass: 33 that -expose the raw value, each refused by an instance, a label and a pattern revocation and leaking -nothing in the message, the stack or the inspection, and 22 that derive a new protected value, each -inheriting the parent's revocation and each able to revoke the parent. diff --git a/.changeset/core-walker-accumulators.md b/.changeset/core-walker-accumulators.md deleted file mode 100644 index 4f8dda1..0000000 --- a/.changeset/core-walker-accumulators.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@platform-sec/sensitive-string-core': minor ---- - -Take the structure walkers' accumulators out of their public signatures, and hand -`withAuditedReveals` the whole value. - -`containsSensitive(obj, seen?)`, `redactInStructure(obj, seen?)` and -`sensitiveClassesIn(obj, found?, seen?)` published their recursion state as optional parameters, -documented as "(internal use)" and enforced by nothing. That is not a style point. Measured on the -built package, passing a `WeakSet` that already holds the root object made `containsSensitive` -answer `false` about a payload holding a credential, `redactInStructure` return `'[Circular]'` for -the whole payload, and a primed `found` set made `sensitiveClassesIn` report a class the payload -does not contain. The first of those is a safety check failing open, and the way a caller reaches it -is the obvious reason the parameter looked useful: share one `seen` across a walk of a related -object graph and any object already visited answers `false`. - -Each walk now lives in a private function and the exported one takes the value alone. Three tests -pin the answers a stray argument used to produce, calling through a cast so an untyped caller is -covered too. `sensitiveFormat`'s record scan was the only internal user of an accumulator; it unions -the two sets it collects instead, which it could always have done, since the two walks shared no -`seen`. - -`withAuditedReveals`'s callback typed its value as `{ readonly klass: string }`, so a sink written -against the convenience wrapper could not read `label` and so could not say which credential was -read, where the same sink passed to `auditReveal` directly gets the whole `ISensitiveString`. It is -the latter now. Widening a callback parameter is compatible in both directions: existing two- and -three-parameter sinks still compile. diff --git a/.changeset/core-worker-handoff.md b/.changeset/core-worker-handoff.md deleted file mode 100644 index 63368ae..0000000 --- a/.changeset/core-worker-handoff.md +++ /dev/null @@ -1,103 +0,0 @@ ---- -'@platform-sec/sensitive-string-core': minor ---- - -Add the worker handoff: a `SensitiveString` that crosses a thread or process boundary and arrives as -one, with its context. - -ROADMAP 1.6. `structuredClone` keeps an instance's public fields and drops the private one, so -`workerData: { token }` delivered a plain `{ klass, label }` that answered `klass` correctly and -failed at the first method call, in another thread, in a stack that named neither the boundary nor -the library. The fix everybody reached for, `workerData: { token: token.reveal() }`, worked and left -the plaintext in a plain string for the life of the thread with no class, no deadline and no audit -record. And `AsyncLocalStorage` is per isolate, so the work moved into a worker because it was -expensive, which is the work whose reveals matter most, was the work that silently stopped being -recorded. - -`toWorkerPayload({ token }, { worker, context, data, transport })` builds a payload out of the wire -format, so every marker-aware walk in the library already redacts it, and carries the context's -`name`, `reason` and `tags`. Building it is a reveal of each value through the ordinary gate: an -expired, revoked or policy-denied value refuses on the sending side, and each value produces one -audit record with `operation: 'worker-handoff'`, the payload key and the destination. The access -kind stays `expose` rather than gaining a `handoff` of its own, because the raw value does reach the -caller and a policy that refuses `expose` today must keep refusing it here; a new kind would have -routed handoffs around every such policy on the day it shipped. - -`fromWorkerPayload(workerData, { auditReveal })` and `withWorkerPayload(workerData, options, fn)` -rebuild the instances on the far side. The audit hook is the one field that cannot cross, since a -function survives neither structured clone nor JSON, so a payload built under an audited context -arrives marked and the receiver is required to install its own: `UnauditedWorkerError` rather than -a warning, because a warning from a worker is a line on the parent's stderr that nothing reads, and -what it would report is a security control the sender asked for being dropped in silence. -`allowUnaudited: true` is the spellable opt-out and the new `no-allow-unaudited` lint rule reports -it. The reverse direction needs no options at all: a parent handler registered inside -`withRevealContext()` already has an ambient hook, and the returned context forwards it rather than -replacing it with nothing. - -Neither `data` nor the context's `tags` may carry a protected value. Both get the same two checks, -which is a correction found in review: `tags` had only the portability one, so a wire object could -ride across inside the context, and because consumption clears `values` alone it stayed in the -payload after `fromWorkerPayload()` had returned. That is plaintext in `workerData` for the life of -a thread, in the one place the feature promises there is none. - -Consumption is the part that needed a real thread to prove. `workerData` is one mutable object -shared by every module in the thread, so `fromWorkerPayload()` deletes the raw values from it and -marks it consumed: a later `console.log(workerData)` or a crash handler serializing it no longer -reads them, and a second call throws `ConsumedWorkerPayloadError` rather than rebuilding anything. -Deleting rather than overwriting is deliberate, since a sentinel would still deserialize into an -instance that looks valid. - -Consumption is per copy, and the README says so rather than implying otherwise. Every transport -clones, so a payload posted to two workers arrives twice and whole, and the sender's own object is -untouched by either; it shortens how long one thread holds plaintext and is not a use-once token. -It is also best-effort, because it runs after the instances have been rebuilt: a frozen payload -keeps its values rather than turning a successful call into a throw that leaves the caller with -nothing and the plaintext still there. - -What it does not do is erase the string from the heap, and the README says so, next to the three -other things the boundary does not close: `process.env` is copied into every worker by default, an -application error crossing back carries whatever its message holds, and a value that crosses leaves -the sending side's revocation reach entirely. - -That last one is broader than a missing registry, and it is measured against a real thread rather -than reasoned about. An instance `revoke()` is per copy; `revokeByLabel()` and `revokeByPattern()` -act on module state, which is per isolate for the same reason policies are; and a -`revocationRegistry` is a function table, so it does not clone, and nothing on the receiving side can -bind one back, since `deserialize()` takes no options. What still reaches the copy is the worker's -own label list, matched on the label that crossed, so a worker that must honour revocation keeps -one. Revoking before the handoff is not a way round it: the gate refuses to read a revoked value, so -no payload is built. The wire format's `revoked` flag is for a payload assembled by hand or by a -gateway that checked its own list, and `serialize()` never emits it. - -Three refusals the boundary adds. Policies are per isolate, so a class registered on the main -thread does not exist in a worker; `UnregisteredClassError` names the class and says to register it -in the worker's own bootstrap, since a payload that could install a policy could install one that -returns its input. A reveal limit is a counter shared by reference and a thread has no reference to -share, so `toWorkerPayload()` refuses a value carrying `maxReveals` or a rate-limit window with -`UnshareableConstraintError` before reading anything, rather than handing every worker a fresh -budget. And the two transports disagree in both directions, measured: `child_process` IPC drops a -function silently where structured clone throws, and turns a `Date` into a string and a `Map` into -`{}` where the clone keeps all three, so `transport: 'json'` validates `data` and `tags` against -the serializer that will actually run. A `DataCloneError`'s own message quotes the source of the -function it could not clone, so `UnportableDataError` names the failing path and discards the -runtime's message rather than wrapping it. - -`SensitiveStringError` is new, and every refusal `core` raises now extends it and carries a `code`: -the seven worker errors, and `ExpiredSensitiveValueError`, `RenewalNotAllowedError`, -`RevealLimitExceededError`, `RevealRateLimitError`, `RevokedSensitiveValueError` and -`RevealDuringRedactionError`, reparented. `instanceof Error` and each class's own `instanceof` hold -as before. A caller wants either one specific failure or any refusal from this library, and a base -plus a flat code serves both where either alone serves one. `ExpiredSensitiveValueError` also -gained an optional `key`, so the boundary can name the payload key of a value that expired in -transit without misreporting its label. `RevealDetails` gained the `WorkerHandoffAuditDetails` -variant, and development mode names the operation `Worker handoff`. - -`withWorkerPayload()` declares its return type with a default, because TypeScript has no partial -type argument inference: naming only the values type would otherwise be a hard error rather than a -call whose result is `unknown`, and naming only the values type is the shape everybody writes. The -checked example under `examples/worker-handoff/` pins all three call forms, which is what caught it. - -Neither helper imports `node:worker_threads`, so `core` gains no dependency and the same pair works -over `workerData`, `postMessage`, a `MessagePort`, a `BroadcastChannel` and a forked child. The -suite drives each of those for real; the fixtures under `src/__tests__/fixtures/` import the package -by name and so need a build, which the suite says rather than failing inside a thread. diff --git a/.changeset/core-wrap-by-classification.md b/.changeset/core-wrap-by-classification.md deleted file mode 100644 index 09fffac..0000000 --- a/.changeset/core-wrap-by-classification.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -'@platform-sec/sensitive-string-core': minor ---- - -Add `wrapSensitiveByClassification()` and `checkClassifiedFieldsWrapped()`: bulk wrapping for a -bag whose keys the caller does not choose. - -```ts -// Keep two headers readable, wrap every other one, named or not. -const headers = wrapSensitiveByClassification(req.headers, { - fields: { referer: 'plain', host: 'plain' }, - fallback: 'sensitive', -}); - -// A plugin's return value: everything it hands back is a credential. -const creds = wrapSensitiveByClassification(await plugin.getCredentials(), { fallback: 'credential' }); -``` - -`wrapSensitiveByPath` takes an enumerated list of the paths that are sensitive, which is the right -entry when the payload's shape is known and the wrong one for a header bag, a `.env` file, a plugin -loaded at run time or a provider credential object whose keys are data: a path list over such a bag -is a denylist turned inside out, and the key nobody named arrives unwrapped, silently. The new entry -takes the classification spec the secrets and env layers already use, `fields`, then `rules`, then -`fallback`, and a `fallback` inverts the default so the list a caller maintains is the short one. - -Strings only are wrapped; a number, a boolean and `null` keep their type. Every element of an array -of strings is wrapped under the array's own name, as the path helper now does at a declared path. An -already wrapped value is kept, and a protected value handed in as the whole object is refused with a -`TypeError` by both wrappers rather than walked in place. `constructor`, `prototype` and an own -`__proto__` are classified like any other key, since a payload chooses its keys and a header named -`constructor` is legal; the write keeps `__proto__` an own data property. A label may be a fixed -string or a function of the leaf's path and key, never its value, and construction options apply to -every wrapped leaf. - -`checkClassifiedFieldsWrapped(obj, spec)` walks the object and asks the spec, which is the direction -`checkSensitiveFieldsWrapped` cannot look in: that one walks the config and asks the object, so a key -the object carries and the config never named is invisible to it. - -`wrapSensitiveByPath` now wraps every string element of an array at a declared path too, through -nested arrays, with the class, label and options the path declares. Declaring -`'db.replicaUrls': 'credential'` over a list of connection strings used to wrap nothing, silently, -and the two report functions vouched for it; the report side was fixed first, and this closes the -gap itself. It is a behaviour change for a configuration that declares a list and relied on it -passing through: the elements arrive wrapped, which is loud. `unwrapSensitiveByPath` reveals every -instance in such a list, `validateSensitiveFieldConfig` accepts a list of strings and still reports -an object, a number, or a list holding either as unwrappable, and `checkSensitiveFieldsWrapped` -reports a list once, under its path, if anything in it is not an instance. diff --git a/.changeset/data-nested-error-leak.md b/.changeset/data-nested-error-leak.md deleted file mode 100644 index 5b492d8..0000000 --- a/.changeset/data-nested-error-leak.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -'@platform-sec/sensitive-string-data': patch -'@platform-sec/sensitive-string-typeorm': patch -'@platform-sec/sensitive-string-drizzle': patch ---- - -Three fixes from reviewing the extracted layer, one of them a leak that existed in -`sensitive-typeorm` before the extraction and was carried into the shared code. - -**A nested driver error that would not take the censor in place was returned with its plaintext.** -`sanitizeQueryError` censors the parameter property on the error and on any nested one a shape -names, and falls back to a copy of the outer error when a property will not move. That copy carried -the nested error across by descriptor, so a frozen `driverError`, or one whose parameter property is -a non-configurable accessor, ended up on the returned error with the original values still on it. -The outer error reads as sanitized, which makes this the worst of the three possible outcomes rather -than a partial fix. A censored copy of the nested error now goes onto the outer copy. Neither -package's fixtures had a nested error that resists a write, which is why neither suite saw it. - -**The total-failure path dropped a message that was safe to keep.** When everything else fails, -`sanitizeQueryError` returns a minimal error. For a shape that declares no `sanitizeMessage`, which -is a measurement that the ORM's message carries no value rather than a default, the original message -and stack are the diagnosis and are now carried across. This restores what `sensitive-typeorm` did -before the extraction. - -**The recovery path could throw from the function whose contract is that it does not.** The `catch` -that produces the minimal error read `message`, `name` and `stack` as plain properties, so a -throwing getter there escaped `sanitizeQueryError` entirely, at the one moment the contract is being -relied on. Every read in that path is now guarded individually, so a single hostile property costs -only that property rather than the whole recovery, and the path as a whole is guarded again beneath -that. Carried in from `sensitive-typeorm`, where the same three reads were unguarded. - -**The Drizzle logger scanned each statement twice.** `touchesClassifiedColumn` called the shared -predicate once per column set, and the shared predicate scans the statement for identifiers. It now -scans once and checks both sets against the one result, which is what that package's README already -claimed. This is on the path that runs for every query the process makes. - -Two README claims are corrected to match the code. The TypeORM composition section said the write -refusal "does not catch that and cannot" for a reversed transformer array; since the marker detector -moved to core it matches the `[REDACTED:` prefix, so it does catch a transform that leaves the prefix -intact and still cannot catch one that destroys it. Both halves are now asserted. The Drizzle cost -section described the marker memo as keyed on the registered class list, which is no longer how it -works. diff --git a/.changeset/data-shared-layer.md b/.changeset/data-shared-layer.md deleted file mode 100644 index c9170d4..0000000 --- a/.changeset/data-shared-layer.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -'@platform-sec/sensitive-string-data': minor -'@platform-sec/sensitive-string-typeorm': minor -'@platform-sec/sensitive-string-drizzle': minor -'@platform-sec/sensitive-string-prisma': minor ---- - -A shared layer under the ORM integrations, `sensitive-data`, extracted from the three that were -already built rather than designed ahead of them. It holds the bound-parameter walk, the query-log -redactor, the statement identifier scan, the driver error sanitizer and the write reveal context. -Nothing installs it directly; each ORM package takes it as a peer. - -Extracting from three consumers instead of one was the point, and it is a deliberate departure from -how `scrub` and `secrets` were built. Those shipped alongside a single first consumer and held up, -because their consumers are homogeneous. A `customType`, a `ValueTransformer` and a client extension -are not, and every piece of this layer differs from what any one of the three had. - -Two of those differences were gaps in shipped code, and neither was reachable by the suite of the -package that had it, because each package's development driver could not produce the shape the other -covered. - -**The bound-parameter walk missed a shape in each direction.** TypeORM's descended into nested -containers and terminated on cycles, but walked only arrays and objects whose prototype is -`Object.prototype`, so a `Map` or a bag from another realm passed the guard unchecked. Drizzle's -walked every object shape but stopped at one level, so an instance inside a nested filter object -passed. The shared walk does both, and both packages gain what they were missing. - -**The error sanitizer had the message and stack half in one package only.** `DrizzleQueryError` -interpolates the parameters into its message and V8 bakes that message into the stack header at -construction, so Drizzle's sanitizer rewrote all three copies. TypeORM's rewrote the structured -property alone, which is correct for `QueryFailedError` and leaves nothing for the next consumer -whose message does carry a value. The machinery now exists once and is exercised by both shapes. - -Two behaviour changes in the ORM packages follow from unifying them: - -`DrizzleParameterRefusedError.parameter` is now a `string` rather than `number | string`. A numeric -index cannot name a position inside a container, and the walk descends into one, so the field -carries a path such as `0.filters[1]`. - -The TypeORM write context now tags a reveal with `klass`, which the Drizzle one already did, and the -Prisma one now tags with `sink`, which neither of the others was missing. An audit sink receiving -records from an application using more than one of these packages can now tell them apart. - -Prisma keeps its own reveal context builder, deliberately. The shared one is per column and carries -one of six `WriteOperation` members; Prisma's is built once per operation, its `path` is a position -in a nested argument object rather than a column, and its `operation` is Prisma's own free-form -name. Filling the shared shape would mean reporting `unknown` for an operation that hook actually -knows. - -Three things were deliberately not extracted, because three consumers proved they are not shared: -the column specification, which is a path map in one, a model-keyed object in another and a -declaration on the column itself in the third; the drift check, which reads entity metadata, the -runtime data model and registered column objects respectively; and the `sensitive-scrub` fragments, -which name different properties on different errors. Each package also keeps its own error types, on -the same reasoning that kept `SecretWriteRefusedError` where it is. diff --git a/.changeset/datadog-integration.md b/.changeset/datadog-integration.md deleted file mode 100644 index 949e61b..0000000 --- a/.changeset/datadog-integration.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -'@platform-sec/sensitive-string-datadog': minor ---- - -A Datadog APM integration, over the shared `@platform-sec/sensitive-string-scrub` layer. Everything -below was measured on Node 22.15.0 against `dd-trace` 6.14.0, driving the real agent in its own -process, posting to a local HTTP server standing in for the trace agent, and decoding the msgpack, -with a companion case requiring each position to leak once the scrubbing is removed. - -This row does not close a default leak, and the README says so first. Measured at stock defaults, an -inbound `POST /pay?token=&page=2` carrying `authorization`, `cookie`, `x-api-key` and a JSON -body with a `password` field produced a server span of exactly three tags: `http.url` with the query -obfuscated in place, `http.method` and `http.status_code`. No header, no body, no canary. What this -package closes is the opt-in: `headerTags` puts `authorization` and `cookie` into the payload -verbatim, and onto the client span as well as the server one, so turning header capture on to debug -inbound traffic starts transmitting the auth header of every outbound call. - -`installSensitiveScrubbing` composes `hooks.request` on twelve plugins and generates two lists a hook -cannot reach. The plugin list is the correction the design did not have: `dd-trace`'s `web.js` calls -`setFramework`, which replaces a request's whole plugin config with the framework plugin's, hooks and -header list included, so an express-served request runs the **express** plugin's hook and covering -`http` alone leaves `express.request` carrying the raw header. `express`, `fastify`, `koa`, -`connect`, `hapi`, `hono`, `restify`, `microgateway-core` and `fetch` all inherit the hook at the -type level and all needed covering. A seam test reads the shipped `index.d.ts`, resolves the -interface inheritance, and fails naming any plugin that offers a `hooks` option this package has no -opinion about. - -The structural walk is load-bearing here rather than defence in depth, for a reason no other vendor -in this row shares: nothing on the span tag path calls `toJSON()`. `addMixedTag` flattens an object -one level with `Object.entries`, which is exactly enough to reach the `value` key of a `serialize()` -result and put a full credential on a tag. The same flattening turns a plain `SensitiveString` into -`probe.klass` and `probe.label`, so the redaction marker never appears and a label is transmitted -with no option involved. Both are replaced before the agent sees them, on plugin spans and on spans -the application creates itself. - -Two behaviours the package composes with rather than replaces. Its query obfuscator is the agent's -own default pattern, reproduced byte for byte and pinned against the shipped configuration data, with -one anchored branch appended per denied key the default does not already match. Its header capture -list keeps a denied header rather than dropping it, so the tag exists and its presence stays -diagnostic while the hook replaces the value. - -`scrubManualSpans` is on by default and wraps `startSpan`, which measured is what **every** span goes -through, `dns.lookup` and `tcp.connect` included. So the finish-time walk sees every span the process -creates after installation, and the hooks stay the primary mechanism because they are the only route -to the header capture list and the query obfuscator, and because they mark the span so one span -produces one report. A throw at that seam is contained rather than propagated: a hook body is called -by the agent, which answers a throw by dropping one span, while `finish()` is called by the -application, so the last-resort rethrow is caught there, the span is emptied through `setTag` -instead, and it is finished either way. - -The failure output is a censored tag bag, never `null`, and that is forced rather than chosen: -`web.js` discards a hook's return value, so `null` would mean the span ships with its original tags. -It writes `[SCRUB_FAILED]` rather than the ordinary censor, so a reader can tell a denylist hit from -a broken scrubber. If censoring itself fails the hook rethrows, and the agent's answer to that, -measured, is to drop that one span while the request succeeds and a neighbouring plugin's span -survives. - -One thing in the package is not a redaction feature and the README says so. Requiring `http` before -`tracer.init()` and never requiring it again leaves an application with no HTTP instrumentation at -all, silently, and the repair is a single `require` after `init()` because the agent patches the -module object in place. `installSensitiveScrubbing` does that for five core modules, which costs -nothing and fixes the most common way a `dd-trace` deployment is quietly broken. diff --git a/.changeset/drizzle-integration.md b/.changeset/drizzle-integration.md deleted file mode 100644 index da0a543..0000000 --- a/.changeset/drizzle-integration.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -'@platform-sec/sensitive-string-drizzle': minor ---- - -A Drizzle ORM integration. A classified column is declared in the schema, so a select returns a -`SensitiveString` in the type as well as in the value; every write unwraps through an audited -`reveal()`; and the two parameter paths Drizzle routes past the column are refused rather than left -to the driver. Everything below was measured against `drizzle-orm` 0.45.2, `better-sqlite3` 12.11.1, -`pg` 8.23.0 and `mysql2` 3.24.3. - -Reads and writes are one `customType` mapping, and that hook covers considerably more of this ORM -than a `ValueTransformer` covers of TypeORM: inserts, updates, deletes, `returning()`, the predicate -builders, `onConflictDoUpdate`, partial selects, subqueries, transactions, a prepared statement whose -values were bound at build time, `sql.param()`, and the relational query builder's nested rows all -route through it. It is also the only integration in the family where the classification and the -TypeScript type come from a single declaration, so `$inferSelect` carries `SensitiveString` through -relations and partial selects, and a misspelled column name is a compile error rather than a column -that quietly reads back plain. - -**The logger is the parameter guard.** Two paths reach the driver without a column behind them: a -value interpolated into a `sql` template, and a prepared statement whose predicate is -`eq(col, sql.placeholder('x'))`, which is the form Drizzle's own documentation recommends for hot -queries. Both hand the instance to the driver, where `better-sqlite3` throws, `pg` binds -`"[REDACTED:credential]"` and reports success, and `mysql2` escapes the instance's two enumerable -properties into the statement. `logQuery(sql, params)` is the only seam in the ORM called with a -statement's parameters after the placeholders are filled and immediately before the driver runs, on -every session including the ones a transaction opens for itself, so `sensitiveLogger()` is both the -log redaction and the refusal. It is passed to `drizzle()` at construction and cannot be installed -afterwards, because `node-postgres` builds a fresh session for a transaction on a pool. - -`sanitizeQueryError()` removes the revealed parameters from all three places `DrizzleQueryError` -keeps them: `params`, an own enumerable property; `message`, which interpolates them; and `stack`. -The third needs care. `stack` is an own accessor on a real error and stays one, and V8 formats it at -first access from whatever `message` says by then, so fixing the message alone looks correct until -something reads the stack first, which a logger or an APM agent between the throw and the `catch` -does. The stack is read through the getter, before the message moves, and written back. - -`checkSchema(db, schema)` runs once at boot and refuses a database built without the guard, a -configured query cache, which stores the driver's rows before any column's read side has run, and a -column named in `requireClassified` that was declared with a plain factory, which is the one drift -the compiler cannot see. It also reports a classified column carrying a column default, since that is -a secret in the schema file and in every migration generated from it. - -Both directions pass `null` through. Drizzle skips the mapping for `null` almost everywhere, which -makes a column that refuses one look correct against an ordinary insert; one path does send one, -namely a prepared statement whose `values()` used `sql.placeholder` and whose `execute()` supplied -`null`. A value that is neither text nor a `SensitiveString` is refused on the write path, and a -non-string read back raises `DrizzleColumnTypeError`, which is a different failure from a column an -earlier write corrupted. - -A classified column declares a standard SQL type, so `drizzle-kit generate` and `drizzle-kit push` -are unaffected and adoption on an existing column is a type change rather than a migration. -`hydrateRawRows()` is the audited bypass for the two read paths Drizzle does not map, a `sql` -selection and `.values()`. A `sql` selection, `.values()`, `drizzle-kit` introspection, JSON columns -and the edge and serverless drivers are named in the README as uncovered. - -The insert type is the same `SensitiveString` as the select type, because `customType` has one `data` -type serving both directions, so a literal in a predicate takes a cast. `strictWrites` is off by -default for the same reason. diff --git a/.changeset/eslint-hash-context.md b/.changeset/eslint-hash-context.md deleted file mode 100644 index fe36279..0000000 --- a/.changeset/eslint-hash-context.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -'@platform-sec/eslint-plugin-sensitive-string': minor ---- - -`require-reveal-context` covers the other audited reads, and a new rule keeps a password away from -a fast digest. - -The library audits a digest exactly as it audits a reveal: the same gate, the same reveal budget, -the same audit record with `operation: 'hash'` on it. An audit line saying a digest of a credential -was taken, with nothing about who took it or why, is the gap this rule exists to close, and until -now the rule only knew about `reveal()`. - -Only where the receiver's type can be read, which is the difference between this and the way -`reveal()` is matched. `magician.reveal()` being reported is the standing cost of an untyped rule -and a fair trade, because `.reveal()` is rare. `.hash()` is not: `bcrypt`, `argon2`, `node:crypto` -and half the hashing libraries on npm have one, this repository calls two of them, and a rule that -reported every `.hash()` call would be turned off within the hour. So the rule reads the receiver's -type when a program is there and says nothing about these two methods when it is not. - -That makes it the plugin's third kind of rule: it works without types and uses them when present, -where `no-stringify-sensitive` and `prefer-safe-log` cannot run at all without them. It therefore -stays enabled under `disable-type-checked`, doing the half of its job that needs no types, and does -not report missing type information the way those two do, because it is not reduced to silence. - -The context argument is read at the position each method takes it in: the first for `reveal()`, the -third for `hash()`, the fourth for `hmac()`. An options object in the position before it is not a -context and is reported as one missing, which is the mistake the argument order invites. A spread -ahead of the context is left alone, since which argument is the context stops being a question -syntax can answer. The two messages now name the method they are about, so a `hash()` finding no -longer says `reveal()`. - -`mask()` and `serialize()` are covered on the same reasoning, and for a plainer motive than the -digests: one returns a partial view as a plain string and the other returns the raw value in a wire -object. What stays out is the transforms, `substring` and `map` and the rest, which audit too and -would make the rule unusable long before they made an audit log better. - -A `sensitiveTypes` option names the wrapper types, defaulting to `SensitiveString`, the same way it -does on the two type-aware rules. - -`no-password-fast-hash` is new, and it is the one rule in this plugin that reads a name rather than -a shape. It reports `password.hash('sha256')` and points at `hashForStorage()` from the `kdf` -package, because a fast digest is not password storage: a wordlist runs through SHA-256 at billions -of guesses a second, and a salt does nothing about that for whoever has the row it is stored in. -`hmac()` reports too, since a key does not make a fast function slow. - -A name, because no type separates a password from an API key: both are -`SensitiveString<'credential'>`. That is a different question from the one `type-info.ts` refuses to -answer by name, where a type does the job properly, and the rule's page says so rather than leaving -the contradiction for a reader to find. The match is a case-insensitive fragment over `password`, -`passwd`, `passphrase` and `pwd`, against the name of the value being hashed rather than whatever -holds it: `user.password.hash()` reports, `passwordService.token.hash()` does not. `secret`, `token` -and `key` are deliberately absent, since those are the values `hash()` is for. Warning in -`recommended`, error in `strict`, which is the honest severity for a check whose only signal is a -name. diff --git a/.changeset/eslint-no-allow-unaudited.md b/.changeset/eslint-no-allow-unaudited.md deleted file mode 100644 index dbcea94..0000000 --- a/.changeset/eslint-no-allow-unaudited.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@platform-sec/eslint-plugin-sensitive-string': minor ---- - -`no-allow-unaudited` reports a worker accepting a payload without the audit hook its sender asked -for. - -Core's worker handoff (ROADMAP 1.6) carries a value and its context across a thread boundary, and -the one thing it cannot carry is the context's `auditReveal`, because a function survives neither -structured clone nor JSON. A payload built under an audited context therefore arrives marked, and -`fromWorkerPayload()` refuses it unless the receiving side installs a hook of its own. -`allowUnaudited: true` is the way past that refusal, and what it means is exact: the sender asked -for every reveal of this value to be recorded, and reveals in this thread will not be. - -The flag exists because that is sometimes the right call, and this rule exists because it is never -a call to make by accident. It reports the property on `fromWorkerPayload()` and -`withWorkerPayload()`, whatever its value short of a literal `false` or an explicit `undefined`, -since `allowUnaudited: flag` is still a decision to sometimes skip the audit. Error in both presets -rather than warn: the finding is never wrong, and a disable comment on the line is where the reason -belongs. - -Syntax only, like the other rules that read a call's shape: an object property in a call, matched -by the function's name. An options object built elsewhere and passed by name is invisible, the -page says so, and `payloadFunctions` renames the functions for a project that wraps the two in one -of its own. This makes seven untyped rules and nine in all; the design decided the rule ships with -the escape hatch it makes defensible rather than following it. diff --git a/.changeset/eslint-type-aware-rules.md b/.changeset/eslint-type-aware-rules.md deleted file mode 100644 index 6cfc405..0000000 --- a/.changeset/eslint-type-aware-rules.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -'@platform-sec/eslint-plugin-sensitive-string': minor ---- - -Add the two type-aware rules, `no-stringify-sensitive` and `prefer-safe-log`. - -The five rules already in the plugin read the shape of a call. These two answer a question no -syntax answers: does this *value* carry a sensitive one anywhere inside it? They need -typescript-eslint's type-aware mode, and they live only in the new `recommended-type-checked` and -`strict-type-checked` presets, so a project that has not configured a TypeScript program never -meets them. - -```js -import sensitive from '@platform-sec/eslint-plugin-sensitive-string'; -import tseslint from 'typescript-eslint'; - -export default [ - { - files: ['**/*.ts'], - languageOptions: { - parser: tseslint.parser, - parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname }, - }, - }, - sensitive.configs['recommended-type-checked'], - { files: ['**/*.js'], ...sensitive.configs['disable-type-checked'] }, -]; -``` - -Building them corrected the premise both rules were designed from. Neither `JSON.stringify` nor -`console.log` leaks a `SensitiveString`: `toJSON()` and the `nodejs.util.inspect.custom` handler -both redact, so every ordinary transport already prints the placeholder. What actually goes wrong -is two other things. - -`JSON.stringify` substitutes silently. `JSON.stringify({ token })` produces -`{"token":"[REDACTED:credential]"}`. If the JSON was going to a log line, that is what you wanted. -If it was going to another service, a credential has just been replaced by a placeholder, nothing -threw, and the failure surfaces at the far end as an authentication error with no local cause. -`safeStringify()` says the first intent out loud; `serialize()` on the field serves the second. - -A `serialize()` result is a plain object holding the raw value. `toJSON()` cannot reach it and -neither can `util.inspect`, which is why `redactInStructure` recognises the `__sensitive` marker by -hand. So `JSON.stringify(envelope)` writes the credential out in the clear and -`console.log(envelope)` puts it in a log line. That is the one genuine leak either rule catches, -and both report it under its own message. - -Everything else `prefer-safe-log` reports is defence in depth, which is why it warns rather than -errors, and why it leaves `console.log(token)` and `console.log(tokens)` alone: passing the value, -or a plain list of them, is the documented way to log one. What the rule reports is a *structure* -holding one, where the redaction stops being the class's business and becomes the transport's. - -Type-aware and untyped rules cannot share a preset, which is why there are now ten rather than -four. A type-aware rule applied to a file with no program has to either fall silent, which reads as -a clean file, or say so. Both say so, once per file, so a preset that also ran over plain -JavaScript would report a configuration error on every `.js` file in the project. -`recommended`, `strict` and their eslintrc twins are unchanged and still hold the five untyped -rules; `recommended-type-checked` and `strict-type-checked` hold all seven; `disable-type-checked` -turns the two back off for the files a `tsconfig.json` does not cover. `meta.docs.requiresTypeChecking` -is what the presets split on, and a test holds the split to it so a third type-aware rule cannot -land in an untyped preset by accident. - -A type is matched by name, not by resolving its declaration back to this library. Resolving would -be more precise and would break under bundlers, path aliases and re-exports, which is a worse trade -for a rule that runs in an editor. `sensitiveTypes` and `serializedTypes` are the options that -follow, defaulting to `SensitiveString` and `SerializedSensitiveString`. Names match exactly rather -than by prefix, because `SerializedSensitiveString` is the recommended transmission path and a -prefix match would report it where it is correct. - -The type walk mirrors what a serializer walks, which is what bounds it: union and intersection -members, array and tuple elements, base classes, object properties, index signatures. An array -stops there rather than going on to its own members, because `Array` declares a numeric index -signature and following it would reach every element back through a property edge, turning -`console.log(tokens)` into a finding. A `Map` or a `Set` is not walked at all, since neither -survives `JSON.stringify` as anything but `{}` and both redact element by element under -`util.inspect`. One search examines at most 1000 distinct types, a bound on the pathological case -so that one unlucky call cannot make a lint run look hung. - -The plugin still has no dependencies. The TypeScript API is reached structurally, the same way -ESLint's already was, because adding `typescript` as a dependency would put it in the install of -every consumer who lints plain JavaScript, for a pair of rules they cannot run. The checker arrives -through `parserServices`, already built by whoever configured type-aware linting, and nothing in -the package imports `typescript` at runtime or at type level. - -`no-log-reveal` and `prefer-safe-log` now share one logger matcher, in `src/log-calls.ts`. Two -rules that disagreed about what a logger is would be one `logObjects` setting granting half of what -it says. - -24 further cases across the two rule suites, plus a type-aware fixture project, adversarial -coverage for the shapes someone works around a finding with, and a canary that neither rule's -message ever quotes the code it flagged. diff --git a/.changeset/guard-cost-on-the-passing-path.md b/.changeset/guard-cost-on-the-passing-path.md deleted file mode 100644 index 21827e7..0000000 --- a/.changeset/guard-cost-on-the-passing-path.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -'@platform-sec/sensitive-string-http': patch -'@platform-sec/sensitive-string-data': patch ---- - -Stop paying for the failure the guards almost never find. - -Both of these guards run on every request and every statement, and both did work whose only consumer -was a branch that does not fire. Neither change alters what is refused. What changes is what a clean -request costs to clear. - -`assertNoMarker` percent-decoded every outgoing body before scanning it, which allocates two full -copies of the body: one from `replace(/\+/g, ' ')` and one from `decodeURIComponent`. A JSON body -contains neither `%` nor `+`, so `percentDecoded` is the identity on it and the decoded scan searches -a string identical to the one just searched. The decode is now skipped when the body holds neither -character, which is not a heuristic: with no `+` the replace has nothing to replace, with no `%` the -decode has nothing to decode, so `decoded !== wire` was already dead and the branch was unreachable. -Measured on Node 22.15.0 over a marker-free JSON body, median of seven rounds with each size in its -own process: 1 KB from 2.6 us to 105 ns, 16 KB from 34.9 us to 613 ns, 128 KB from 366 us to 5.9 us. -It is the only figure in this release that scales with payload size, so it is worth most to whoever -sends the largest bodies. - -`+` is checked as well as `%`, and that half is load-bearing rather than defensive. A custom -`registerPolicy` redaction may contain a space, a form encoder puts that space on the wire as `+`, -and the resulting body carries the marker with no percent-escape anywhere in it. A check for `%` -alone would skip that body without looking at it, and a test now fails if the `+` is removed. A -second test compares the guard against an implementation that decodes unconditionally over a corpus -of bodies, so the equivalence is asserted rather than argued. - -`findUnsafeParameter` walked the bound parameters through `parameterEntries`, which materialises a -`[string, unknown][]`, then carried a path string down the recursion and allocated a `WeakSet` per -container. On a bag with nothing wrong in it, which is every bag in a working application, all three -are built and discarded. The walk now reads the three bag shapes in place, builds the path only while -unwinding the branch that found something, and creates the cycle guard on the first descent rather -than on entry. Measured the same way: 5 scalars from 237 ns to 81 ns, 6 named scalars from 486 ns to -201 ns, 3 parameters with one nested array from 532 ns to 162 ns, a 4-level nesting from 1017 ns to -372 ns. - -Whether that second one is worth having depends on the driver, and the doc comment says so rather -than quoting the ratio alone. Against a networked Postgres, 300 ns is invisible. Against in-process -`better-sqlite3`, where a small query is single-digit microseconds, it is a few percent of the -statement. - -`parameterEntries` keeps its shape and its exported signature, but nothing calls it on the hot path -any more, so two walkers now read the same three bag shapes. They are held together by a test that -runs both over the same bags rather than by one calling the other, and adding a shape to one without -the other fails it. - -Mutation testing found two coverage gaps that a green suite did not. Deleting the array segment from -the path on the unwind passed every test, because no fixture had an offence nested inside a container -inside an array, only inside an array directly. Removing the array cycle guard also passed, because -the only cycle in the fixtures was an object cycle. Both are now in the corpus, along with a mutual -cycle between an array and an object, and all six mutations of the new walk fail. diff --git a/.changeset/http-instance-headers.md b/.changeset/http-instance-headers.md deleted file mode 100644 index 748cc14..0000000 --- a/.changeset/http-instance-headers.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -'@platform-sec/sensitive-string-http': minor -'@platform-sec/sensitive-string-axios': minor -'@platform-sec/sensitive-string-got': minor -'@platform-sec/sensitive-string-ky': minor ---- - -`headers` on the wrapper's options: a typed place for an instance-level credential. - -The got and axios wrappers read their client's own default headers, so a wrapped value or an -`apiKey()` descriptor placed in `got.extend({ headers })` or on `defaults.headers.common` is -revealed, recorded and stripped like any other. That is the run-time story. The compile-time one is -that none of it type-checks: measured on got 16.0.0, axios 1.20.0 and ky 2.1.0, each client types an -instance's default headers as strings, both a `SensitiveString` and a descriptor are rejected, and -the types are aliases nothing can widen from outside. A TypeScript caller had a cast as the only way -in, which is what this repository's own got suite was doing. - -`SensitiveInstanceOptions` extends `SensitiveClientOptions` with `readonly headers?: -SensitiveHeaders`, and the three wrappers built around a client instance take it: -`SensitiveGotOptions`, `SensitiveAxiosOptions` and `SensitiveKyOptions` are now aliases of it. The -headers sit over the client's own defaults, where the wrapper can read them, and under each call's -headers, and go through the same pipeline: revealed once per attempt, in the scrub plan, scrubbed on -both settlement paths, and under `allowedHosts: 'strip'` dropped at the origin boundary. The merge -happens before the dropped-name accounting, so a name stripped at one hop is not refilled from the -option on the next; the axios and ky suites assert that across a bounce through a second origin and -back. A credential there flips the redirect default to refusing, the same as one in the call, and -each suite asserts that too. - -For ky this is the only instance-level layer there is. ky does not expose an extended instance's -defaults, so a wrapped value in `ky.extend({ headers })` was never seen by the wrapper and the README -said to put it in the call instead; it now says to put it here. - -`fetch` and undici are unchanged. Neither has an instance to wrap, so their per-call bag is the whole -surface and already takes a wrapped header. - -Nothing changes for a caller who does not set it. The got and axios wrappers still read their -client's defaults at run time, so a JavaScript caller keeps what they had. diff --git a/.changeset/http-per-client-stripping.md b/.changeset/http-per-client-stripping.md deleted file mode 100644 index 788ed27..0000000 --- a/.changeset/http-per-client-stripping.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -'@platform-sec/sensitive-string-http': minor ---- - -The cross-origin stripping exemption is per client, and fails closed for a client nobody measured. - -`isStrippedAcrossOrigins(name)` answered from one global set of the three header names the Fetch -specification says a client must drop on a cross-origin redirect. That is the specification's claim, -not any given client's behaviour, and the two are not the same thing: a survey of Node HTTP clients -turned up several that forward `Authorization` across origins. The exemption is what flips a request -carrying a credential from refusing redirects to delegating them, so granting it on the -specification's authority means trusting an implementation nobody looked at. - -It now takes the client as well: `isStrippedAcrossOrigins(name, client)`. A client in the measured -table gets the exemption it was measured to deserve, and any other name gets nothing, so its requests -default to refusing redirects. `prepareRequest` already carries the client name and passes it -through, and `allowedHosts` still overrides the default in either direction, because naming the hosts -is the caller saying they have thought about it. - -The table was measured rather than read off the specification, and the measurement now lives in the -repository as `__tests__/redirect-stripping.test.ts` rather than in a design document: two loopback -servers on different ports, a 302 from the first to the second, and the second recording what -arrived. On axios 1.20.0, got 16.0.0, ky 2.1.0, undici 8.10.1 and Node 22.15's `fetch`, all five drop -`Authorization`, `Cookie` and `Proxy-Authorization`, and all five forward `x-api-key` untouched. The -test asserts the table in both directions against the clients themselves, so a client that changes -behaviour in a future release fails there rather than quietly widening what the table promises. - -`Proxy-Authorization` was in the original set on the specification's authority alone and had never -been measured. It is now measured, and it does belong. - -No wrapped client changes behaviour: all five were already in the exempt set and all five still are. -What changes is a caller who drives `prepareRequest` directly with a client name of their own, which -used to inherit the full exemption and now inherits none of it. - -`measuredClients()` is exported alongside, so a caller can see which names carry the exemption rather -than discovering it from a redirect that did not happen. diff --git a/.changeset/http-proxy-tag.md b/.changeset/http-proxy-tag.md deleted file mode 100644 index ae37863..0000000 --- a/.changeset/http-proxy-tag.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@platform-sec/sensitive-string-http': minor -'@platform-sec/sensitive-string-axios': minor ---- - -Tag the proxy a request goes through, so a host-bound credential refuses one its binding does not -name. - -A proxy setting changes the machine that receives the bytes and leaves the URL untouched, so the -host check alone passed it; the corpus recorded that shape against axios, and noted that the only -place this row mentioned a proxy was an instruction for turning off the check that incidentally -covered it. `OutgoingRequest` gains `proxy`, a URL or a `host[:port]`, and the attempt context tags -it as `tags.proxy`, which core's destination binding reads as a second machine the bytes reach. - -`sensitive-axios` resolves the effective proxy the way axios itself does: `config.proxy`, an agent -on `httpAgent` or `httpsAgent` that exposes its `proxy` as `https-proxy-agent` does, or the -`_proxy` and `all_proxy` environment under `no_proxy`, with `proxy-from-env` 2.1.0's rules -restated rather than imported, since that package is axios's dependency and not this one's. An -opaque agent reads as no proxy and the README says so. got, ky, undici and fetch honour no proxy -option of their own and pass none. diff --git a/.changeset/http-redirect-query-and-body.md b/.changeset/http-redirect-query-and-body.md deleted file mode 100644 index e6fb505..0000000 --- a/.changeset/http-redirect-query-and-body.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -'@platform-sec/sensitive-string-http': patch -'@platform-sec/sensitive-string-axios': patch -'@platform-sec/sensitive-string-fetch': patch -'@platform-sec/sensitive-string-got': patch -'@platform-sec/sensitive-string-ky': patch -'@platform-sec/sensitive-string-undici': patch ---- - -A redirect hop no longer carries the caller's query, and a cross-origin 307 or 308 that would -re-send a body carrying a wrapped value is refused under `allowedHosts: 'strip'`. - -Both were found by an independent verification of the corpus study that produced `'strip'`, by -measurement rather than by reading. With a wrapped credential passed as a query parameter and a 302 -whose `Location` carried no query, the axios, fetch, ky and undici wrappers sent the second origin a -URL containing the credential: each hop handed the caller's query to `prepareRequest` again, which -appended it to whatever URL it was given. got did not, because it follows hops itself. The corpus -had recorded exactly this gap against GHSA-p2f4-r6v6-j797 and then withdrawn it, on the reasoning -that a redirect target is the URL the server names; that reasoning was correct only for the delegated -mode, where the client follows and nothing is re-merged. - -`prepareRequest` now drops the query on any hop after the first, so no wrapper has to remember. A -redirect target is the URL the server named and nothing else, which is what every client in the -measured set does on its own. - -A 307 or 308 re-sends the body, and all five wrappers delivered a JSON body carrying a wrapped value -to the cross-origin target under `'strip'`. A header can be dropped at the origin boundary without -changing what the request means; a body cannot, since a body with one field removed is a different -request that succeeds at doing something else. So the hop is refused with -`RedirectWouldResendCredentialError`, before anything is revealed for it, and the message says to -name the destination in `allowedHosts` if it is trusted with the body. A 301, 302 or 303 drops the -body on its own and is followed as before. `PreparedRequest` gains `bodyCarriesCredential`, which -covers a form body as well as a structured one, and `assertBodyMayFollow` is exported for a wrapper -that follows hops itself. - -The got package now merges the instance's `defaults.options.headers` under the call's headers, as the -axios package already did for `defaults.headers`. Measured, a wrapped value placed in -`got.extend({ headers })` reached the wire as `[REDACTED:credential]` and an `apiKey()` descriptor as -`[object Object]`, on every hop, because the wrapper never saw either; they are now revealed, -recorded in the plan, scrubbed, and under `'strip'` dropped at the boundary. ky does not expose an -extended instance's defaults, so the ky README says to put a wrapped value in the call instead. diff --git a/.changeset/http-strip-and-follow.md b/.changeset/http-strip-and-follow.md deleted file mode 100644 index 4aec034..0000000 --- a/.changeset/http-strip-and-follow.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -'@platform-sec/sensitive-string-http': minor -'@platform-sec/sensitive-string-axios': minor -'@platform-sec/sensitive-string-fetch': minor -'@platform-sec/sensitive-string-got': minor -'@platform-sec/sensitive-string-ky': minor -'@platform-sec/sensitive-string-undici': minor ---- - -`allowedHosts: 'strip'`: follow a redirect with the credential removed instead of refusing it. - -There were two answers to a credential-bearing request that gets redirected, and for one real shape -both are wrong. Refusing breaks a provider that redirects a release asset or a download to object -storage, which is the ordinary way private artifact hosting works. Naming the hosts is impossible for -exactly the same reason: the caller cannot enumerate a bucket endpoint the provider picks at request -time. `'strip'` is the third answer, and it is the one the upstream project behind -[GHSA-p2f4-r6v6-j797](https://github.com/advisories/GHSA-p2f4-r6v6-j797) reached for when it fixed -the same problem: drop the credential at the origin boundary and keep going. - -The difference is where the list of headers to drop comes from. That project had to guess which names -carry credentials, shipped nine of them, and added a registration function so publishers could add -their own. Here the list is derived: the scrub plan already records exactly which header names carried -a wrapped value, because the caller decided that when they wrapped it. A header the library has never -heard of is covered on the same terms as `Authorization`, and there is nothing to register. - -`'strip'` is never a default and cannot become one. Refusing is the answer that cannot go wrong -quietly: a request that stops is a bug report, while a credential dropped on a hop the caller did not -know was a boundary is a request that succeeds at doing less than it appears to. Same-origin hops keep -everything, since a redirect inside one origin discloses nothing to anyone new. - -`isCrossOrigin` decides the boundary by the scheme/host/port rule, normalizing implied ports. It makes -one exception, `http:` to `https:` on the same host where neither side names a non-default port, which -is an upgrade to the same server that every client in the measured set follows without stripping. The -reverse is never allowed, and the allowance does not extend to a non-default port, since a different -port is a different service as often as it is the same one. - -All five wrappers implement it and each is tested end to end against a real pair of loopback origins, -asserting both that the canary does not reach the second origin and that the request still completes. -got is the one that differs: it follows hops itself but hands the next request's options over through -`beforeRedirect`, so there it is a deletion rather than a rebuilt request. - -What every wrapper accumulates is the set of dropped header *names*, applied where the next request is -built, rather than a narrowed header object carried forward. The two are not equivalent and the -difference is a real leak: axios merges `defaults.headers` underneath the request's own, and the axios -wrapper deliberately supports a wrapped value there, so narrowing only the per-request headers would -strip the call site's credential and forward the instance's. Dropped names are also set to `null` in -the outgoing axios config, because removing a name from what is sent is not enough on its own when the -client refills it from its defaults. - -got tracks the previous hop rather than comparing every hop against the request's original URL. -Same-origin is not transitive once the http-to-https upgrade is allowed: A `http://x` to B `https://x` -is an upgrade, B to C `http://x` is a downgrade, and C is same-origin with A, so comparing against A -would forward a credential into plaintext. Distinguishing the two needs TLS and is not covered by a -test; the other four wrappers rebuild each hop and already hold the previous URL. - -New exports from the http package: `isCrossOrigin`, `headersWithout`, and the `RedirectPolicy` and -`ResolvedRedirectPolicy` types. `allowedHosts` accepts `'strip'` alongside the array and `false`. -Nothing changes for a caller who does not ask for it. diff --git a/.changeset/kdf-password-hashing.md b/.changeset/kdf-password-hashing.md deleted file mode 100644 index e12d7ee..0000000 --- a/.changeset/kdf-password-hashing.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -'@platform-sec/sensitive-string-kdf': minor ---- - -A `kdf` package: password hashing with Argon2, bcrypt or scrypt, and the stored hash wrapped as a -credential. - -ROADMAP 1.2, the half that cannot live in `core`. Argon2 and bcrypt are native modules and `core` -has no runtime dependencies (rule R7), so `hashForStorage` and `verifyHash` ship here, as free -functions rather than as methods, with both modules optional peer dependencies. An application that -installs neither still hashes with scrypt, which `node:crypto` computes, and that is what makes the -package usable in an environment where a native build is not an option. - -Reading a protected password goes through `core`'s ordinary gate, so hashing one appears in an audit -log as the exposure it is and a revoked password cannot be hashed. The hash itself is a fresh -`credential` that does not inherit the password's deadline or reveal budget. It is the one place in -this library where a derived value does not carry its source's constraints, stated here because it -is a deviation and not an oversight: a stored hash has to outlive the password and be read on every -login, and a salted, memory-hard hash is not a reversible copy of the value the way a fast digest of -a guessable one is. `core`'s `hash()` inherits for exactly that reason. The case where the reasoning -is thin is a secret an attacker can enumerate outright, a four-digit PIN, and no KDF rescues that. - -Three things the design sketch did not have. bcrypt hashes the first 72 bytes of a password and -silently ignores the rest, so two passwords sharing a 72-byte prefix verify against each other's -hash; this refuses the longer password on both the hashing and the verifying side, counting bytes -rather than characters, because an error at registration is a bug report and a silent truncation is -an authentication bypass nobody finds. The scrypt PHC string records `ln`, the base-two logarithm of -the cost, rather than the `N=16384` the sketch wrote, which is what the format actually specifies -and what makes a hash written here readable by something that is not this package. And a stored -string is untrusted input, since it arrives from a database column: its parameters are bounded on -the way back in, or one row asking for `ln=22, r=64, p=16` is a denial of service. - -Every parameter has a floor as well as a default, and a value below it is refused rather than -quietly raised. bcrypt's rounds also has a ceiling, because it is an exponent: 20 is around thirty -seconds per hash and 31, which bcrypt accepts, is measured in weeks. - -`needsRehash` is the other half of that. Verification reads the parameters out of the stored string, -so a password hashed years ago keeps verifying forever; this is how an application notices that it -should not, and rehashes at the one moment it holds the plaintext. It reports weaker, never -different: rolling a configuration back must not rehash a fleet of passwords down to the lower -setting, though a different algorithm family always reports, since that is the migration it exists -for. It hashes nothing and loads no native module, which lets it answer for an algorithm the process -cannot compute, and it cost one bug worth recording: the specification lists Argon2's parameters as -`m,t,p` and the `argon2` package writes `m,p,t`, so a parser written from the format matches no hash -in production. The parameters are read as a list rather than in a fixed order. - -Verification takes the algorithm and parameters from the stored hash rather than from anything -configured today, so a store hashed under weaker parameters keeps verifying while new passwords use -stronger ones. A wrong password answers `false`; a stored hash that cannot be read throws, because a -corrupted column reported as a failed login is an account nobody can sign in to and nobody can -explain. The string it could not read is never repeated in the message. diff --git a/.changeset/llm-prompt-scrubbing.md b/.changeset/llm-prompt-scrubbing.md deleted file mode 100644 index d4d3ff3..0000000 --- a/.changeset/llm-prompt-scrubbing.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -'@platform-sec/sensitive-string-llm': minor ---- - -An LLM package: an authorization gate for values leaving the trust boundary, structural prompt -scrubbing over the shared `@platform-sec/sensitive-string-scrub` walk, and keyed entity handles. - -`sendToExternal` is the feature, and it is the only export in the package that returns a raw string; -a test enumerates the module's exports to keep it that way. It is the first thing in this repository -that reads `advisory.allowSendToExternal`, which has been declared on `SensitivityAdvisory` since -the beginning with no reader. The leak it addresses is not a value nobody wrapped: interpolation into -an ordinary template literal already redacts. It is `reveal()` followed by concatenation, where every -existing control has already run and passed, because a prompt leaks when a developer puts the value -there on purpose and is right to need it. The gate resolves the class's policy, refuses anything but -a literal `true`, reveals through the ordinary gate so TTL, revocation, counting and the audit hook -all run, and refuses a revealed form carrying a redaction marker. - -`undefined` is a refusal, which makes a stock installation refuse everything and is the only -defensible reading of a flag meaning "this data may leave for a third party". The error names the -class and the policy field to change. The audit context is a child of whatever is ambient, so an -`auditReveal` installed upstream still fires, and it always carries `external:` as its name, so -an audit sink can answer "what left for a third party today" without the application having named its -contexts well. - -The marker rule runs here in its strictest form in the monorepo, and inverted twice: 3.7 throws on a -marker at the wire, the error tracking row inverted it because a marker is the desired output at a -sink, and this restores the original. Every other integration that ships a marker sends a broken -value to something that rejects it; a model accepts it, answers as though the value were real, and -the placeholder is then in the conversation history. - -`createPromptScrubber` and `scrubForPrompt` are `scrub`'s walk with this package's envelope keys and -one extra pass over the prose positions. **The README says in its own section that this closes the -smaller half of the leak**, and the canary suite asserts a canary is *present* after scrubbing a -prose position, because a test that documents a known gap is worth more than a gap nobody wrote down. -A request body sorts into three positions: wrapped values at keys are already safe, unwrapped strings -under credential-shaped keys are caught by the denylist, and prose is not reachable by either. The -key is `content`, the key is always `content`, and denying it denies the request. So the package -ships no content classifier and offers `transformContent` as the seam for one the caller already -runs. Failure throws rather than returning a stub, parting from `guardedScrub` deliberately: a stub -event preserves the signal that an error happened, and a stub prompt is a paid request answering a -question nobody asked. `onScrub` fires with `failed: true` first, and the thrown error carries the -stage, the path and the exception type with no message, no stack and no `cause`. - -`entityHandle` mints `HMAC-SHA256(key, scope || 0x00 || klass || 0x00 || value)`, truncated to 12 hex -characters, over core's new `deriveKeyedToken`. Keyed with no unkeyed mode, since the values that get -handles are low-entropy by definition and the premise of a handle is that it appears where an offline -attack can reach it. Refused for `credential`, hard-coded rather than read from a policy flag, with a -test that keeps it refused when the policy is opened all the way up. 8 hex characters is not -available: at 32 bits a one-in-a-thousand collision arrives at about 3,000 entities, and a collision -merges two customers into one in the model's view. - -One thing this package can report and cannot fix, and the README says so under its own heading: -structural redaction replaces a wrapped value with `[REDACTED:credential]`, which leaks nothing and -leaves a placeholder where a value belonged, and a model answers as though the placeholder were real. -A marker in a body is unambiguously wrong for a model and unambiguously right for a log line, and the -scrubber is handed a body that may become either, so it counts rather than decides. Read -`removed.instances` before sending and treat a non-zero count as a bug in the calling code. - -Rehydration is the half the roadmap sentence did not mention, and it is a substitution oracle: a -function taking text an attacker can steer and returning secrets, since a model echoes verbatim spans -from documents in its context and forging a handle costs nothing. So it resolves only against a map -the calling request built, returns a `SensitiveString` rather than a raw string so the authorization -stays with whatever consumes it, takes assembled text rather than a stream, and never runs inside -`scrubForPrompt`. Substitution is by splitting and reassembly through core's template tag rather than -`replaceAll`, which interprets `$` patterns in a *string* replacement even when the pattern is a plain -string: measured on Node 22.15.0, `` `$` `` and `$'` splice surrounding prompt text into what the -caller believes is a bare value, and neither throws. - -The per-provider content paths in `shapes.ts` were **not** measured against an installed SDK. No -provider SDK is a dependency of this package, not even a dev one, and a fixture written from a -document tests the document rather than the provider. Each shape carries `verified: false` and the -schema it was written from rather than a version number, and the README says what that does and does -not affect: content paths decide where a caller's hook runs, not what is redacted. diff --git a/.changeset/metrics-collection.md b/.changeset/metrics-collection.md deleted file mode 100644 index 46bbed5..0000000 --- a/.changeset/metrics-collection.md +++ /dev/null @@ -1,132 +0,0 @@ ---- -'@platform-sec/sensitive-string-metrics': minor -'@platform-sec/sensitive-string-core': minor ---- - -Count what the library prevented, and price the rest against the emission points that exist. - -`@platform-sec/sensitive-string-metrics` is the collector the redaction observer was built for. It -subscribes to `observeRedactions()` and publishes -`sensitive_redactions_total{class, label, surface}` first in its catalog, because every rendering of -a marker is a place a plain string would have been written, and that is the number a deployment is -judged on. Installing the package, calling `start()` and mounting a Prometheus endpoint is the whole -of a measurement-first deployment: no call site changes, and the counter is complete from `start()` -onward. - -The rest of the catalog is smaller than the design asked for, and each omission has the same reason. -Core has three emission points, and only three. `observeRedactions()`, process-wide. The -`auditReveal` hook, which fires for the eight operations in `RevealDetails` and only under a context -carrying it. And the memory hygiene `WeakRef` count. A refusal fires nothing at all, deliberately: -the gate throws before the policy hook and before the audit hook so that a denied read is not logged -as a reveal. Construction and `equals()` fire nothing either. - -So three counters ship rather than six. `sensitive_reveals_total{class, label, operation, context}` -carries the operation as a dimension, since one hook reports all eight, which makes the design's -separate revocations counter a dimension value and makes the expirations counter impossible: a value -does not expire, a read finds it expired and throws. `sensitive_refusals_total{class, code}` is fed -by the caller through `recordRefusal(error)` or the `meterRefusals` tee, and the README says so -rather than implying a coverage it does not have. The three histograms are not built, because each -needs a bracket around an operation and the one hook available fires at a single instant inside the -gate, so timing from it would measure the hook's own dispatch. - -The three counters have three different coverages, which is the fact most likely to be misread, so -the README leads with the table and the instruction not to divide one into another. A -reveals-per-redaction ratio measures how much of your code you wired up, not how your system behaves. - -`withMeteredReveals` exists because the obvious wiring is wrong. Installing a context with -`auditReveal: metrics.auditReveal` replaces the ambient context wholesale and silently drops whatever -audit sink the application put higher up the stack, which is a bad trade for a counter. It builds -through `createChildContext`, inherits the ambient tags, and chains the hooks, calling each through -the object it was declared on rather than extracting it, since `auditReveal` is a method in -`RevealContext` and core invokes it as one. The counter runs last, so a reveal an audit sink refused -is not counted as one that happened. - -Two dimensions carry text the library does not choose, and both are treated as such. The `context` -dimension is **off by default**: a context name is free-form text from a call site where a request id -is also at hand, and a metrics store keeps it for months. There is no `context.tags.*` dimension. -`maxSeries` folds the caller-supplied dimensions into `__other__` rather than dropping events, so the -total stays right and only the breakdown is lost, and a hard ceiling underneath makes the bound hold -even where the non-folding dimensions are fed by hand. - -Both exposition formats are line-oriented and one of the label values is caller text, so the escaping -is the point rather than a detail. A label of `k",surface="x"} 99\nintruder_total{a="b` closes the -brace, ends the line, and writes a metric of its own into a scrape; the StatsD equivalent is a -newline followed by `intruder:99|c|#x:y`. Each suite drives the injection its own format is -vulnerable to and asserts that the result is not a line, and the StatsD suite asserts on bytes that -crossed a real loopback socket rather than on a string an internal method returned. - -Five defects found while building, and every one is the same mistake: a label value is caller text, -and the code treated it as a well-behaved identifier. A breakdown accumulated on an ordinary object -read `toString` off the prototype and rendered `"function toString() { [native code] }1"` as a metric -value, and would have rewritten the object's prototype for a label of `__proto__`. `StatsDExporter` -closed its socket in the same turn as its final flush, discarding the datagrams on exactly the -`SIGTERM` path that flush exists for. `PrometheusExporter` reserved five of its six dimension names -against `defaultLabels` and missed `context`, and a series carrying one label name twice is a scrape -Prometheus rejects outright, so a single misconfigured default label would take the whole `/metrics` -endpoint down. `StatsDExporter` also computed a delta per collector series, but sanitizing a tag is -many-to-one, so `a:b` and `a,b` both land on `a_b` and each computed its delta against the other's -baseline, inflating the counter on every flush and never recovering. And `collect()` documented an -ordering it did not implement. - -Two more were resource defects rather than formatting ones. The StatsD TCP path ignored what `write` -returned, so a far end that accepts the connection and stops reading made Node buffer every payload -in this process: measured, twenty thousand writes held 193 MB, none of it freed while the connection -stayed open. And the cardinality cap bounded how many series a counter held but nothing about how -large one was, so a thousand legal series still made a scrape as large as the caller's labels were -long; dimension values are now truncated at ingest, where the truncated value becomes the series -identity and the two exporters cannot be handed series that render identically. - -A third pass asked where the guarantees stop rather than what an input renders as or costs, and found -four more. Two were false sentences in the README about behaviour that was already correct, and both -concern a boundary. `sensitive_redactions_total` covers **one isolate**, not the process: measured, a -worker thread rendering five markers left a main-thread collector reporting zero, which inverts the -headline number for anyone whose real work happens in a worker pool. A collector plus a StatsD -exporter per worker composes, measured, because the agent sums the deltas; a Prometheus endpoint does -not, because it exports only the isolate serving it. And the stated reason core is a peer dependency -was backwards: two physical copies of core **share** the observer registry, because core keys it on -`Symbol.for()`, so redactions from both are counted; what does not survive is `recordRefusal`, which -recognises a refusal by `instanceof` and silently misses the other copy's. Both are now asserted, -which rule R6 asks for anyway. The other two were unvalidated configuration: `flushInterval` and -`maxPacketSize` accepted zero, negative and `NaN`, and `setInterval` turns any of those into a -one-millisecond timer rather than an error, so the mistake shipped as a flush loop; and both -exporters accepted a non-collector and failed at the first scrape instead of at boot. - -A fourth pass added benchmarks and asked what recording costs, which found the largest defect of the -lot. Series were held in one `Map` keyed on the dimension values joined into a length-prefixed -composite, and that cost about 450 ns per redaction against a rendering core prices at 17 to 26 ns. -The expense is not the string building, it is the hashing: a freshly built key has no cached hash, so -every lookup hashes the whole string, where the dimension values are repeated strings V8 has already -hashed. Measured over three dimensions, 96 ns to build a key, 257 ns to build it and look it up, and -12 ns to walk three nested maps and build nothing. The counter now holds series in nested maps, one -level per dimension: `inc` into an existing series went from 1022 ns to 16 ns in a tight loop, and -the collector's end-to-end cost from about 450 ns to about 70 ns over a no-op observer. The shape is -collision-free by construction too, rather than needing the length prefix to earn it. - -That rewrite changed one behaviour and three existing tests caught it. The composite key coerced its -inputs, so a non-string label, which core permits, threw on the way into the key and was counted as a -recording error; a trie has no such step and would have stored it, moving the failure to export time -where it is a broken `/metrics` response rather than a counted error. The check is explicit now, in -the one function every caller-supplied dimension passes through. - -Eleven of the twelve were found by review after the suite was already green: four past 100 passing -tests, two past 109, three past 114, one past 129. Each has a test confirmed red against the unfixed -code, except the two that were wrong sentences rather than wrong lines, whose tests pin the measured -behaviour instead. - -StatsD goes out over `node:dgram` and `node:net`, so both exporters ship in one package with no -third-party runtime dependency, against the design's proposal to split them. `collect()` is the -public seam between collection and export, and a CloudWatch or OpenTelemetry exporter needs nothing -else from a collector. - -Rule R6 does not apply to the collector, which is where the design was wrong about it: a subscriber -is not a singleton. There is no process-global collector to reconfigure, nothing for a duplicate -install to split, and no reset to gate. Two collectors in one process are two independent sets of -numbers, which is what lets this package and a future OpenTelemetry one run side by side. - -In core, `trackedRefCount()` becomes the public `trackedInstanceCount()`, which is the only new -export. The gauge over it is `sensitive_values_tracked` rather than the design's -`sensitive_values_active{class}`, on both counts deliberately: the `WeakRef` set is pruned by a -`FinalizationRegistry` that needs at least two GC cycles and two macrotask turns, so `active` is a -claim the number cannot support, and a per-class split needs a `deref()` on every entry, which keeps -its target alive to the end of the job. A gauge that extended the life of what it measured would be a -strange thing to put in a memory-hygiene metric. diff --git a/.changeset/newrelic-integration.md b/.changeset/newrelic-integration.md deleted file mode 100644 index 7b8565e..0000000 --- a/.changeset/newrelic-integration.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -'@platform-sec/sensitive-string-newrelic': minor ---- - -A New Relic integration, over the shared `@platform-sec/sensitive-string-scrub` layer. Everything -below was measured on Node 22.15.0 against `newrelic` 14.3.10, driving the real agent in its -serverless mode and decoding the gzipped payload it would otherwise post to the collector, plus -direct calls into the shipped `AttributeFilter`, with a companion case requiring each position to -leak once the configuration is removed. - -Like the Datadog half of this row, it does not close a default leak. At stock defaults the agent -collects exactly six headers off a hard-coded list, none of them a credential, writes query -parameters at destination `NONE`, and never reads the body. `allow_all_headers: true` is the opt-in -that leaks, and it puts `authorization`, `cookie` and `request.headers.xApiKey` into the payload. - -There is nothing to wrap: the agent's 39 public API methods include no payload callback. So -`sensitiveAgentConfig` is a pure function returning a configuration fragment, and two measured facts -shape what it generates. - -Names are translated. The agent maps 50 known headers to camelCase and puts the rest through a -fallback that title-cases every segment after the first, so an exclusion written as -`request.headers.x-api-key` is silently ignored where `request.headers.xApiKey` is honoured. The map -and the fallback are reproduced rather than imported, and a test pins the reproduction against the -shipped agent on all 50 names plus a fixture set of shapes outside it. Because the fallback is not -injective over casing, the generator emits every spelling it can derive rather than guessing which -one will arrive. - -Rules are exact, and emitted in every casing. The agent compiles them into a `RegExp` with no `i` -flag while `resolveScrubPolicy` lowercases, so generating from the resolved denylist alone excluded -`accesstoken` and transmitted `accessToken`, and did the same for `refreshToken`, `sessionId`, -`clientSecret`, `privateKey`, `confirmPassword`, `passwordConfirmation`, `secretKey`, `secretToken` -and `idToken`. Each of those is in the shared list in that casing precisely because that is how it -appears in a JavaScript payload, so the generator reads the source lists as written and emits both -spellings. `filter.test.ts` pins all ten through the agent's own `AttributeFilter`. - -Rules are exact, not wildcards. The agent resolves include against exclude by match strength, so a -wildcard exclusion is a default that any more specific include lifts, global or per destination, -while an exact global exclusion is a floor nothing in the configuration surface can raise. A -generator that emitted `request.headers.*` and called it done would produce a configuration that one -later `include` line silently reopens. The one wildcard the package can emit is opt-in and documented -as beatable, with the measured table in the README. - -`sensitiveAttributes` is the small opt-in beside it. The agent accepts only `string`, `number` and -`boolean` and drops everything else with a debug log, so a wrapped value added as a custom attribute -arrives as nothing rather than as its marker, and the obvious fix a hurried engineer reaches for is -`reveal()`. It runs the shared walk and coerces what is left, so the marker arrives instead. - -The README states what no part of this package can reach, and names log forwarding first: -`application_logging.enabled` and its `forwarding.enabled` both default to true, which is the one -default-on egress in either agent of this row, and by the time a log record exists a revealed value -is an ordinary string. diff --git a/.changeset/prisma-integration.md b/.changeset/prisma-integration.md deleted file mode 100644 index c0ba086..0000000 --- a/.changeset/prisma-integration.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -'@platform-sec/sensitive-string-prisma': minor ---- - -Add the Prisma integration: classified columns read back wrapped, and every write unwraps through an -audited reveal. - -ROADMAP 3.3, the Prisma half. Reads are a Prisma `result` extension, one computed field per -classified column. Prisma applies a result extension per model wherever that model appears in a -result, so relations loaded through `include` or `select` are covered by the same definition and this -package never walks a result tree; the generated result types pick up each `compute`'s declared -return type, so `select`, `include` and `omit` keep narrowing in the value and in the type. The -design document left that as a spike. It is resolved affirmatively on Prisma 6.19.3 and 7.10.0: -`user.email` is exactly `SensitiveString<'sensitive'>` rather than a union with `string`, and an -`omit` of a classified field, per query or global, removes it from both the type and the value with -no raw copy left under the original key. - -Writes do not work the way the design assumed, and the reason is worth stating because it inverts -what the package is for. `applyQueryExtensions` calls `deepCloneArgs` before it hands arguments to -any query hook, and that copy reproduces only the types Prisma knows: object enum values, field -references, `Decimal`, `Date` and typed arrays. Every other class instance is rebuilt from a -`for...in` copy, which cannot reach an ES `#private` field. A `SensitiveString` in `data` is -therefore already `{ klass, label }` with no value behind it by the time a hook could see it, so no -query extension can unwrap a write. `sensitiveInput()` does the reveal at the call site instead, -which is the last point at which the value exists; it was already required there for Prisma's -generated input types, so the amount of code at a call site is unchanged. - -What makes that the point rather than a technicality is the measurement on a client with no query -extension. There is no clone, the instance reaches Prisma's serialiser, `toJSON()` is called, and the -column ends up holding `[REDACTED:credential]`. The write reports success, and a later lookup by the -same unwrapped value matches the corrupted row because both sides are the marker, so even a -round-trip test passes. The secret is destroyed silently. The extension's query hook is what closes -that: it refuses any operation still carrying the flattened remains of an instance, naming the model, -the operation and the argument path, and it unwraps the one argument shape the copy does preserve, -the `values` of a `$queryRaw`, `$executeRaw` or `$queryRawTyped` template. See ADR-003. - -Every unwrap is one `reveal()` under a child context tagged with the model, the operation and the -argument path, layered on any ambient `withRevealContext()` so its `auditReveal` hook and tags -survive. Core hands that context to the hook as its third argument, which is where a sink reads the -tags. - -The names in the configuration are checked against the client when the extension is applied, which -turned out to matter as much as anything else here. Neither TypeScript nor Prisma catches a typo: -`models` is keyed by string, and measured three ways on both versions, Prisma ignores a result -component under a model it does not know, adds a computed field under a name it does not know beside -the real column rather than replacing it, and does not throw on a `needs` entry for a column that -does not exist. In all three the configured column reads back as a plain string, so one wrong -character turned the guarantee off with nothing said. `PrismaUnknownModelError` and -`PrismaUnknownFieldError` are raised at `$extends` time instead, before a query runs. - -Also in this release: `wrapRows` for raw query results, since a type argument such as -`$queryRaw` is erased before the query runs; configuration validated when the extension is -built rather than on the first query that reaches it; and `nullable` and `list` in a field spec, -because the `compute` return type is what Prisma publishes and a nullable column typed as a required -scalar is a lie the compiler propagates. - -Peer range is `@prisma/client@^6 || ^7`, measured on 6.19.3 and 7.10.0. Scalar lists are unit-tested -against the real result builder rather than end to end, because the suite runs on SQLite, which has -none; TypedSQL is matched by shape and not exercised end to end. Prisma's own query log carries bound -parameters and no extension can intercept engine logging, so the README says not to enable query -logging in production and a test pins the leak so a Prisma change is noticed. diff --git a/.changeset/rollbar-integration.md b/.changeset/rollbar-integration.md deleted file mode 100644 index a8f75a6..0000000 --- a/.changeset/rollbar-integration.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@platform-sec/sensitive-string-rollbar': minor ---- - -A Rollbar integration, over the shared `@platform-sec/sensitive-string-scrub` layer. Everything -below was measured on Node 22.15.0 against `rollbar` 3.1.0, driving the real SDK to a server on -loopback and asserting on the bytes that left the process, with a companion case requiring each -position to leak once the scrubbing is removed. - -`rollbar` is thin, and its README says so rather than manufacturing value. The vendor already ships -the best defaults measured and applies them before the one seam a user can install. Two findings are -what the package is actually for. Rollbar is the only SDK measured that does not honour `toJSON()`: a -wrapped value arrives at every seam as `{ klass, label }`, so its value is safe because the private -field is unreachable, and its **label was being transmitted** on every payload. The package -recognizes that shape, puts the marker back, and routes the label into the report where -`reportLabels` governs it. And a `transform` that throws makes Rollbar set `options.transform = null` -and send the original untransformed item, so one exception disables scrubbing for the life of the -process; a caller's own `transform` is therefore called inside the guard rather than beside it. - -A `transform` you supply runs *before* the scrubber and is called inside the guard, so a bug in -your callback cannot disable this package. - -The seam inventory is checked against the shipped `index.d.ts` rather than at the type level, -because `keyof Rollbar.Configuration` widens to `string` in this program and a type-level check -against it would pass vacuously. diff --git a/.changeset/scrub-observe-mode.md b/.changeset/scrub-observe-mode.md deleted file mode 100644 index e258236..0000000 --- a/.changeset/scrub-observe-mode.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -'@platform-sec/sensitive-string-scrub': minor ---- - -Add `onMatch: 'censor' | 'observe'` to the scrub policy. Default `'censor'`, which is what every -payload got before. - -```ts -const policy = resolveScrubPolicy({ - onMatch: 'observe', - onScrub: (report) => counts.record(report.kind, report.deniedKeys, report.deniedValues), -}); -``` - -Under `'observe'` a denylist match is left in place and still reported, so a payload reaches the -vendor as it did before this package was installed and the report says what would have been -censored. The report is the same one `'censor'` produces for the same payload, asserted across every -position in the canary sweep, and it still carries no value. This is the measurement mode for the -weeks before a denylist is trusted: an operator reads the denied key names and matched shapes off -the reports, adds the `allowKeys` entries their application needs, and flips the switch with a -number in hand rather than a hope. It is not an operating mode, and the README says so in the first -sentence. - -It governs stage 2 only. A `SensitiveString` and a `serialize()` result are replaced under either -setting, because that stage touches nothing the application did not classify itself, and a wrapped -value renders its marker under every vendor's serializer regardless. Under `'observe'` the failure -path hands the vendor the payload it was given rather than a stub, since a mode whose promise is to -change nothing cannot replace an event; the report still says `failed`, which is where the operator -learns that the walk broke on a shape before `'censor'` would have broken it in the open. A misspelt -mode censors, so the option fails closed. `mergeScrubPolicy` carries it with the caller winning over -the vendor package, like the other single-valued fields, and every vendor package accepts it through -its existing policy argument. diff --git a/.changeset/scrub-value-patterns.md b/.changeset/scrub-value-patterns.md deleted file mode 100644 index c1e5136..0000000 --- a/.changeset/scrub-value-patterns.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@platform-sec/sensitive-string-scrub': minor ---- - -Opt-in value shape recognizers, and two exports the vendor packages needed. - -`denyValues` takes pattern names or your own `RegExp` and is empty by default, which is the whole of -this layer's answer to whether a denylist should ever match on value shape. Everything else here -matches on key names and paths, because a scrubber that decides by looking at a string is a -classifier and is wrong some of the time, and the thing a user does about an error tracker that hides -values they needed is turn the scrubbing off entirely. A bearer token forwarded into a field nobody -named `authorization` is invisible to a key list, though, so the shapes ship: `jwt`, `bearer`, -`basicAuth`, `pemPrivateKey`, `awsAccessKeyId`, `githubToken`, `slackToken` and `openaiKey`. - -Three rules every shipped pattern follows, each with a test over every pattern rather than an -argument. Anchored at both ends, including against a trailing newline, since JavaScript's `$` also -matches before a final line terminator. Linear time, checked against a pathological subject. And a -prefix a vendor issues rather than a shape that merely looks random. A caller's own `RegExp` is taken -as written with `g` and `y` stripped, because a sticky pattern carries `lastIndex` between payloads -and would match on one event and not the next. - -`ScrubReport` gains `deniedValues`, which names the shapes that matched and never the values or the -paths they matched at: a path is a key name your application chose and is safe on its own, but a path -paired with a shape name is a map of where to look. - -Also exported: `mergeReports`, which folds a second pass's findings into the report a seam will -publish while leaving the marker count alone, and `reportScrub`, so a vendor whose callback contract -is mutate-in-place runs its own guard and still swallows a throwing `onScrub` the same way. diff --git a/.changeset/secrets-transmission-references.md b/.changeset/secrets-transmission-references.md deleted file mode 100644 index c1b5b1d..0000000 --- a/.changeset/secrets-transmission-references.md +++ /dev/null @@ -1,97 +0,0 @@ ---- -'@platform-sec/sensitive-string-core': minor -'@platform-sec/sensitive-string-secrets': minor ---- - -Add transmission references: send a pointer to a secret instead of the secret. - -ROADMAP 6.5, release one of two. `serialize()` puts the raw value on the wire and asks the transport -for confidentiality, which is a fair deal for mTLS between two services in one trust domain and a -poor one everywhere the payload comes to rest. A job in SQS, Kafka, a `jobs` table or a webhook -retry buffer sits there in plaintext for as long as the retention allows, and a transport that -terminates at a mesh sidecar, an API gateway or a logging proxy is decrypted somewhere in the -middle. Where the value came from a store both sides can reach, `toReference(record, { store })` -sends where it is and the receiver fetches it with its own credentials, so the value never leaves -the process that read it. - -There is no `token.toReference()`, and the reason is not ergonomic. `#derived` unions the state of -every source instance into the derivative, which is right for constraints and inverts what -provenance means: a `toReference()` on the instance would let `token.substring(0, 4)` mint a pointer -to the whole credential, and the receiver would have no way to tell the difference. The label rule -is the second reason, since it strips everything identifying the account, project, tenancy or host, -so an instance knows a path and not which of an organization's stores that path is in, and the same -logical path usually exists in staging and in production. Minting takes the `ref` a read returned, -which contains no secret material by construction, and `store` is required rather than derived from -the source, because inventing one from the endpoint would put an internal hostname into a payload. - -Minting is not a reveal. It performs no I/O and never touches `#value`, and recording it as one -would make a sink that counts reveals to decide whether a credential is over-used count every -message that merely mentions the secret. That is the call `SecretLoadEvent` already made for loads, -for the same reason. - -Resolving is the half with the security content, and it is not `get()` under another name. -`createReferenceResolver({ store, source, allow }).resolve(ref, { context })` runs in the receiver, -with the receiver's credentials, against a path a sender chose, which is the shape of every confused -deputy: unconstrained, it lets anything that can enqueue a job read any secret the receiver's role -can read, using the receiver as the oracle. So the allowlist is required, there is no permissive -default, and the empty list is legal and resolves nothing. The checks run in order and each precedes -the fetch: the caller's own context, then the shape and the format version, the store identity -compared exactly, a path carrying a traversal, an empty segment, a leading slash or a control -character, a version outside `[A-Za-z0-9._:-]{1,128}`, an entry covering the path, and an entry that -resolves this kind of reference and declares this field. Then the class, as the strictest among the -covering entries and then the more restrictive of that and the reference's: a class on the wire may tighten what the receiver does and may never loosen it, because -a receiver that took `klass` from a payload would let a sender turn off constant-time comparison for -a credential, lift the refusal on partial queries, and let `mask()` disclose a prefix. - -A path is refused rather than normalized. Normalizing would mean this library and the provider each -holding an opinion about what `kv/a/../b` means, and a disagreement between two normalizers is how a -path that fails the allowlist reaches the store anyway. It is also refused for carrying `%`, `\`, -`?` or `#`, which is a stronger rule than "no `..` segment" and a measured one rather than a -cautious one: a provider builds its request as `new URL(base + path)`, which is what -`sensitive-vault` does, and on Node 22.15.0 the WHATWG parser applies its double-dot rule to the -percent-decoded segment and reads a backslash as a separator, so both -`kv/payments/%2e%2e/%2e%2e/auth/token/lookup-self` and `kv/payments/..\..\auth/token/lookup-self` -leave an allowlisted subtree while passing a check over `/`-split segments, and the second carries -no percent sign at all. A `?` turns a read of a permitted secret into a different request against -the same store. The suite pins that parser behaviour next to the refusals, so the rule keeps its -reason attached. A version is checked against a charset rather than escaped, for the same reason. -Patterns are a trailing `*` on a segment boundary and nothing else, with no regex, and a malformed -one throws at construction rather than silently matching nothing. - -Where entries overlap, the class is the strictest among them rather than the first one's. A subtree -rule and a rule for one path under it are how an operator writes an allowlist, and taking the first -would make `[{ path: 'kv/*', klass: 'sensitive' }, { path: 'kv/payments/stripe', klass: -'credential' }]` resolve the Stripe key as `sensitive`, which is a security control whose answer -depends on the order two lines were written in. The allowlist is copied entry by entry and frozen at -construction for the same kind of reason, so a caller holding the array or an entry cannot widen a -live resolver afterwards. - -A refusal is a `ReferenceRefusedError` carrying `reason`, and the path is on `error.path` and never -in `error.message`: a refusal is the case where the sender is least likely to be one this receiver -trusts, and interpolating what they sent would put attacker-chosen text into the receiver's own -logs. As everywhere in this package, no error sets `cause`. - -`core` gains the format and nothing else: `SensitiveValueReference`, `isSensitiveValueReference()`, -`carriesReferenceMarker()` and `referenceFormatVersion()`. It has no minting and no resolving, -because both need the store, the version and the credentials, and it would have had to model stores -and accounts to hold them. What the format is doing in `core` is that the walks which must decide -what to do with a wire object are there, and what they decide for this one is to leave it alone: a -reference carries no secret material, resolving it needs credentials of the reader's own, and -redacting it would make a reference-passing system undebuggable for exactly no gain. Those -assertions are positive in the suite rather than absent from it, so a later change to redact -everything with a `__sensitive`-shaped marker fails a test instead of shipping. - -Two smaller decisions worth recording. `resolve()` requires its own `RevealContext`, for the reason -`PutOptions.context` does on the write path, and checks for it before it looks at the reference at -all, because a receiver that forgot its context has a mistake of its own and reporting that as a -fault in what arrived would point whoever reads the log at the wrong party. That context is -installed as a child of the ambient one, so an application's `auditReveal` is inherited rather than -dropped and a provider that reveals its own token to authenticate the request records that reveal -under the caller's name. And `pinVersion` -defaults to true, since a reference stands in for a value and a value does not change between the -send and the receive, with the cost stated where the option is: a pinned reference outlives the -rotation that was meant to retire the old version, and queue retention shorter than the rotation -interval is the honest fix. The `'unknown'` a provider reports when it has no version is a sentinel -rather than a version, and is never pinned. - -The encrypted envelope, the other half of 6.5, is a separate release and is not in this one. diff --git a/.changeset/sentry-own-marker-tag.md b/.changeset/sentry-own-marker-tag.md deleted file mode 100644 index 6161fbb..0000000 --- a/.changeset/sentry-own-marker-tag.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@platform-sec/sensitive-string-sentry': patch ---- - -Stop tagging events with marker sightings this package wrote itself. - -`sensitive.marker_seen` is meant to be evidence that a *different* integration sent a redaction -placeholder where a value belonged, which is worth an operator's attention. Every breadcrumb on an -event has already been through this package's `beforeBreadcrumb`, where structural redaction wrote a -marker wherever a wrapped value stood, and the scope event processor was counting those: measured, -two breadcrumbs each holding one wrapped value produced `sensitive.marker_seen: 2` on an event with -no marker of its own. An operator following the tag would have investigated an incident that did not -happen. - -The breadcrumbs are now walked separately with marker counting off, and their classes and denied keys -are folded back into the event's report. The pass itself is kept, because it is what catches a -breadcrumb an event processor registered before this one added straight to `event.breadcrumbs`. - -Also adds a transport-level case to the suite: one canary sweep driven through the SDK's own HTTP -transport to a server on loopback, asserting on the serialized envelope bytes rather than on the -envelope object a capturing transport receives. diff --git a/.changeset/typeorm-integration.md b/.changeset/typeorm-integration.md deleted file mode 100644 index 868260b..0000000 --- a/.changeset/typeorm-integration.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -'@platform-sec/sensitive-string-typeorm': minor ---- - -Add the TypeORM integration: classified columns read back wrapped, writes unwrap through an audited -reveal, and the query builder path where TypeORM never calls the transformer is refused rather than -left to the driver. - -ROADMAP 3.3, which did not list TypeORM as a target. Reads and writes are one `ValueTransformer`, -built through `defineSchema()` so the class of a column is named once next to the entity rather than -repeated at each `@Column` and drifting from it. That hook is the right one, it is exactly what the -ORM advertises for intercepting column values, and it is sufficient for neither of the section's two -security constraints on its own. - -**TypeORM does not call the transformer for a query builder's named parameters.** Measured on 1.1.1: -`qb.where('u.email = :e', { e: value })` is a SQL fragment and a name, with no `ColumnMetadata` -connecting the two, so the parameter map is bound straight to the driver. What happens next belongs -to the driver and the three common ones disagree. `better-sqlite3` throws. `pg` 8.23.0 binds -`"[REDACTED:sensitive]"`, quotes included, because `prepareValue` falls through to `JSON.stringify` -for an object with no `toPostgres` and `toJSON()` returns the marker. `mysql2` 3.24.3 stringifies it -to the same marker, and with `SET ?` produces syntactically valid SQL assigning the instance's two -own enumerable properties, `klass` and `label`, to columns that do not exist. Only SQLite fails -loudly; on the two drivers TypeORM applications overwhelmingly run on, the query executes and matches -nothing, and the same fragment in an `UPDATE ... SET` stores the marker. This is the ORM's most-used -query API. `guardParameters()` refuses an instance or a marker at the query runner, which is the only -seam that sees the parameter map between the query builder and the driver, and it names the -find-options form as the fix because that one does run the transformer. - -The guard checks both parameter shapes TypeORM passes. The positional array is the usual one; the -named map is the other, because `ds.query(sql, { e: value })` hands the runner that object unchanged -and `better-sqlite3` answers a named-placeholder statement with `Your driver does not support named -placeholders`, so an array-only check looks correct on the driver most suites run against. The -logger redacts both shapes for the same reason: TypeORM's own `Logger` types the argument as an -array and a named map arrives as an object. - -The wrapper has to be idempotent per runner, and that is not tidiness. The SQLite family's driver -caches a single query runner and returns it from every `createQueryRunner()` call, while `pg` builds -a fresh one each time, so a wrapper without a marker would layer itself once per call and no test -written against `pg` would ever have shown it. The uninstall flips a flag those wrappers share -rather than restoring each runner, because a guard that remembered runners in order to restore them -would keep every query runner the process ever made alive, which on `pg` is one per query. - -Three more surfaces carry plaintext beside the column and none is reachable from a transformer. -`logging: ['query']`, which appears in most getting-started templates, prints every statement with -its bound parameters, and the transformer has already run by then, so what is printed is the revealed -value; `sensitiveLogger()` rewrites the parameters of any statement mentioning a classified column. -`QueryFailedError` carries `parameters` as an own enumerable array on every failing statement on -every driver, which is exactly what an error tracker serialises; -`sanitizeQueryFailedError()` removes it through property descriptors so a throwing getter is never -invoked, in place where the property allows it and by a prototype-preserving copy where it does not, -and `typeormScrubFragment` is the `sensitive-scrub` seam for a payload that already reached a sink. -And `cache: true` writes into a second table with none of the first table's grants: measured, it -stores the driver's rows before the read transformer runs **and** the statement with its bound -parameters as part of the cache key, so a cached lookup by a classified column leaks the predicate -even when it selects nothing classified. `schema.check()` refuses it unless -`allowCachedPlaintext: true` says otherwise in writing. - -Two measurements shape the README rather than the code. `save()` on an entity with N classified -columns of which k changed costs `2N + k` reveals, because `ColumnMetadata.getEntityValue` applies -the transformer unguarded and `SubjectChangedColumnsComputer` applies it again to answer "did this -change" and then discards the answer; at k = 0 that is 2N reveals and no `UPDATE` statement at all. -So `insert()` and `update()` are recommended over `save()` with a number behind it, and it is an -audit-volume recommendation before it is a performance one. And `operation` in the audit tags is -always `'unknown'`, permanently: a `ValueTransformer` receives one argument and is not told whether -the value is being inserted, updated, compared for change detection or used as a predicate, and the -only way to recover that is to read the stack, which is not something a security library does in a -hot path. - -Both directions of the transformer guard `value == null` rather than `value === null`, which is one -character and two ordinary paths: `IsNull()` on a nullable classified column routes through -`transformValue` with no operand, and `upsert` passes `undefined` for a column the row object omits. -A transformer that guards only `null` throws a `TypeError` from inside the ORM on both, and a test -asserts that so the correction cannot silently regress. - -The package imports no `typeorm` and no driver at runtime. Every ORM type it reads is restated -structurally, which keeps it correct in a process holding two copies of the ORM and is what lets the -guard be right about a driver nobody has measured. - -`schema.check()` catches configuration drift in four directions, and the fourth is the one worth -naming: an exact `fields` entry that no column on the `DataSource` matches. That is what a renamed -table or a mistyped column name produces, and it is the failure that looks most like success, -because the entry classifies nothing, no column is wrapped, every read returns a plain string where -the property type promises an instance, and every other check passes. `allowUnmatchedFields` is the -bypass for a specification deliberately shared across `DataSource`s. A `rules` entry cannot be -checked this way, since a pattern has no list of names behind it, and the README says so. The other -three are a classified column with no transformer, a transformer the specification no longer -classifies, and a transformer built for a different path than the column it sits on, which works and -audits every reveal under the wrong column. - -The DDL for a classified column is byte-identical to the DDL without it, measured, so adoption is a -one-line diff per column with no migration. - -Named as uncovered in the README rather than implied: `ds.query()` and `getRawMany()` return -plaintext, with `hydrateRaw()` as the audited bypass; `Raw()` builds SQL and binds nothing; -migrations and seed scripts bypass everything here; `softDelete()` leaves the plaintext row; there is -no read and write subpath split, because a `ValueTransformer` is one object holding both directions; -and there is no compile-time guarantee, because a TypeORM property is typed by the author's own -annotation and no transformer can make the compiler check it. The Mongo driver is not supported and -not measured. See ADR-004. diff --git a/.changeset/zod-4-support.md b/.changeset/zod-4-support.md deleted file mode 100644 index c2efe0c..0000000 --- a/.changeset/zod-4-support.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@platform-sec/sensitive-string-zod': minor ---- - -Move to Zod 4. The peer range is now `zod@^4.0.0`; Zod 3 is not supported, because a v3 schema and a -v4 schema cannot be composed in either direction and the failure is a raw `TypeError` at schema -construction rather than a type error. - -Three security-relevant changes come with the port: - -- `zSensitiveDefault` holds its fallback in a closure instead of a `z.string().default(...)`. - `z.toJSONSchema(schema, { io: 'input' })` reads a schema definition's `default` and emits it - verbatim, calling a function default to do so, which put the live value of a default such as - `() => process.env.API_KEY` into any generated OpenAPI document. Every documented behaviour of the - helper is unchanged. -- `error-map.ts` is re-derived from Zod 4's issue shapes rather than translated. The enum and literal - branches are gone, because v4 closed those leaks upstream. `redactZodError` now recurses into the - issue lists nested under `invalid_key` and `invalid_element` as well as `invalid_union`, redacts - `invalid_element.key`, and strips `input` from every issue. -- The three parse helpers use v4's `error` parse option in place of `errorMap`, which v4 accepts and - silently ignores, and force `reportInput` off. - -The suite now enumerates every issue code Zod 4 can emit, ties that table to `z.ZodIssueCode` so a -code added upstream fails the build rather than going unredacted, and asserts that a schema this -package builds emits a canary-free JSON Schema on the input side and refuses the output side. - -Two API changes, both accepting strictly more than before: `zSensitiveWithValidation` and the three -`*Preprocess` helpers take any string-to-string schema rather than a `z.ZodString`, so Zod 4's format -constructors (`z.email()`, `z.uuid()`) work without falling back to the deprecated -`z.string().email()` spelling. `SensitiveParseOptions` is exported for callers who name the parse -options type. diff --git a/packages/sensitive-aws-secrets/CHANGELOG.md b/packages/sensitive-aws-secrets/CHANGELOG.md index bb92ab9..68a1397 100644 --- a/packages/sensitive-aws-secrets/CHANGELOG.md +++ b/packages/sensitive-aws-secrets/CHANGELOG.md @@ -1,5 +1,35 @@ # @platform-sec/sensitive-string-aws-secrets +## 0.1.1 + +### Patch Changes + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [59ace7a] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + - @platform-sec/sensitive-string-secrets@0.2.0 + ## 0.1.0 ### Minor Changes diff --git a/packages/sensitive-aws-secrets/package.json b/packages/sensitive-aws-secrets/package.json index 8d5db64..c381f50 100644 --- a/packages/sensitive-aws-secrets/package.json +++ b/packages/sensitive-aws-secrets/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-aws-secrets", - "version": "0.1.0", + "version": "0.1.1", "description": "AWS Secrets Manager secret source - staging labels, resolved version ids, every value wrapped as a SensitiveString", "main": "./dist/index.js", "module": "./dist/index.js", @@ -32,8 +32,8 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0", - "@platform-sec/sensitive-string-secrets": "^0.1.0", + "@platform-sec/sensitive-string-core": "^0.6.0", + "@platform-sec/sensitive-string-secrets": "^0.2.0", "@aws-sdk/client-secrets-manager": "^3.0.0" }, "devDependencies": { diff --git a/packages/sensitive-axios/CHANGELOG.md b/packages/sensitive-axios/CHANGELOG.md index acbda81..e422b57 100644 --- a/packages/sensitive-axios/CHANGELOG.md +++ b/packages/sensitive-axios/CHANGELOG.md @@ -1,5 +1,173 @@ # @platform-sec/sensitive-string-axios +## 0.2.0 + +### Minor Changes + +- 59d6a4b: `headers` on the wrapper's options: a typed place for an instance-level credential. + + The got and axios wrappers read their client's own default headers, so a wrapped value or an + `apiKey()` descriptor placed in `got.extend({ headers })` or on `defaults.headers.common` is + revealed, recorded and stripped like any other. That is the run-time story. The compile-time one is + that none of it type-checks: measured on got 16.0.0, axios 1.20.0 and ky 2.1.0, each client types an + instance's default headers as strings, both a `SensitiveString` and a descriptor are rejected, and + the types are aliases nothing can widen from outside. A TypeScript caller had a cast as the only way + in, which is what this repository's own got suite was doing. + + `SensitiveInstanceOptions` extends `SensitiveClientOptions` with `readonly headers?: +SensitiveHeaders`, and the three wrappers built around a client instance take it: + `SensitiveGotOptions`, `SensitiveAxiosOptions` and `SensitiveKyOptions` are now aliases of it. The + headers sit over the client's own defaults, where the wrapper can read them, and under each call's + headers, and go through the same pipeline: revealed once per attempt, in the scrub plan, scrubbed on + both settlement paths, and under `allowedHosts: 'strip'` dropped at the origin boundary. The merge + happens before the dropped-name accounting, so a name stripped at one hop is not refilled from the + option on the next; the axios and ky suites assert that across a bounce through a second origin and + back. A credential there flips the redirect default to refusing, the same as one in the call, and + each suite asserts that too. + + For ky this is the only instance-level layer there is. ky does not expose an extended instance's + defaults, so a wrapped value in `ky.extend({ headers })` was never seen by the wrapper and the README + said to put it in the call instead; it now says to put it here. + + `fetch` and undici are unchanged. Neither has an instance to wrap, so their per-call bag is the whole + surface and already takes a wrapped header. + + Nothing changes for a caller who does not set it. The got and axios wrappers still read their + client's defaults at run time, so a JavaScript caller keeps what they had. + +- aadfec7: Tag the proxy a request goes through, so a host-bound credential refuses one its binding does not + name. + + A proxy setting changes the machine that receives the bytes and leaves the URL untouched, so the + host check alone passed it; the corpus recorded that shape against axios, and noted that the only + place this row mentioned a proxy was an instruction for turning off the check that incidentally + covered it. `OutgoingRequest` gains `proxy`, a URL or a `host[:port]`, and the attempt context tags + it as `tags.proxy`, which core's destination binding reads as a second machine the bytes reach. + + `sensitive-axios` resolves the effective proxy the way axios itself does: `config.proxy`, an agent + on `httpAgent` or `httpsAgent` that exposes its `proxy` as `https-proxy-agent` does, or the + `_proxy` and `all_proxy` environment under `no_proxy`, with `proxy-from-env` 2.1.0's rules + restated rather than imported, since that package is axios's dependency and not this one's. An + opaque agent reads as no proxy and the README says so. got, ky, undici and fetch honour no proxy + option of their own and pass none. + +- 256911b: `allowedHosts: 'strip'`: follow a redirect with the credential removed instead of refusing it. + + There were two answers to a credential-bearing request that gets redirected, and for one real shape + both are wrong. Refusing breaks a provider that redirects a release asset or a download to object + storage, which is the ordinary way private artifact hosting works. Naming the hosts is impossible for + exactly the same reason: the caller cannot enumerate a bucket endpoint the provider picks at request + time. `'strip'` is the third answer, and it is the one the upstream project behind + [GHSA-p2f4-r6v6-j797](https://github.com/advisories/GHSA-p2f4-r6v6-j797) reached for when it fixed + the same problem: drop the credential at the origin boundary and keep going. + + The difference is where the list of headers to drop comes from. That project had to guess which names + carry credentials, shipped nine of them, and added a registration function so publishers could add + their own. Here the list is derived: the scrub plan already records exactly which header names carried + a wrapped value, because the caller decided that when they wrapped it. A header the library has never + heard of is covered on the same terms as `Authorization`, and there is nothing to register. + + `'strip'` is never a default and cannot become one. Refusing is the answer that cannot go wrong + quietly: a request that stops is a bug report, while a credential dropped on a hop the caller did not + know was a boundary is a request that succeeds at doing less than it appears to. Same-origin hops keep + everything, since a redirect inside one origin discloses nothing to anyone new. + + `isCrossOrigin` decides the boundary by the scheme/host/port rule, normalizing implied ports. It makes + one exception, `http:` to `https:` on the same host where neither side names a non-default port, which + is an upgrade to the same server that every client in the measured set follows without stripping. The + reverse is never allowed, and the allowance does not extend to a non-default port, since a different + port is a different service as often as it is the same one. + + All five wrappers implement it and each is tested end to end against a real pair of loopback origins, + asserting both that the canary does not reach the second origin and that the request still completes. + got is the one that differs: it follows hops itself but hands the next request's options over through + `beforeRedirect`, so there it is a deletion rather than a rebuilt request. + + What every wrapper accumulates is the set of dropped header _names_, applied where the next request is + built, rather than a narrowed header object carried forward. The two are not equivalent and the + difference is a real leak: axios merges `defaults.headers` underneath the request's own, and the axios + wrapper deliberately supports a wrapped value there, so narrowing only the per-request headers would + strip the call site's credential and forward the instance's. Dropped names are also set to `null` in + the outgoing axios config, because removing a name from what is sent is not enough on its own when the + client refills it from its defaults. + + got tracks the previous hop rather than comparing every hop against the request's original URL. + Same-origin is not transitive once the http-to-https upgrade is allowed: A `http://x` to B `https://x` + is an upgrade, B to C `http://x` is a downgrade, and C is same-origin with A, so comparing against A + would forward a credential into plaintext. Distinguishing the two needs TLS and is not covered by a + test; the other four wrappers rebuild each hop and already hold the previous URL. + + New exports from the http package: `isCrossOrigin`, `headersWithout`, and the `RedirectPolicy` and + `ResolvedRedirectPolicy` types. `allowedHosts` accepts `'strip'` alongside the array and `false`. + Nothing changes for a caller who does not ask for it. + +### Patch Changes + +- e95bf0b: A redirect hop no longer carries the caller's query, and a cross-origin 307 or 308 that would + re-send a body carrying a wrapped value is refused under `allowedHosts: 'strip'`. + + Both were found by an independent verification of the corpus study that produced `'strip'`, by + measurement rather than by reading. With a wrapped credential passed as a query parameter and a 302 + whose `Location` carried no query, the axios, fetch, ky and undici wrappers sent the second origin a + URL containing the credential: each hop handed the caller's query to `prepareRequest` again, which + appended it to whatever URL it was given. got did not, because it follows hops itself. The corpus + had recorded exactly this gap against GHSA-p2f4-r6v6-j797 and then withdrawn it, on the reasoning + that a redirect target is the URL the server names; that reasoning was correct only for the delegated + mode, where the client follows and nothing is re-merged. + + `prepareRequest` now drops the query on any hop after the first, so no wrapper has to remember. A + redirect target is the URL the server named and nothing else, which is what every client in the + measured set does on its own. + + A 307 or 308 re-sends the body, and all five wrappers delivered a JSON body carrying a wrapped value + to the cross-origin target under `'strip'`. A header can be dropped at the origin boundary without + changing what the request means; a body cannot, since a body with one field removed is a different + request that succeeds at doing something else. So the hop is refused with + `RedirectWouldResendCredentialError`, before anything is revealed for it, and the message says to + name the destination in `allowedHosts` if it is trusted with the body. A 301, 302 or 303 drops the + body on its own and is followed as before. `PreparedRequest` gains `bodyCarriesCredential`, which + covers a form body as well as a structured one, and `assertBodyMayFollow` is exported for a wrapper + that follows hops itself. + + The got package now merges the instance's `defaults.options.headers` under the call's headers, as the + axios package already did for `defaults.headers`. Measured, a wrapped value placed in + `got.extend({ headers })` reached the wire as `[REDACTED:credential]` and an `apiKey()` descriptor as + `[object Object]`, on every hop, because the wrapper never saw either; they are now revealed, + recorded in the plan, scrubbed, and under `'strip'` dropped at the boundary. ky does not expose an + extended instance's defaults, so the ky README says to put a wrapped value in the call instead. + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [635b4c2] +- Updated dependencies [59d6a4b] +- Updated dependencies [612ccbd] +- Updated dependencies [aadfec7] +- Updated dependencies [e95bf0b] +- Updated dependencies [256911b] +- Updated dependencies [59ace7a] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + - @platform-sec/sensitive-string-http@0.2.0 + ## 0.1.0 ### Minor Changes diff --git a/packages/sensitive-axios/package.json b/packages/sensitive-axios/package.json index 3ec9b51..0cc51e8 100644 --- a/packages/sensitive-axios/package.json +++ b/packages/sensitive-axios/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-axios", - "version": "0.1.0", + "version": "0.2.0", "description": "axios integration for SensitiveString - one reveal point at the wire, the marker guard, and the scrub that keeps a revealed credential out of the error, the response and toJSON()", "main": "./dist/index.js", "module": "./dist/index.js", @@ -32,8 +32,8 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0", - "@platform-sec/sensitive-string-http": "^0.1.0", + "@platform-sec/sensitive-string-core": "^0.6.0", + "@platform-sec/sensitive-string-http": "^0.2.0", "axios": "^1.7.0" }, "devDependencies": { diff --git a/packages/sensitive-azure-keyvault/CHANGELOG.md b/packages/sensitive-azure-keyvault/CHANGELOG.md index 322d8c4..9074ce9 100644 --- a/packages/sensitive-azure-keyvault/CHANGELOG.md +++ b/packages/sensitive-azure-keyvault/CHANGELOG.md @@ -1,5 +1,35 @@ # @platform-sec/sensitive-string-azure-keyvault +## 0.1.1 + +### Patch Changes + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [59ace7a] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + - @platform-sec/sensitive-string-secrets@0.2.0 + ## 0.1.0 ### Minor Changes diff --git a/packages/sensitive-azure-keyvault/package.json b/packages/sensitive-azure-keyvault/package.json index 958f681..e595b68 100644 --- a/packages/sensitive-azure-keyvault/package.json +++ b/packages/sensitive-azure-keyvault/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-azure-keyvault", - "version": "0.1.0", + "version": "0.1.1", "description": "Azure Key Vault secret source - expiresOn bound as a deadline, notBefore refused, every value wrapped as a SensitiveString", "main": "./dist/index.js", "module": "./dist/index.js", @@ -32,8 +32,8 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0", - "@platform-sec/sensitive-string-secrets": "^0.1.0", + "@platform-sec/sensitive-string-core": "^0.6.0", + "@platform-sec/sensitive-string-secrets": "^0.2.0", "@azure/keyvault-secrets": "^4.8.0" }, "devDependencies": { diff --git a/packages/sensitive-bugsnag/CHANGELOG.md b/packages/sensitive-bugsnag/CHANGELOG.md new file mode 100644 index 0000000..6e71dbb --- /dev/null +++ b/packages/sensitive-bugsnag/CHANGELOG.md @@ -0,0 +1,58 @@ +# @platform-sec/sensitive-string-bugsnag + +## 0.2.0 + +### Minor Changes + +- 38387f6: A Bugsnag integration, over the shared `@platform-sec/sensitive-string-scrub` layer. Everything + below was measured on Node 22.15.0 against `@bugsnag/js` 8.10.0, driving the real SDK to a server + on loopback and asserting on the bytes that left the process, with a companion case requiring each + position to leak once the scrubbing is removed. + + `bugsnag` composes `onError`, `onBreadcrumb` and `onSession`, and separately unions the resolved + denylist into `redactedKeys`. Both, because neither covers what the other does: `redactedKeys` + defaults to the single key `password` and applies under exactly four payload paths, so `context`, + `user`, `featureFlags`, `app` and the exception itself are the callbacks' work alone. Bugsnag's + callback contract is to mutate the event it hands over and return a boolean, so the package projects + the event's regions into a snapshot, scrubs that, and writes back through the SDK's own setters. + Every write is an assignment, never a write through something the caller still holds: measured, + `event.originalError` is the caller's own error and a `Breadcrumb` stores the metadata object + `leaveBreadcrumb` was given by reference. + + A callback you supply runs _before_ the scrubber, which is the opposite of the Sentry package. + Bugsnag has no second seam, so holding the last position it allows is the only way a callback of + yours cannot add raw data behind the scrubber's back. + + The seam inventory is a type-level exhaustiveness check against the SDK's own config keys, so a + fourth `on*` option in a future release fails `pnpm typecheck` with its name in the failure rather + than opening quietly. + +### Patch Changes + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [59ace7a] +- Updated dependencies [f67efd7] +- Updated dependencies [48f6213] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + - @platform-sec/sensitive-string-scrub@0.2.0 diff --git a/packages/sensitive-bugsnag/package.json b/packages/sensitive-bugsnag/package.json index d070f08..b2f3d73 100644 --- a/packages/sensitive-bugsnag/package.json +++ b/packages/sensitive-bugsnag/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-bugsnag", - "version": "0.1.0", + "version": "0.2.0", "description": "Bugsnag integration for SensitiveString - composes every callback and unions the redactedKeys list", "main": "./dist/index.js", "module": "./dist/index.js", @@ -33,8 +33,8 @@ }, "peerDependencies": { "@bugsnag/js": "^8.0.0", - "@platform-sec/sensitive-string-core": "^0.5.0", - "@platform-sec/sensitive-string-scrub": "^0.1.0" + "@platform-sec/sensitive-string-core": "^0.6.0", + "@platform-sec/sensitive-string-scrub": "^0.2.0" }, "devDependencies": { "@bugsnag/js": "^8.10.0", diff --git a/packages/sensitive-core/CHANGELOG.md b/packages/sensitive-core/CHANGELOG.md index ff4eb78..d68f6a0 100644 --- a/packages/sensitive-core/CHANGELOG.md +++ b/packages/sensitive-core/CHANGELOG.md @@ -1,5 +1,1433 @@ # @platform-sec/sensitive-string-core +## 0.6.0 + +### Minor Changes + +- 3e054e2: Hand `auditReveal` the context the operation ran under. + + The hook was called with the value and the operation and nothing else, so a sink could not tell which + unit of work a reveal belonged to. That is not a gap anyone works around cheaply: `sensitive-prisma` + tags each unwrap with the model, the operation and the argument path, and `sensitive-typeorm` tags + each column write with the table and the column, and both had to install their per-value context as + the ambient one around every single reveal so a sink could recover the tags from + `getCurrentRevealContext()` inside its own hook. One `AsyncLocalStorage` entry per sensitive value, + paid to move three strings. + + `auditReveal(value, details, context)` now carries it. The context is the one passed to the call, or + the ambient one when the call passed none, which is the same context the policy's + `checkRevealAllowed` was asked to authorize, so an audit record and an authorization decision are + comparable after the fact. It is not always the context the hook was installed on: a reveal under a + `createChildContext()` is audited under the child, which is exactly what makes an integration's tags + reachable. Both packages now reveal plainly, and the entry went with the workaround. + + The argument is typed as `AuditContext`: `name`, `reason` and `tags`, and no `auditReveal`, since the + sink reading it is that hook. The narrowing is in the type and not in a copy, so a sink may compare + the context by identity and an audited reveal allocates nothing; `tags` is the caller's own object, + shared by every reveal under the context, so a sink copies out what it means to keep rather than + writing to it. + + A hook written with two parameters is unaffected, in types and at runtime. Code that calls a + context's `auditReveal` itself must pass the third argument; the only such caller in this repository + was the worker handoff's hook forwarder, which now passes the receiving context through so a + forwarded sink sees the work the sender named. + + In `sensitive-string-testing`, `AuditCall` gains `context` and `AuditEntry.contextName` is read from + what core reports rather than from the log's own name, so an entry made under a nested child is + recorded under the child. The recorded context is a one-level copy: a record pointing at the live + mock context would close a cycle through `calls` that `JSON.stringify` could not walk. Both helpers + degrade rather than throw when a caller forwards the hook with two arguments, recording the mock's + own name; they run inside the reveal gate, where a `TypeError` would surface as a failing reveal in + somebody's suite rather than as a missing tag. + +- 49ad21c: Refuse BSON serialization instead of silently corrupting the document. + + `SensitiveString` now has a `toBSON()` that throws `SensitiveBsonError`. This is a behavior change + for any application that persists an unrevealed instance to MongoDB today: that write starts + throwing. Throwing is the point, and what it replaces is worse. + + Measured against `bson` 7.3.2 on Node 22.15.0. BSON honours `toBSON()` on a value and ignores + `toJSON()`, and the class had neither, so the encoder fell back to the instance's own enumerable + properties. Those are `klass` and `label`. A write of a classified token stored + `{ klass: 'credential', label: 'token' }` and dropped the value, with nothing thrown and nothing + logged, and the column looked populated. The same husk is produced for every value of a given class + and label, so a filter built from one instance matched a document written from another, which is an + authentication bypass rather than a storage bug. The refusal covers the top level of a document, a + subdocument and an array, and therefore every write path, every filter path, both bulk paths and the + raw driver, since all of them serialise through the same encoder. + + `toJSON()` still redacts and still does not throw, because logging must not throw and a redacted log + line is useful. BSON is a persistence and matching format, where an unrevealed value has no useful + degraded outcome. Nothing but a BSON encoder calls `toBSON()`, so no other path pays for it. + + Reveal at the write boundary, where the access is audited: + + ```ts + await users.updateOne({ _id }, { $set: { apiKey: token.reveal({ name: 'rotate-key' }) } }); + ``` + + Two alternatives were rejected. Returning the redaction marker keeps the corruption and makes every + classified path match the same constant, which is the same bypass with a different value in it. + Revealing under the ambient context makes writes work everywhere and quietly ships secrets into + filters, aggregation stages and every log line built from a command. + +- b362710: Add destination binding: a value can say which sinks and hosts it may be revealed toward, and the + raw-value gate checks the reveal context against it. + + ```ts + const registryPassword = new SensitiveString(pw, 'credential', 'registry', { + destinations: { hosts: ['registry-1.docker.io'] }, + }); + + registryPassword.reveal(); // DestinationRestrictedError + registryPassword.reveal({ name: 'push', tags: { host: 'registry-1.docker.io.attacker.net' } }); + // DestinationRestrictedError + registryPassword.reveal({ name: 'push', tags: { sink: 'http', host: 'registry-1.docker.io' } }); + // the password + ``` + + Every other check in the gate answers a question about the value or about the process. None asked + where the plaintext was about to go, so a credential could be wrapped at entry, revealed at exactly + the call site the design says, audited, and still written to the wrong host. The adoption corpus + recorded that seven times, twice as the same substring host comparison from unrelated maintainers. + + The destination is the context's `tags.sink` and `tags.host`, which are the tags the integrations + already write on every reveal: the HTTP clients set both from the request URL per attempt, the data + layer sets the sink to its driver, and `sendToExternal` sets it to the sink it was given. So a bound + value works unchanged through `sensitive-axios`, `sensitive-fetch` and the rest, and refuses through + them when the URL the program computed points somewhere else. A context that states no destination + refuses, on the allowlist rule the environment restrictions follow; there is no denylist. + + Every operation that hands plaintext out is checked, `hash()`, `mask()`, `map()`, `serialize()` and + `toWorkerPayload()` included, so serializing a bound value is itself a reveal toward wherever the + payload goes. `equals()` is not checked: a comparison sends the value nowhere. Derived values carry + the binding, a value derived from two bound values carries both, and bindings cross the wire and the + worker handoff as data that can only tighten. `zSensitiveDeserialize()` declares the wire field so an + object schema does not strip it. + + A host entry is what `new URL(url).host` spells, compared case-insensitively, with the port when it + is not the scheme's default; `*.example.com` matches at least one label below and not the domain + itself, with a port as part of the suffix. A URL, a path, a userinfo, a bare `*`, a misplaced + wildcard, or any spelling the URL parser would change, a Unicode domain or a stated default port, + is refused at construction, because an entry that can never equal a host tag sits in the list + matching nothing. `sensitive-fetch`'s standalone `sensitiveHeaders` helper takes no URL and writes + no host, so a host-bound value refuses through it unless the caller's context names the host. + + Three additions on the same mechanism. `principals` is a third literal list, matched against + `tags.principal`, whatever identity the application tags its reveals with, so a credential fetched + for one caller and handed out of a shared cache to another refuses. A `proxy` tag is read the other + way round: absent means the request goes to its host directly, present means a second machine + receives the bytes first and has to match the same `hosts` list; `sensitive-http` tags it when a + client resolves one. And `releasedBy: Promise` holds every exposing read with + `UnreleasedSensitiveValueError` (code `unreleased-value`, `status` `'pending'` or `'refused'`) until + the promise resolves `true`, for good when it resolves anything else or rejects: a credential whose + recipient is trusted only after an asynchronous check, an SSH host key against a known-hosts store, + is constructed with the check in flight and cannot be sent until it says so. A plain boolean is + refused at construction, `equals()` is not held, and the promise never crosses the wire because an + unreleased value cannot be serialized and a released one has nothing to carry. + + `DestinationRestrictedError` extends `SensitiveStringError` with the code `destination-restricted` + and names the tag that refused, what the context stated, and the allowed list, none of which is any + part of the value. A process that binds nothing pays a length check on an empty frozen array. + +- aa609af: `deterministicRedaction()`: a redacted rendering that tells two values apart. + + ROADMAP 6.1. Three log lines saying `[REDACTED:sensitive]` may be one subject or three, and the + difference is usually the question being asked. Registering this policy replaces the constant with + `[REDACTED:sensitive:5f2a91c0b74e]`, a stable tag derived from the value, so the lines that share a + subject look like they do. It is opt-in through `registerPolicy` and changes nothing for anyone who + does not register it. + + It changes what redacted output means, which is why it is a decision with a threat model behind it + rather than a formatting option. Redacted output has until now carried no information at all; + under this policy it carries a keyed fingerprint. Anyone holding the key and a guess can confirm the + guess against a log line, and anyone holding neither still learns which lines concern the same + subject, which is the feature and is still disclosure. + + The tag is an HMAC over `deriveKeyedToken`, and there is no unkeyed mode. That primitive was built + for this feature and for the LLM package's entity handles, and its rules apply here unchanged: a + 16-byte key floor and a 12-hex-character tag floor. Both override the design sketch, which + defaulted to a plain SHA-256 with an optional HMAC and an eight-character tag. An email address + under an unkeyed digest is a wordlist lookup, and a redacted log is exactly where an offline attack + happens, so the factory refuses without a key instead of defaulting to the dangerous version. The + sketch's `deterministicRedactors` presets are gone with it, since all three were unkeyed. Eight + characters is 32 bits, where a one-in-a-thousand collision arrives at about 3,000 distinct values, + and a collision merges two subjects into one in a log, where it reads as a fact. + + `salt` became `scope`: a domain component mixed in ahead of the value on top of a mandatory key, + rather than the sketch's alternative to one. It keeps the use case the sketch wanted it for, + bounding correlation to a session or a tenant, and drops the reading that a salt is a substitute + for a key, which it is not, because it sits in the same row as the digest. + + `credential` is refused. A password or an API key is guessable by construction, so a stable tag + turns every log line into an oracle for confirming a guess against, for anyone who later obtains + the key. The sketch's own "When NOT to Use" section says so; this makes it the behaviour. The + refusal is hard-coded rather than read from an advisory flag, because every existing flag answers a + different question, and it is escapable through `createDeterministicRedactor`, the bare + `(value) => string` primitive, which is one line that shows up in review. + + `deterministicRedaction` starts from the class policy currently registered and replaces only + `redact`, so `requireConstantTimeEquals`, `allowMask`, `checkRevealAllowed`, the advisory flags and + every deadline rule survive a registration that would otherwise drop whichever one a hand-written + policy forgot to name. It also mixes the class into the derivation, the way `entityHandle` does, so + one string classified two ways gets two tags. + + One registration covers `toString()`, `toJSON()`, `inspect()`, `redactInStructure`, `redactCopy`, + `safeStringify`, a `mask()` that could not meet its floor, and a `serialize()` payload caught by a + redaction walk, which renders the same tag as the instance it came from. `expectRedacted` in + `sensitive-testing` needed no change, because it has always read the expected rendering off the + instance rather than assuming `[REDACTED:class]`; that claim is now covered by a test rather than + by a comment. + + That reach is worth naming twice over. It includes output bound for somewhere else: the + error tracking and APM integrations scrub through `redactCopy`, so an instance in a Sentry event, a + Bugsnag report, a Rollbar item or a Datadog span carries the tag. And the guards that refuse to send + a redaction onward learn a policy's marker by redacting two probes and keeping the answer when both + agree, which a value-dependent policy defeats by construction, so `SensitivityPolicy` gains an + optional `markerPattern` and this policy declares one. Without it a custom `prefix` would have + silently blinded `assertNoMarker` in the HTTP clients. The marker recognizers in `llm` and `scrub` + had a 64-character inner bound that the longest tag overran, and now allow 96. + + The key is not the only way to confirm a guess, which the design's threat model did not say. Anyone + who can get a value of their own choosing redacted into output they can read has an oracle: submit a + guess, read its tag, compare it with a tag from the log. HMAC-SHA256 is a PRF, so this does not + recover the key; it does reduce "an attacker who holds the key" to "an attacker who can reach the + service". + + Where redaction runs has two consequences, both deliberate and both worth knowing. It is not a + reveal, so it fires no audit hook, charges no reveal budget, and keeps working on an expired or + revoked value: a dead value still emits its tag, which is the same disclosure the live one made and + no new one. And it runs on every rendering with no cache, because a cache keyed by value is a table + of plaintexts with the lifetime of the process. Measured on Node 22.15.0, a redaction costs about + 20 ns under the constant policy and about 2.6 µs under this one, so the sketch's "negligible + overhead" holds for a log line and not for a hot loop. + +- 14ed60e: Add development mode: what a value's own history was, for the engineer debugging it. + + A reveal that fails, or one that should never have happened, says very little on its own. The stack + names the caller of `reveal()` and nothing else: not where the value came from, not how often it has + already been read, not which of the four `SensitiveString`s in scope this one is. `setDevMode()` + turns on recording of exactly that, `getDevModeConfig()` reads the resolved configuration back, and + `withDevMode(config, fn)` installs one for the length of a call, which is what a test wants. + + ```ts + setDevMode({ + enabled: true, + captureStackTraces: true, + captureCreationStack: true, + warnOnMissingContext: true, + warnOnRepeatedReveal: { threshold: 5, window: 60_000 }, + logReveals: { enabled: true, showPartialValue: true }, + }); + ``` + + The history is also readable, through a new `__devInfo` getter: `createdAt`, `creationStack`, + `revealCount`, `revealTimestamps` and `lastRevealStack`. It is `undefined` when development mode is + off rather than a missing property, so debugging code written against it compiles and runs in + production. Records live in a `WeakMap` keyed by the instance, so a value's history is collected + with the value, and a record is only kept for a value constructed while development mode was on. + + Everything is off until it is turned on. What a process that never calls `setDevMode` pays is one + property read in the constructor and one in the raw-value gate. + + What counts as an exposure is exactly what an audit hook sees, because the recording sits in the + same gate, immediately after the audit call: `reveal()`, `mask()`, `serialize()`, `map()` and the + string, regex and template operations count, and each is reported under its own operation name. + `equals()` does not, because a comparison reads the value without disclosing it, and neither does + redaction. A read the gate refused, or one an audit hook stopped by throwing, is not counted: a + denied reveal is not a reveal. + + Five decisions differ from the design document, all in the same direction. + + `showPartialValue` prints a prefix of the raw value, which is a real disclosure and the reason the + flag is off by default and capped at eight characters. It is bounded a second time, by the same + floor on hidden characters that `mask()` enforces for the class: four for `sensitive`, eight for + `credential`. A ten-character credential shows two characters rather than the four asked for, and an + eight-character one shows nothing at all rather than most of itself. Counts are code points, so a + prefix never ends inside a surrogate pair. This is still a wider disclosure than `mask()` allows, + which refuses `credential` outright, and the README says so. + + Repeated reveals are counted per value in fixed windows rather than a sliding one, and the warning + fires as the threshold is crossed and then stays quiet for the rest of the window. The design's + sketch warns on every reveal past the threshold, which floods the console for exactly the runaway + loop the feature exists to find; one line per window is the useful form of the same signal. It also + answers the design's open question about timestamp memory in the process: the window needs two + numbers, so `revealTimestamps` is free to be a plain 64-entry ring, which is what bounds a value + revealed in a loop for an hour to a fixed amount of memory. `revealCount` stays exact. + + Captured stacks are trimmed by file rather than by matching the string `sensitive-core` in a frame, + which would also match an application directory of that name and would match nothing at all once a + frame has been rewritten by a bundler. This module reads its own file out of a stack at load time, + drops its own frames, and then drops the contiguous run of frames belonging to whichever module + called into it, which is the raw-value gate. A build that inlines the two into one file leaves + nothing after the filter, and the unfiltered frames are kept instead. + + An error thrown by a configured logger is swallowed. Without that, a broken logger turns a + successful reveal into a failed one, after the audit hook has already recorded it as successful, and + only in development: the one environment where the difference would be mistaken for a bug in the + caller. + + One item from the design is deliberately absent. Its open questions propose that `setDevMode` warn + when the configuration changes while `SensitiveString`s already exist, and nothing can answer that + question: records live in a `WeakMap`, so whether any instance is still alive is exactly what cannot + be observed. The nearest implementable version, a flag set the first time anything is recorded, + would fire on every `withDevMode` call in a test suite, which is the use case the neighbouring open + question adds `withDevMode` to support. + + The design's `<1%` overhead tests are `src/__benchmarks__/dev-mode.bench.ts` rather than assertions, + for the reason the timing analysis is not part of `pnpm test`: a wall clock verdict depends on how + busy the machine is and must not gate a release. Each group holds a development-mode-off control, + and the gap between it and the guarded path is the whole production price of the feature. The bound + on memory is asserted, because that one is deterministic. + + Measured on this machine at Node 22.15.0, with the harness floor subtracted: a reveal costs 195ns + with development mode off and 187ns with it on over a value it never recorded, which is the same + figure and is what the guard is worth. Recording one costs 334ns, the repeated-reveal window 578ns, + logging 629ns, a partial rendering 1949ns, and capturing the stack 214us, which is the whole of the + gap and is why the two capture options are separate flags. That last figure is inflated by the + runner's source-map-aware `Error.prepareStackTrace`: the same capture in a plain Node process + measures 13.5us. Construction is 6.8x with recording on and 795x with the creation stack. + + The benchmark hooks are worth one warning. Vitest hands a benchmark's third argument to the + tinybench `Bench` constructor rather than to the `Task`, so `beforeAll` is accepted by the type + checker through a non-literal and then silently ignored; the first version of this file used it, + installed no configuration at all, and reported capturing a creation stack as cheaper than capturing + none. `setup` and `teardown` are the hooks that run. The same file also revealed a value built + before development mode was on, which has no record, so the group measured the guard six times over + rather than the recording; that case is now a labelled task of its own. + + 58 cases across `src/dev-mode.test.ts` and `src/__tests__/dev-mode-security.test.ts`, including + canaries that the full value reaches no log line under every option at once, that it is in nothing + `__devInfo` exposes, and that every redacted rendering, the wire format included, is byte for byte + what it was with development mode off. + +- 7de6338: Add environment restrictions: a value can say which deployment environment, region or datacenter it + may be read in, and the check runs in the raw-value gate rather than at the application layer. + + ```ts + setEnvironmentInfo({ environment: process.env.NODE_ENV, region: await instanceRegion() }); + lockEnvironmentInfo(); + + const euCustomer = new SensitiveString(pii, 'sensitive', 'eu_pii', { + restrictions: { allowedRegions: ['eu-*'] }, + }); + + euCustomer.reveal(); // in us-east-1: throws EnvironmentRestrictedError + ``` + + Six declarative lists, `allowed` and `denied` over environment, region and datacenter, plus a + `custom` predicate asked last and handed the frozen environment. Every field that is present has to + pass, so a name on both lists is denied. Region entries take a trailing `*` as a prefix and nothing + else; a `*` anywhere but the last character is refused at construction, because `*-east-1` matched + as a literal is a denylist that matches nothing. + + The check sits in the gate the TTL, revocation and reveal-budget checks sit in, which is what rule + R2 requires and what the design sketch did not do: the sketch put it at the top of `reveal()`, where + `secret.map(v => v).reveal()` walks past it. Every exposing read refuses, and so does `equals()`, + which R2 names explicitly: a value that may not be read in this region must not still be able to + answer whether a guess matches it here. Redaction and metadata keep working, because a value that + cannot be used still has to be safe to log. It is decided after the deadline and before the reveal + budget, so a read in the wrong region costs nothing and a revoked or expired value still reports the + condition that invites the right response. + + `renew()` checks it too, and needs its own check because it reads nothing and so does not pass the + gate at all. Revocation already had that check for the same reason; the reason transfers, because + extending the life of a value this deployment may not use is using it. Expiry is the one that does + not transfer: reviving a lapsed deadline is what `renew()` is for. + + Restrictions are read at each access, not at construction, and derived values share the state, so a + substring is restricted exactly as its parent is and a value derived from two restricted values + carries both. That makes a derivative at least as restricted as each source, which is R2's + combination rule with nothing left to decide: an intersection is what sharing already gives. + + Declarative restrictions cross the wire, as rule R3's default requires, and like the `revoked` flag + they can only tighten: a deserialized value starts with none and there is no spelling of "no + restriction". The wire field is a list, because a value derived from two restricted values carries + two, and carrying only the first would silently drop the other. A payload entry with a key this + receiver does not know is rejected rather than ignored, since enforcing part of a restriction and + reporting nothing about the rest is the failure the feature exists to prevent. The zod + `zSensitiveDeserialize()` schema declares the field for the same reason it declares `revoked`: an + object schema strips what it does not declare, and stripping this one turns a value restricted to + the EU into one readable anywhere. + + Four places the sketch had to change, all of them the same shape: it chose to read silence as + permission, and silence is where this feature fails. + + - **A custom restriction refuses to cross.** The sketch skipped it on serialize. A function has no + wire form, so skipping it hands the receiver a value it believes it has fully checked while the + half that named the actual condition is gone. `serialize()` now refuses such a value before + reading it, with `UnserializableRestrictionError`, and `toWorkerPayload()` refuses it with + `UnshareableConstraintError` alongside a reveal budget, which is the error for a constraint that + cannot cross an isolate. The serialize refusal is deliberately not an + `EnvironmentRestrictedError`: that one means "not here, try where the value allows" and a caller + may sensibly retry elsewhere on it, while this one means "not anywhere", and a caller who cannot + tell them apart retries in every region forever. + - **An empty list is refused at construction.** The sketch specified that `allowedRegions: []` + allows everything. Read literally it says the opposite, and a security control must not pick one + of those silently. A caller whose configuration came back empty finds out at the call that wrote + it. A restriction set that expresses nothing at all is refused for the same reason. + - **A field the environment does not state is refused by an allowlist.** The sketch skipped both + kinds of list, which makes `allowedRegions: ['eu-*']` inert on any deployment that never sets a + region, which is the deployment most likely to be the wrong one. An allowlist is the statement + "only here", and a process that cannot say where it is has not shown that it is here. A denylist + still passes: "not there" concludes nothing from silence, and refusing would break every laptop + and CI runner. The first read of a restricted value before `setEnvironmentInfo()` warns once, so + the quiet half of that asymmetry is audible. + - **A custom function that answers with anything but `true` denies.** A throw denies, for the reason + a revocation registry that throws does, and an `async` function returns a Promise, which is truthy + and would otherwise allow every read it was written to refuse. + + `EnvironmentRestrictedError` extends `SensitiveStringError` with the code `environment-restricted`, + `EnvironmentLockedError` with `environment-locked`, and `UnserializableRestrictionError` with + `unserializable-restriction`, so all three are catchable as library refusals and greppable in a log. The error names the restriction that refused, the environment's current + value for it, and the configured lists. All of that is configuration and none of it is any part of + the protected value. + + The environment itself is process-global state under rule R6: one `Symbol.for()` singleton so + duplicate installs of core declare one environment, with a test that reads it back through that key, + and documentation whose first paragraph says it defends against a deploy to the wrong region rather + than against a caller who wants the value. `lockEnvironmentInfo()` freezes it for the rest of the + process, and `resetEnvironmentInfo()` clears both it and the lock for test isolation, which is the + honest statement of what the lock is worth. Every field is copied and frozen when it is declared, so + neither a caller mutating the object it passed nor a custom restriction reading it can move the + environment after the fact, and the same applies to the lists a value is constructed with. + + A process that restricts nothing pays a length check on an empty frozen array, on the same shape the + reveal budget uses. + + 69 cases across two files. The security file drives the two tables the TTL, reveal-budget and + revocation suites drive, so an operation added later that forgets the gate fails there rather than + shipping as a bypass: 35 that reach the raw value, each refused in the wrong region and leaking + nothing in the message, the stack or the inspection, and 24 that derive a new protected value, each + inheriting the restriction of the value it came from. + +- a90709f: Write `allowSendToExternal` explicitly on both default policies, and add the keyed derivation two + features want. + + `advisory.allowSendToExternal` has been declared on `SensitivityAdvisory` since the beginning and set + by neither `DEFAULT_POLICIES` entry, so it read `undefined` on every value in a stock installation. + The two obvious ways to test a flag, `if (flag)` and `if (flag !== false)`, therefore disagreed on + every value in the library, and the first reader of the field would have picked one of them by + accident. Both classes now say `false` out loud. That is behaviourally identical once `undefined` is + defined as a refusal, which is how `@platform-sec/sensitive-string-llm` reads it, and it is + completely different to somebody reviewing a policy: an absent field looks like an oversight, which + until now it was. `sensitive` starts closed rather than permissive because the class is deliberately + coarse and covers a customer email as readily as an internal note; a caller who wants either in a + prompt registers a policy that says so, in one place, in code that shows up in review. This is the + last piece of the roadmap's Phase 0. + + `deriveKeyedToken` is `HMAC-SHA256(key, domain[0] || 0x00 || ... || value)`, hex, truncated. It lives + here rather than in a consumer because two features want the same primitive and a truncated keyed + HMAC over a low-entropy value has exactly one correct shape: deterministic redaction correlates a + value across log lines, and an LLM entity handle stands in for a value inside a prompt. Both derive a + short stable token from something an attacker can guess, and both publish the result where the + attacker can see it, which is why there is no unkeyed mode and no way to configure one. An email + address under a plain SHA-256 is recoverable from a wordlist in seconds. + + Three refusals, each of which is a correctness guard as much as a security one. A token shorter than + 12 hex characters: 12 is 48 bits, where a one-in-a-million collision arrives at roughly 24,000 + distinct values, while 8 is 32 bits, where a one-in-a-thousand collision arrives at about 3,000 and a + collision silently merges two subjects into one. A key under 16 bytes, which is an unkeyed hash with + extra steps because the key space is enumerable alongside the wordlist the value came from. And a + domain component containing the NUL separator, without which `['a', 'bc']` and `['ab', 'c']` would + hash identically and two domains would quietly share a token space. + + The module reads no `SensitiveString`, authorizes nothing and audits nothing: a caller reveals through + the ordinary gate and hands the result over, so the audit record comes from the reveal rather than + from a second, quieter path to the same value. + + `assertKeyedDerivationOptions` raises every one of those refusals without deriving anything, for the + caller who has to read a protected value first. Without it a bad key or length is caught after the + read, which leaves an audit record for a reveal that produced nothing and gives an auditor + reconciling reveals against derived tokens one that does not balance. The raw-value gate already + applies this rule in the other direction, refusing a denied read before the audit hook on the grounds + that a denied read is not a reveal; this is the same rule one step later. + +- f8377ff: `hash()` and `hmac()`: a one-way digest of a protected value, as a protected value. + + ROADMAP 1.2, the `core` half. The alternative was `reveal()` followed by handing the raw value to + `crypto.createHash`, which is a full reveal recorded as one and a plain string afterwards, for a + lookup key that never needed the value in the first place. Both methods read through the ordinary + gate, so the class policy, the deadline, the reveal budget and any revocation all apply, and the + audit record says `operation: 'hash'` or `'hmac'` with the algorithm and encoding on it. + + `advisory.allowHash` has been declared on `SensitivityAdvisory` since the beginning and read by + nothing. This is what reads it. `undefined` allows, which is the opposite of how the LLM package + reads `allowSendToExternal` and is deliberate: hashing is the operation the library wants used in + place of holding a value, so the flag exists to forbid it for a class where even a digest is too + much, not to require permission for the safe path. + + The result is `credential` whatever the source class was, and it is a derived value: it carries the + deadline, the reveal budget and the revocation of what it came from, through the same `#derived` + path as `substring` and `map`, and the three enumerated security suites now name `hash` and `hmac` + alongside them. That is the R2 decision this feature had to make, and it goes the way it does + because a fast digest of a guessable value is that value behind a lookup table. Ten billion Social + Security numbers hash in under a minute on a laptop, so a digest that started a fresh budget would + be a way to spend one reveal and keep a recoverable copy of the value forever. + + `hmac()` is the version to reach for when the value is guessable, and it is why the sketch's + salt-only API was not enough: a salt stops precomputation and nothing else, because it sits in the + same row as the digest. The key floor is 16 bytes, the constant `deriveKeyedToken` already applies, + since a key an attacker can enumerate alongside the value is an unkeyed hash that reads as a keyed + one in review. A `SensitiveString` key is read through its own gate and the result carries the + constraints of the value and the key both, which is the one place in this class where an argument is + read before the receiver: a key below the floor produces no digest, and refusing after the receiver + had been read would charge an exposure for nothing. + + MD5 and SHA-1 are refused by name and with a reason rather than by absence from a list, since a + caller asking for one has usually copied it from code that predates the break. SHA-3 is checked + against `getHashes()` before use, because a FIPS build can be without it. A salt is prepended, never + appended, which is what makes the digest reproducible by anything else computing + `sha256(salt || value)`, and an empty salt is refused rather than read as no salt at all: it + produces exactly the unsalted digest and would report as `salted: true` in the audit record. + + Development mode names a digest as a digest. `[SensitiveString] Hash of 'api_key' without context` + rather than `Reveal of`, because a line telling a developer their value was handed out when it was + not is worse than no line. + + What is deliberately absent is password storage. `hash('sha256')` is fast by construction, which is + the one property a stored password must not have, and the functions that are not fast need a native + module. Those are in `@platform-sec/sensitive-string-kdf`. + +- 1eb5030: Add the `url` mask strategy: keep the scheme, host, port and path of a URL, and hide the userinfo, + every query value and the fragment. + + ```ts + new SensitiveString( + 'https://x-access-token:ghp_abcdef123456@github.com/org/repo.git', + 'sensitive' + ).mask({ + strategy: 'url', + }); + // 'https://***@github.com/org/repo.git' + ``` + + Two corpus records wanted this rendering and could not get it: a clone message should keep which + repository it names, and a custom policy cannot do it because `validateRedactFunction` refuses a + `redact` that is the identity on its non-URL canaries. The hidden parts are written at a fixed width + so the rendering does not disclose a credential's length, and the rendering is the parsed URL written + back, so it is normalized. The floor applies as to every strategy: a URL with nothing to hide hides + nothing and redacts in full, which is deliberate. A caller who marked a URL sensitive and wants its + bare form shown has marked the wrong thing; wrap the credential and keep the URL. + +- dfe8ae4: Add memory hygiene: `clear()` to destroy a value at a point the caller chooses, a bulk clear for a + shutdown or incident path, and optional instance freezing. + + ```ts + token.clear(); + token.isCleared; // true + token.clearedAt; // Date + token.toString(); // '[REDACTED:credential]', unchanged + token.reveal(); // throws ClearedSensitiveValueError + + setMemoryHygiene({ trackInstances: true, freezeInstances: true }); + clearAllSensitiveValues(); // returns how many it destroyed + ``` + + **`clear()` drops the library's reference and nothing else.** It is not zeroing. JavaScript cannot + overwrite the backing store of a string it holds a reference to, and there is no primitive that + would let it. Whether the library's was the last reference is a property of the calling code, which + is why the documentation leads with the two shapes rather than with the API: a credential decrypted + into a local and cleared after use really does leave the heap, and a route that wraps + `req.body.token` while the handler keeps the parsed body gains nothing, because `req.body.token` and + the instance's value are the same string object. No integration fixes the second shape, and it is + worth being exact about why: a body has to be parsed into strings before any field can be + classified, and `wrapSensitiveByPath` returns primitives by reference, so the wrapped value holds + the string the pre-wrap body holds. + + The check lives in the raw-value gate, as rule R2 requires, and goes first among the lifecycle + checks. Ahead of revocation as well as expiry, and not because a destruction outranks a revocation: + it is the only one of the three that reads a plain field, so asking a `revocationRegistry` about a + value this process already destroyed would let a registry outage decide which refusal a destroyed + value reports. `renew()` takes the same check first too, ahead of its own `derived` refusal, so a + derivative of a cleared origin now reports `ClearedSensitiveValueError` rather than + `RenewalNotAllowedError`. + + Redaction survives a clear, because logging a sensitive value must never throw: an error handler + doing `logger.error({ err, token })` on a cleared token would otherwise raise a second error from + inside the first and lose the diagnostic. `clear()` snapshots the redacted rendering from the live + value immediately before dropping it. That snapshot is not decoration: under a + deterministic-redaction policy, every cleared value in the process would otherwise redact to the + HMAC of the empty string, and one shared tag across unrelated values is a false correlation in an + audit log, which is worse than no correlation. Its cost is that a policy re-registered after a clear + does not change what a cleared value renders. + + `length` and `isEmpty()` refuse instead of answering from the empty string. `0` and `true` are wrong + answers rather than degraded ones, and a caller branching on `if (!token.length)` would read a + destroyed credential as an absent one. The length is deliberately not snapshotted alongside the + rendering: the rendering is retained because a copy of it is already in a log file, and no + equivalent argument covers the length. + + Derived values are refused, not released, and the distinction is the finding the roadmap sketch did + not have. Clearing an origin refuses everything descended from it, because they share the state + object the clear writes; clearing a derivative refuses only that derivative. The traffic also runs + the other way: a derivative of 13 characters or more is a V8 `SlicedString` pinning the origin's + entire backing store, and a `concat()` result is a cons string pinning both operands, so the + origin's own `clear()` releases nothing at all until the derivatives are cleared too. Measured end + to end in the retention suite. That is what makes `trackInstances` more than a convenience: + derivatives are usually short-lived locals whose holder a caller cannot name, and walking the + tracked set is the only way to break a pin like that. Derivations are not flattened to avoid the + pin: the three idioms that actually work cost between 20x and 88x the derivation itself, on the path + every string method routes through, and each makes its own transient plaintext copy while it runs. + + No policy is consulted. Clearing reduces exposure, so there is no confidentiality outcome a denial + protects, and the same caller could drop the reference by letting it go out of scope. It is audited + after the fact with `{ operation: 'clear', propagated }`, and a sink that throws is swallowed, as + `revoke()` already does: a sweep in a `SIGTERM` handler must not stop halfway because one sink is + down, which is when a sink is most likely to be down. A policy whose `redact` throws does not + prevent a clear either; the instance falls back to the class's default marker. + + Both switches are off by default and read at construction, so they belong beside `registerPolicy` in + the entry module. `trackInstances` costs about 202 bytes per instance, roughly 87% on top of the + instance itself, which a service wrapping a few dozen credentials at boot will never notice and a + service wrapping a value per request will. `freezeInstances` costs about 51 ns per construction and + blocks four real tamper paths, two of which the roadmap did not mention: an own `toJSON` replaces + redaction outright on the path most likely to reach a log aggregator, and a `klass` rewrite + downgrades a credential to a class with different redaction, permitted partial queries, permitted + masking and no constant-time comparison. It is off by default because it is a breaking change for + any consumer that decorates an instance, not because of the 51 ns. It does not touch `#private` + fields, so `clear()` works on a frozen instance, and it does not freeze the lifecycle: `renew()` + still moves a frozen value's deadline, because the deadline lives in a state object the instance + references rather than on the instance. + + The tracked set is a `WeakRef` set pruned by a `FinalizationRegistry`, so tracking never keeps alive + the thing it exists to destroy, and its size is a lagging indicator with no bound worth promising: + a prune pass that calls `deref()` keeps every target it touches alive to the end of the turn, and + finalizer callbacks were measured needing two GC cycles and two macrotask turns before a set of + 5,005 entries fell to 5. `clearAllSensitiveValues()` therefore clears what it can reach and does not + claim the set is empty afterwards. + + A cleared value cannot be serialized: the wire format is read through the gate, so there is nothing + to send, and no field a cleared state could cross on. The registry and both switches are per + isolate, like every other process-global here. + + 69 cases across three suites. The security file drives the two enumerating tables the TTL and + revocation suites established, so an operation added later that forgets the gate fails there rather + than shipping as a bypass: 36 that reach the raw value, each refused and leaking nothing in the + message or the stack, and 24 that derive a new protected value, each inheriting the origin's cleared + state. One of them is the guarantee that would be easiest to break quietly: a cleared value, and a + derivative that only inherited the clear, through `redactInStructure`, `safeStringify`, `safeLog`, + `containsSensitive`, `sensitiveClassesIn` and `redactCopy`, none of which may throw. + + Eight cases measure the collector rather than behaviour, and all eight live under `pnpm test:timing` + with `--expose-gc`: five search a heap snapshot for a canary held nowhere else, and three check that + tracking never keeps an instance alive, that the finalizer prunes the set, and that a swept instance + is not counted twice. They belong there rather than in the functional suite because `pnpm test` has + no `global.gc`, and a test guarded by `typeof global.gc` that returns early reports a pass for a + measurement it never took. `describe.skipIf` reports a skip instead. + +- 1c87ee4: `containsRedactionMarker()` and `redactionMatchers()` in core, replacing four separate answers to + the same question: given a string about to be written to a column, sent in a request body or stored + in a secret, does it carry the text a `SensitiveString` renders as? `sensitive-http` derived its + answer from the policy registry, `sensitive-typeorm` and `sensitive-drizzle` each shipped a narrower + copy of that derivation, and `sensitive-secrets/write` used a hardcoded `[REDACTED:`. Core owns the + format, so the detector lives there and the four call it. + + Two gaps close, both in the database packages and both security-relevant. + + **`markerPattern` was ignored.** Core declares it on `SensitivityPolicy` for a policy whose redaction + varies with the value, `deterministicRedaction()` sets it, and core's own documentation for the field + names the http guard as its consumer. The two copies written for a database did not read it, so a + deployment using deterministic redaction had a marker check in front of its columns that could not + recognise its own markers. + + **A marker for an unregistered class went unrecognised.** Both copies enumerated the registry and + looked for `[REDACTED:${klass}]` per class. For a reader that is the ordinary case rather than an + edge one: the column was written by an older release, or by a service that registered a class this + process does not have. The detector matches the `[REDACTED:` prefix, which covers those, and also + catches a marker that a composed transformer altered without destroying. + + `policyRegistryVersion()` is new and exported. The memo is keyed on it rather than on the set of + registered class names, because an `overwrite` re-registration replaces a policy's `redact` while + leaving the names identical, and both copies kept answering from the previous policy until something + else changed. + + `sensitive-typeorm` and `sensitive-drizzle` no longer export `redactionMarkers` or + `resetMarkerCache`; they re-export core's `redactionMatchers` and `resetRedactionMarkerCache` + instead. `redactionMatchers()` returns `(string | RegExp)[]` rather than a `Set`, since a + `markerPattern` is a pattern. + +- 7de3033: Add `observeRedactions()`, a process-wide subscription to every rendering of a redaction marker. + + ```ts + const stop = observeRedactions(({ klass, label, surface }) => { + redactions.inc({ klass, label: label ?? '', surface }); + }); + ``` + + A reveal was always audited. A redaction was recorded nowhere, and it is the number that says what + the library prevented: every time `toString()`, `toJSON()` or the inspect hook rendered a marker, + some caller was about to write the plain string somewhere and the marker went instead. The + rendering runs on the `'safe'` read, which fires no audit hook, charges no budget and consults no + policy, and that stays so: a log line must not need authorization. Observation sits beside the + rendering rather than inside the gate, after the rendering succeeds, so a policy whose `redact` + throws reports nothing, because nothing was written. + + The event carries the class, the label and the surface, frozen, and never the value or its + rendering. Under a deterministic-redaction policy the marker is a pseudonym of the value, and a + metric dimension is the wrong place for one. The four surfaces separate the readings an operator + has to make differently: + + - `'toString'` is a coercion: a template literal, concatenation, `String()`. It is the surface T13 + describes, where a rendering may be a prevented log line or a broken outbound request, and the + event cannot tell the two apart. A count here is meaningful only once the HTTP guard is installed. + - `'toJSON'` is `JSON.stringify()`, which a JSON logger and a JSON request body both reach. + - `'inspect'` is `util.inspect()` and `console.log()`. + - `'structure'` is a walker: `redactInStructure`, `redactCopy`, `safeStringify`, `safeLog`, the + winston format and the pino serializer. This is the one surface that is unambiguously a sink that + installed redaction, and a `serialize()` result a walker replaced reports here too, with no label, + since the label in a wire shape is data off the wire and an event carrying it would let whoever + built the payload choose a metric dimension. + + The walkers render through the class's own path now rather than through `String(value)`. That is + what gives them their own surface, and it closes a gap on the way: an own `toString` on an unfrozen + instance, or a subclass override, used to decide what `redactInStructure` and `redactCopy` wrote. A + value a walker was asked to clean comes out clean whatever its `toString` says; the coercion itself + is still the instance's own business, which is what `freezeInstances` exists for. + + An observer that throws is warned about once through `process.emitWarning` and its later throws are + swallowed, because a redaction is a log line and a log line must never throw. An observer's own + redactions are not observed, so a dashboard that logs a wrapped value inside its observer does not + recurse. The list is replaced rather than mutated on subscribe and unsubscribe, so a notification in + flight sees the list it started with. The registry is process-global under rule R6 and keyed on + `Symbol.for()`, so a second installed copy of core shares it; it protects a dashboard against a + missing subscription, not a secret against a hostile caller, since no event ever carried one. + + What an unobserved process pays is one array-length read per rendering. Priced on Node 22.15.0 the + way the reveal counter was, two copies of `dist` with the read commented out of one, two million + `toString()` calls per round, twelve rounds interleaved, median taken: across three runs the delta + was 0.0, 0.7 and 1.5 ns on a rendering of 17 to 26 ns, inside the run-to-run spread. The observed + path adds one frozen object and one call per observer; `redaction.bench.ts` carries the ratio + between a no-op observer and a counting one, which is the exporter's cost rather than the library's. + + 34 cases across two suites. The security file asserts the event shape on every surface and through + every walker, that a renderer deriving its marker from the value leaves nothing of it in the event, + that a throwing, mutating or returning observer changes no rendering, that an observer's own + redactions and mid-notification subscriptions do not reach the notification in flight, and that an + own or subclass `toString` no longer reaches a walker. Nine mutations were run against the + mechanism list, each caught by at least one named test. + +- 98fd326: Add reveal counting and rate limiting: a token that can only be read once, and a way to notice one + being read in a loop. + + A bootstrap token or a reset link is meant to be used once, and nothing enforced that. A secret read + thousands of times a second is a retry storm or a caching layer that stopped caching, and nothing + noticed. `maxReveals` is the lifetime limit, `revealWindow` and `maxRevealsPerWindow` are a sliding + window, and both are optional fields on the same `SensitiveStringOptions` the TTL options live on. + + ```ts + const bootstrap = new SensitiveString(key, 'credential', 'bootstrap', { maxReveals: 1 }); + + bootstrap.reveal(); // works + bootstrap.revealsRemaining; // 0 + bootstrap.isExhausted(); // true + bootstrap.reveal(); // throws RevealLimitExceededError + + const apiKey = new SensitiveString(key, 'credential', 'api_key', { + revealWindow: 60_000, + maxRevealsPerWindow: 100, + }); + // throws RevealRateLimitError, whose `retryAfter` says how long to wait + ``` + + The two errors are separate because a caller recovers from them differently: one waits, the other + fetches a new secret. When both would refuse, the lifetime limit is reported, being the condition + waiting will not clear. + + The check lives in the raw-value gate, so `mask()`, `serialize()`, `map()` and every string, regex + and template operation are charged alongside `reveal()`, one per call. `equals()` is not charged and + is not refused by a spent budget: a comparison reads the value without disclosing it, and it is the + operation to reach for in place of a reveal, so a stored credential stays usable for the only thing + it is stored to do. Redaction and metadata are not charged either, so a spent value is still safe to + log. + + Derived values share the counter rather than copying it, which is what makes the limit mean + anything: `secret.substring(0).reveal()` throws, because the substring spent the budget and shares + it. A value built from two constrained sources is charged against both, reports the highest count + and the smallest remainder, and is exhausted as soon as either source is. `revealCount` is kept for + every value, whether or not it limits anything, since returning zero forever for an unlimited value + would be a lie in an API declared to return a number. + + Counters do not cross the wire. `serialize()` charges the sender, `deserialize()` starts a fresh + count with no limits, and a payload claiming `maxReveals` for itself is ignored rather than obeyed. + Nothing resets a count, in test builds or otherwise: a control the process can switch off is not a + control, and a test wanting a fresh budget constructs a fresh value. + + Five decisions differ from the design document. + + The charge happens after the policy and audit hooks, where the design incremented before both. The + gate already refuses ahead of the hooks because a denied read is not a reveal; the same rule one step + later says a read an audit sink stopped by throwing is not one either, so a broken sink cannot + quietly spend a single-use token. The consequence is documented rather than defended: an audit hook + that re-enters the gate to read the same value sees the pre-charge count. Reading a value from an + audit hook already recursed without bound before this feature existed, and `RevealContext.auditReveal` + now carries the same warning `checkRevealAllowed` does. + + `revealWindow` and `maxRevealsPerWindow` are a construction error apart. The design guarded the rate + limit with `if (window && limit)`, so either alone silently configured nothing and `revealWindow: 0` + disabled the limit rather than tightening it, which is the truthiness defect the TTL work found in + `ttl: 0` pointing the other way. `maxRevealsPerWindow: 0` is rejected outright: it describes a value + that can never be exposed, which `maxReveals: 0` says without ambiguity, and it would raise an error + whose `retryAfter` no waiting could satisfy. + + There is no `__resetForTesting()`. The design proposed one gated on `NODE_ENV`, which is a string any + code in the process can assign. + + The window is a ring with a head index rather than an array rebuilt by `filter` on every check. The + design's version allocates per reveal and stays as large as the burst that filled it; amortized + compaction bounds the backing array at twice the limit however long the value lives, and the bound is + asserted over 10,000 charges rather than argued. + + `#value` is not cleared when a value is exhausted. The design recommended it. It would break + `equals()` and `toString()`, both of which stay available on purpose, and it would not do what it + sounds like: dropping a reference to a JavaScript string does not erase the string, which the threat + model already lists as out of scope. Memory hygiene is its own roadmap item. + + `src/__benchmarks__/reveal-budget.bench.ts` measures what this costs, as ratios within each group + rather than as absolute figures, because the machine it ran on was not idle and four runs disagreed + about the nanoseconds while agreeing about the shape. Against the unbudgeted control on the cleanest + run, at Node 22.15.0: a lifetime limit is within 2% of the control, which is to say indistinguishable + from it, since the limit is one comparison. A sliding window costs 25% and both limits together 27%, + which is the timestamp push and the prune. Two budgeted sources cost 11%, being the same work over a + second state. Redaction, comparison and construction do not move, because none of them is charged. A + refusal is roughly 40x a successful reveal, which is the error's stack capture and is not on anyone's + hot path. + + That group cannot price the counter, because its control pays for it too. Measured separately, by + timing two million reveals against a build with the charge removed: a reveal on an unbudgeted value + costs about 20ns and about 26ns with the counter, so the counter is roughly 6ns. Inlining the walk + for the single-state case, which is every value that was not built by `concat`, measured the same, so + that is the increment rather than the call. It is what an honest `revealCount` costs on a value that + configures nothing, and the alternative is an API declared to return a number that returns zero + forever. Both figures are a tight loop rather than the benchmark harness, whose per-call floor is + several times the whole operation. + + 66 cases across `src/reveal-budget.test.ts` and `src/__tests__/reveal-budget-security.test.ts`. The + security file drives two tables the way the TTL suite does, so an operation added later that forgets + the gate fails there rather than shipping as a bypass: 31 operations that expose the raw value, each + asserted to refuse an exhausted value and a full window and to leak nothing in the message, the + stack, or the inspection, and 22 that derive a new protected value, each asserted to share the + parent's spent budget. + +- f1d6d96: Add revocation: a way to stop a value being read after it is rotated or compromised, including + instances a caller no longer holds a reference to. + + After a rotation the old secret is still in memory, still held by whatever closed over it, and still + works. `revoke()` ends one value. `revokeByLabel()` and `revokeByPattern()` end every value in the + process built under a label, without needing a reference to any of them. A `revocationRegistry` + passed at construction is consulted on every read, which is where a store-backed list belongs. + + ```ts + apiKey.revoke('Rotated 2026-08-01'); + apiKey.reveal(); // throws RevokedSensitiveValueError + apiKey.isRevoked; // true + apiKey.revocationReason; // 'Rotated 2026-08-01' + + revokeByLabel('api_key', 'Emergency rotation'); + revokeByPattern(/^session:/, 'User logged out'); + ``` + + There is no way back. No `unrevoke`, no reset, no options object and no wire payload that lifts one. + `pruneRevocations()` shrinks the process-global lists, and it cannot resurrect anything that has + already been refused: a value latches its own revocation the first time it observes one, so pruning + only ever drops entries nothing has matched yet. That is also the rule for when pruning is safe, and + the only condition under which it is. + + The check lives in the raw-value gate, as rule R2 requires and as the design's sketch did not: the + sketch put it at the top of `reveal()`, where `secret.map(v => v).reveal()` walks past it. Every + exposing read refuses, and so does `equals()`, which R2 names explicitly: a comparison reads the + value, and a rotated credential must not still be able to answer whether a guess matches it. + Redaction and metadata keep working, because a value that can no longer be used still has to be safe + to log. `renew()` refuses too, though it reads nothing: a lifecycle control that keeps working on a + revoked value is one somebody will eventually mistake for the value working. + + Revocation runs along derivation edges in both directions, which is the decision R2 leaves to each + feature. A derivative is revoked by its sources, and revoking a derivative revokes its sources and + their other derivatives. That is the opposite of `renew()`, which refuses on a derivative, and the + reason for the asymmetry is which way each fails: refusing to extend a lifetime fails closed, + refusing to end one does not. An incident responder holding a substring, a `matchGroups()` result or + a template result has no way to know it is derived, and refusing them would leave the credential + alive. It is also the honest reading of what a derivative is: it carries its sources' material, so a + leaked substring is a leaked secret. + + Label and pattern revocation match the label a value was **constructed** with, and a derived value + is matched against its sources' labels rather than the one the derivation gave it. Revoking + `api_key` reaches every substring, match group and template built from that value; revoking the + combined label `a/b` that `concat()` produced matches nothing, because no value was ever constructed + under it. + + When both a revocation and a deadline would refuse, the revocation is reported. `ExpiredSensitiveValueError` + invites a renewal and a retry, and a revoked value must never look renewable. + + Nothing may refuse a revocation. No policy hook is consulted, and the audit record is reported after + the fact with anything the sink throws swallowed, which is the mirror image of `renew()`: that one + audits first so a sink _can_ stop it. A second `revoke()` on an already-revoked value changes + nothing and reports nothing, keeping the first reason and timestamp, because a revocation is a fact + that already happened. + + Three things the design sketch had to change. `RevocationRegistry` ships as the interface the option + accepts rather than a class to subclass, so a store-backed registry in another package satisfies it + structurally; the in-process implementation is `RevocationList`. A registry that throws denies the + read, as specified, and so does one that answers with anything but a boolean: an `async isRevoked` + returns a Promise, which is truthy and not `true`, so reading it either way silently revokes + everything or nothing. And `revoke()` does not clear the value, against the design's recommendation: + it would make `length` lie about a value that is still there, and it cannot actually erase a string + from the V8 heap, which is memory hygiene's problem rather than revocation's. + + `revokeByPattern` rejects a pattern that applies an unbounded quantifier to a group that can match + the same input more than one way. A stored pattern is tested against every label a value carries, on + a thread nothing can interrupt, so `(a+)+` in the list is a process that hangs on the first read + after somebody revokes something. The heuristic is deliberately strict and rejects some safe + patterns; the message says what to rewrite. Patterns are also copied out of the caller's RegExp with + `g` and `y` dropped, which would otherwise make a stored pattern match every other time it was + consulted. + + Instance revocation crosses the wire as a flag that can only tighten. `serialize()` never writes it, + because the gate refuses to read a revoked value at all; `deserialize()` honours it, and rejects + `revoked: false` rather than reading it as permission. The zod `zSensitiveDeserialize()` schema + declares both fields, because an object schema strips what it does not declare, and stripping these + would turn a payload that says "do not use this" into one that says nothing. + + The process-global lists follow rule R6: one `Symbol.for()` singleton so duplicate installs of core + share it, with a test that reads it back through that key, and documentation saying it defends + against a stale secret and a misconfiguration rather than against code already running in the + process. `pruneRevocations` is the only way to shrink them, in test builds or otherwise, which is + what a test uses to isolate itself. + + The cost to a process that revokes nothing is a generation counter. A value that has looked once + compares one integer instead of searching the label map and walking the pattern list, and both that + comparison and the registry guard are inline, so the common read reaches no call at all. Measured + against a build with the check removed, on Node 22.15.0: roughly 2 to 4ns on a read of about 20ns, + close enough to the machine's own drift between rounds that it takes the paired measurement to see. + Before the guards were inlined the same measurement showed 5 to 24ns. What the counter is avoiding + is in the benchmark: against 200 revoked labels and 20 patterns, a value reading for the first time, + which is the one case that has to search, is 2.5x slower than one with no label to look up. + + 84 cases. The security file drives the two tables the TTL and reveal-budget suites do, so an + operation added later that forgets the gate fails there rather than shipping as a bypass: 33 that + expose the raw value, each refused by an instance, a label and a pattern revocation and leaking + nothing in the message, the stack or the inspection, and 22 that derive a new protected value, each + inheriting the parent's revocation and each able to revoke the parent. + +- 5950b87: Take the structure walkers' accumulators out of their public signatures, and hand + `withAuditedReveals` the whole value. + + `containsSensitive(obj, seen?)`, `redactInStructure(obj, seen?)` and + `sensitiveClassesIn(obj, found?, seen?)` published their recursion state as optional parameters, + documented as "(internal use)" and enforced by nothing. That is not a style point. Measured on the + built package, passing a `WeakSet` that already holds the root object made `containsSensitive` + answer `false` about a payload holding a credential, `redactInStructure` return `'[Circular]'` for + the whole payload, and a primed `found` set made `sensitiveClassesIn` report a class the payload + does not contain. The first of those is a safety check failing open, and the way a caller reaches it + is the obvious reason the parameter looked useful: share one `seen` across a walk of a related + object graph and any object already visited answers `false`. + + Each walk now lives in a private function and the exported one takes the value alone. Three tests + pin the answers a stray argument used to produce, calling through a cast so an untyped caller is + covered too. `sensitiveFormat`'s record scan was the only internal user of an accumulator; it unions + the two sets it collects instead, which it could always have done, since the two walks shared no + `seen`. + + `withAuditedReveals`'s callback typed its value as `{ readonly klass: string }`, so a sink written + against the convenience wrapper could not read `label` and so could not say which credential was + read, where the same sink passed to `auditReveal` directly gets the whole `ISensitiveString`. It is + the latter now. Widening a callback parameter is compatible in both directions: existing two- and + three-parameter sinks still compile. + +- b92ff33: Add the worker handoff: a `SensitiveString` that crosses a thread or process boundary and arrives as + one, with its context. + + ROADMAP 1.6. `structuredClone` keeps an instance's public fields and drops the private one, so + `workerData: { token }` delivered a plain `{ klass, label }` that answered `klass` correctly and + failed at the first method call, in another thread, in a stack that named neither the boundary nor + the library. The fix everybody reached for, `workerData: { token: token.reveal() }`, worked and left + the plaintext in a plain string for the life of the thread with no class, no deadline and no audit + record. And `AsyncLocalStorage` is per isolate, so the work moved into a worker because it was + expensive, which is the work whose reveals matter most, was the work that silently stopped being + recorded. + + `toWorkerPayload({ token }, { worker, context, data, transport })` builds a payload out of the wire + format, so every marker-aware walk in the library already redacts it, and carries the context's + `name`, `reason` and `tags`. Building it is a reveal of each value through the ordinary gate: an + expired, revoked or policy-denied value refuses on the sending side, and each value produces one + audit record with `operation: 'worker-handoff'`, the payload key and the destination. The access + kind stays `expose` rather than gaining a `handoff` of its own, because the raw value does reach the + caller and a policy that refuses `expose` today must keep refusing it here; a new kind would have + routed handoffs around every such policy on the day it shipped. + + `fromWorkerPayload(workerData, { auditReveal })` and `withWorkerPayload(workerData, options, fn)` + rebuild the instances on the far side. The audit hook is the one field that cannot cross, since a + function survives neither structured clone nor JSON, so a payload built under an audited context + arrives marked and the receiver is required to install its own: `UnauditedWorkerError` rather than + a warning, because a warning from a worker is a line on the parent's stderr that nothing reads, and + what it would report is a security control the sender asked for being dropped in silence. + `allowUnaudited: true` is the spellable opt-out and the new `no-allow-unaudited` lint rule reports + it. The reverse direction needs no options at all: a parent handler registered inside + `withRevealContext()` already has an ambient hook, and the returned context forwards it rather than + replacing it with nothing. + + Neither `data` nor the context's `tags` may carry a protected value. Both get the same two checks, + which is a correction found in review: `tags` had only the portability one, so a wire object could + ride across inside the context, and because consumption clears `values` alone it stayed in the + payload after `fromWorkerPayload()` had returned. That is plaintext in `workerData` for the life of + a thread, in the one place the feature promises there is none. + + Consumption is the part that needed a real thread to prove. `workerData` is one mutable object + shared by every module in the thread, so `fromWorkerPayload()` deletes the raw values from it and + marks it consumed: a later `console.log(workerData)` or a crash handler serializing it no longer + reads them, and a second call throws `ConsumedWorkerPayloadError` rather than rebuilding anything. + Deleting rather than overwriting is deliberate, since a sentinel would still deserialize into an + instance that looks valid. + + Consumption is per copy, and the README says so rather than implying otherwise. Every transport + clones, so a payload posted to two workers arrives twice and whole, and the sender's own object is + untouched by either; it shortens how long one thread holds plaintext and is not a use-once token. + It is also best-effort, because it runs after the instances have been rebuilt: a frozen payload + keeps its values rather than turning a successful call into a throw that leaves the caller with + nothing and the plaintext still there. + + What it does not do is erase the string from the heap, and the README says so, next to the three + other things the boundary does not close: `process.env` is copied into every worker by default, an + application error crossing back carries whatever its message holds, and a value that crosses leaves + the sending side's revocation reach entirely. + + That last one is broader than a missing registry, and it is measured against a real thread rather + than reasoned about. An instance `revoke()` is per copy; `revokeByLabel()` and `revokeByPattern()` + act on module state, which is per isolate for the same reason policies are; and a + `revocationRegistry` is a function table, so it does not clone, and nothing on the receiving side can + bind one back, since `deserialize()` takes no options. What still reaches the copy is the worker's + own label list, matched on the label that crossed, so a worker that must honour revocation keeps + one. Revoking before the handoff is not a way round it: the gate refuses to read a revoked value, so + no payload is built. The wire format's `revoked` flag is for a payload assembled by hand or by a + gateway that checked its own list, and `serialize()` never emits it. + + Three refusals the boundary adds. Policies are per isolate, so a class registered on the main + thread does not exist in a worker; `UnregisteredClassError` names the class and says to register it + in the worker's own bootstrap, since a payload that could install a policy could install one that + returns its input. A reveal limit is a counter shared by reference and a thread has no reference to + share, so `toWorkerPayload()` refuses a value carrying `maxReveals` or a rate-limit window with + `UnshareableConstraintError` before reading anything, rather than handing every worker a fresh + budget. And the two transports disagree in both directions, measured: `child_process` IPC drops a + function silently where structured clone throws, and turns a `Date` into a string and a `Map` into + `{}` where the clone keeps all three, so `transport: 'json'` validates `data` and `tags` against + the serializer that will actually run. A `DataCloneError`'s own message quotes the source of the + function it could not clone, so `UnportableDataError` names the failing path and discards the + runtime's message rather than wrapping it. + + `SensitiveStringError` is new, and every refusal `core` raises now extends it and carries a `code`: + the seven worker errors, and `ExpiredSensitiveValueError`, `RenewalNotAllowedError`, + `RevealLimitExceededError`, `RevealRateLimitError`, `RevokedSensitiveValueError` and + `RevealDuringRedactionError`, reparented. `instanceof Error` and each class's own `instanceof` hold + as before. A caller wants either one specific failure or any refusal from this library, and a base + plus a flat code serves both where either alone serves one. `ExpiredSensitiveValueError` also + gained an optional `key`, so the boundary can name the payload key of a value that expired in + transit without misreporting its label. `RevealDetails` gained the `WorkerHandoffAuditDetails` + variant, and development mode names the operation `Worker handoff`. + + `withWorkerPayload()` declares its return type with a default, because TypeScript has no partial + type argument inference: naming only the values type would otherwise be a hard error rather than a + call whose result is `unknown`, and naming only the values type is the shape everybody writes. The + checked example under `examples/worker-handoff/` pins all three call forms, which is what caught it. + + Neither helper imports `node:worker_threads`, so `core` gains no dependency and the same pair works + over `workerData`, `postMessage`, a `MessagePort`, a `BroadcastChannel` and a forked child. The + suite drives each of those for real; the fixtures under `src/__tests__/fixtures/` import the package + by name and so need a build, which the suite says rather than failing inside a thread. + +- dbb643a: Add `wrapSensitiveByClassification()` and `checkClassifiedFieldsWrapped()`: bulk wrapping for a + bag whose keys the caller does not choose. + + ```ts + // Keep two headers readable, wrap every other one, named or not. + const headers = wrapSensitiveByClassification(req.headers, { + fields: { referer: 'plain', host: 'plain' }, + fallback: 'sensitive', + }); + + // A plugin's return value: everything it hands back is a credential. + const creds = wrapSensitiveByClassification(await plugin.getCredentials(), { + fallback: 'credential', + }); + ``` + + `wrapSensitiveByPath` takes an enumerated list of the paths that are sensitive, which is the right + entry when the payload's shape is known and the wrong one for a header bag, a `.env` file, a plugin + loaded at run time or a provider credential object whose keys are data: a path list over such a bag + is a denylist turned inside out, and the key nobody named arrives unwrapped, silently. The new entry + takes the classification spec the secrets and env layers already use, `fields`, then `rules`, then + `fallback`, and a `fallback` inverts the default so the list a caller maintains is the short one. + + Strings only are wrapped; a number, a boolean and `null` keep their type. Every element of an array + of strings is wrapped under the array's own name, as the path helper now does at a declared path. An + already wrapped value is kept, and a protected value handed in as the whole object is refused with a + `TypeError` by both wrappers rather than walked in place. `constructor`, `prototype` and an own + `__proto__` are classified like any other key, since a payload chooses its keys and a header named + `constructor` is legal; the write keeps `__proto__` an own data property. A label may be a fixed + string or a function of the leaf's path and key, never its value, and construction options apply to + every wrapped leaf. + + `checkClassifiedFieldsWrapped(obj, spec)` walks the object and asks the spec, which is the direction + `checkSensitiveFieldsWrapped` cannot look in: that one walks the config and asks the object, so a key + the object carries and the config never named is invisible to it. + + `wrapSensitiveByPath` now wraps every string element of an array at a declared path too, through + nested arrays, with the class, label and options the path declares. Declaring + `'db.replicaUrls': 'credential'` over a list of connection strings used to wrap nothing, silently, + and the two report functions vouched for it; the report side was fixed first, and this closes the + gap itself. It is a behaviour change for a configuration that declares a list and relied on it + passing through: the elements arrive wrapped, which is loud. `unwrapSensitiveByPath` reveals every + instance in such a list, `validateSensitiveFieldConfig` accepts a list of strings and still reports + an object, a number, or a list holding either as unwrappable, and `checkSensitiveFieldsWrapped` + reports a list once, under its path, if anything in it is not an instance. + +- 59ace7a: Count what the library prevented, and price the rest against the emission points that exist. + + `@platform-sec/sensitive-string-metrics` is the collector the redaction observer was built for. It + subscribes to `observeRedactions()` and publishes + `sensitive_redactions_total{class, label, surface}` first in its catalog, because every rendering of + a marker is a place a plain string would have been written, and that is the number a deployment is + judged on. Installing the package, calling `start()` and mounting a Prometheus endpoint is the whole + of a measurement-first deployment: no call site changes, and the counter is complete from `start()` + onward. + + The rest of the catalog is smaller than the design asked for, and each omission has the same reason. + Core has three emission points, and only three. `observeRedactions()`, process-wide. The + `auditReveal` hook, which fires for the eight operations in `RevealDetails` and only under a context + carrying it. And the memory hygiene `WeakRef` count. A refusal fires nothing at all, deliberately: + the gate throws before the policy hook and before the audit hook so that a denied read is not logged + as a reveal. Construction and `equals()` fire nothing either. + + So three counters ship rather than six. `sensitive_reveals_total{class, label, operation, context}` + carries the operation as a dimension, since one hook reports all eight, which makes the design's + separate revocations counter a dimension value and makes the expirations counter impossible: a value + does not expire, a read finds it expired and throws. `sensitive_refusals_total{class, code}` is fed + by the caller through `recordRefusal(error)` or the `meterRefusals` tee, and the README says so + rather than implying a coverage it does not have. The three histograms are not built, because each + needs a bracket around an operation and the one hook available fires at a single instant inside the + gate, so timing from it would measure the hook's own dispatch. + + The three counters have three different coverages, which is the fact most likely to be misread, so + the README leads with the table and the instruction not to divide one into another. A + reveals-per-redaction ratio measures how much of your code you wired up, not how your system behaves. + + `withMeteredReveals` exists because the obvious wiring is wrong. Installing a context with + `auditReveal: metrics.auditReveal` replaces the ambient context wholesale and silently drops whatever + audit sink the application put higher up the stack, which is a bad trade for a counter. It builds + through `createChildContext`, inherits the ambient tags, and chains the hooks, calling each through + the object it was declared on rather than extracting it, since `auditReveal` is a method in + `RevealContext` and core invokes it as one. The counter runs last, so a reveal an audit sink refused + is not counted as one that happened. + + Two dimensions carry text the library does not choose, and both are treated as such. The `context` + dimension is **off by default**: a context name is free-form text from a call site where a request id + is also at hand, and a metrics store keeps it for months. There is no `context.tags.*` dimension. + `maxSeries` folds the caller-supplied dimensions into `__other__` rather than dropping events, so the + total stays right and only the breakdown is lost, and a hard ceiling underneath makes the bound hold + even where the non-folding dimensions are fed by hand. + + Both exposition formats are line-oriented and one of the label values is caller text, so the escaping + is the point rather than a detail. A label of `k",surface="x"} 99\nintruder_total{a="b` closes the + brace, ends the line, and writes a metric of its own into a scrape; the StatsD equivalent is a + newline followed by `intruder:99|c|#x:y`. Each suite drives the injection its own format is + vulnerable to and asserts that the result is not a line, and the StatsD suite asserts on bytes that + crossed a real loopback socket rather than on a string an internal method returned. + + Five defects found while building, and every one is the same mistake: a label value is caller text, + and the code treated it as a well-behaved identifier. A breakdown accumulated on an ordinary object + read `toString` off the prototype and rendered `"function toString() { [native code] }1"` as a metric + value, and would have rewritten the object's prototype for a label of `__proto__`. `StatsDExporter` + closed its socket in the same turn as its final flush, discarding the datagrams on exactly the + `SIGTERM` path that flush exists for. `PrometheusExporter` reserved five of its six dimension names + against `defaultLabels` and missed `context`, and a series carrying one label name twice is a scrape + Prometheus rejects outright, so a single misconfigured default label would take the whole `/metrics` + endpoint down. `StatsDExporter` also computed a delta per collector series, but sanitizing a tag is + many-to-one, so `a:b` and `a,b` both land on `a_b` and each computed its delta against the other's + baseline, inflating the counter on every flush and never recovering. And `collect()` documented an + ordering it did not implement. + + Two more were resource defects rather than formatting ones. The StatsD TCP path ignored what `write` + returned, so a far end that accepts the connection and stops reading made Node buffer every payload + in this process: measured, twenty thousand writes held 193 MB, none of it freed while the connection + stayed open. And the cardinality cap bounded how many series a counter held but nothing about how + large one was, so a thousand legal series still made a scrape as large as the caller's labels were + long; dimension values are now truncated at ingest, where the truncated value becomes the series + identity and the two exporters cannot be handed series that render identically. + + A third pass asked where the guarantees stop rather than what an input renders as or costs, and found + four more. Two were false sentences in the README about behaviour that was already correct, and both + concern a boundary. `sensitive_redactions_total` covers **one isolate**, not the process: measured, a + worker thread rendering five markers left a main-thread collector reporting zero, which inverts the + headline number for anyone whose real work happens in a worker pool. A collector plus a StatsD + exporter per worker composes, measured, because the agent sums the deltas; a Prometheus endpoint does + not, because it exports only the isolate serving it. And the stated reason core is a peer dependency + was backwards: two physical copies of core **share** the observer registry, because core keys it on + `Symbol.for()`, so redactions from both are counted; what does not survive is `recordRefusal`, which + recognises a refusal by `instanceof` and silently misses the other copy's. Both are now asserted, + which rule R6 asks for anyway. The other two were unvalidated configuration: `flushInterval` and + `maxPacketSize` accepted zero, negative and `NaN`, and `setInterval` turns any of those into a + one-millisecond timer rather than an error, so the mistake shipped as a flush loop; and both + exporters accepted a non-collector and failed at the first scrape instead of at boot. + + A fourth pass added benchmarks and asked what recording costs, which found the largest defect of the + lot. Series were held in one `Map` keyed on the dimension values joined into a length-prefixed + composite, and that cost about 450 ns per redaction against a rendering core prices at 17 to 26 ns. + The expense is not the string building, it is the hashing: a freshly built key has no cached hash, so + every lookup hashes the whole string, where the dimension values are repeated strings V8 has already + hashed. Measured over three dimensions, 96 ns to build a key, 257 ns to build it and look it up, and + 12 ns to walk three nested maps and build nothing. The counter now holds series in nested maps, one + level per dimension: `inc` into an existing series went from 1022 ns to 16 ns in a tight loop, and + the collector's end-to-end cost from about 450 ns to about 70 ns over a no-op observer. The shape is + collision-free by construction too, rather than needing the length prefix to earn it. + + That rewrite changed one behaviour and three existing tests caught it. The composite key coerced its + inputs, so a non-string label, which core permits, threw on the way into the key and was counted as a + recording error; a trie has no such step and would have stored it, moving the failure to export time + where it is a broken `/metrics` response rather than a counted error. The check is explicit now, in + the one function every caller-supplied dimension passes through. + + Eleven of the twelve were found by review after the suite was already green: four past 100 passing + tests, two past 109, three past 114, one past 129. Each has a test confirmed red against the unfixed + code, except the two that were wrong sentences rather than wrong lines, whose tests pin the measured + behaviour instead. + + StatsD goes out over `node:dgram` and `node:net`, so both exporters ship in one package with no + third-party runtime dependency, against the design's proposal to split them. `collect()` is the + public seam between collection and export, and a CloudWatch or OpenTelemetry exporter needs nothing + else from a collector. + + Rule R6 does not apply to the collector, which is where the design was wrong about it: a subscriber + is not a singleton. There is no process-global collector to reconfigure, nothing for a duplicate + install to split, and no reset to gate. Two collectors in one process are two independent sets of + numbers, which is what lets this package and a future OpenTelemetry one run side by side. + + In core, `trackedRefCount()` becomes the public `trackedInstanceCount()`, which is the only new + export. The gauge over it is `sensitive_values_tracked` rather than the design's + `sensitive_values_active{class}`, on both counts deliberately: the `WeakRef` set is pruned by a + `FinalizationRegistry` that needs at least two GC cycles and two macrotask turns, so `active` is a + claim the number cannot support, and a per-class split needs a `deref()` on every entry, which keeps + its target alive to the end of the job. A gauge that extended the life of what it measured would be a + strange thing to put in a memory-hygiene metric. + +- 29c6f87: Add transmission references: send a pointer to a secret instead of the secret. + + ROADMAP 6.5, release one of two. `serialize()` puts the raw value on the wire and asks the transport + for confidentiality, which is a fair deal for mTLS between two services in one trust domain and a + poor one everywhere the payload comes to rest. A job in SQS, Kafka, a `jobs` table or a webhook + retry buffer sits there in plaintext for as long as the retention allows, and a transport that + terminates at a mesh sidecar, an API gateway or a logging proxy is decrypted somewhere in the + middle. Where the value came from a store both sides can reach, `toReference(record, { store })` + sends where it is and the receiver fetches it with its own credentials, so the value never leaves + the process that read it. + + There is no `token.toReference()`, and the reason is not ergonomic. `#derived` unions the state of + every source instance into the derivative, which is right for constraints and inverts what + provenance means: a `toReference()` on the instance would let `token.substring(0, 4)` mint a pointer + to the whole credential, and the receiver would have no way to tell the difference. The label rule + is the second reason, since it strips everything identifying the account, project, tenancy or host, + so an instance knows a path and not which of an organization's stores that path is in, and the same + logical path usually exists in staging and in production. Minting takes the `ref` a read returned, + which contains no secret material by construction, and `store` is required rather than derived from + the source, because inventing one from the endpoint would put an internal hostname into a payload. + + Minting is not a reveal. It performs no I/O and never touches `#value`, and recording it as one + would make a sink that counts reveals to decide whether a credential is over-used count every + message that merely mentions the secret. That is the call `SecretLoadEvent` already made for loads, + for the same reason. + + Resolving is the half with the security content, and it is not `get()` under another name. + `createReferenceResolver({ store, source, allow }).resolve(ref, { context })` runs in the receiver, + with the receiver's credentials, against a path a sender chose, which is the shape of every confused + deputy: unconstrained, it lets anything that can enqueue a job read any secret the receiver's role + can read, using the receiver as the oracle. So the allowlist is required, there is no permissive + default, and the empty list is legal and resolves nothing. The checks run in order and each precedes + the fetch: the caller's own context, then the shape and the format version, the store identity + compared exactly, a path carrying a traversal, an empty segment, a leading slash or a control + character, a version outside `[A-Za-z0-9._:-]{1,128}`, an entry covering the path, and an entry that + resolves this kind of reference and declares this field. Then the class, as the strictest among the + covering entries and then the more restrictive of that and the reference's: a class on the wire may tighten what the receiver does and may never loosen it, because + a receiver that took `klass` from a payload would let a sender turn off constant-time comparison for + a credential, lift the refusal on partial queries, and let `mask()` disclose a prefix. + + A path is refused rather than normalized. Normalizing would mean this library and the provider each + holding an opinion about what `kv/a/../b` means, and a disagreement between two normalizers is how a + path that fails the allowlist reaches the store anyway. It is also refused for carrying `%`, `\`, + `?` or `#`, which is a stronger rule than "no `..` segment" and a measured one rather than a + cautious one: a provider builds its request as `new URL(base + path)`, which is what + `sensitive-vault` does, and on Node 22.15.0 the WHATWG parser applies its double-dot rule to the + percent-decoded segment and reads a backslash as a separator, so both + `kv/payments/%2e%2e/%2e%2e/auth/token/lookup-self` and `kv/payments/..\..\auth/token/lookup-self` + leave an allowlisted subtree while passing a check over `/`-split segments, and the second carries + no percent sign at all. A `?` turns a read of a permitted secret into a different request against + the same store. The suite pins that parser behaviour next to the refusals, so the rule keeps its + reason attached. A version is checked against a charset rather than escaped, for the same reason. + Patterns are a trailing `*` on a segment boundary and nothing else, with no regex, and a malformed + one throws at construction rather than silently matching nothing. + + Where entries overlap, the class is the strictest among them rather than the first one's. A subtree + rule and a rule for one path under it are how an operator writes an allowlist, and taking the first + would make `[{ path: 'kv/*', klass: 'sensitive' }, { path: 'kv/payments/stripe', klass: +'credential' }]` resolve the Stripe key as `sensitive`, which is a security control whose answer + depends on the order two lines were written in. The allowlist is copied entry by entry and frozen at + construction for the same kind of reason, so a caller holding the array or an entry cannot widen a + live resolver afterwards. + + A refusal is a `ReferenceRefusedError` carrying `reason`, and the path is on `error.path` and never + in `error.message`: a refusal is the case where the sender is least likely to be one this receiver + trusts, and interpolating what they sent would put attacker-chosen text into the receiver's own + logs. As everywhere in this package, no error sets `cause`. + + `core` gains the format and nothing else: `SensitiveValueReference`, `isSensitiveValueReference()`, + `carriesReferenceMarker()` and `referenceFormatVersion()`. It has no minting and no resolving, + because both need the store, the version and the credentials, and it would have had to model stores + and accounts to hold them. What the format is doing in `core` is that the walks which must decide + what to do with a wire object are there, and what they decide for this one is to leave it alone: a + reference carries no secret material, resolving it needs credentials of the reader's own, and + redacting it would make a reference-passing system undebuggable for exactly no gain. Those + assertions are positive in the suite rather than absent from it, so a later change to redact + everything with a `__sensitive`-shaped marker fails a test instead of shipping. + + Two smaller decisions worth recording. `resolve()` requires its own `RevealContext`, for the reason + `PutOptions.context` does on the write path, and checks for it before it looks at the reference at + all, because a receiver that forgot its context has a mistake of its own and reporting that as a + fault in what arrived would point whoever reads the log at the wrong party. That context is + installed as a child of the ambient one, so an application's `auditReveal` is inherited rather than + dropped and a provider that reveals its own token to authenticate the request records that reveal + under the caller's name. And `pinVersion` + defaults to true, since a reference stands in for a value and a value does not change between the + send and the receive, with the cost stated where the option is: a pinned reference outlives the + rotation that was meant to retire the old version, and queue retention shorter than the rotation + interval is the honest fix. The `'unknown'` a provider reports when it has no version is a sentinel + rather than a version, and is never pinned. + + The encrypted envelope, the other half of 6.5, is a separate release and is not in this one. + +### Patch Changes + +- 200a5c9: Reuse the compiled form of a classification spec instead of rebuilding it per call. + + `classifyField` compiles the spec it is handed, which means building a Map, validating every class + and recompiling every rule's `RegExp`, and it did that on every call. Over a payload of five keys it + measured 4339ns against 281ns for the same work through a compiled classification held across the + loop. The gap is the whole reason `compileClassification` is exported, and it is not a gap a caller + should have to know about to avoid. + + `compileClassification` now memoizes per spec object in a `WeakMap`. On its own it went from 769ns + to 17ns, `classifyField` on one key from 868ns to 66ns, and over five keys from 4339ns to 281ns. The + entry is held only as long as the spec object is, so a spec built per request is collected with it. + + `compileClassification` now also refuses a specification that is not an object, with + `Invalid classification specification: expected an object, received string.` A primitive reached + `Object.entries(spec.fields ?? {})`, compiled to a classification that matched nothing and reported + that to nobody, so a caller who passed the wrong argument got an answer instead of an error. The + check sits ahead of the memo because `WeakMap` refuses a primitive key and would otherwise report + the mistake as `Invalid value used as weak map key`, which names an implementation detail rather + than what the caller did. + + One consequence worth stating, since it is observable. Reuse goes by object identity, so the same + spec object hands back the same compiled form, two equal specs compile separately, and a spec + mutated after it has been classified with keeps the form it compiled to the first time. A + `ClassificationSpec` is `readonly` throughout its type, so mutating one was never supported, but the + old behaviour would have picked the change up and the new one does not. All three cases are covered + by tests. + +- e721fbd: `equals()` no longer leaks the stored value's length through timing. + + The constant-time path compared lengths first and returned early when they differed, under two + comments calling that leak unavoidable. It is not unavoidable, and the early return was measurable: + a comparand of the wrong length answered before any byte was examined, so an attacker who can time + the call learns the length of the credential by finding the length that costs more. A length is not + the secret, but it bounds the search for one, and this was the one call site the library advertises + as safe to hand a credential to without thinking about it. + + Both operands are now hashed to a fixed width before they are compared, so `timingSafeEqual` always + receives two 32-byte digests and there is no length to check and no path to choose between. The + per-process key it hashes under is ephemeral and never exported: it exists to make a digest of a + guessable value worthless if it were ever observed, not to be agreed with anything. + + What remains is the HMAC's own cost, which grows with its input one 64-byte block at a time, over + the stored value as well as over the comparand. Measured independently, stored values from 8 to 256 + bytes were indistinguishable within the timing analysis's noise floor; 1024 bytes cost about 460ns + more than 8 and 4096 about 2.5us more. A credential-sized value has its length hidden to the + resolution anyone has measured. A value hundreds of bytes long has it hidden at block granularity, + which is a weaker promise than "no dependence" and is what the source comments now say. + + Comparing a wrong-length comparand against a zero-filled buffer of its own length is the more common + repair and was not used. It moves the branch rather than removing it, since only the mismatch path + allocates and fills that buffer, so that path becomes the slower one and the signal survives with + its sign inverted. + + This costs real time. Measured on Node 22.15 with 32-character operands, a `credential` comparison + went from about 130ns to about 5us, dominated by two `createHmac` calls. That is once per + authentication event, against a password verification that is four orders of magnitude slower by + design. A caller matching one credential against many wants `hash()` and an index rather than a + linear scan of comparisons, which was already true before this change. + + No API changed and no behaviour changed other than the timing: the same comparisons answer the same + way. The timing analysis run by `pnpm test:timing` now measures the direction an attacker actually + controls, holding the comparand fixed and varying the stored length, which is the case the previous + version of that test documented as expected to leak. + +- 785c101: Stop paying for a clock read, an ISO rendering and a Set on paths that do not need them. + + Three costs found by the benchmark suite and attributed against the primitives underneath them, all + measured on this machine at Node 22.15.0 with the harness overhead subtracted. + + `#expiredDeadline` read `Date.now()` before looking at whether any state carried a deadline, so a + value that cannot expire paid for a clock read on every reveal, comparison, transform, mask and + serialize. `Date.now()` measured 82ns here, against 141ns for the whole of `reveal()`: the gate spent + more on asking the time than on everything else together. The loop now reads the clock lazily, at + most once, and only when a state actually has a deadline. `reveal()` on a value with no deadline + went from 141ns to 40ns, and `equals()` on a `sensitive` value from 160ns to 75ns. + + `serialize()` called `new Date(deadline).toISOString()` on every call, which measured 1060ns on its + own, more than the rest of the method put together. The rendering is memoized by the deadline it + renders, so a renewal misses and re-renders on its own, and the memo is discarded once it passes 256 + entries so a service with many distinct deadlines cannot grow it without bound. A deadline is a + number that already travels on the wire in exactly this form, so nothing secret is held there. + `serialize()` on an expiring value went from 1393ns to 153ns. + + Deriving from a single source rebuilt the state list through a `Set` and an `Array.from`, when the + source's own list is already what the derivative needs. `#states` is assigned once and never + mutated, and the state objects are shared between derivatives by design, so the single-source case + now reuses it. `substring()` went from 273ns to 96ns and `concat()` from 359ns to 259ns. + + No behaviour changes. The expiry gate, the wire format and the deadline a derivative inherits are + all unchanged, and the suite that covers them is unchanged too. Two cases were added to the wire + format tests: that a renewed value serializes its new deadline rather than the one it first + rendered, and that more distinct deadlines than the memo holds each serialize correctly across two + passes. Both fail against a memo keyed wrongly. + +- 342f7e4: Documents one case where pino loses the redaction marker, and the one-line remedy. + + No code changed. This is a behaviour of pino that a user of this library will otherwise meet without + warning, and the README's pino section now describes it where someone configuring pino will read it. + + With a wildcard entry in pino's `redact.paths`, a wrapped value logged as a direct property of the + merging argument serializes as `[unable to serialize, circular reference is too complex to analyze]` + rather than as its marker. Three conditions have to hold at once and dropping any one avoids it: a + wildcard in the path list, since a literal list is unaffected at any depth; a class instance carrying + an ES private field; and that instance at the top level of the log object, since one level down or + inside an array renders correctly. + + Nothing is disclosed. The value does not appear in either case. What is lost is the marker, so a line + that should say a credential was present instead says the serializer gave up, and the diagnostic this + library exists to preserve is exactly the thing that goes. + + It is not fixable from inside the library. The same failure reproduces with any class carrying a + `#private` field and no `toJSON` at all, and does not reproduce with the identical class without the + private field; `Date` and plain objects with a `toJSON` are unaffected. So it is not caused by + `toJSON`, and `#value` is what makes a wrapped value unreadable by reflection, which is not a + property to trade away for this. + + The remedy is a `formatters.log` that converts the payload before pino's redaction machinery sees it, + and it leaves pino's own path redaction working alongside the marker. + + The top-level condition sounds like it should reduce this to nothing, and does not: `logger.info(data, +msg)` is the ordinary pino idiom and puts every field of `data` at exactly that level. It was found + in two real applications that both reach it that way through their own logging wrappers. + + Measured against pino 10.3.1. + `0.5.0` is the first version published to npm. The `0.2.0` through `0.4.0` entries below are development milestones rather than releases: they record the four waves of work this package went through before it was publishable, so that its history is legible from the version it starts at. diff --git a/packages/sensitive-core/package.json b/packages/sensitive-core/package.json index 747f80c..4be31ec 100644 --- a/packages/sensitive-core/package.json +++ b/packages/sensitive-core/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-core", - "version": "0.5.0", + "version": "0.6.0", "description": "Core library for handling sensitive string data with automatic redaction", "main": "./dist/index.js", "module": "./dist/index.js", diff --git a/packages/sensitive-data/CHANGELOG.md b/packages/sensitive-data/CHANGELOG.md new file mode 100644 index 0000000..8be21fc --- /dev/null +++ b/packages/sensitive-data/CHANGELOG.md @@ -0,0 +1,167 @@ +# @platform-sec/sensitive-string-data + +## 0.2.0 + +### Minor Changes + +- 1c87ee4: A shared layer under the ORM integrations, `sensitive-data`, extracted from the three that were + already built rather than designed ahead of them. It holds the bound-parameter walk, the query-log + redactor, the statement identifier scan, the driver error sanitizer and the write reveal context. + Nothing installs it directly; each ORM package takes it as a peer. + + Extracting from three consumers instead of one was the point, and it is a deliberate departure from + how `scrub` and `secrets` were built. Those shipped alongside a single first consumer and held up, + because their consumers are homogeneous. A `customType`, a `ValueTransformer` and a client extension + are not, and every piece of this layer differs from what any one of the three had. + + Two of those differences were gaps in shipped code, and neither was reachable by the suite of the + package that had it, because each package's development driver could not produce the shape the other + covered. + + **The bound-parameter walk missed a shape in each direction.** TypeORM's descended into nested + containers and terminated on cycles, but walked only arrays and objects whose prototype is + `Object.prototype`, so a `Map` or a bag from another realm passed the guard unchecked. Drizzle's + walked every object shape but stopped at one level, so an instance inside a nested filter object + passed. The shared walk does both, and both packages gain what they were missing. + + **The error sanitizer had the message and stack half in one package only.** `DrizzleQueryError` + interpolates the parameters into its message and V8 bakes that message into the stack header at + construction, so Drizzle's sanitizer rewrote all three copies. TypeORM's rewrote the structured + property alone, which is correct for `QueryFailedError` and leaves nothing for the next consumer + whose message does carry a value. The machinery now exists once and is exercised by both shapes. + + Two behaviour changes in the ORM packages follow from unifying them: + + `DrizzleParameterRefusedError.parameter` is now a `string` rather than `number | string`. A numeric + index cannot name a position inside a container, and the walk descends into one, so the field + carries a path such as `0.filters[1]`. + + The TypeORM write context now tags a reveal with `klass`, which the Drizzle one already did, and the + Prisma one now tags with `sink`, which neither of the others was missing. An audit sink receiving + records from an application using more than one of these packages can now tell them apart. + + Prisma keeps its own reveal context builder, deliberately. The shared one is per column and carries + one of six `WriteOperation` members; Prisma's is built once per operation, its `path` is a position + in a nested argument object rather than a column, and its `operation` is Prisma's own free-form + name. Filling the shared shape would mean reporting `unknown` for an operation that hook actually + knows. + + Three things were deliberately not extracted, because three consumers proved they are not shared: + the column specification, which is a path map in one, a model-keyed object in another and a + declaration on the column itself in the third; the drift check, which reads entity metadata, the + runtime data model and registered column objects respectively; and the `sensitive-scrub` fragments, + which name different properties on different errors. Each package also keeps its own error types, on + the same reasoning that kept `SecretWriteRefusedError` where it is. + +### Patch Changes + +- 2c5bd7f: Three fixes from reviewing the extracted layer, one of them a leak that existed in + `sensitive-typeorm` before the extraction and was carried into the shared code. + + **A nested driver error that would not take the censor in place was returned with its plaintext.** + `sanitizeQueryError` censors the parameter property on the error and on any nested one a shape + names, and falls back to a copy of the outer error when a property will not move. That copy carried + the nested error across by descriptor, so a frozen `driverError`, or one whose parameter property is + a non-configurable accessor, ended up on the returned error with the original values still on it. + The outer error reads as sanitized, which makes this the worst of the three possible outcomes rather + than a partial fix. A censored copy of the nested error now goes onto the outer copy. Neither + package's fixtures had a nested error that resists a write, which is why neither suite saw it. + + **The total-failure path dropped a message that was safe to keep.** When everything else fails, + `sanitizeQueryError` returns a minimal error. For a shape that declares no `sanitizeMessage`, which + is a measurement that the ORM's message carries no value rather than a default, the original message + and stack are the diagnosis and are now carried across. This restores what `sensitive-typeorm` did + before the extraction. + + **The recovery path could throw from the function whose contract is that it does not.** The `catch` + that produces the minimal error read `message`, `name` and `stack` as plain properties, so a + throwing getter there escaped `sanitizeQueryError` entirely, at the one moment the contract is being + relied on. Every read in that path is now guarded individually, so a single hostile property costs + only that property rather than the whole recovery, and the path as a whole is guarded again beneath + that. Carried in from `sensitive-typeorm`, where the same three reads were unguarded. + + **The Drizzle logger scanned each statement twice.** `touchesClassifiedColumn` called the shared + predicate once per column set, and the shared predicate scans the statement for identifiers. It now + scans once and checks both sets against the one result, which is what that package's README already + claimed. This is on the path that runs for every query the process makes. + + Two README claims are corrected to match the code. The TypeORM composition section said the write + refusal "does not catch that and cannot" for a reversed transformer array; since the marker detector + moved to core it matches the `[REDACTED:` prefix, so it does catch a transform that leaves the prefix + intact and still cannot catch one that destroys it. Both halves are now asserted. The Drizzle cost + section described the marker memo as keyed on the registered class list, which is no longer how it + works. + +- 635b4c2: Stop paying for the failure the guards almost never find. + + Both of these guards run on every request and every statement, and both did work whose only consumer + was a branch that does not fire. Neither change alters what is refused. What changes is what a clean + request costs to clear. + + `assertNoMarker` percent-decoded every outgoing body before scanning it, which allocates two full + copies of the body: one from `replace(/\+/g, ' ')` and one from `decodeURIComponent`. A JSON body + contains neither `%` nor `+`, so `percentDecoded` is the identity on it and the decoded scan searches + a string identical to the one just searched. The decode is now skipped when the body holds neither + character, which is not a heuristic: with no `+` the replace has nothing to replace, with no `%` the + decode has nothing to decode, so `decoded !== wire` was already dead and the branch was unreachable. + Measured on Node 22.15.0 over a marker-free JSON body, median of seven rounds with each size in its + own process: 1 KB from 2.6 us to 105 ns, 16 KB from 34.9 us to 613 ns, 128 KB from 366 us to 5.9 us. + It is the only figure in this release that scales with payload size, so it is worth most to whoever + sends the largest bodies. + + `+` is checked as well as `%`, and that half is load-bearing rather than defensive. A custom + `registerPolicy` redaction may contain a space, a form encoder puts that space on the wire as `+`, + and the resulting body carries the marker with no percent-escape anywhere in it. A check for `%` + alone would skip that body without looking at it, and a test now fails if the `+` is removed. A + second test compares the guard against an implementation that decodes unconditionally over a corpus + of bodies, so the equivalence is asserted rather than argued. + + `findUnsafeParameter` walked the bound parameters through `parameterEntries`, which materialises a + `[string, unknown][]`, then carried a path string down the recursion and allocated a `WeakSet` per + container. On a bag with nothing wrong in it, which is every bag in a working application, all three + are built and discarded. The walk now reads the three bag shapes in place, builds the path only while + unwinding the branch that found something, and creates the cycle guard on the first descent rather + than on entry. Measured the same way: 5 scalars from 237 ns to 81 ns, 6 named scalars from 486 ns to + 201 ns, 3 parameters with one nested array from 532 ns to 162 ns, a 4-level nesting from 1017 ns to + 372 ns. + + Whether that second one is worth having depends on the driver, and the doc comment says so rather + than quoting the ratio alone. Against a networked Postgres, 300 ns is invisible. Against in-process + `better-sqlite3`, where a small query is single-digit microseconds, it is a few percent of the + statement. + + `parameterEntries` keeps its shape and its exported signature, but nothing calls it on the hot path + any more, so two walkers now read the same three bag shapes. They are held together by a test that + runs both over the same bags rather than by one calling the other, and adding a shape to one without + the other fails it. + + Mutation testing found two coverage gaps that a green suite did not. Deleting the array segment from + the path on the unwind passed every test, because no fixture had an offence nested inside a container + inside an array, only inside an array directly. Removing the array cycle guard also passed, because + the only cycle in the fixtures was an object cycle. Both are now in the corpus, along with a mutual + cycle between an array and an object, and all six mutations of the new walk fail. + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [59ace7a] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 diff --git a/packages/sensitive-data/package.json b/packages/sensitive-data/package.json index d2e3624..136a744 100644 --- a/packages/sensitive-data/package.json +++ b/packages/sensitive-data/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-data", - "version": "0.1.0", + "version": "0.2.0", "description": "Shared layer under the data layer integrations - the bound-parameter guard and its log redactor, the statement identifier scan, the driver error sanitizer, and the write reveal context", "main": "./dist/index.js", "module": "./dist/index.js", @@ -32,7 +32,7 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0" + "@platform-sec/sensitive-string-core": "^0.6.0" }, "devDependencies": { "@platform-sec/sensitive-string-core": "workspace:*", diff --git a/packages/sensitive-datadog/CHANGELOG.md b/packages/sensitive-datadog/CHANGELOG.md new file mode 100644 index 0000000..d3c7b7b --- /dev/null +++ b/packages/sensitive-datadog/CHANGELOG.md @@ -0,0 +1,94 @@ +# @platform-sec/sensitive-string-datadog + +## 0.2.0 + +### Minor Changes + +- 825ae8e: A Datadog APM integration, over the shared `@platform-sec/sensitive-string-scrub` layer. Everything + below was measured on Node 22.15.0 against `dd-trace` 6.14.0, driving the real agent in its own + process, posting to a local HTTP server standing in for the trace agent, and decoding the msgpack, + with a companion case requiring each position to leak once the scrubbing is removed. + + This row does not close a default leak, and the README says so first. Measured at stock defaults, an + inbound `POST /pay?token=&page=2` carrying `authorization`, `cookie`, `x-api-key` and a JSON + body with a `password` field produced a server span of exactly three tags: `http.url` with the query + obfuscated in place, `http.method` and `http.status_code`. No header, no body, no canary. What this + package closes is the opt-in: `headerTags` puts `authorization` and `cookie` into the payload + verbatim, and onto the client span as well as the server one, so turning header capture on to debug + inbound traffic starts transmitting the auth header of every outbound call. + + `installSensitiveScrubbing` composes `hooks.request` on twelve plugins and generates two lists a hook + cannot reach. The plugin list is the correction the design did not have: `dd-trace`'s `web.js` calls + `setFramework`, which replaces a request's whole plugin config with the framework plugin's, hooks and + header list included, so an express-served request runs the **express** plugin's hook and covering + `http` alone leaves `express.request` carrying the raw header. `express`, `fastify`, `koa`, + `connect`, `hapi`, `hono`, `restify`, `microgateway-core` and `fetch` all inherit the hook at the + type level and all needed covering. A seam test reads the shipped `index.d.ts`, resolves the + interface inheritance, and fails naming any plugin that offers a `hooks` option this package has no + opinion about. + + The structural walk is load-bearing here rather than defence in depth, for a reason no other vendor + in this row shares: nothing on the span tag path calls `toJSON()`. `addMixedTag` flattens an object + one level with `Object.entries`, which is exactly enough to reach the `value` key of a `serialize()` + result and put a full credential on a tag. The same flattening turns a plain `SensitiveString` into + `probe.klass` and `probe.label`, so the redaction marker never appears and a label is transmitted + with no option involved. Both are replaced before the agent sees them, on plugin spans and on spans + the application creates itself. + + Two behaviours the package composes with rather than replaces. Its query obfuscator is the agent's + own default pattern, reproduced byte for byte and pinned against the shipped configuration data, with + one anchored branch appended per denied key the default does not already match. Its header capture + list keeps a denied header rather than dropping it, so the tag exists and its presence stays + diagnostic while the hook replaces the value. + + `scrubManualSpans` is on by default and wraps `startSpan`, which measured is what **every** span goes + through, `dns.lookup` and `tcp.connect` included. So the finish-time walk sees every span the process + creates after installation, and the hooks stay the primary mechanism because they are the only route + to the header capture list and the query obfuscator, and because they mark the span so one span + produces one report. A throw at that seam is contained rather than propagated: a hook body is called + by the agent, which answers a throw by dropping one span, while `finish()` is called by the + application, so the last-resort rethrow is caught there, the span is emptied through `setTag` + instead, and it is finished either way. + + The failure output is a censored tag bag, never `null`, and that is forced rather than chosen: + `web.js` discards a hook's return value, so `null` would mean the span ships with its original tags. + It writes `[SCRUB_FAILED]` rather than the ordinary censor, so a reader can tell a denylist hit from + a broken scrubber. If censoring itself fails the hook rethrows, and the agent's answer to that, + measured, is to drop that one span while the request succeeds and a neighbouring plugin's span + survives. + + One thing in the package is not a redaction feature and the README says so. Requiring `http` before + `tracer.init()` and never requiring it again leaves an application with no HTTP instrumentation at + all, silently, and the repair is a single `require` after `init()` because the agent patches the + module object in place. `installSensitiveScrubbing` does that for five core modules, which costs + nothing and fixes the most common way a `dd-trace` deployment is quietly broken. + +### Patch Changes + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [59ace7a] +- Updated dependencies [f67efd7] +- Updated dependencies [48f6213] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + - @platform-sec/sensitive-string-scrub@0.2.0 diff --git a/packages/sensitive-datadog/package.json b/packages/sensitive-datadog/package.json index f05e31b..adcf849 100644 --- a/packages/sensitive-datadog/package.json +++ b/packages/sensitive-datadog/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-datadog", - "version": "0.1.0", + "version": "0.2.0", "description": "Datadog APM integration for SensitiveString - scrubs span tags through dd-trace plugin hooks and generates the header capture and query obfuscator configuration", "main": "./dist/index.js", "module": "./dist/index.js", @@ -33,8 +33,8 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0", - "@platform-sec/sensitive-string-scrub": "^0.1.0", + "@platform-sec/sensitive-string-core": "^0.6.0", + "@platform-sec/sensitive-string-scrub": "^0.2.0", "dd-trace": "^6.0.0" }, "devDependencies": { diff --git a/packages/sensitive-decorators/CHANGELOG.md b/packages/sensitive-decorators/CHANGELOG.md index 7f7b455..e03bede 100644 --- a/packages/sensitive-decorators/CHANGELOG.md +++ b/packages/sensitive-decorators/CHANGELOG.md @@ -1,5 +1,34 @@ # @platform-sec/sensitive-string-decorators +## 0.1.1 + +### Patch Changes + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [59ace7a] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + ## 0.1.0 ### Minor Changes diff --git a/packages/sensitive-decorators/package.json b/packages/sensitive-decorators/package.json index 8c9c63f..5d22b4f 100644 --- a/packages/sensitive-decorators/package.json +++ b/packages/sensitive-decorators/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-decorators", - "version": "0.1.0", + "version": "0.1.1", "description": "Framework-agnostic Stage 3 decorators for SensitiveString - declarative field wrapping and reveal context", "main": "./dist/index.js", "module": "./dist/index.js", @@ -32,7 +32,7 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0" + "@platform-sec/sensitive-string-core": "^0.6.0" }, "devDependencies": { "@platform-sec/sensitive-string-core": "workspace:*", diff --git a/packages/sensitive-doppler/CHANGELOG.md b/packages/sensitive-doppler/CHANGELOG.md index 1a1a83b..a9efb1e 100644 --- a/packages/sensitive-doppler/CHANGELOG.md +++ b/packages/sensitive-doppler/CHANGELOG.md @@ -1,5 +1,35 @@ # @platform-sec/sensitive-string-doppler +## 0.1.1 + +### Patch Changes + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [59ace7a] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + - @platform-sec/sensitive-string-secrets@0.2.0 + ## 0.1.0 ### Minor Changes diff --git a/packages/sensitive-doppler/package.json b/packages/sensitive-doppler/package.json index c2ffc18..b43298e 100644 --- a/packages/sensitive-doppler/package.json +++ b/packages/sensitive-doppler/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-doppler", - "version": "0.1.0", + "version": "0.1.1", "description": "Doppler secret source - one fetch per config, mixed configuration classified by rule, every secret wrapped as a SensitiveString", "main": "./dist/index.js", "module": "./dist/index.js", @@ -32,8 +32,8 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0", - "@platform-sec/sensitive-string-secrets": "^0.1.0" + "@platform-sec/sensitive-string-core": "^0.6.0", + "@platform-sec/sensitive-string-secrets": "^0.2.0" }, "devDependencies": { "@platform-sec/sensitive-string-core": "workspace:*", diff --git a/packages/sensitive-drizzle/CHANGELOG.md b/packages/sensitive-drizzle/CHANGELOG.md new file mode 100644 index 0000000..3c68993 --- /dev/null +++ b/packages/sensitive-drizzle/CHANGELOG.md @@ -0,0 +1,212 @@ +# @platform-sec/sensitive-string-drizzle + +## 0.2.0 + +### Minor Changes + +- 1c87ee4: A shared layer under the ORM integrations, `sensitive-data`, extracted from the three that were + already built rather than designed ahead of them. It holds the bound-parameter walk, the query-log + redactor, the statement identifier scan, the driver error sanitizer and the write reveal context. + Nothing installs it directly; each ORM package takes it as a peer. + + Extracting from three consumers instead of one was the point, and it is a deliberate departure from + how `scrub` and `secrets` were built. Those shipped alongside a single first consumer and held up, + because their consumers are homogeneous. A `customType`, a `ValueTransformer` and a client extension + are not, and every piece of this layer differs from what any one of the three had. + + Two of those differences were gaps in shipped code, and neither was reachable by the suite of the + package that had it, because each package's development driver could not produce the shape the other + covered. + + **The bound-parameter walk missed a shape in each direction.** TypeORM's descended into nested + containers and terminated on cycles, but walked only arrays and objects whose prototype is + `Object.prototype`, so a `Map` or a bag from another realm passed the guard unchecked. Drizzle's + walked every object shape but stopped at one level, so an instance inside a nested filter object + passed. The shared walk does both, and both packages gain what they were missing. + + **The error sanitizer had the message and stack half in one package only.** `DrizzleQueryError` + interpolates the parameters into its message and V8 bakes that message into the stack header at + construction, so Drizzle's sanitizer rewrote all three copies. TypeORM's rewrote the structured + property alone, which is correct for `QueryFailedError` and leaves nothing for the next consumer + whose message does carry a value. The machinery now exists once and is exercised by both shapes. + + Two behaviour changes in the ORM packages follow from unifying them: + + `DrizzleParameterRefusedError.parameter` is now a `string` rather than `number | string`. A numeric + index cannot name a position inside a container, and the walk descends into one, so the field + carries a path such as `0.filters[1]`. + + The TypeORM write context now tags a reveal with `klass`, which the Drizzle one already did, and the + Prisma one now tags with `sink`, which neither of the others was missing. An audit sink receiving + records from an application using more than one of these packages can now tell them apart. + + Prisma keeps its own reveal context builder, deliberately. The shared one is per column and carries + one of six `WriteOperation` members; Prisma's is built once per operation, its `path` is a position + in a nested argument object rather than a column, and its `operation` is Prisma's own free-form + name. Filling the shared shape would mean reporting `unknown` for an operation that hook actually + knows. + + Three things were deliberately not extracted, because three consumers proved they are not shared: + the column specification, which is a path map in one, a model-keyed object in another and a + declaration on the column itself in the third; the drift check, which reads entity metadata, the + runtime data model and registered column objects respectively; and the `sensitive-scrub` fragments, + which name different properties on different errors. Each package also keeps its own error types, on + the same reasoning that kept `SecretWriteRefusedError` where it is. + +- 8e492e7: A Drizzle ORM integration. A classified column is declared in the schema, so a select returns a + `SensitiveString` in the type as well as in the value; every write unwraps through an audited + `reveal()`; and the two parameter paths Drizzle routes past the column are refused rather than left + to the driver. Everything below was measured against `drizzle-orm` 0.45.2, `better-sqlite3` 12.11.1, + `pg` 8.23.0 and `mysql2` 3.24.3. + + Reads and writes are one `customType` mapping, and that hook covers considerably more of this ORM + than a `ValueTransformer` covers of TypeORM: inserts, updates, deletes, `returning()`, the predicate + builders, `onConflictDoUpdate`, partial selects, subqueries, transactions, a prepared statement whose + values were bound at build time, `sql.param()`, and the relational query builder's nested rows all + route through it. It is also the only integration in the family where the classification and the + TypeScript type come from a single declaration, so `$inferSelect` carries `SensitiveString` through + relations and partial selects, and a misspelled column name is a compile error rather than a column + that quietly reads back plain. + + **The logger is the parameter guard.** Two paths reach the driver without a column behind them: a + value interpolated into a `sql` template, and a prepared statement whose predicate is + `eq(col, sql.placeholder('x'))`, which is the form Drizzle's own documentation recommends for hot + queries. Both hand the instance to the driver, where `better-sqlite3` throws, `pg` binds + `"[REDACTED:credential]"` and reports success, and `mysql2` escapes the instance's two enumerable + properties into the statement. `logQuery(sql, params)` is the only seam in the ORM called with a + statement's parameters after the placeholders are filled and immediately before the driver runs, on + every session including the ones a transaction opens for itself, so `sensitiveLogger()` is both the + log redaction and the refusal. It is passed to `drizzle()` at construction and cannot be installed + afterwards, because `node-postgres` builds a fresh session for a transaction on a pool. + + `sanitizeQueryError()` removes the revealed parameters from all three places `DrizzleQueryError` + keeps them: `params`, an own enumerable property; `message`, which interpolates them; and `stack`. + The third needs care. `stack` is an own accessor on a real error and stays one, and V8 formats it at + first access from whatever `message` says by then, so fixing the message alone looks correct until + something reads the stack first, which a logger or an APM agent between the throw and the `catch` + does. The stack is read through the getter, before the message moves, and written back. + + `checkSchema(db, schema)` runs once at boot and refuses a database built without the guard, a + configured query cache, which stores the driver's rows before any column's read side has run, and a + column named in `requireClassified` that was declared with a plain factory, which is the one drift + the compiler cannot see. It also reports a classified column carrying a column default, since that is + a secret in the schema file and in every migration generated from it. + + Both directions pass `null` through. Drizzle skips the mapping for `null` almost everywhere, which + makes a column that refuses one look correct against an ordinary insert; one path does send one, + namely a prepared statement whose `values()` used `sql.placeholder` and whose `execute()` supplied + `null`. A value that is neither text nor a `SensitiveString` is refused on the write path, and a + non-string read back raises `DrizzleColumnTypeError`, which is a different failure from a column an + earlier write corrupted. + + A classified column declares a standard SQL type, so `drizzle-kit generate` and `drizzle-kit push` + are unaffected and adoption on an existing column is a type change rather than a migration. + `hydrateRawRows()` is the audited bypass for the two read paths Drizzle does not map, a `sql` + selection and `.values()`. A `sql` selection, `.values()`, `drizzle-kit` introspection, JSON columns + and the edge and serverless drivers are named in the README as uncovered. + + The insert type is the same `SensitiveString` as the select type, because `customType` has one `data` + type serving both directions, so a literal in a predicate takes a cast. `strictWrites` is off by + default for the same reason. + +### Patch Changes + +- 1c87ee4: `containsRedactionMarker()` and `redactionMatchers()` in core, replacing four separate answers to + the same question: given a string about to be written to a column, sent in a request body or stored + in a secret, does it carry the text a `SensitiveString` renders as? `sensitive-http` derived its + answer from the policy registry, `sensitive-typeorm` and `sensitive-drizzle` each shipped a narrower + copy of that derivation, and `sensitive-secrets/write` used a hardcoded `[REDACTED:`. Core owns the + format, so the detector lives there and the four call it. + + Two gaps close, both in the database packages and both security-relevant. + + **`markerPattern` was ignored.** Core declares it on `SensitivityPolicy` for a policy whose redaction + varies with the value, `deterministicRedaction()` sets it, and core's own documentation for the field + names the http guard as its consumer. The two copies written for a database did not read it, so a + deployment using deterministic redaction had a marker check in front of its columns that could not + recognise its own markers. + + **A marker for an unregistered class went unrecognised.** Both copies enumerated the registry and + looked for `[REDACTED:${klass}]` per class. For a reader that is the ordinary case rather than an + edge one: the column was written by an older release, or by a service that registered a class this + process does not have. The detector matches the `[REDACTED:` prefix, which covers those, and also + catches a marker that a composed transformer altered without destroying. + + `policyRegistryVersion()` is new and exported. The memo is keyed on it rather than on the set of + registered class names, because an `overwrite` re-registration replaces a policy's `redact` while + leaving the names identical, and both copies kept answering from the previous policy until something + else changed. + + `sensitive-typeorm` and `sensitive-drizzle` no longer export `redactionMarkers` or + `resetMarkerCache`; they re-export core's `redactionMatchers` and `resetRedactionMarkerCache` + instead. `redactionMatchers()` returns `(string | RegExp)[]` rather than a `Set`, since a + `markerPattern` is a pattern. + +- 2c5bd7f: Three fixes from reviewing the extracted layer, one of them a leak that existed in + `sensitive-typeorm` before the extraction and was carried into the shared code. + + **A nested driver error that would not take the censor in place was returned with its plaintext.** + `sanitizeQueryError` censors the parameter property on the error and on any nested one a shape + names, and falls back to a copy of the outer error when a property will not move. That copy carried + the nested error across by descriptor, so a frozen `driverError`, or one whose parameter property is + a non-configurable accessor, ended up on the returned error with the original values still on it. + The outer error reads as sanitized, which makes this the worst of the three possible outcomes rather + than a partial fix. A censored copy of the nested error now goes onto the outer copy. Neither + package's fixtures had a nested error that resists a write, which is why neither suite saw it. + + **The total-failure path dropped a message that was safe to keep.** When everything else fails, + `sanitizeQueryError` returns a minimal error. For a shape that declares no `sanitizeMessage`, which + is a measurement that the ORM's message carries no value rather than a default, the original message + and stack are the diagnosis and are now carried across. This restores what `sensitive-typeorm` did + before the extraction. + + **The recovery path could throw from the function whose contract is that it does not.** The `catch` + that produces the minimal error read `message`, `name` and `stack` as plain properties, so a + throwing getter there escaped `sanitizeQueryError` entirely, at the one moment the contract is being + relied on. Every read in that path is now guarded individually, so a single hostile property costs + only that property rather than the whole recovery, and the path as a whole is guarded again beneath + that. Carried in from `sensitive-typeorm`, where the same three reads were unguarded. + + **The Drizzle logger scanned each statement twice.** `touchesClassifiedColumn` called the shared + predicate once per column set, and the shared predicate scans the statement for identifiers. It now + scans once and checks both sets against the one result, which is what that package's README already + claimed. This is on the path that runs for every query the process makes. + + Two README claims are corrected to match the code. The TypeORM composition section said the write + refusal "does not catch that and cannot" for a reversed transformer array; since the marker detector + moved to core it matches the `[REDACTED:` prefix, so it does catch a transform that leaves the prefix + intact and still cannot catch one that destroys it. Both halves are now asserted. The Drizzle cost + section described the marker memo as keyed on the registered class list, which is no longer how it + works. + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [2c5bd7f] +- Updated dependencies [1c87ee4] +- Updated dependencies [635b4c2] +- Updated dependencies [59ace7a] +- Updated dependencies [f67efd7] +- Updated dependencies [48f6213] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + - @platform-sec/sensitive-string-scrub@0.2.0 + - @platform-sec/sensitive-string-data@0.2.0 diff --git a/packages/sensitive-drizzle/package.json b/packages/sensitive-drizzle/package.json index c4f3128..38068a1 100644 --- a/packages/sensitive-drizzle/package.json +++ b/packages/sensitive-drizzle/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-drizzle", - "version": "0.1.0", + "version": "0.2.0", "description": "Drizzle ORM integration for SensitiveString - classified columns are declared in the schema and read back wrapped, writes unwrap through an audited reveal, and the parameter paths the column never sees are refused", "main": "./dist/index.js", "module": "./dist/index.js", @@ -46,9 +46,9 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0", - "@platform-sec/sensitive-string-data": "^0.1.0", - "@platform-sec/sensitive-string-scrub": "^0.1.0", + "@platform-sec/sensitive-string-core": "^0.6.0", + "@platform-sec/sensitive-string-data": "^0.2.0", + "@platform-sec/sensitive-string-scrub": "^0.2.0", "drizzle-orm": ">=0.30.0 <1" }, "peerDependenciesMeta": { diff --git a/packages/sensitive-env/CHANGELOG.md b/packages/sensitive-env/CHANGELOG.md index 94c0023..284d730 100644 --- a/packages/sensitive-env/CHANGELOG.md +++ b/packages/sensitive-env/CHANGELOG.md @@ -1,5 +1,34 @@ # @platform-sec/sensitive-string-env +## 0.1.1 + +### Patch Changes + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [59ace7a] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + ## 0.1.0 ### Minor Changes diff --git a/packages/sensitive-env/package.json b/packages/sensitive-env/package.json index bc6786e..ff9ff22 100644 --- a/packages/sensitive-env/package.json +++ b/packages/sensitive-env/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-env", - "version": "0.1.0", + "version": "0.1.1", "description": "Config and environment loader for SensitiveString - classify by variable name, wrap at the boundary, and report what removal from process.env cannot reach", "main": "./dist/index.js", "module": "./dist/index.js", @@ -37,7 +37,7 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0" + "@platform-sec/sensitive-string-core": "^0.6.0" }, "devDependencies": { "@platform-sec/sensitive-string-core": "workspace:*", diff --git a/packages/sensitive-envalid/CHANGELOG.md b/packages/sensitive-envalid/CHANGELOG.md index 54c2b20..6f0ab4d 100644 --- a/packages/sensitive-envalid/CHANGELOG.md +++ b/packages/sensitive-envalid/CHANGELOG.md @@ -1,5 +1,34 @@ # @platform-sec/sensitive-string-envalid +## 0.1.1 + +### Patch Changes + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [59ace7a] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + ## 0.1.0 ### Minor Changes diff --git a/packages/sensitive-envalid/package.json b/packages/sensitive-envalid/package.json index 1c7b50a..ce2495d 100644 --- a/packages/sensitive-envalid/package.json +++ b/packages/sensitive-envalid/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-envalid", - "version": "0.1.0", + "version": "0.1.1", "description": "envalid integration for SensitiveString - sensitive validators, the construction-time refusals, and a cleanEnv whose report never prints a value", "main": "./dist/index.js", "module": "./dist/index.js", @@ -32,7 +32,7 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0", + "@platform-sec/sensitive-string-core": "^0.6.0", "@platform-sec/sensitive-string-env": "^0.1.0", "envalid": "^8.0.0" }, diff --git a/packages/sensitive-eslint-plugin/CHANGELOG.md b/packages/sensitive-eslint-plugin/CHANGELOG.md index ff93ce2..aaa6548 100644 --- a/packages/sensitive-eslint-plugin/CHANGELOG.md +++ b/packages/sensitive-eslint-plugin/CHANGELOG.md @@ -1,5 +1,170 @@ # @platform-sec/eslint-plugin-sensitive-string +## 0.2.0 + +### Minor Changes + +- 75261e8: `require-reveal-context` covers the other audited reads, and a new rule keeps a password away from + a fast digest. + + The library audits a digest exactly as it audits a reveal: the same gate, the same reveal budget, + the same audit record with `operation: 'hash'` on it. An audit line saying a digest of a credential + was taken, with nothing about who took it or why, is the gap this rule exists to close, and until + now the rule only knew about `reveal()`. + + Only where the receiver's type can be read, which is the difference between this and the way + `reveal()` is matched. `magician.reveal()` being reported is the standing cost of an untyped rule + and a fair trade, because `.reveal()` is rare. `.hash()` is not: `bcrypt`, `argon2`, `node:crypto` + and half the hashing libraries on npm have one, this repository calls two of them, and a rule that + reported every `.hash()` call would be turned off within the hour. So the rule reads the receiver's + type when a program is there and says nothing about these two methods when it is not. + + That makes it the plugin's third kind of rule: it works without types and uses them when present, + where `no-stringify-sensitive` and `prefer-safe-log` cannot run at all without them. It therefore + stays enabled under `disable-type-checked`, doing the half of its job that needs no types, and does + not report missing type information the way those two do, because it is not reduced to silence. + + The context argument is read at the position each method takes it in: the first for `reveal()`, the + third for `hash()`, the fourth for `hmac()`. An options object in the position before it is not a + context and is reported as one missing, which is the mistake the argument order invites. A spread + ahead of the context is left alone, since which argument is the context stops being a question + syntax can answer. The two messages now name the method they are about, so a `hash()` finding no + longer says `reveal()`. + + `mask()` and `serialize()` are covered on the same reasoning, and for a plainer motive than the + digests: one returns a partial view as a plain string and the other returns the raw value in a wire + object. What stays out is the transforms, `substring` and `map` and the rest, which audit too and + would make the rule unusable long before they made an audit log better. + + A `sensitiveTypes` option names the wrapper types, defaulting to `SensitiveString`, the same way it + does on the two type-aware rules. + + `no-password-fast-hash` is new, and it is the one rule in this plugin that reads a name rather than + a shape. It reports `password.hash('sha256')` and points at `hashForStorage()` from the `kdf` + package, because a fast digest is not password storage: a wordlist runs through SHA-256 at billions + of guesses a second, and a salt does nothing about that for whoever has the row it is stored in. + `hmac()` reports too, since a key does not make a fast function slow. + + A name, because no type separates a password from an API key: both are + `SensitiveString<'credential'>`. That is a different question from the one `type-info.ts` refuses to + answer by name, where a type does the job properly, and the rule's page says so rather than leaving + the contradiction for a reader to find. The match is a case-insensitive fragment over `password`, + `passwd`, `passphrase` and `pwd`, against the name of the value being hashed rather than whatever + holds it: `user.password.hash()` reports, `passwordService.token.hash()` does not. `secret`, `token` + and `key` are deliberately absent, since those are the values `hash()` is for. Warning in + `recommended`, error in `strict`, which is the honest severity for a check whose only signal is a + name. + +- b92ff33: `no-allow-unaudited` reports a worker accepting a payload without the audit hook its sender asked + for. + + Core's worker handoff (ROADMAP 1.6) carries a value and its context across a thread boundary, and + the one thing it cannot carry is the context's `auditReveal`, because a function survives neither + structured clone nor JSON. A payload built under an audited context therefore arrives marked, and + `fromWorkerPayload()` refuses it unless the receiving side installs a hook of its own. + `allowUnaudited: true` is the way past that refusal, and what it means is exact: the sender asked + for every reveal of this value to be recorded, and reveals in this thread will not be. + + The flag exists because that is sometimes the right call, and this rule exists because it is never + a call to make by accident. It reports the property on `fromWorkerPayload()` and + `withWorkerPayload()`, whatever its value short of a literal `false` or an explicit `undefined`, + since `allowUnaudited: flag` is still a decision to sometimes skip the audit. Error in both presets + rather than warn: the finding is never wrong, and a disable comment on the line is where the reason + belongs. + + Syntax only, like the other rules that read a call's shape: an object property in a call, matched + by the function's name. An options object built elsewhere and passed by name is invisible, the + page says so, and `payloadFunctions` renames the functions for a project that wraps the two in one + of its own. This makes seven untyped rules and nine in all; the design decided the rule ships with + the escape hatch it makes defensible rather than following it. + +- f5ba5a4: Add the two type-aware rules, `no-stringify-sensitive` and `prefer-safe-log`. + + The five rules already in the plugin read the shape of a call. These two answer a question no + syntax answers: does this _value_ carry a sensitive one anywhere inside it? They need + typescript-eslint's type-aware mode, and they live only in the new `recommended-type-checked` and + `strict-type-checked` presets, so a project that has not configured a TypeScript program never + meets them. + + ```js + import sensitive from '@platform-sec/eslint-plugin-sensitive-string'; + import tseslint from 'typescript-eslint'; + + export default [ + { + files: ['**/*.ts'], + languageOptions: { + parser: tseslint.parser, + parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname }, + }, + }, + sensitive.configs['recommended-type-checked'], + { files: ['**/*.js'], ...sensitive.configs['disable-type-checked'] }, + ]; + ``` + + Building them corrected the premise both rules were designed from. Neither `JSON.stringify` nor + `console.log` leaks a `SensitiveString`: `toJSON()` and the `nodejs.util.inspect.custom` handler + both redact, so every ordinary transport already prints the placeholder. What actually goes wrong + is two other things. + + `JSON.stringify` substitutes silently. `JSON.stringify({ token })` produces + `{"token":"[REDACTED:credential]"}`. If the JSON was going to a log line, that is what you wanted. + If it was going to another service, a credential has just been replaced by a placeholder, nothing + threw, and the failure surfaces at the far end as an authentication error with no local cause. + `safeStringify()` says the first intent out loud; `serialize()` on the field serves the second. + + A `serialize()` result is a plain object holding the raw value. `toJSON()` cannot reach it and + neither can `util.inspect`, which is why `redactInStructure` recognises the `__sensitive` marker by + hand. So `JSON.stringify(envelope)` writes the credential out in the clear and + `console.log(envelope)` puts it in a log line. That is the one genuine leak either rule catches, + and both report it under its own message. + + Everything else `prefer-safe-log` reports is defence in depth, which is why it warns rather than + errors, and why it leaves `console.log(token)` and `console.log(tokens)` alone: passing the value, + or a plain list of them, is the documented way to log one. What the rule reports is a _structure_ + holding one, where the redaction stops being the class's business and becomes the transport's. + + Type-aware and untyped rules cannot share a preset, which is why there are now ten rather than + four. A type-aware rule applied to a file with no program has to either fall silent, which reads as + a clean file, or say so. Both say so, once per file, so a preset that also ran over plain + JavaScript would report a configuration error on every `.js` file in the project. + `recommended`, `strict` and their eslintrc twins are unchanged and still hold the five untyped + rules; `recommended-type-checked` and `strict-type-checked` hold all seven; `disable-type-checked` + turns the two back off for the files a `tsconfig.json` does not cover. `meta.docs.requiresTypeChecking` + is what the presets split on, and a test holds the split to it so a third type-aware rule cannot + land in an untyped preset by accident. + + A type is matched by name, not by resolving its declaration back to this library. Resolving would + be more precise and would break under bundlers, path aliases and re-exports, which is a worse trade + for a rule that runs in an editor. `sensitiveTypes` and `serializedTypes` are the options that + follow, defaulting to `SensitiveString` and `SerializedSensitiveString`. Names match exactly rather + than by prefix, because `SerializedSensitiveString` is the recommended transmission path and a + prefix match would report it where it is correct. + + The type walk mirrors what a serializer walks, which is what bounds it: union and intersection + members, array and tuple elements, base classes, object properties, index signatures. An array + stops there rather than going on to its own members, because `Array` declares a numeric index + signature and following it would reach every element back through a property edge, turning + `console.log(tokens)` into a finding. A `Map` or a `Set` is not walked at all, since neither + survives `JSON.stringify` as anything but `{}` and both redact element by element under + `util.inspect`. One search examines at most 1000 distinct types, a bound on the pathological case + so that one unlucky call cannot make a lint run look hung. + + The plugin still has no dependencies. The TypeScript API is reached structurally, the same way + ESLint's already was, because adding `typescript` as a dependency would put it in the install of + every consumer who lints plain JavaScript, for a pair of rules they cannot run. The checker arrives + through `parserServices`, already built by whoever configured type-aware linting, and nothing in + the package imports `typescript` at runtime or at type level. + + `no-log-reveal` and `prefer-safe-log` now share one logger matcher, in `src/log-calls.ts`. Two + rules that disagreed about what a logger is would be one `logObjects` setting granting half of what + it says. + + 24 further cases across the two rule suites, plus a type-aware fixture project, adversarial + coverage for the shapes someone works around a finding with, and a canary that neither rule's + message ever quotes the code it flagged. + ## 0.1.0 ### Minor Changes diff --git a/packages/sensitive-eslint-plugin/package.json b/packages/sensitive-eslint-plugin/package.json index ce667d5..4b3f7e7 100644 --- a/packages/sensitive-eslint-plugin/package.json +++ b/packages/sensitive-eslint-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/eslint-plugin-sensitive-string", - "version": "0.1.0", + "version": "0.2.0", "description": "ESLint rules that catch the mistakes SensitiveString's type system cannot: logged reveals, timing-unsafe comparisons, and unaudited transmission", "main": "./dist/index.js", "module": "./dist/index.js", diff --git a/packages/sensitive-eslint-plugin/src/index.ts b/packages/sensitive-eslint-plugin/src/index.ts index c51bfa9..cf5ad14 100644 --- a/packages/sensitive-eslint-plugin/src/index.ts +++ b/packages/sensitive-eslint-plugin/src/index.ts @@ -80,7 +80,7 @@ export const rules: Readonly> = { */ export const meta = { name: '@platform-sec/eslint-plugin-sensitive-string', - version: '0.1.0', + version: '0.2.0', } as const; /** diff --git a/packages/sensitive-express/CHANGELOG.md b/packages/sensitive-express/CHANGELOG.md index 9b9b645..ea2a468 100644 --- a/packages/sensitive-express/CHANGELOG.md +++ b/packages/sensitive-express/CHANGELOG.md @@ -1,5 +1,34 @@ # @platform-sec/sensitive-string-express +## 0.1.1 + +### Patch Changes + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [59ace7a] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + ## 0.1.0 ### Minor Changes diff --git a/packages/sensitive-express/package.json b/packages/sensitive-express/package.json index db73994..a5ccc21 100644 --- a/packages/sensitive-express/package.json +++ b/packages/sensitive-express/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-express", - "version": "0.1.0", + "version": "0.1.1", "description": "Express integration for SensitiveString - body wrapping, header extraction, reveal context, and response redaction", "main": "./dist/index.js", "module": "./dist/index.js", @@ -32,7 +32,7 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0", + "@platform-sec/sensitive-string-core": "^0.6.0", "express": "^4.21.0 || ^5.0.0" }, "devDependencies": { diff --git a/packages/sensitive-fastify/CHANGELOG.md b/packages/sensitive-fastify/CHANGELOG.md index 8996927..d1d86c0 100644 --- a/packages/sensitive-fastify/CHANGELOG.md +++ b/packages/sensitive-fastify/CHANGELOG.md @@ -1,5 +1,34 @@ # @platform-sec/sensitive-string-fastify +## 0.1.1 + +### Patch Changes + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [59ace7a] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + ## 0.1.0 ### Minor Changes diff --git a/packages/sensitive-fastify/package.json b/packages/sensitive-fastify/package.json index 1557245..e208d0f 100644 --- a/packages/sensitive-fastify/package.json +++ b/packages/sensitive-fastify/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-fastify", - "version": "0.1.0", + "version": "0.1.1", "description": "Fastify integration for SensitiveString - schema keyword wrapping, body field maps, header extraction, reveal context, and response redaction", "main": "./dist/index.js", "module": "./dist/index.js", @@ -35,7 +35,7 @@ "fastify-plugin": "^5.0.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0", + "@platform-sec/sensitive-string-core": "^0.6.0", "fastify": "^5.0.0" }, "devDependencies": { diff --git a/packages/sensitive-fetch/CHANGELOG.md b/packages/sensitive-fetch/CHANGELOG.md index 17cac5d..b81c126 100644 --- a/packages/sensitive-fetch/CHANGELOG.md +++ b/packages/sensitive-fetch/CHANGELOG.md @@ -1,5 +1,126 @@ # @platform-sec/sensitive-string-fetch +## 0.2.0 + +### Minor Changes + +- 256911b: `allowedHosts: 'strip'`: follow a redirect with the credential removed instead of refusing it. + + There were two answers to a credential-bearing request that gets redirected, and for one real shape + both are wrong. Refusing breaks a provider that redirects a release asset or a download to object + storage, which is the ordinary way private artifact hosting works. Naming the hosts is impossible for + exactly the same reason: the caller cannot enumerate a bucket endpoint the provider picks at request + time. `'strip'` is the third answer, and it is the one the upstream project behind + [GHSA-p2f4-r6v6-j797](https://github.com/advisories/GHSA-p2f4-r6v6-j797) reached for when it fixed + the same problem: drop the credential at the origin boundary and keep going. + + The difference is where the list of headers to drop comes from. That project had to guess which names + carry credentials, shipped nine of them, and added a registration function so publishers could add + their own. Here the list is derived: the scrub plan already records exactly which header names carried + a wrapped value, because the caller decided that when they wrapped it. A header the library has never + heard of is covered on the same terms as `Authorization`, and there is nothing to register. + + `'strip'` is never a default and cannot become one. Refusing is the answer that cannot go wrong + quietly: a request that stops is a bug report, while a credential dropped on a hop the caller did not + know was a boundary is a request that succeeds at doing less than it appears to. Same-origin hops keep + everything, since a redirect inside one origin discloses nothing to anyone new. + + `isCrossOrigin` decides the boundary by the scheme/host/port rule, normalizing implied ports. It makes + one exception, `http:` to `https:` on the same host where neither side names a non-default port, which + is an upgrade to the same server that every client in the measured set follows without stripping. The + reverse is never allowed, and the allowance does not extend to a non-default port, since a different + port is a different service as often as it is the same one. + + All five wrappers implement it and each is tested end to end against a real pair of loopback origins, + asserting both that the canary does not reach the second origin and that the request still completes. + got is the one that differs: it follows hops itself but hands the next request's options over through + `beforeRedirect`, so there it is a deletion rather than a rebuilt request. + + What every wrapper accumulates is the set of dropped header _names_, applied where the next request is + built, rather than a narrowed header object carried forward. The two are not equivalent and the + difference is a real leak: axios merges `defaults.headers` underneath the request's own, and the axios + wrapper deliberately supports a wrapped value there, so narrowing only the per-request headers would + strip the call site's credential and forward the instance's. Dropped names are also set to `null` in + the outgoing axios config, because removing a name from what is sent is not enough on its own when the + client refills it from its defaults. + + got tracks the previous hop rather than comparing every hop against the request's original URL. + Same-origin is not transitive once the http-to-https upgrade is allowed: A `http://x` to B `https://x` + is an upgrade, B to C `http://x` is a downgrade, and C is same-origin with A, so comparing against A + would forward a credential into plaintext. Distinguishing the two needs TLS and is not covered by a + test; the other four wrappers rebuild each hop and already hold the previous URL. + + New exports from the http package: `isCrossOrigin`, `headersWithout`, and the `RedirectPolicy` and + `ResolvedRedirectPolicy` types. `allowedHosts` accepts `'strip'` alongside the array and `false`. + Nothing changes for a caller who does not ask for it. + +### Patch Changes + +- e95bf0b: A redirect hop no longer carries the caller's query, and a cross-origin 307 or 308 that would + re-send a body carrying a wrapped value is refused under `allowedHosts: 'strip'`. + + Both were found by an independent verification of the corpus study that produced `'strip'`, by + measurement rather than by reading. With a wrapped credential passed as a query parameter and a 302 + whose `Location` carried no query, the axios, fetch, ky and undici wrappers sent the second origin a + URL containing the credential: each hop handed the caller's query to `prepareRequest` again, which + appended it to whatever URL it was given. got did not, because it follows hops itself. The corpus + had recorded exactly this gap against GHSA-p2f4-r6v6-j797 and then withdrawn it, on the reasoning + that a redirect target is the URL the server names; that reasoning was correct only for the delegated + mode, where the client follows and nothing is re-merged. + + `prepareRequest` now drops the query on any hop after the first, so no wrapper has to remember. A + redirect target is the URL the server named and nothing else, which is what every client in the + measured set does on its own. + + A 307 or 308 re-sends the body, and all five wrappers delivered a JSON body carrying a wrapped value + to the cross-origin target under `'strip'`. A header can be dropped at the origin boundary without + changing what the request means; a body cannot, since a body with one field removed is a different + request that succeeds at doing something else. So the hop is refused with + `RedirectWouldResendCredentialError`, before anything is revealed for it, and the message says to + name the destination in `allowedHosts` if it is trusted with the body. A 301, 302 or 303 drops the + body on its own and is followed as before. `PreparedRequest` gains `bodyCarriesCredential`, which + covers a form body as well as a structured one, and `assertBodyMayFollow` is exported for a wrapper + that follows hops itself. + + The got package now merges the instance's `defaults.options.headers` under the call's headers, as the + axios package already did for `defaults.headers`. Measured, a wrapped value placed in + `got.extend({ headers })` reached the wire as `[REDACTED:credential]` and an `apiKey()` descriptor as + `[object Object]`, on every hop, because the wrapper never saw either; they are now revealed, + recorded in the plan, scrubbed, and under `'strip'` dropped at the boundary. ky does not expose an + extended instance's defaults, so the ky README says to put a wrapped value in the call instead. + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [635b4c2] +- Updated dependencies [59d6a4b] +- Updated dependencies [612ccbd] +- Updated dependencies [aadfec7] +- Updated dependencies [e95bf0b] +- Updated dependencies [256911b] +- Updated dependencies [59ace7a] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + - @platform-sec/sensitive-string-http@0.2.0 + ## 0.1.0 ### Minor Changes diff --git a/packages/sensitive-fetch/package.json b/packages/sensitive-fetch/package.json index ce05c41..a1cf262 100644 --- a/packages/sensitive-fetch/package.json +++ b/packages/sensitive-fetch/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-fetch", - "version": "0.1.0", + "version": "0.2.0", "description": "fetch integration for SensitiveString - one reveal point at the wire for Node's global fetch and undici.fetch, the marker guard, the redirect host policy, and the response decoder", "main": "./dist/index.js", "module": "./dist/index.js", @@ -33,8 +33,8 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0", - "@platform-sec/sensitive-string-http": "^0.1.0" + "@platform-sec/sensitive-string-core": "^0.6.0", + "@platform-sec/sensitive-string-http": "^0.2.0" }, "devDependencies": { "@platform-sec/sensitive-string-core": "workspace:*", diff --git a/packages/sensitive-gcp-secrets/CHANGELOG.md b/packages/sensitive-gcp-secrets/CHANGELOG.md index 69320f1..847f5db 100644 --- a/packages/sensitive-gcp-secrets/CHANGELOG.md +++ b/packages/sensitive-gcp-secrets/CHANGELOG.md @@ -1,5 +1,35 @@ # @platform-sec/sensitive-string-gcp-secrets +## 0.1.1 + +### Patch Changes + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [59ace7a] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + - @platform-sec/sensitive-string-secrets@0.2.0 + ## 0.1.0 ### Minor Changes diff --git a/packages/sensitive-gcp-secrets/package.json b/packages/sensitive-gcp-secrets/package.json index 09dbdc7..a88867f 100644 --- a/packages/sensitive-gcp-secrets/package.json +++ b/packages/sensitive-gcp-secrets/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-gcp-secrets", - "version": "0.1.0", + "version": "0.1.1", "description": "GCP Secret Manager secret source - CRC32C verification, resolved version numbers, every value wrapped as a SensitiveString", "main": "./dist/index.js", "module": "./dist/index.js", @@ -32,8 +32,8 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0", - "@platform-sec/sensitive-string-secrets": "^0.1.0", + "@platform-sec/sensitive-string-core": "^0.6.0", + "@platform-sec/sensitive-string-secrets": "^0.2.0", "@google-cloud/secret-manager": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "devDependencies": { diff --git a/packages/sensitive-got/CHANGELOG.md b/packages/sensitive-got/CHANGELOG.md index bae6f9d..b7213b8 100644 --- a/packages/sensitive-got/CHANGELOG.md +++ b/packages/sensitive-got/CHANGELOG.md @@ -1,5 +1,157 @@ # @platform-sec/sensitive-string-got +## 0.2.0 + +### Minor Changes + +- 59d6a4b: `headers` on the wrapper's options: a typed place for an instance-level credential. + + The got and axios wrappers read their client's own default headers, so a wrapped value or an + `apiKey()` descriptor placed in `got.extend({ headers })` or on `defaults.headers.common` is + revealed, recorded and stripped like any other. That is the run-time story. The compile-time one is + that none of it type-checks: measured on got 16.0.0, axios 1.20.0 and ky 2.1.0, each client types an + instance's default headers as strings, both a `SensitiveString` and a descriptor are rejected, and + the types are aliases nothing can widen from outside. A TypeScript caller had a cast as the only way + in, which is what this repository's own got suite was doing. + + `SensitiveInstanceOptions` extends `SensitiveClientOptions` with `readonly headers?: +SensitiveHeaders`, and the three wrappers built around a client instance take it: + `SensitiveGotOptions`, `SensitiveAxiosOptions` and `SensitiveKyOptions` are now aliases of it. The + headers sit over the client's own defaults, where the wrapper can read them, and under each call's + headers, and go through the same pipeline: revealed once per attempt, in the scrub plan, scrubbed on + both settlement paths, and under `allowedHosts: 'strip'` dropped at the origin boundary. The merge + happens before the dropped-name accounting, so a name stripped at one hop is not refilled from the + option on the next; the axios and ky suites assert that across a bounce through a second origin and + back. A credential there flips the redirect default to refusing, the same as one in the call, and + each suite asserts that too. + + For ky this is the only instance-level layer there is. ky does not expose an extended instance's + defaults, so a wrapped value in `ky.extend({ headers })` was never seen by the wrapper and the README + said to put it in the call instead; it now says to put it here. + + `fetch` and undici are unchanged. Neither has an instance to wrap, so their per-call bag is the whole + surface and already takes a wrapped header. + + Nothing changes for a caller who does not set it. The got and axios wrappers still read their + client's defaults at run time, so a JavaScript caller keeps what they had. + +- 256911b: `allowedHosts: 'strip'`: follow a redirect with the credential removed instead of refusing it. + + There were two answers to a credential-bearing request that gets redirected, and for one real shape + both are wrong. Refusing breaks a provider that redirects a release asset or a download to object + storage, which is the ordinary way private artifact hosting works. Naming the hosts is impossible for + exactly the same reason: the caller cannot enumerate a bucket endpoint the provider picks at request + time. `'strip'` is the third answer, and it is the one the upstream project behind + [GHSA-p2f4-r6v6-j797](https://github.com/advisories/GHSA-p2f4-r6v6-j797) reached for when it fixed + the same problem: drop the credential at the origin boundary and keep going. + + The difference is where the list of headers to drop comes from. That project had to guess which names + carry credentials, shipped nine of them, and added a registration function so publishers could add + their own. Here the list is derived: the scrub plan already records exactly which header names carried + a wrapped value, because the caller decided that when they wrapped it. A header the library has never + heard of is covered on the same terms as `Authorization`, and there is nothing to register. + + `'strip'` is never a default and cannot become one. Refusing is the answer that cannot go wrong + quietly: a request that stops is a bug report, while a credential dropped on a hop the caller did not + know was a boundary is a request that succeeds at doing less than it appears to. Same-origin hops keep + everything, since a redirect inside one origin discloses nothing to anyone new. + + `isCrossOrigin` decides the boundary by the scheme/host/port rule, normalizing implied ports. It makes + one exception, `http:` to `https:` on the same host where neither side names a non-default port, which + is an upgrade to the same server that every client in the measured set follows without stripping. The + reverse is never allowed, and the allowance does not extend to a non-default port, since a different + port is a different service as often as it is the same one. + + All five wrappers implement it and each is tested end to end against a real pair of loopback origins, + asserting both that the canary does not reach the second origin and that the request still completes. + got is the one that differs: it follows hops itself but hands the next request's options over through + `beforeRedirect`, so there it is a deletion rather than a rebuilt request. + + What every wrapper accumulates is the set of dropped header _names_, applied where the next request is + built, rather than a narrowed header object carried forward. The two are not equivalent and the + difference is a real leak: axios merges `defaults.headers` underneath the request's own, and the axios + wrapper deliberately supports a wrapped value there, so narrowing only the per-request headers would + strip the call site's credential and forward the instance's. Dropped names are also set to `null` in + the outgoing axios config, because removing a name from what is sent is not enough on its own when the + client refills it from its defaults. + + got tracks the previous hop rather than comparing every hop against the request's original URL. + Same-origin is not transitive once the http-to-https upgrade is allowed: A `http://x` to B `https://x` + is an upgrade, B to C `http://x` is a downgrade, and C is same-origin with A, so comparing against A + would forward a credential into plaintext. Distinguishing the two needs TLS and is not covered by a + test; the other four wrappers rebuild each hop and already hold the previous URL. + + New exports from the http package: `isCrossOrigin`, `headersWithout`, and the `RedirectPolicy` and + `ResolvedRedirectPolicy` types. `allowedHosts` accepts `'strip'` alongside the array and `false`. + Nothing changes for a caller who does not ask for it. + +### Patch Changes + +- e95bf0b: A redirect hop no longer carries the caller's query, and a cross-origin 307 or 308 that would + re-send a body carrying a wrapped value is refused under `allowedHosts: 'strip'`. + + Both were found by an independent verification of the corpus study that produced `'strip'`, by + measurement rather than by reading. With a wrapped credential passed as a query parameter and a 302 + whose `Location` carried no query, the axios, fetch, ky and undici wrappers sent the second origin a + URL containing the credential: each hop handed the caller's query to `prepareRequest` again, which + appended it to whatever URL it was given. got did not, because it follows hops itself. The corpus + had recorded exactly this gap against GHSA-p2f4-r6v6-j797 and then withdrawn it, on the reasoning + that a redirect target is the URL the server names; that reasoning was correct only for the delegated + mode, where the client follows and nothing is re-merged. + + `prepareRequest` now drops the query on any hop after the first, so no wrapper has to remember. A + redirect target is the URL the server named and nothing else, which is what every client in the + measured set does on its own. + + A 307 or 308 re-sends the body, and all five wrappers delivered a JSON body carrying a wrapped value + to the cross-origin target under `'strip'`. A header can be dropped at the origin boundary without + changing what the request means; a body cannot, since a body with one field removed is a different + request that succeeds at doing something else. So the hop is refused with + `RedirectWouldResendCredentialError`, before anything is revealed for it, and the message says to + name the destination in `allowedHosts` if it is trusted with the body. A 301, 302 or 303 drops the + body on its own and is followed as before. `PreparedRequest` gains `bodyCarriesCredential`, which + covers a form body as well as a structured one, and `assertBodyMayFollow` is exported for a wrapper + that follows hops itself. + + The got package now merges the instance's `defaults.options.headers` under the call's headers, as the + axios package already did for `defaults.headers`. Measured, a wrapped value placed in + `got.extend({ headers })` reached the wire as `[REDACTED:credential]` and an `apiKey()` descriptor as + `[object Object]`, on every hop, because the wrapper never saw either; they are now revealed, + recorded in the plan, scrubbed, and under `'strip'` dropped at the boundary. ky does not expose an + extended instance's defaults, so the ky README says to put a wrapped value in the call instead. + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [635b4c2] +- Updated dependencies [59d6a4b] +- Updated dependencies [612ccbd] +- Updated dependencies [aadfec7] +- Updated dependencies [e95bf0b] +- Updated dependencies [256911b] +- Updated dependencies [59ace7a] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + - @platform-sec/sensitive-string-http@0.2.0 + ## 0.1.0 ### Minor Changes diff --git a/packages/sensitive-got/package.json b/packages/sensitive-got/package.json index 20e0f54..8da98d1 100644 --- a/packages/sensitive-got/package.json +++ b/packages/sensitive-got/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-got", - "version": "0.1.0", + "version": "0.2.0", "description": "got integration for SensitiveString - one reveal point at the wire, the marker guard, per-attempt audit on retries, and the scrub that keeps a revealed credential out of options on both settlement paths", "main": "./dist/index.js", "module": "./dist/index.js", @@ -32,8 +32,8 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0", - "@platform-sec/sensitive-string-http": "^0.1.0", + "@platform-sec/sensitive-string-core": "^0.6.0", + "@platform-sec/sensitive-string-http": "^0.2.0", "got": "^14.0.0 || ^16.0.0" }, "devDependencies": { diff --git a/packages/sensitive-http/CHANGELOG.md b/packages/sensitive-http/CHANGELOG.md index 4ee5d4c..36e04de 100644 --- a/packages/sensitive-http/CHANGELOG.md +++ b/packages/sensitive-http/CHANGELOG.md @@ -1,5 +1,279 @@ # @platform-sec/sensitive-string-http +## 0.2.0 + +### Minor Changes + +- 59d6a4b: `headers` on the wrapper's options: a typed place for an instance-level credential. + + The got and axios wrappers read their client's own default headers, so a wrapped value or an + `apiKey()` descriptor placed in `got.extend({ headers })` or on `defaults.headers.common` is + revealed, recorded and stripped like any other. That is the run-time story. The compile-time one is + that none of it type-checks: measured on got 16.0.0, axios 1.20.0 and ky 2.1.0, each client types an + instance's default headers as strings, both a `SensitiveString` and a descriptor are rejected, and + the types are aliases nothing can widen from outside. A TypeScript caller had a cast as the only way + in, which is what this repository's own got suite was doing. + + `SensitiveInstanceOptions` extends `SensitiveClientOptions` with `readonly headers?: +SensitiveHeaders`, and the three wrappers built around a client instance take it: + `SensitiveGotOptions`, `SensitiveAxiosOptions` and `SensitiveKyOptions` are now aliases of it. The + headers sit over the client's own defaults, where the wrapper can read them, and under each call's + headers, and go through the same pipeline: revealed once per attempt, in the scrub plan, scrubbed on + both settlement paths, and under `allowedHosts: 'strip'` dropped at the origin boundary. The merge + happens before the dropped-name accounting, so a name stripped at one hop is not refilled from the + option on the next; the axios and ky suites assert that across a bounce through a second origin and + back. A credential there flips the redirect default to refusing, the same as one in the call, and + each suite asserts that too. + + For ky this is the only instance-level layer there is. ky does not expose an extended instance's + defaults, so a wrapped value in `ky.extend({ headers })` was never seen by the wrapper and the README + said to put it in the call instead; it now says to put it here. + + `fetch` and undici are unchanged. Neither has an instance to wrap, so their per-call bag is the whole + surface and already takes a wrapped header. + + Nothing changes for a caller who does not set it. The got and axios wrappers still read their + client's defaults at run time, so a JavaScript caller keeps what they had. + +- 612ccbd: The cross-origin stripping exemption is per client, and fails closed for a client nobody measured. + + `isStrippedAcrossOrigins(name)` answered from one global set of the three header names the Fetch + specification says a client must drop on a cross-origin redirect. That is the specification's claim, + not any given client's behaviour, and the two are not the same thing: a survey of Node HTTP clients + turned up several that forward `Authorization` across origins. The exemption is what flips a request + carrying a credential from refusing redirects to delegating them, so granting it on the + specification's authority means trusting an implementation nobody looked at. + + It now takes the client as well: `isStrippedAcrossOrigins(name, client)`. A client in the measured + table gets the exemption it was measured to deserve, and any other name gets nothing, so its requests + default to refusing redirects. `prepareRequest` already carries the client name and passes it + through, and `allowedHosts` still overrides the default in either direction, because naming the hosts + is the caller saying they have thought about it. + + The table was measured rather than read off the specification, and the measurement now lives in the + repository as `__tests__/redirect-stripping.test.ts` rather than in a design document: two loopback + servers on different ports, a 302 from the first to the second, and the second recording what + arrived. On axios 1.20.0, got 16.0.0, ky 2.1.0, undici 8.10.1 and Node 22.15's `fetch`, all five drop + `Authorization`, `Cookie` and `Proxy-Authorization`, and all five forward `x-api-key` untouched. The + test asserts the table in both directions against the clients themselves, so a client that changes + behaviour in a future release fails there rather than quietly widening what the table promises. + + `Proxy-Authorization` was in the original set on the specification's authority alone and had never + been measured. It is now measured, and it does belong. + + No wrapped client changes behaviour: all five were already in the exempt set and all five still are. + What changes is a caller who drives `prepareRequest` directly with a client name of their own, which + used to inherit the full exemption and now inherits none of it. + + `measuredClients()` is exported alongside, so a caller can see which names carry the exemption rather + than discovering it from a redirect that did not happen. + +- aadfec7: Tag the proxy a request goes through, so a host-bound credential refuses one its binding does not + name. + + A proxy setting changes the machine that receives the bytes and leaves the URL untouched, so the + host check alone passed it; the corpus recorded that shape against axios, and noted that the only + place this row mentioned a proxy was an instruction for turning off the check that incidentally + covered it. `OutgoingRequest` gains `proxy`, a URL or a `host[:port]`, and the attempt context tags + it as `tags.proxy`, which core's destination binding reads as a second machine the bytes reach. + + `sensitive-axios` resolves the effective proxy the way axios itself does: `config.proxy`, an agent + on `httpAgent` or `httpsAgent` that exposes its `proxy` as `https-proxy-agent` does, or the + `_proxy` and `all_proxy` environment under `no_proxy`, with `proxy-from-env` 2.1.0's rules + restated rather than imported, since that package is axios's dependency and not this one's. An + opaque agent reads as no proxy and the README says so. got, ky, undici and fetch honour no proxy + option of their own and pass none. + +- 256911b: `allowedHosts: 'strip'`: follow a redirect with the credential removed instead of refusing it. + + There were two answers to a credential-bearing request that gets redirected, and for one real shape + both are wrong. Refusing breaks a provider that redirects a release asset or a download to object + storage, which is the ordinary way private artifact hosting works. Naming the hosts is impossible for + exactly the same reason: the caller cannot enumerate a bucket endpoint the provider picks at request + time. `'strip'` is the third answer, and it is the one the upstream project behind + [GHSA-p2f4-r6v6-j797](https://github.com/advisories/GHSA-p2f4-r6v6-j797) reached for when it fixed + the same problem: drop the credential at the origin boundary and keep going. + + The difference is where the list of headers to drop comes from. That project had to guess which names + carry credentials, shipped nine of them, and added a registration function so publishers could add + their own. Here the list is derived: the scrub plan already records exactly which header names carried + a wrapped value, because the caller decided that when they wrapped it. A header the library has never + heard of is covered on the same terms as `Authorization`, and there is nothing to register. + + `'strip'` is never a default and cannot become one. Refusing is the answer that cannot go wrong + quietly: a request that stops is a bug report, while a credential dropped on a hop the caller did not + know was a boundary is a request that succeeds at doing less than it appears to. Same-origin hops keep + everything, since a redirect inside one origin discloses nothing to anyone new. + + `isCrossOrigin` decides the boundary by the scheme/host/port rule, normalizing implied ports. It makes + one exception, `http:` to `https:` on the same host where neither side names a non-default port, which + is an upgrade to the same server that every client in the measured set follows without stripping. The + reverse is never allowed, and the allowance does not extend to a non-default port, since a different + port is a different service as often as it is the same one. + + All five wrappers implement it and each is tested end to end against a real pair of loopback origins, + asserting both that the canary does not reach the second origin and that the request still completes. + got is the one that differs: it follows hops itself but hands the next request's options over through + `beforeRedirect`, so there it is a deletion rather than a rebuilt request. + + What every wrapper accumulates is the set of dropped header _names_, applied where the next request is + built, rather than a narrowed header object carried forward. The two are not equivalent and the + difference is a real leak: axios merges `defaults.headers` underneath the request's own, and the axios + wrapper deliberately supports a wrapped value there, so narrowing only the per-request headers would + strip the call site's credential and forward the instance's. Dropped names are also set to `null` in + the outgoing axios config, because removing a name from what is sent is not enough on its own when the + client refills it from its defaults. + + got tracks the previous hop rather than comparing every hop against the request's original URL. + Same-origin is not transitive once the http-to-https upgrade is allowed: A `http://x` to B `https://x` + is an upgrade, B to C `http://x` is a downgrade, and C is same-origin with A, so comparing against A + would forward a credential into plaintext. Distinguishing the two needs TLS and is not covered by a + test; the other four wrappers rebuild each hop and already hold the previous URL. + + New exports from the http package: `isCrossOrigin`, `headersWithout`, and the `RedirectPolicy` and + `ResolvedRedirectPolicy` types. `allowedHosts` accepts `'strip'` alongside the array and `false`. + Nothing changes for a caller who does not ask for it. + +### Patch Changes + +- 1c87ee4: `containsRedactionMarker()` and `redactionMatchers()` in core, replacing four separate answers to + the same question: given a string about to be written to a column, sent in a request body or stored + in a secret, does it carry the text a `SensitiveString` renders as? `sensitive-http` derived its + answer from the policy registry, `sensitive-typeorm` and `sensitive-drizzle` each shipped a narrower + copy of that derivation, and `sensitive-secrets/write` used a hardcoded `[REDACTED:`. Core owns the + format, so the detector lives there and the four call it. + + Two gaps close, both in the database packages and both security-relevant. + + **`markerPattern` was ignored.** Core declares it on `SensitivityPolicy` for a policy whose redaction + varies with the value, `deterministicRedaction()` sets it, and core's own documentation for the field + names the http guard as its consumer. The two copies written for a database did not read it, so a + deployment using deterministic redaction had a marker check in front of its columns that could not + recognise its own markers. + + **A marker for an unregistered class went unrecognised.** Both copies enumerated the registry and + looked for `[REDACTED:${klass}]` per class. For a reader that is the ordinary case rather than an + edge one: the column was written by an older release, or by a service that registered a class this + process does not have. The detector matches the `[REDACTED:` prefix, which covers those, and also + catches a marker that a composed transformer altered without destroying. + + `policyRegistryVersion()` is new and exported. The memo is keyed on it rather than on the set of + registered class names, because an `overwrite` re-registration replaces a policy's `redact` while + leaving the names identical, and both copies kept answering from the previous policy until something + else changed. + + `sensitive-typeorm` and `sensitive-drizzle` no longer export `redactionMarkers` or + `resetMarkerCache`; they re-export core's `redactionMatchers` and `resetRedactionMarkerCache` + instead. `redactionMatchers()` returns `(string | RegExp)[]` rather than a `Set`, since a + `markerPattern` is a pattern. + +- 635b4c2: Stop paying for the failure the guards almost never find. + + Both of these guards run on every request and every statement, and both did work whose only consumer + was a branch that does not fire. Neither change alters what is refused. What changes is what a clean + request costs to clear. + + `assertNoMarker` percent-decoded every outgoing body before scanning it, which allocates two full + copies of the body: one from `replace(/\+/g, ' ')` and one from `decodeURIComponent`. A JSON body + contains neither `%` nor `+`, so `percentDecoded` is the identity on it and the decoded scan searches + a string identical to the one just searched. The decode is now skipped when the body holds neither + character, which is not a heuristic: with no `+` the replace has nothing to replace, with no `%` the + decode has nothing to decode, so `decoded !== wire` was already dead and the branch was unreachable. + Measured on Node 22.15.0 over a marker-free JSON body, median of seven rounds with each size in its + own process: 1 KB from 2.6 us to 105 ns, 16 KB from 34.9 us to 613 ns, 128 KB from 366 us to 5.9 us. + It is the only figure in this release that scales with payload size, so it is worth most to whoever + sends the largest bodies. + + `+` is checked as well as `%`, and that half is load-bearing rather than defensive. A custom + `registerPolicy` redaction may contain a space, a form encoder puts that space on the wire as `+`, + and the resulting body carries the marker with no percent-escape anywhere in it. A check for `%` + alone would skip that body without looking at it, and a test now fails if the `+` is removed. A + second test compares the guard against an implementation that decodes unconditionally over a corpus + of bodies, so the equivalence is asserted rather than argued. + + `findUnsafeParameter` walked the bound parameters through `parameterEntries`, which materialises a + `[string, unknown][]`, then carried a path string down the recursion and allocated a `WeakSet` per + container. On a bag with nothing wrong in it, which is every bag in a working application, all three + are built and discarded. The walk now reads the three bag shapes in place, builds the path only while + unwinding the branch that found something, and creates the cycle guard on the first descent rather + than on entry. Measured the same way: 5 scalars from 237 ns to 81 ns, 6 named scalars from 486 ns to + 201 ns, 3 parameters with one nested array from 532 ns to 162 ns, a 4-level nesting from 1017 ns to + 372 ns. + + Whether that second one is worth having depends on the driver, and the doc comment says so rather + than quoting the ratio alone. Against a networked Postgres, 300 ns is invisible. Against in-process + `better-sqlite3`, where a small query is single-digit microseconds, it is a few percent of the + statement. + + `parameterEntries` keeps its shape and its exported signature, but nothing calls it on the hot path + any more, so two walkers now read the same three bag shapes. They are held together by a test that + runs both over the same bags rather than by one calling the other, and adding a shape to one without + the other fails it. + + Mutation testing found two coverage gaps that a green suite did not. Deleting the array segment from + the path on the unwind passed every test, because no fixture had an offence nested inside a container + inside an array, only inside an array directly. Removing the array cycle guard also passed, because + the only cycle in the fixtures was an object cycle. Both are now in the corpus, along with a mutual + cycle between an array and an object, and all six mutations of the new walk fail. + +- e95bf0b: A redirect hop no longer carries the caller's query, and a cross-origin 307 or 308 that would + re-send a body carrying a wrapped value is refused under `allowedHosts: 'strip'`. + + Both were found by an independent verification of the corpus study that produced `'strip'`, by + measurement rather than by reading. With a wrapped credential passed as a query parameter and a 302 + whose `Location` carried no query, the axios, fetch, ky and undici wrappers sent the second origin a + URL containing the credential: each hop handed the caller's query to `prepareRequest` again, which + appended it to whatever URL it was given. got did not, because it follows hops itself. The corpus + had recorded exactly this gap against GHSA-p2f4-r6v6-j797 and then withdrawn it, on the reasoning + that a redirect target is the URL the server names; that reasoning was correct only for the delegated + mode, where the client follows and nothing is re-merged. + + `prepareRequest` now drops the query on any hop after the first, so no wrapper has to remember. A + redirect target is the URL the server named and nothing else, which is what every client in the + measured set does on its own. + + A 307 or 308 re-sends the body, and all five wrappers delivered a JSON body carrying a wrapped value + to the cross-origin target under `'strip'`. A header can be dropped at the origin boundary without + changing what the request means; a body cannot, since a body with one field removed is a different + request that succeeds at doing something else. So the hop is refused with + `RedirectWouldResendCredentialError`, before anything is revealed for it, and the message says to + name the destination in `allowedHosts` if it is trusted with the body. A 301, 302 or 303 drops the + body on its own and is followed as before. `PreparedRequest` gains `bodyCarriesCredential`, which + covers a form body as well as a structured one, and `assertBodyMayFollow` is exported for a wrapper + that follows hops itself. + + The got package now merges the instance's `defaults.options.headers` under the call's headers, as the + axios package already did for `defaults.headers`. Measured, a wrapped value placed in + `got.extend({ headers })` reached the wire as `[REDACTED:credential]` and an `apiKey()` descriptor as + `[object Object]`, on every hop, because the wrapper never saw either; they are now revealed, + recorded in the plan, scrubbed, and under `'strip'` dropped at the boundary. ky does not expose an + extended instance's defaults, so the ky README says to put a wrapped value in the call instead. + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [59ace7a] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + ## 0.1.0 ### Minor Changes diff --git a/packages/sensitive-http/package.json b/packages/sensitive-http/package.json index dcbff67..48c37a0 100644 --- a/packages/sensitive-http/package.json +++ b/packages/sensitive-http/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-http", - "version": "0.1.0", + "version": "0.2.0", "description": "Shared layer under the HTTP client integrations - the reveal walker and its scrub plan, the marker guard, the auth header descriptors, the two-path scrub, the redirect and transport checks, and the response decoder", "main": "./dist/index.js", "module": "./dist/index.js", @@ -36,7 +36,7 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0" + "@platform-sec/sensitive-string-core": "^0.6.0" }, "devDependencies": { "@platform-sec/sensitive-string-core": "workspace:*", diff --git a/packages/sensitive-infisical/CHANGELOG.md b/packages/sensitive-infisical/CHANGELOG.md index c370cb8..2d7d03e 100644 --- a/packages/sensitive-infisical/CHANGELOG.md +++ b/packages/sensitive-infisical/CHANGELOG.md @@ -1,5 +1,35 @@ # @platform-sec/sensitive-string-infisical +## 0.1.1 + +### Patch Changes + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [59ace7a] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + - @platform-sec/sensitive-string-secrets@0.2.0 + ## 0.1.0 ### Minor Changes diff --git a/packages/sensitive-infisical/package.json b/packages/sensitive-infisical/package.json index 3ef5583..d59d7d4 100644 --- a/packages/sensitive-infisical/package.json +++ b/packages/sensitive-infisical/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-infisical", - "version": "0.1.0", + "version": "0.1.1", "description": "Infisical secret source - environment and path scoping, mixed configuration classified by rule, every secret wrapped as a SensitiveString", "main": "./dist/index.js", "module": "./dist/index.js", @@ -32,8 +32,8 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0", - "@platform-sec/sensitive-string-secrets": "^0.1.0" + "@platform-sec/sensitive-string-core": "^0.6.0", + "@platform-sec/sensitive-string-secrets": "^0.2.0" }, "devDependencies": { "@platform-sec/sensitive-string-core": "workspace:*", diff --git a/packages/sensitive-k8s/CHANGELOG.md b/packages/sensitive-k8s/CHANGELOG.md index e7b0df4..c6a107e 100644 --- a/packages/sensitive-k8s/CHANGELOG.md +++ b/packages/sensitive-k8s/CHANGELOG.md @@ -1,5 +1,35 @@ # @platform-sec/sensitive-string-k8s +## 0.1.1 + +### Patch Changes + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [59ace7a] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + - @platform-sec/sensitive-string-secrets@0.2.0 + ## 0.1.0 ### Minor Changes diff --git a/packages/sensitive-k8s/package.json b/packages/sensitive-k8s/package.json index ad82ec2..0c8b22d 100644 --- a/packages/sensitive-k8s/package.json +++ b/packages/sensitive-k8s/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-k8s", - "version": "0.1.0", + "version": "0.1.1", "description": "Kubernetes Secret source - the API read path, the built-in type specs, the annotation strip, and the mounted volume watcher", "main": "./dist/index.js", "module": "./dist/index.js", @@ -32,8 +32,8 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0", - "@platform-sec/sensitive-string-secrets": "^0.1.0", + "@platform-sec/sensitive-string-core": "^0.6.0", + "@platform-sec/sensitive-string-secrets": "^0.2.0", "@kubernetes/client-node": ">=0.20.0 <3" }, "devDependencies": { diff --git a/packages/sensitive-kdf/CHANGELOG.md b/packages/sensitive-kdf/CHANGELOG.md new file mode 100644 index 0000000..122148f --- /dev/null +++ b/packages/sensitive-kdf/CHANGELOG.md @@ -0,0 +1,80 @@ +# @platform-sec/sensitive-string-kdf + +## 0.2.0 + +### Minor Changes + +- f8377ff: A `kdf` package: password hashing with Argon2, bcrypt or scrypt, and the stored hash wrapped as a + credential. + + ROADMAP 1.2, the half that cannot live in `core`. Argon2 and bcrypt are native modules and `core` + has no runtime dependencies (rule R7), so `hashForStorage` and `verifyHash` ship here, as free + functions rather than as methods, with both modules optional peer dependencies. An application that + installs neither still hashes with scrypt, which `node:crypto` computes, and that is what makes the + package usable in an environment where a native build is not an option. + + Reading a protected password goes through `core`'s ordinary gate, so hashing one appears in an audit + log as the exposure it is and a revoked password cannot be hashed. The hash itself is a fresh + `credential` that does not inherit the password's deadline or reveal budget. It is the one place in + this library where a derived value does not carry its source's constraints, stated here because it + is a deviation and not an oversight: a stored hash has to outlive the password and be read on every + login, and a salted, memory-hard hash is not a reversible copy of the value the way a fast digest of + a guessable one is. `core`'s `hash()` inherits for exactly that reason. The case where the reasoning + is thin is a secret an attacker can enumerate outright, a four-digit PIN, and no KDF rescues that. + + Three things the design sketch did not have. bcrypt hashes the first 72 bytes of a password and + silently ignores the rest, so two passwords sharing a 72-byte prefix verify against each other's + hash; this refuses the longer password on both the hashing and the verifying side, counting bytes + rather than characters, because an error at registration is a bug report and a silent truncation is + an authentication bypass nobody finds. The scrypt PHC string records `ln`, the base-two logarithm of + the cost, rather than the `N=16384` the sketch wrote, which is what the format actually specifies + and what makes a hash written here readable by something that is not this package. And a stored + string is untrusted input, since it arrives from a database column: its parameters are bounded on + the way back in, or one row asking for `ln=22, r=64, p=16` is a denial of service. + + Every parameter has a floor as well as a default, and a value below it is refused rather than + quietly raised. bcrypt's rounds also has a ceiling, because it is an exponent: 20 is around thirty + seconds per hash and 31, which bcrypt accepts, is measured in weeks. + + `needsRehash` is the other half of that. Verification reads the parameters out of the stored string, + so a password hashed years ago keeps verifying forever; this is how an application notices that it + should not, and rehashes at the one moment it holds the plaintext. It reports weaker, never + different: rolling a configuration back must not rehash a fleet of passwords down to the lower + setting, though a different algorithm family always reports, since that is the migration it exists + for. It hashes nothing and loads no native module, which lets it answer for an algorithm the process + cannot compute, and it cost one bug worth recording: the specification lists Argon2's parameters as + `m,t,p` and the `argon2` package writes `m,p,t`, so a parser written from the format matches no hash + in production. The parameters are read as a list rather than in a fixed order. + + Verification takes the algorithm and parameters from the stored hash rather than from anything + configured today, so a store hashed under weaker parameters keeps verifying while new passwords use + stronger ones. A wrong password answers `false`; a stored hash that cannot be read throws, because a + corrupted column reported as a failed login is an account nobody can sign in to and nobody can + explain. The string it could not read is never repeated in the message. + +### Patch Changes + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [59ace7a] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 diff --git a/packages/sensitive-kdf/package.json b/packages/sensitive-kdf/package.json index 5dad8a6..da6cae6 100644 --- a/packages/sensitive-kdf/package.json +++ b/packages/sensitive-kdf/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-kdf", - "version": "0.1.0", + "version": "0.2.0", "description": "Password hashing for SensitiveString values - Argon2, bcrypt and scrypt behind one audited API, with the stored hash wrapped as a credential", "main": "./dist/index.js", "module": "./dist/index.js", @@ -34,7 +34,7 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0", + "@platform-sec/sensitive-string-core": "^0.6.0", "argon2": "^0.44.0 || ^0.45.0", "bcrypt": "^5.1.0 || ^6.0.0" }, diff --git a/packages/sensitive-ky/CHANGELOG.md b/packages/sensitive-ky/CHANGELOG.md index a7be917..5887754 100644 --- a/packages/sensitive-ky/CHANGELOG.md +++ b/packages/sensitive-ky/CHANGELOG.md @@ -1,5 +1,157 @@ # @platform-sec/sensitive-string-ky +## 0.2.0 + +### Minor Changes + +- 59d6a4b: `headers` on the wrapper's options: a typed place for an instance-level credential. + + The got and axios wrappers read their client's own default headers, so a wrapped value or an + `apiKey()` descriptor placed in `got.extend({ headers })` or on `defaults.headers.common` is + revealed, recorded and stripped like any other. That is the run-time story. The compile-time one is + that none of it type-checks: measured on got 16.0.0, axios 1.20.0 and ky 2.1.0, each client types an + instance's default headers as strings, both a `SensitiveString` and a descriptor are rejected, and + the types are aliases nothing can widen from outside. A TypeScript caller had a cast as the only way + in, which is what this repository's own got suite was doing. + + `SensitiveInstanceOptions` extends `SensitiveClientOptions` with `readonly headers?: +SensitiveHeaders`, and the three wrappers built around a client instance take it: + `SensitiveGotOptions`, `SensitiveAxiosOptions` and `SensitiveKyOptions` are now aliases of it. The + headers sit over the client's own defaults, where the wrapper can read them, and under each call's + headers, and go through the same pipeline: revealed once per attempt, in the scrub plan, scrubbed on + both settlement paths, and under `allowedHosts: 'strip'` dropped at the origin boundary. The merge + happens before the dropped-name accounting, so a name stripped at one hop is not refilled from the + option on the next; the axios and ky suites assert that across a bounce through a second origin and + back. A credential there flips the redirect default to refusing, the same as one in the call, and + each suite asserts that too. + + For ky this is the only instance-level layer there is. ky does not expose an extended instance's + defaults, so a wrapped value in `ky.extend({ headers })` was never seen by the wrapper and the README + said to put it in the call instead; it now says to put it here. + + `fetch` and undici are unchanged. Neither has an instance to wrap, so their per-call bag is the whole + surface and already takes a wrapped header. + + Nothing changes for a caller who does not set it. The got and axios wrappers still read their + client's defaults at run time, so a JavaScript caller keeps what they had. + +- 256911b: `allowedHosts: 'strip'`: follow a redirect with the credential removed instead of refusing it. + + There were two answers to a credential-bearing request that gets redirected, and for one real shape + both are wrong. Refusing breaks a provider that redirects a release asset or a download to object + storage, which is the ordinary way private artifact hosting works. Naming the hosts is impossible for + exactly the same reason: the caller cannot enumerate a bucket endpoint the provider picks at request + time. `'strip'` is the third answer, and it is the one the upstream project behind + [GHSA-p2f4-r6v6-j797](https://github.com/advisories/GHSA-p2f4-r6v6-j797) reached for when it fixed + the same problem: drop the credential at the origin boundary and keep going. + + The difference is where the list of headers to drop comes from. That project had to guess which names + carry credentials, shipped nine of them, and added a registration function so publishers could add + their own. Here the list is derived: the scrub plan already records exactly which header names carried + a wrapped value, because the caller decided that when they wrapped it. A header the library has never + heard of is covered on the same terms as `Authorization`, and there is nothing to register. + + `'strip'` is never a default and cannot become one. Refusing is the answer that cannot go wrong + quietly: a request that stops is a bug report, while a credential dropped on a hop the caller did not + know was a boundary is a request that succeeds at doing less than it appears to. Same-origin hops keep + everything, since a redirect inside one origin discloses nothing to anyone new. + + `isCrossOrigin` decides the boundary by the scheme/host/port rule, normalizing implied ports. It makes + one exception, `http:` to `https:` on the same host where neither side names a non-default port, which + is an upgrade to the same server that every client in the measured set follows without stripping. The + reverse is never allowed, and the allowance does not extend to a non-default port, since a different + port is a different service as often as it is the same one. + + All five wrappers implement it and each is tested end to end against a real pair of loopback origins, + asserting both that the canary does not reach the second origin and that the request still completes. + got is the one that differs: it follows hops itself but hands the next request's options over through + `beforeRedirect`, so there it is a deletion rather than a rebuilt request. + + What every wrapper accumulates is the set of dropped header _names_, applied where the next request is + built, rather than a narrowed header object carried forward. The two are not equivalent and the + difference is a real leak: axios merges `defaults.headers` underneath the request's own, and the axios + wrapper deliberately supports a wrapped value there, so narrowing only the per-request headers would + strip the call site's credential and forward the instance's. Dropped names are also set to `null` in + the outgoing axios config, because removing a name from what is sent is not enough on its own when the + client refills it from its defaults. + + got tracks the previous hop rather than comparing every hop against the request's original URL. + Same-origin is not transitive once the http-to-https upgrade is allowed: A `http://x` to B `https://x` + is an upgrade, B to C `http://x` is a downgrade, and C is same-origin with A, so comparing against A + would forward a credential into plaintext. Distinguishing the two needs TLS and is not covered by a + test; the other four wrappers rebuild each hop and already hold the previous URL. + + New exports from the http package: `isCrossOrigin`, `headersWithout`, and the `RedirectPolicy` and + `ResolvedRedirectPolicy` types. `allowedHosts` accepts `'strip'` alongside the array and `false`. + Nothing changes for a caller who does not ask for it. + +### Patch Changes + +- e95bf0b: A redirect hop no longer carries the caller's query, and a cross-origin 307 or 308 that would + re-send a body carrying a wrapped value is refused under `allowedHosts: 'strip'`. + + Both were found by an independent verification of the corpus study that produced `'strip'`, by + measurement rather than by reading. With a wrapped credential passed as a query parameter and a 302 + whose `Location` carried no query, the axios, fetch, ky and undici wrappers sent the second origin a + URL containing the credential: each hop handed the caller's query to `prepareRequest` again, which + appended it to whatever URL it was given. got did not, because it follows hops itself. The corpus + had recorded exactly this gap against GHSA-p2f4-r6v6-j797 and then withdrawn it, on the reasoning + that a redirect target is the URL the server names; that reasoning was correct only for the delegated + mode, where the client follows and nothing is re-merged. + + `prepareRequest` now drops the query on any hop after the first, so no wrapper has to remember. A + redirect target is the URL the server named and nothing else, which is what every client in the + measured set does on its own. + + A 307 or 308 re-sends the body, and all five wrappers delivered a JSON body carrying a wrapped value + to the cross-origin target under `'strip'`. A header can be dropped at the origin boundary without + changing what the request means; a body cannot, since a body with one field removed is a different + request that succeeds at doing something else. So the hop is refused with + `RedirectWouldResendCredentialError`, before anything is revealed for it, and the message says to + name the destination in `allowedHosts` if it is trusted with the body. A 301, 302 or 303 drops the + body on its own and is followed as before. `PreparedRequest` gains `bodyCarriesCredential`, which + covers a form body as well as a structured one, and `assertBodyMayFollow` is exported for a wrapper + that follows hops itself. + + The got package now merges the instance's `defaults.options.headers` under the call's headers, as the + axios package already did for `defaults.headers`. Measured, a wrapped value placed in + `got.extend({ headers })` reached the wire as `[REDACTED:credential]` and an `apiKey()` descriptor as + `[object Object]`, on every hop, because the wrapper never saw either; they are now revealed, + recorded in the plan, scrubbed, and under `'strip'` dropped at the boundary. ky does not expose an + extended instance's defaults, so the ky README says to put a wrapped value in the call instead. + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [635b4c2] +- Updated dependencies [59d6a4b] +- Updated dependencies [612ccbd] +- Updated dependencies [aadfec7] +- Updated dependencies [e95bf0b] +- Updated dependencies [256911b] +- Updated dependencies [59ace7a] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + - @platform-sec/sensitive-string-http@0.2.0 + ## 0.1.0 ### Minor Changes diff --git a/packages/sensitive-ky/package.json b/packages/sensitive-ky/package.json index 8b8bb06..56eb6bf 100644 --- a/packages/sensitive-ky/package.json +++ b/packages/sensitive-ky/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-ky", - "version": "0.1.0", + "version": "0.2.0", "description": "ky integration for SensitiveString - one reveal point at the wire, the marker guard, per-attempt audit on retries, and the error scrub ky's reject-on-non-2xx default makes a common path", "main": "./dist/index.js", "module": "./dist/index.js", @@ -33,8 +33,8 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0", - "@platform-sec/sensitive-string-http": "^0.1.0", + "@platform-sec/sensitive-string-core": "^0.6.0", + "@platform-sec/sensitive-string-http": "^0.2.0", "ky": "^2.0.0" }, "devDependencies": { diff --git a/packages/sensitive-llm/CHANGELOG.md b/packages/sensitive-llm/CHANGELOG.md new file mode 100644 index 0000000..fa9a77f --- /dev/null +++ b/packages/sensitive-llm/CHANGELOG.md @@ -0,0 +1,177 @@ +# @platform-sec/sensitive-string-llm + +## 0.2.0 + +### Minor Changes + +- dc08fa7: An LLM package: an authorization gate for values leaving the trust boundary, structural prompt + scrubbing over the shared `@platform-sec/sensitive-string-scrub` walk, and keyed entity handles. + + `sendToExternal` is the feature, and it is the only export in the package that returns a raw string; + a test enumerates the module's exports to keep it that way. It is the first thing in this repository + that reads `advisory.allowSendToExternal`, which has been declared on `SensitivityAdvisory` since + the beginning with no reader. The leak it addresses is not a value nobody wrapped: interpolation into + an ordinary template literal already redacts. It is `reveal()` followed by concatenation, where every + existing control has already run and passed, because a prompt leaks when a developer puts the value + there on purpose and is right to need it. The gate resolves the class's policy, refuses anything but + a literal `true`, reveals through the ordinary gate so TTL, revocation, counting and the audit hook + all run, and refuses a revealed form carrying a redaction marker. + + `undefined` is a refusal, which makes a stock installation refuse everything and is the only + defensible reading of a flag meaning "this data may leave for a third party". The error names the + class and the policy field to change. The audit context is a child of whatever is ambient, so an + `auditReveal` installed upstream still fires, and it always carries `external:` as its name, so + an audit sink can answer "what left for a third party today" without the application having named its + contexts well. + + The marker rule runs here in its strictest form in the monorepo, and inverted twice: 3.7 throws on a + marker at the wire, the error tracking row inverted it because a marker is the desired output at a + sink, and this restores the original. Every other integration that ships a marker sends a broken + value to something that rejects it; a model accepts it, answers as though the value were real, and + the placeholder is then in the conversation history. + + `createPromptScrubber` and `scrubForPrompt` are `scrub`'s walk with this package's envelope keys and + one extra pass over the prose positions. **The README says in its own section that this closes the + smaller half of the leak**, and the canary suite asserts a canary is _present_ after scrubbing a + prose position, because a test that documents a known gap is worth more than a gap nobody wrote down. + A request body sorts into three positions: wrapped values at keys are already safe, unwrapped strings + under credential-shaped keys are caught by the denylist, and prose is not reachable by either. The + key is `content`, the key is always `content`, and denying it denies the request. So the package + ships no content classifier and offers `transformContent` as the seam for one the caller already + runs. Failure throws rather than returning a stub, parting from `guardedScrub` deliberately: a stub + event preserves the signal that an error happened, and a stub prompt is a paid request answering a + question nobody asked. `onScrub` fires with `failed: true` first, and the thrown error carries the + stage, the path and the exception type with no message, no stack and no `cause`. + + `entityHandle` mints `HMAC-SHA256(key, scope || 0x00 || klass || 0x00 || value)`, truncated to 12 hex + characters, over core's new `deriveKeyedToken`. Keyed with no unkeyed mode, since the values that get + handles are low-entropy by definition and the premise of a handle is that it appears where an offline + attack can reach it. Refused for `credential`, hard-coded rather than read from a policy flag, with a + test that keeps it refused when the policy is opened all the way up. 8 hex characters is not + available: at 32 bits a one-in-a-thousand collision arrives at about 3,000 entities, and a collision + merges two customers into one in the model's view. + + One thing this package can report and cannot fix, and the README says so under its own heading: + structural redaction replaces a wrapped value with `[REDACTED:credential]`, which leaks nothing and + leaves a placeholder where a value belonged, and a model answers as though the placeholder were real. + A marker in a body is unambiguously wrong for a model and unambiguously right for a log line, and the + scrubber is handed a body that may become either, so it counts rather than decides. Read + `removed.instances` before sending and treat a non-zero count as a bug in the calling code. + + Rehydration is the half the roadmap sentence did not mention, and it is a substitution oracle: a + function taking text an attacker can steer and returning secrets, since a model echoes verbatim spans + from documents in its context and forging a handle costs nothing. So it resolves only against a map + the calling request built, returns a `SensitiveString` rather than a raw string so the authorization + stays with whatever consumes it, takes assembled text rather than a stream, and never runs inside + `scrubForPrompt`. Substitution is by splitting and reassembly through core's template tag rather than + `replaceAll`, which interprets `$` patterns in a _string_ replacement even when the pattern is a plain + string: measured on Node 22.15.0, `` `$` `` and `$'` splice surrounding prompt text into what the + caller believes is a bare value, and neither throws. + + The per-provider content paths in `shapes.ts` were **not** measured against an installed SDK. No + provider SDK is a dependency of this package, not even a dev one, and a fixture written from a + document tests the document rather than the provider. Each shape carries `verified: false` and the + schema it was written from rather than a version number, and the README says what that does and does + not affect: content paths decide where a caller's hook runs, not what is redacted. + +### Patch Changes + +- aa609af: `deterministicRedaction()`: a redacted rendering that tells two values apart. + + ROADMAP 6.1. Three log lines saying `[REDACTED:sensitive]` may be one subject or three, and the + difference is usually the question being asked. Registering this policy replaces the constant with + `[REDACTED:sensitive:5f2a91c0b74e]`, a stable tag derived from the value, so the lines that share a + subject look like they do. It is opt-in through `registerPolicy` and changes nothing for anyone who + does not register it. + + It changes what redacted output means, which is why it is a decision with a threat model behind it + rather than a formatting option. Redacted output has until now carried no information at all; + under this policy it carries a keyed fingerprint. Anyone holding the key and a guess can confirm the + guess against a log line, and anyone holding neither still learns which lines concern the same + subject, which is the feature and is still disclosure. + + The tag is an HMAC over `deriveKeyedToken`, and there is no unkeyed mode. That primitive was built + for this feature and for the LLM package's entity handles, and its rules apply here unchanged: a + 16-byte key floor and a 12-hex-character tag floor. Both override the design sketch, which + defaulted to a plain SHA-256 with an optional HMAC and an eight-character tag. An email address + under an unkeyed digest is a wordlist lookup, and a redacted log is exactly where an offline attack + happens, so the factory refuses without a key instead of defaulting to the dangerous version. The + sketch's `deterministicRedactors` presets are gone with it, since all three were unkeyed. Eight + characters is 32 bits, where a one-in-a-thousand collision arrives at about 3,000 distinct values, + and a collision merges two subjects into one in a log, where it reads as a fact. + + `salt` became `scope`: a domain component mixed in ahead of the value on top of a mandatory key, + rather than the sketch's alternative to one. It keeps the use case the sketch wanted it for, + bounding correlation to a session or a tenant, and drops the reading that a salt is a substitute + for a key, which it is not, because it sits in the same row as the digest. + + `credential` is refused. A password or an API key is guessable by construction, so a stable tag + turns every log line into an oracle for confirming a guess against, for anyone who later obtains + the key. The sketch's own "When NOT to Use" section says so; this makes it the behaviour. The + refusal is hard-coded rather than read from an advisory flag, because every existing flag answers a + different question, and it is escapable through `createDeterministicRedactor`, the bare + `(value) => string` primitive, which is one line that shows up in review. + + `deterministicRedaction` starts from the class policy currently registered and replaces only + `redact`, so `requireConstantTimeEquals`, `allowMask`, `checkRevealAllowed`, the advisory flags and + every deadline rule survive a registration that would otherwise drop whichever one a hand-written + policy forgot to name. It also mixes the class into the derivation, the way `entityHandle` does, so + one string classified two ways gets two tags. + + One registration covers `toString()`, `toJSON()`, `inspect()`, `redactInStructure`, `redactCopy`, + `safeStringify`, a `mask()` that could not meet its floor, and a `serialize()` payload caught by a + redaction walk, which renders the same tag as the instance it came from. `expectRedacted` in + `sensitive-testing` needed no change, because it has always read the expected rendering off the + instance rather than assuming `[REDACTED:class]`; that claim is now covered by a test rather than + by a comment. + + That reach is worth naming twice over. It includes output bound for somewhere else: the + error tracking and APM integrations scrub through `redactCopy`, so an instance in a Sentry event, a + Bugsnag report, a Rollbar item or a Datadog span carries the tag. And the guards that refuse to send + a redaction onward learn a policy's marker by redacting two probes and keeping the answer when both + agree, which a value-dependent policy defeats by construction, so `SensitivityPolicy` gains an + optional `markerPattern` and this policy declares one. Without it a custom `prefix` would have + silently blinded `assertNoMarker` in the HTTP clients. The marker recognizers in `llm` and `scrub` + had a 64-character inner bound that the longest tag overran, and now allow 96. + + The key is not the only way to confirm a guess, which the design's threat model did not say. Anyone + who can get a value of their own choosing redacted into output they can read has an oracle: submit a + guess, read its tag, compare it with a tag from the log. HMAC-SHA256 is a PRF, so this does not + recover the key; it does reduce "an attacker who holds the key" to "an attacker who can reach the + service". + + Where redaction runs has two consequences, both deliberate and both worth knowing. It is not a + reveal, so it fires no audit hook, charges no reveal budget, and keeps working on an expired or + revoked value: a dead value still emits its tag, which is the same disclosure the live one made and + no new one. And it runs on every rendering with no cache, because a cache keyed by value is a table + of plaintexts with the lifetime of the process. Measured on Node 22.15.0, a redaction costs about + 20 ns under the constant policy and about 2.6 µs under this one, so the sketch's "negligible + overhead" holds for a log line and not for a hot loop. + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [59ace7a] +- Updated dependencies [f67efd7] +- Updated dependencies [48f6213] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + - @platform-sec/sensitive-string-scrub@0.2.0 diff --git a/packages/sensitive-llm/package.json b/packages/sensitive-llm/package.json index bbde7ab..e1d4e15 100644 --- a/packages/sensitive-llm/package.json +++ b/packages/sensitive-llm/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-llm", - "version": "0.1.0", + "version": "0.2.0", "description": "Prompt scrubbing, an audited external-sink gate, and keyed entity handles for sending data to a language model", "main": "./dist/index.js", "module": "./dist/index.js", @@ -34,8 +34,8 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0", - "@platform-sec/sensitive-string-scrub": "^0.1.0" + "@platform-sec/sensitive-string-core": "^0.6.0", + "@platform-sec/sensitive-string-scrub": "^0.2.0" }, "devDependencies": { "@platform-sec/sensitive-string-core": "workspace:*", diff --git a/packages/sensitive-metrics/CHANGELOG.md b/packages/sensitive-metrics/CHANGELOG.md new file mode 100644 index 0000000..8b417cd --- /dev/null +++ b/packages/sensitive-metrics/CHANGELOG.md @@ -0,0 +1,160 @@ +# @platform-sec/sensitive-string-metrics + +## 0.2.0 + +### Minor Changes + +- 59ace7a: Count what the library prevented, and price the rest against the emission points that exist. + + `@platform-sec/sensitive-string-metrics` is the collector the redaction observer was built for. It + subscribes to `observeRedactions()` and publishes + `sensitive_redactions_total{class, label, surface}` first in its catalog, because every rendering of + a marker is a place a plain string would have been written, and that is the number a deployment is + judged on. Installing the package, calling `start()` and mounting a Prometheus endpoint is the whole + of a measurement-first deployment: no call site changes, and the counter is complete from `start()` + onward. + + The rest of the catalog is smaller than the design asked for, and each omission has the same reason. + Core has three emission points, and only three. `observeRedactions()`, process-wide. The + `auditReveal` hook, which fires for the eight operations in `RevealDetails` and only under a context + carrying it. And the memory hygiene `WeakRef` count. A refusal fires nothing at all, deliberately: + the gate throws before the policy hook and before the audit hook so that a denied read is not logged + as a reveal. Construction and `equals()` fire nothing either. + + So three counters ship rather than six. `sensitive_reveals_total{class, label, operation, context}` + carries the operation as a dimension, since one hook reports all eight, which makes the design's + separate revocations counter a dimension value and makes the expirations counter impossible: a value + does not expire, a read finds it expired and throws. `sensitive_refusals_total{class, code}` is fed + by the caller through `recordRefusal(error)` or the `meterRefusals` tee, and the README says so + rather than implying a coverage it does not have. The three histograms are not built, because each + needs a bracket around an operation and the one hook available fires at a single instant inside the + gate, so timing from it would measure the hook's own dispatch. + + The three counters have three different coverages, which is the fact most likely to be misread, so + the README leads with the table and the instruction not to divide one into another. A + reveals-per-redaction ratio measures how much of your code you wired up, not how your system behaves. + + `withMeteredReveals` exists because the obvious wiring is wrong. Installing a context with + `auditReveal: metrics.auditReveal` replaces the ambient context wholesale and silently drops whatever + audit sink the application put higher up the stack, which is a bad trade for a counter. It builds + through `createChildContext`, inherits the ambient tags, and chains the hooks, calling each through + the object it was declared on rather than extracting it, since `auditReveal` is a method in + `RevealContext` and core invokes it as one. The counter runs last, so a reveal an audit sink refused + is not counted as one that happened. + + Two dimensions carry text the library does not choose, and both are treated as such. The `context` + dimension is **off by default**: a context name is free-form text from a call site where a request id + is also at hand, and a metrics store keeps it for months. There is no `context.tags.*` dimension. + `maxSeries` folds the caller-supplied dimensions into `__other__` rather than dropping events, so the + total stays right and only the breakdown is lost, and a hard ceiling underneath makes the bound hold + even where the non-folding dimensions are fed by hand. + + Both exposition formats are line-oriented and one of the label values is caller text, so the escaping + is the point rather than a detail. A label of `k",surface="x"} 99\nintruder_total{a="b` closes the + brace, ends the line, and writes a metric of its own into a scrape; the StatsD equivalent is a + newline followed by `intruder:99|c|#x:y`. Each suite drives the injection its own format is + vulnerable to and asserts that the result is not a line, and the StatsD suite asserts on bytes that + crossed a real loopback socket rather than on a string an internal method returned. + + Five defects found while building, and every one is the same mistake: a label value is caller text, + and the code treated it as a well-behaved identifier. A breakdown accumulated on an ordinary object + read `toString` off the prototype and rendered `"function toString() { [native code] }1"` as a metric + value, and would have rewritten the object's prototype for a label of `__proto__`. `StatsDExporter` + closed its socket in the same turn as its final flush, discarding the datagrams on exactly the + `SIGTERM` path that flush exists for. `PrometheusExporter` reserved five of its six dimension names + against `defaultLabels` and missed `context`, and a series carrying one label name twice is a scrape + Prometheus rejects outright, so a single misconfigured default label would take the whole `/metrics` + endpoint down. `StatsDExporter` also computed a delta per collector series, but sanitizing a tag is + many-to-one, so `a:b` and `a,b` both land on `a_b` and each computed its delta against the other's + baseline, inflating the counter on every flush and never recovering. And `collect()` documented an + ordering it did not implement. + + Two more were resource defects rather than formatting ones. The StatsD TCP path ignored what `write` + returned, so a far end that accepts the connection and stops reading made Node buffer every payload + in this process: measured, twenty thousand writes held 193 MB, none of it freed while the connection + stayed open. And the cardinality cap bounded how many series a counter held but nothing about how + large one was, so a thousand legal series still made a scrape as large as the caller's labels were + long; dimension values are now truncated at ingest, where the truncated value becomes the series + identity and the two exporters cannot be handed series that render identically. + + A third pass asked where the guarantees stop rather than what an input renders as or costs, and found + four more. Two were false sentences in the README about behaviour that was already correct, and both + concern a boundary. `sensitive_redactions_total` covers **one isolate**, not the process: measured, a + worker thread rendering five markers left a main-thread collector reporting zero, which inverts the + headline number for anyone whose real work happens in a worker pool. A collector plus a StatsD + exporter per worker composes, measured, because the agent sums the deltas; a Prometheus endpoint does + not, because it exports only the isolate serving it. And the stated reason core is a peer dependency + was backwards: two physical copies of core **share** the observer registry, because core keys it on + `Symbol.for()`, so redactions from both are counted; what does not survive is `recordRefusal`, which + recognises a refusal by `instanceof` and silently misses the other copy's. Both are now asserted, + which rule R6 asks for anyway. The other two were unvalidated configuration: `flushInterval` and + `maxPacketSize` accepted zero, negative and `NaN`, and `setInterval` turns any of those into a + one-millisecond timer rather than an error, so the mistake shipped as a flush loop; and both + exporters accepted a non-collector and failed at the first scrape instead of at boot. + + A fourth pass added benchmarks and asked what recording costs, which found the largest defect of the + lot. Series were held in one `Map` keyed on the dimension values joined into a length-prefixed + composite, and that cost about 450 ns per redaction against a rendering core prices at 17 to 26 ns. + The expense is not the string building, it is the hashing: a freshly built key has no cached hash, so + every lookup hashes the whole string, where the dimension values are repeated strings V8 has already + hashed. Measured over three dimensions, 96 ns to build a key, 257 ns to build it and look it up, and + 12 ns to walk three nested maps and build nothing. The counter now holds series in nested maps, one + level per dimension: `inc` into an existing series went from 1022 ns to 16 ns in a tight loop, and + the collector's end-to-end cost from about 450 ns to about 70 ns over a no-op observer. The shape is + collision-free by construction too, rather than needing the length prefix to earn it. + + That rewrite changed one behaviour and three existing tests caught it. The composite key coerced its + inputs, so a non-string label, which core permits, threw on the way into the key and was counted as a + recording error; a trie has no such step and would have stored it, moving the failure to export time + where it is a broken `/metrics` response rather than a counted error. The check is explicit now, in + the one function every caller-supplied dimension passes through. + + Eleven of the twelve were found by review after the suite was already green: four past 100 passing + tests, two past 109, three past 114, one past 129. Each has a test confirmed red against the unfixed + code, except the two that were wrong sentences rather than wrong lines, whose tests pin the measured + behaviour instead. + + StatsD goes out over `node:dgram` and `node:net`, so both exporters ship in one package with no + third-party runtime dependency, against the design's proposal to split them. `collect()` is the + public seam between collection and export, and a CloudWatch or OpenTelemetry exporter needs nothing + else from a collector. + + Rule R6 does not apply to the collector, which is where the design was wrong about it: a subscriber + is not a singleton. There is no process-global collector to reconfigure, nothing for a duplicate + install to split, and no reset to gate. Two collectors in one process are two independent sets of + numbers, which is what lets this package and a future OpenTelemetry one run side by side. + + In core, `trackedRefCount()` becomes the public `trackedInstanceCount()`, which is the only new + export. The gauge over it is `sensitive_values_tracked` rather than the design's + `sensitive_values_active{class}`, on both counts deliberately: the `WeakRef` set is pruned by a + `FinalizationRegistry` that needs at least two GC cycles and two macrotask turns, so `active` is a + claim the number cannot support, and a per-class split needs a `deref()` on every entry, which keeps + its target alive to the end of the job. A gauge that extended the life of what it measured would be a + strange thing to put in a memory-hygiene metric. + +### Patch Changes + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [59ace7a] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 diff --git a/packages/sensitive-metrics/package.json b/packages/sensitive-metrics/package.json index df3e05b..e26c05e 100644 --- a/packages/sensitive-metrics/package.json +++ b/packages/sensitive-metrics/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-metrics", - "version": "0.1.0", + "version": "0.2.0", "description": "Aggregated metrics for sensitive data handling - redactions prevented, reveals audited, refusals raised, with Prometheus and StatsD exporters", "main": "./dist/index.js", "module": "./dist/index.js", @@ -34,7 +34,7 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0" + "@platform-sec/sensitive-string-core": "^0.6.0" }, "devDependencies": { "@platform-sec/sensitive-string-core": "workspace:*", diff --git a/packages/sensitive-nestjs/CHANGELOG.md b/packages/sensitive-nestjs/CHANGELOG.md index 56ce0af..20bf876 100644 --- a/packages/sensitive-nestjs/CHANGELOG.md +++ b/packages/sensitive-nestjs/CHANGELOG.md @@ -1,5 +1,34 @@ # @platform-sec/sensitive-string-nestjs +## 0.1.1 + +### Patch Changes + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [59ace7a] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + ## 0.1.0 ### Minor Changes diff --git a/packages/sensitive-nestjs/package.json b/packages/sensitive-nestjs/package.json index 8bf72c4..cc30f44 100644 --- a/packages/sensitive-nestjs/package.json +++ b/packages/sensitive-nestjs/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-nestjs", - "version": "0.1.0", + "version": "0.1.1", "description": "NestJS integration for SensitiveString - decorator-driven wrapping, reveal context, and response redaction", "main": "./dist/index.js", "module": "./dist/index.js", @@ -35,7 +35,7 @@ "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0 || ^12.0.0", "@nestjs/core": "^10.0.0 || ^11.0.0 || ^12.0.0", - "@platform-sec/sensitive-string-core": "^0.5.0", + "@platform-sec/sensitive-string-core": "^0.6.0", "reflect-metadata": "^0.1.13 || ^0.2.0", "rxjs": "^7.0.0" }, diff --git a/packages/sensitive-newrelic/CHANGELOG.md b/packages/sensitive-newrelic/CHANGELOG.md new file mode 100644 index 0000000..8a5068a --- /dev/null +++ b/packages/sensitive-newrelic/CHANGELOG.md @@ -0,0 +1,83 @@ +# @platform-sec/sensitive-string-newrelic + +## 0.2.0 + +### Minor Changes + +- 825ae8e: A New Relic integration, over the shared `@platform-sec/sensitive-string-scrub` layer. Everything + below was measured on Node 22.15.0 against `newrelic` 14.3.10, driving the real agent in its + serverless mode and decoding the gzipped payload it would otherwise post to the collector, plus + direct calls into the shipped `AttributeFilter`, with a companion case requiring each position to + leak once the configuration is removed. + + Like the Datadog half of this row, it does not close a default leak. At stock defaults the agent + collects exactly six headers off a hard-coded list, none of them a credential, writes query + parameters at destination `NONE`, and never reads the body. `allow_all_headers: true` is the opt-in + that leaks, and it puts `authorization`, `cookie` and `request.headers.xApiKey` into the payload. + + There is nothing to wrap: the agent's 39 public API methods include no payload callback. So + `sensitiveAgentConfig` is a pure function returning a configuration fragment, and two measured facts + shape what it generates. + + Names are translated. The agent maps 50 known headers to camelCase and puts the rest through a + fallback that title-cases every segment after the first, so an exclusion written as + `request.headers.x-api-key` is silently ignored where `request.headers.xApiKey` is honoured. The map + and the fallback are reproduced rather than imported, and a test pins the reproduction against the + shipped agent on all 50 names plus a fixture set of shapes outside it. Because the fallback is not + injective over casing, the generator emits every spelling it can derive rather than guessing which + one will arrive. + + Rules are exact, and emitted in every casing. The agent compiles them into a `RegExp` with no `i` + flag while `resolveScrubPolicy` lowercases, so generating from the resolved denylist alone excluded + `accesstoken` and transmitted `accessToken`, and did the same for `refreshToken`, `sessionId`, + `clientSecret`, `privateKey`, `confirmPassword`, `passwordConfirmation`, `secretKey`, `secretToken` + and `idToken`. Each of those is in the shared list in that casing precisely because that is how it + appears in a JavaScript payload, so the generator reads the source lists as written and emits both + spellings. `filter.test.ts` pins all ten through the agent's own `AttributeFilter`. + + Rules are exact, not wildcards. The agent resolves include against exclude by match strength, so a + wildcard exclusion is a default that any more specific include lifts, global or per destination, + while an exact global exclusion is a floor nothing in the configuration surface can raise. A + generator that emitted `request.headers.*` and called it done would produce a configuration that one + later `include` line silently reopens. The one wildcard the package can emit is opt-in and documented + as beatable, with the measured table in the README. + + `sensitiveAttributes` is the small opt-in beside it. The agent accepts only `string`, `number` and + `boolean` and drops everything else with a debug log, so a wrapped value added as a custom attribute + arrives as nothing rather than as its marker, and the obvious fix a hurried engineer reaches for is + `reveal()`. It runs the shared walk and coerces what is left, so the marker arrives instead. + + The README states what no part of this package can reach, and names log forwarding first: + `application_logging.enabled` and its `forwarding.enabled` both default to true, which is the one + default-on egress in either agent of this row, and by the time a log record exists a revealed value + is an ordinary string. + +### Patch Changes + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [59ace7a] +- Updated dependencies [f67efd7] +- Updated dependencies [48f6213] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + - @platform-sec/sensitive-string-scrub@0.2.0 diff --git a/packages/sensitive-newrelic/package.json b/packages/sensitive-newrelic/package.json index 14e156f..662317f 100644 --- a/packages/sensitive-newrelic/package.json +++ b/packages/sensitive-newrelic/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-newrelic", - "version": "0.1.0", + "version": "0.2.0", "description": "New Relic integration for SensitiveString - generates the exact attribute exclusions the agent's rule engine cannot lift", "main": "./dist/index.js", "module": "./dist/index.js", @@ -32,8 +32,8 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0", - "@platform-sec/sensitive-string-scrub": "^0.1.0", + "@platform-sec/sensitive-string-core": "^0.6.0", + "@platform-sec/sensitive-string-scrub": "^0.2.0", "newrelic": "^14.0.0" }, "peerDependenciesMeta": { diff --git a/packages/sensitive-oci-vault/CHANGELOG.md b/packages/sensitive-oci-vault/CHANGELOG.md index ac82117..c96a797 100644 --- a/packages/sensitive-oci-vault/CHANGELOG.md +++ b/packages/sensitive-oci-vault/CHANGELOG.md @@ -1,5 +1,35 @@ # @platform-sec/sensitive-string-oci-vault +## 0.1.1 + +### Patch Changes + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [59ace7a] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + - @platform-sec/sensitive-string-secrets@0.2.0 + ## 0.1.0 ### Minor Changes diff --git a/packages/sensitive-oci-vault/package.json b/packages/sensitive-oci-vault/package.json index 44a15c7..8dfb9ca 100644 --- a/packages/sensitive-oci-vault/package.json +++ b/packages/sensitive-oci-vault/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-oci-vault", - "version": "0.1.0", + "version": "0.1.1", "description": "OCI Vault secret source - the secret retrieval data plane, base64 bundle validation, every value wrapped as a SensitiveString", "main": "./dist/index.js", "module": "./dist/index.js", @@ -32,8 +32,8 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0", - "@platform-sec/sensitive-string-secrets": "^0.1.0", + "@platform-sec/sensitive-string-core": "^0.6.0", + "@platform-sec/sensitive-string-secrets": "^0.2.0", "oci-secrets": "^2.0.0" }, "devDependencies": { diff --git a/packages/sensitive-prisma/CHANGELOG.md b/packages/sensitive-prisma/CHANGELOG.md index bbc1bf6..d5b060d 100644 --- a/packages/sensitive-prisma/CHANGELOG.md +++ b/packages/sensitive-prisma/CHANGELOG.md @@ -1 +1,179 @@ # @platform-sec/sensitive-string-prisma + +## 0.2.0 + +### Minor Changes + +- 1c87ee4: A shared layer under the ORM integrations, `sensitive-data`, extracted from the three that were + already built rather than designed ahead of them. It holds the bound-parameter walk, the query-log + redactor, the statement identifier scan, the driver error sanitizer and the write reveal context. + Nothing installs it directly; each ORM package takes it as a peer. + + Extracting from three consumers instead of one was the point, and it is a deliberate departure from + how `scrub` and `secrets` were built. Those shipped alongside a single first consumer and held up, + because their consumers are homogeneous. A `customType`, a `ValueTransformer` and a client extension + are not, and every piece of this layer differs from what any one of the three had. + + Two of those differences were gaps in shipped code, and neither was reachable by the suite of the + package that had it, because each package's development driver could not produce the shape the other + covered. + + **The bound-parameter walk missed a shape in each direction.** TypeORM's descended into nested + containers and terminated on cycles, but walked only arrays and objects whose prototype is + `Object.prototype`, so a `Map` or a bag from another realm passed the guard unchecked. Drizzle's + walked every object shape but stopped at one level, so an instance inside a nested filter object + passed. The shared walk does both, and both packages gain what they were missing. + + **The error sanitizer had the message and stack half in one package only.** `DrizzleQueryError` + interpolates the parameters into its message and V8 bakes that message into the stack header at + construction, so Drizzle's sanitizer rewrote all three copies. TypeORM's rewrote the structured + property alone, which is correct for `QueryFailedError` and leaves nothing for the next consumer + whose message does carry a value. The machinery now exists once and is exercised by both shapes. + + Two behaviour changes in the ORM packages follow from unifying them: + + `DrizzleParameterRefusedError.parameter` is now a `string` rather than `number | string`. A numeric + index cannot name a position inside a container, and the walk descends into one, so the field + carries a path such as `0.filters[1]`. + + The TypeORM write context now tags a reveal with `klass`, which the Drizzle one already did, and the + Prisma one now tags with `sink`, which neither of the others was missing. An audit sink receiving + records from an application using more than one of these packages can now tell them apart. + + Prisma keeps its own reveal context builder, deliberately. The shared one is per column and carries + one of six `WriteOperation` members; Prisma's is built once per operation, its `path` is a position + in a nested argument object rather than a column, and its `operation` is Prisma's own free-form + name. Filling the shared shape would mean reporting `unknown` for an operation that hook actually + knows. + + Three things were deliberately not extracted, because three consumers proved they are not shared: + the column specification, which is a path map in one, a model-keyed object in another and a + declaration on the column itself in the third; the drift check, which reads entity metadata, the + runtime data model and registered column objects respectively; and the `sensitive-scrub` fragments, + which name different properties on different errors. Each package also keeps its own error types, on + the same reasoning that kept `SecretWriteRefusedError` where it is. + +- 820e703: Add the Prisma integration: classified columns read back wrapped, and every write unwraps through an + audited reveal. + + ROADMAP 3.3, the Prisma half. Reads are a Prisma `result` extension, one computed field per + classified column. Prisma applies a result extension per model wherever that model appears in a + result, so relations loaded through `include` or `select` are covered by the same definition and this + package never walks a result tree; the generated result types pick up each `compute`'s declared + return type, so `select`, `include` and `omit` keep narrowing in the value and in the type. The + design document left that as a spike. It is resolved affirmatively on Prisma 6.19.3 and 7.10.0: + `user.email` is exactly `SensitiveString<'sensitive'>` rather than a union with `string`, and an + `omit` of a classified field, per query or global, removes it from both the type and the value with + no raw copy left under the original key. + + Writes do not work the way the design assumed, and the reason is worth stating because it inverts + what the package is for. `applyQueryExtensions` calls `deepCloneArgs` before it hands arguments to + any query hook, and that copy reproduces only the types Prisma knows: object enum values, field + references, `Decimal`, `Date` and typed arrays. Every other class instance is rebuilt from a + `for...in` copy, which cannot reach an ES `#private` field. A `SensitiveString` in `data` is + therefore already `{ klass, label }` with no value behind it by the time a hook could see it, so no + query extension can unwrap a write. `sensitiveInput()` does the reveal at the call site instead, + which is the last point at which the value exists; it was already required there for Prisma's + generated input types, so the amount of code at a call site is unchanged. + + What makes that the point rather than a technicality is the measurement on a client with no query + extension. There is no clone, the instance reaches Prisma's serialiser, `toJSON()` is called, and the + column ends up holding `[REDACTED:credential]`. The write reports success, and a later lookup by the + same unwrapped value matches the corrupted row because both sides are the marker, so even a + round-trip test passes. The secret is destroyed silently. The extension's query hook is what closes + that: it refuses any operation still carrying the flattened remains of an instance, naming the model, + the operation and the argument path, and it unwraps the one argument shape the copy does preserve, + the `values` of a `$queryRaw`, `$executeRaw` or `$queryRawTyped` template. See ADR-003. + + Every unwrap is one `reveal()` under a child context tagged with the model, the operation and the + argument path, layered on any ambient `withRevealContext()` so its `auditReveal` hook and tags + survive. Core hands that context to the hook as its third argument, which is where a sink reads the + tags. + + The names in the configuration are checked against the client when the extension is applied, which + turned out to matter as much as anything else here. Neither TypeScript nor Prisma catches a typo: + `models` is keyed by string, and measured three ways on both versions, Prisma ignores a result + component under a model it does not know, adds a computed field under a name it does not know beside + the real column rather than replacing it, and does not throw on a `needs` entry for a column that + does not exist. In all three the configured column reads back as a plain string, so one wrong + character turned the guarantee off with nothing said. `PrismaUnknownModelError` and + `PrismaUnknownFieldError` are raised at `$extends` time instead, before a query runs. + + Also in this release: `wrapRows` for raw query results, since a type argument such as + `$queryRaw` is erased before the query runs; configuration validated when the extension is + built rather than on the first query that reaches it; and `nullable` and `list` in a field spec, + because the `compute` return type is what Prisma publishes and a nullable column typed as a required + scalar is a lie the compiler propagates. + + Peer range is `@prisma/client@^6 || ^7`, measured on 6.19.3 and 7.10.0. Scalar lists are unit-tested + against the real result builder rather than end to end, because the suite runs on SQLite, which has + none; TypedSQL is matched by shape and not exercised end to end. Prisma's own query log carries bound + parameters and no extension can intercept engine logging, so the README says not to enable query + logging in production and a test pins the leak so a Prisma change is noticed. + +### Patch Changes + +- 3e054e2: Hand `auditReveal` the context the operation ran under. + + The hook was called with the value and the operation and nothing else, so a sink could not tell which + unit of work a reveal belonged to. That is not a gap anyone works around cheaply: `sensitive-prisma` + tags each unwrap with the model, the operation and the argument path, and `sensitive-typeorm` tags + each column write with the table and the column, and both had to install their per-value context as + the ambient one around every single reveal so a sink could recover the tags from + `getCurrentRevealContext()` inside its own hook. One `AsyncLocalStorage` entry per sensitive value, + paid to move three strings. + + `auditReveal(value, details, context)` now carries it. The context is the one passed to the call, or + the ambient one when the call passed none, which is the same context the policy's + `checkRevealAllowed` was asked to authorize, so an audit record and an authorization decision are + comparable after the fact. It is not always the context the hook was installed on: a reveal under a + `createChildContext()` is audited under the child, which is exactly what makes an integration's tags + reachable. Both packages now reveal plainly, and the entry went with the workaround. + + The argument is typed as `AuditContext`: `name`, `reason` and `tags`, and no `auditReveal`, since the + sink reading it is that hook. The narrowing is in the type and not in a copy, so a sink may compare + the context by identity and an audited reveal allocates nothing; `tags` is the caller's own object, + shared by every reveal under the context, so a sink copies out what it means to keep rather than + writing to it. + + A hook written with two parameters is unaffected, in types and at runtime. Code that calls a + context's `auditReveal` itself must pass the third argument; the only such caller in this repository + was the worker handoff's hook forwarder, which now passes the receiving context through so a + forwarded sink sees the work the sender named. + + In `sensitive-string-testing`, `AuditCall` gains `context` and `AuditEntry.contextName` is read from + what core reports rather than from the log's own name, so an entry made under a nested child is + recorded under the child. The recorded context is a one-level copy: a record pointing at the live + mock context would close a cycle through `calls` that `JSON.stringify` could not walk. Both helpers + degrade rather than throw when a caller forwards the hook with two arguments, recording the mock's + own name; they run inside the reveal gate, where a `TypeError` would surface as a failing reveal in + somebody's suite rather than as a missing tag. + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [2c5bd7f] +- Updated dependencies [1c87ee4] +- Updated dependencies [635b4c2] +- Updated dependencies [59ace7a] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + - @platform-sec/sensitive-string-data@0.2.0 diff --git a/packages/sensitive-prisma/package.json b/packages/sensitive-prisma/package.json index 34053bf..cdd3a5c 100644 --- a/packages/sensitive-prisma/package.json +++ b/packages/sensitive-prisma/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-prisma", - "version": "0.1.0", + "version": "0.2.0", "description": "Prisma integration for SensitiveString - configured columns read back wrapped, and every write unwraps through an audited reveal", "main": "./dist/index.js", "module": "./dist/index.js", @@ -35,8 +35,8 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0", - "@platform-sec/sensitive-string-data": "^0.1.0", + "@platform-sec/sensitive-string-core": "^0.6.0", + "@platform-sec/sensitive-string-data": "^0.2.0", "@prisma/client": "^7.0.0" }, "devDependencies": { diff --git a/packages/sensitive-rollbar/CHANGELOG.md b/packages/sensitive-rollbar/CHANGELOG.md new file mode 100644 index 0000000..044e397 --- /dev/null +++ b/packages/sensitive-rollbar/CHANGELOG.md @@ -0,0 +1,57 @@ +# @platform-sec/sensitive-string-rollbar + +## 0.2.0 + +### Minor Changes + +- 38387f6: A Rollbar integration, over the shared `@platform-sec/sensitive-string-scrub` layer. Everything + below was measured on Node 22.15.0 against `rollbar` 3.1.0, driving the real SDK to a server on + loopback and asserting on the bytes that left the process, with a companion case requiring each + position to leak once the scrubbing is removed. + + `rollbar` is thin, and its README says so rather than manufacturing value. The vendor already ships + the best defaults measured and applies them before the one seam a user can install. Two findings are + what the package is actually for. Rollbar is the only SDK measured that does not honour `toJSON()`: a + wrapped value arrives at every seam as `{ klass, label }`, so its value is safe because the private + field is unreachable, and its **label was being transmitted** on every payload. The package + recognizes that shape, puts the marker back, and routes the label into the report where + `reportLabels` governs it. And a `transform` that throws makes Rollbar set `options.transform = null` + and send the original untransformed item, so one exception disables scrubbing for the life of the + process; a caller's own `transform` is therefore called inside the guard rather than beside it. + + A `transform` you supply runs _before_ the scrubber and is called inside the guard, so a bug in + your callback cannot disable this package. + + The seam inventory is checked against the shipped `index.d.ts` rather than at the type level, + because `keyof Rollbar.Configuration` widens to `string` in this program and a type-level check + against it would pass vacuously. + +### Patch Changes + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [59ace7a] +- Updated dependencies [f67efd7] +- Updated dependencies [48f6213] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + - @platform-sec/sensitive-string-scrub@0.2.0 diff --git a/packages/sensitive-rollbar/package.json b/packages/sensitive-rollbar/package.json index ef6a148..b3f9a6e 100644 --- a/packages/sensitive-rollbar/package.json +++ b/packages/sensitive-rollbar/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-rollbar", - "version": "0.1.0", + "version": "0.2.0", "description": "Rollbar integration for SensitiveString - composes transform and checkIgnore and extends the shipped scrub lists", "main": "./dist/index.js", "module": "./dist/index.js", @@ -32,8 +32,8 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0", - "@platform-sec/sensitive-string-scrub": "^0.1.0", + "@platform-sec/sensitive-string-core": "^0.6.0", + "@platform-sec/sensitive-string-scrub": "^0.2.0", "rollbar": "^3.0.0" }, "devDependencies": { diff --git a/packages/sensitive-scrub/CHANGELOG.md b/packages/sensitive-scrub/CHANGELOG.md index cfa5441..0eafc14 100644 --- a/packages/sensitive-scrub/CHANGELOG.md +++ b/packages/sensitive-scrub/CHANGELOG.md @@ -1,5 +1,162 @@ # @platform-sec/sensitive-string-scrub +## 0.2.0 + +### Minor Changes + +- f67efd7: Add `onMatch: 'censor' | 'observe'` to the scrub policy. Default `'censor'`, which is what every + payload got before. + + ```ts + const policy = resolveScrubPolicy({ + onMatch: 'observe', + onScrub: (report) => counts.record(report.kind, report.deniedKeys, report.deniedValues), + }); + ``` + + Under `'observe'` a denylist match is left in place and still reported, so a payload reaches the + vendor as it did before this package was installed and the report says what would have been + censored. The report is the same one `'censor'` produces for the same payload, asserted across every + position in the canary sweep, and it still carries no value. This is the measurement mode for the + weeks before a denylist is trusted: an operator reads the denied key names and matched shapes off + the reports, adds the `allowKeys` entries their application needs, and flips the switch with a + number in hand rather than a hope. It is not an operating mode, and the README says so in the first + sentence. + + It governs stage 2 only. A `SensitiveString` and a `serialize()` result are replaced under either + setting, because that stage touches nothing the application did not classify itself, and a wrapped + value renders its marker under every vendor's serializer regardless. Under `'observe'` the failure + path hands the vendor the payload it was given rather than a stub, since a mode whose promise is to + change nothing cannot replace an event; the report still says `failed`, which is where the operator + learns that the walk broke on a shape before `'censor'` would have broken it in the open. A misspelt + mode censors, so the option fails closed. `mergeScrubPolicy` carries it with the caller winning over + the vendor package, like the other single-valued fields, and every vendor package accepts it through + its existing policy argument. + +- 48f6213: Opt-in value shape recognizers, and two exports the vendor packages needed. + + `denyValues` takes pattern names or your own `RegExp` and is empty by default, which is the whole of + this layer's answer to whether a denylist should ever match on value shape. Everything else here + matches on key names and paths, because a scrubber that decides by looking at a string is a + classifier and is wrong some of the time, and the thing a user does about an error tracker that hides + values they needed is turn the scrubbing off entirely. A bearer token forwarded into a field nobody + named `authorization` is invisible to a key list, though, so the shapes ship: `jwt`, `bearer`, + `basicAuth`, `pemPrivateKey`, `awsAccessKeyId`, `githubToken`, `slackToken` and `openaiKey`. + + Three rules every shipped pattern follows, each with a test over every pattern rather than an + argument. Anchored at both ends, including against a trailing newline, since JavaScript's `$` also + matches before a final line terminator. Linear time, checked against a pathological subject. And a + prefix a vendor issues rather than a shape that merely looks random. A caller's own `RegExp` is taken + as written with `g` and `y` stripped, because a sticky pattern carries `lastIndex` between payloads + and would match on one event and not the next. + + `ScrubReport` gains `deniedValues`, which names the shapes that matched and never the values or the + paths they matched at: a path is a key name your application chose and is safe on its own, but a path + paired with a shape name is a map of where to look. + + Also exported: `mergeReports`, which folds a second pass's findings into the report a seam will + publish while leaving the marker count alone, and `reportScrub`, so a vendor whose callback contract + is mutate-in-place runs its own guard and still swallows a throwing `onScrub` the same way. + +### Patch Changes + +- aa609af: `deterministicRedaction()`: a redacted rendering that tells two values apart. + + ROADMAP 6.1. Three log lines saying `[REDACTED:sensitive]` may be one subject or three, and the + difference is usually the question being asked. Registering this policy replaces the constant with + `[REDACTED:sensitive:5f2a91c0b74e]`, a stable tag derived from the value, so the lines that share a + subject look like they do. It is opt-in through `registerPolicy` and changes nothing for anyone who + does not register it. + + It changes what redacted output means, which is why it is a decision with a threat model behind it + rather than a formatting option. Redacted output has until now carried no information at all; + under this policy it carries a keyed fingerprint. Anyone holding the key and a guess can confirm the + guess against a log line, and anyone holding neither still learns which lines concern the same + subject, which is the feature and is still disclosure. + + The tag is an HMAC over `deriveKeyedToken`, and there is no unkeyed mode. That primitive was built + for this feature and for the LLM package's entity handles, and its rules apply here unchanged: a + 16-byte key floor and a 12-hex-character tag floor. Both override the design sketch, which + defaulted to a plain SHA-256 with an optional HMAC and an eight-character tag. An email address + under an unkeyed digest is a wordlist lookup, and a redacted log is exactly where an offline attack + happens, so the factory refuses without a key instead of defaulting to the dangerous version. The + sketch's `deterministicRedactors` presets are gone with it, since all three were unkeyed. Eight + characters is 32 bits, where a one-in-a-thousand collision arrives at about 3,000 distinct values, + and a collision merges two subjects into one in a log, where it reads as a fact. + + `salt` became `scope`: a domain component mixed in ahead of the value on top of a mandatory key, + rather than the sketch's alternative to one. It keeps the use case the sketch wanted it for, + bounding correlation to a session or a tenant, and drops the reading that a salt is a substitute + for a key, which it is not, because it sits in the same row as the digest. + + `credential` is refused. A password or an API key is guessable by construction, so a stable tag + turns every log line into an oracle for confirming a guess against, for anyone who later obtains + the key. The sketch's own "When NOT to Use" section says so; this makes it the behaviour. The + refusal is hard-coded rather than read from an advisory flag, because every existing flag answers a + different question, and it is escapable through `createDeterministicRedactor`, the bare + `(value) => string` primitive, which is one line that shows up in review. + + `deterministicRedaction` starts from the class policy currently registered and replaces only + `redact`, so `requireConstantTimeEquals`, `allowMask`, `checkRevealAllowed`, the advisory flags and + every deadline rule survive a registration that would otherwise drop whichever one a hand-written + policy forgot to name. It also mixes the class into the derivation, the way `entityHandle` does, so + one string classified two ways gets two tags. + + One registration covers `toString()`, `toJSON()`, `inspect()`, `redactInStructure`, `redactCopy`, + `safeStringify`, a `mask()` that could not meet its floor, and a `serialize()` payload caught by a + redaction walk, which renders the same tag as the instance it came from. `expectRedacted` in + `sensitive-testing` needed no change, because it has always read the expected rendering off the + instance rather than assuming `[REDACTED:class]`; that claim is now covered by a test rather than + by a comment. + + That reach is worth naming twice over. It includes output bound for somewhere else: the + error tracking and APM integrations scrub through `redactCopy`, so an instance in a Sentry event, a + Bugsnag report, a Rollbar item or a Datadog span carries the tag. And the guards that refuse to send + a redaction onward learn a policy's marker by redacting two probes and keeping the answer when both + agree, which a value-dependent policy defeats by construction, so `SensitivityPolicy` gains an + optional `markerPattern` and this policy declares one. Without it a custom `prefix` would have + silently blinded `assertNoMarker` in the HTTP clients. The marker recognizers in `llm` and `scrub` + had a 64-character inner bound that the longest tag overran, and now allow 96. + + The key is not the only way to confirm a guess, which the design's threat model did not say. Anyone + who can get a value of their own choosing redacted into output they can read has an oracle: submit a + guess, read its tag, compare it with a tag from the log. HMAC-SHA256 is a PRF, so this does not + recover the key; it does reduce "an attacker who holds the key" to "an attacker who can reach the + service". + + Where redaction runs has two consequences, both deliberate and both worth knowing. It is not a + reveal, so it fires no audit hook, charges no reveal budget, and keeps working on an expired or + revoked value: a dead value still emits its tag, which is the same disclosure the live one made and + no new one. And it runs on every rendering with no cache, because a cache keyed by value is a table + of plaintexts with the lifetime of the process. Measured on Node 22.15.0, a redaction costs about + 20 ns under the constant policy and about 2.6 µs under this one, so the sketch's "negligible + overhead" holds for a log line and not for a hot loop. + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [59ace7a] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + ## 0.1.0 ### Minor Changes diff --git a/packages/sensitive-scrub/package.json b/packages/sensitive-scrub/package.json index 237a2c4..6106e77 100644 --- a/packages/sensitive-scrub/package.json +++ b/packages/sensitive-scrub/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-scrub", - "version": "0.1.0", + "version": "0.2.0", "description": "Shared payload scrubbing for error tracking and APM integrations - structural redaction, key denylist, and a never-throw contract", "main": "./dist/index.js", "module": "./dist/index.js", @@ -37,7 +37,7 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0" + "@platform-sec/sensitive-string-core": "^0.6.0" }, "devDependencies": { "@platform-sec/sensitive-string-core": "workspace:*", diff --git a/packages/sensitive-secrets/CHANGELOG.md b/packages/sensitive-secrets/CHANGELOG.md index 62871a4..fda5a53 100644 --- a/packages/sensitive-secrets/CHANGELOG.md +++ b/packages/sensitive-secrets/CHANGELOG.md @@ -1,5 +1,160 @@ # @platform-sec/sensitive-string-secrets +## 0.2.0 + +### Minor Changes + +- 29c6f87: Add transmission references: send a pointer to a secret instead of the secret. + + ROADMAP 6.5, release one of two. `serialize()` puts the raw value on the wire and asks the transport + for confidentiality, which is a fair deal for mTLS between two services in one trust domain and a + poor one everywhere the payload comes to rest. A job in SQS, Kafka, a `jobs` table or a webhook + retry buffer sits there in plaintext for as long as the retention allows, and a transport that + terminates at a mesh sidecar, an API gateway or a logging proxy is decrypted somewhere in the + middle. Where the value came from a store both sides can reach, `toReference(record, { store })` + sends where it is and the receiver fetches it with its own credentials, so the value never leaves + the process that read it. + + There is no `token.toReference()`, and the reason is not ergonomic. `#derived` unions the state of + every source instance into the derivative, which is right for constraints and inverts what + provenance means: a `toReference()` on the instance would let `token.substring(0, 4)` mint a pointer + to the whole credential, and the receiver would have no way to tell the difference. The label rule + is the second reason, since it strips everything identifying the account, project, tenancy or host, + so an instance knows a path and not which of an organization's stores that path is in, and the same + logical path usually exists in staging and in production. Minting takes the `ref` a read returned, + which contains no secret material by construction, and `store` is required rather than derived from + the source, because inventing one from the endpoint would put an internal hostname into a payload. + + Minting is not a reveal. It performs no I/O and never touches `#value`, and recording it as one + would make a sink that counts reveals to decide whether a credential is over-used count every + message that merely mentions the secret. That is the call `SecretLoadEvent` already made for loads, + for the same reason. + + Resolving is the half with the security content, and it is not `get()` under another name. + `createReferenceResolver({ store, source, allow }).resolve(ref, { context })` runs in the receiver, + with the receiver's credentials, against a path a sender chose, which is the shape of every confused + deputy: unconstrained, it lets anything that can enqueue a job read any secret the receiver's role + can read, using the receiver as the oracle. So the allowlist is required, there is no permissive + default, and the empty list is legal and resolves nothing. The checks run in order and each precedes + the fetch: the caller's own context, then the shape and the format version, the store identity + compared exactly, a path carrying a traversal, an empty segment, a leading slash or a control + character, a version outside `[A-Za-z0-9._:-]{1,128}`, an entry covering the path, and an entry that + resolves this kind of reference and declares this field. Then the class, as the strictest among the + covering entries and then the more restrictive of that and the reference's: a class on the wire may tighten what the receiver does and may never loosen it, because + a receiver that took `klass` from a payload would let a sender turn off constant-time comparison for + a credential, lift the refusal on partial queries, and let `mask()` disclose a prefix. + + A path is refused rather than normalized. Normalizing would mean this library and the provider each + holding an opinion about what `kv/a/../b` means, and a disagreement between two normalizers is how a + path that fails the allowlist reaches the store anyway. It is also refused for carrying `%`, `\`, + `?` or `#`, which is a stronger rule than "no `..` segment" and a measured one rather than a + cautious one: a provider builds its request as `new URL(base + path)`, which is what + `sensitive-vault` does, and on Node 22.15.0 the WHATWG parser applies its double-dot rule to the + percent-decoded segment and reads a backslash as a separator, so both + `kv/payments/%2e%2e/%2e%2e/auth/token/lookup-self` and `kv/payments/..\..\auth/token/lookup-self` + leave an allowlisted subtree while passing a check over `/`-split segments, and the second carries + no percent sign at all. A `?` turns a read of a permitted secret into a different request against + the same store. The suite pins that parser behaviour next to the refusals, so the rule keeps its + reason attached. A version is checked against a charset rather than escaped, for the same reason. + Patterns are a trailing `*` on a segment boundary and nothing else, with no regex, and a malformed + one throws at construction rather than silently matching nothing. + + Where entries overlap, the class is the strictest among them rather than the first one's. A subtree + rule and a rule for one path under it are how an operator writes an allowlist, and taking the first + would make `[{ path: 'kv/*', klass: 'sensitive' }, { path: 'kv/payments/stripe', klass: +'credential' }]` resolve the Stripe key as `sensitive`, which is a security control whose answer + depends on the order two lines were written in. The allowlist is copied entry by entry and frozen at + construction for the same kind of reason, so a caller holding the array or an entry cannot widen a + live resolver afterwards. + + A refusal is a `ReferenceRefusedError` carrying `reason`, and the path is on `error.path` and never + in `error.message`: a refusal is the case where the sender is least likely to be one this receiver + trusts, and interpolating what they sent would put attacker-chosen text into the receiver's own + logs. As everywhere in this package, no error sets `cause`. + + `core` gains the format and nothing else: `SensitiveValueReference`, `isSensitiveValueReference()`, + `carriesReferenceMarker()` and `referenceFormatVersion()`. It has no minting and no resolving, + because both need the store, the version and the credentials, and it would have had to model stores + and accounts to hold them. What the format is doing in `core` is that the walks which must decide + what to do with a wire object are there, and what they decide for this one is to leave it alone: a + reference carries no secret material, resolving it needs credentials of the reader's own, and + redacting it would make a reference-passing system undebuggable for exactly no gain. Those + assertions are positive in the suite rather than absent from it, so a later change to redact + everything with a `__sensitive`-shaped marker fails a test instead of shipping. + + Two smaller decisions worth recording. `resolve()` requires its own `RevealContext`, for the reason + `PutOptions.context` does on the write path, and checks for it before it looks at the reference at + all, because a receiver that forgot its context has a mistake of its own and reporting that as a + fault in what arrived would point whoever reads the log at the wrong party. That context is + installed as a child of the ambient one, so an application's `auditReveal` is inherited rather than + dropped and a provider that reveals its own token to authenticate the request records that reveal + under the caller's name. And `pinVersion` + defaults to true, since a reference stands in for a value and a value does not change between the + send and the receive, with the cost stated where the option is: a pinned reference outlives the + rotation that was meant to retire the old version, and queue retention shorter than the rotation + interval is the honest fix. The `'unknown'` a provider reports when it has no version is a sentinel + rather than a version, and is never pinned. + + The encrypted envelope, the other half of 6.5, is a separate release and is not in this one. + +### Patch Changes + +- 1c87ee4: `containsRedactionMarker()` and `redactionMatchers()` in core, replacing four separate answers to + the same question: given a string about to be written to a column, sent in a request body or stored + in a secret, does it carry the text a `SensitiveString` renders as? `sensitive-http` derived its + answer from the policy registry, `sensitive-typeorm` and `sensitive-drizzle` each shipped a narrower + copy of that derivation, and `sensitive-secrets/write` used a hardcoded `[REDACTED:`. Core owns the + format, so the detector lives there and the four call it. + + Two gaps close, both in the database packages and both security-relevant. + + **`markerPattern` was ignored.** Core declares it on `SensitivityPolicy` for a policy whose redaction + varies with the value, `deterministicRedaction()` sets it, and core's own documentation for the field + names the http guard as its consumer. The two copies written for a database did not read it, so a + deployment using deterministic redaction had a marker check in front of its columns that could not + recognise its own markers. + + **A marker for an unregistered class went unrecognised.** Both copies enumerated the registry and + looked for `[REDACTED:${klass}]` per class. For a reader that is the ordinary case rather than an + edge one: the column was written by an older release, or by a service that registered a class this + process does not have. The detector matches the `[REDACTED:` prefix, which covers those, and also + catches a marker that a composed transformer altered without destroying. + + `policyRegistryVersion()` is new and exported. The memo is keyed on it rather than on the set of + registered class names, because an `overwrite` re-registration replaces a policy's `redact` while + leaving the names identical, and both copies kept answering from the previous policy until something + else changed. + + `sensitive-typeorm` and `sensitive-drizzle` no longer export `redactionMarkers` or + `resetMarkerCache`; they re-export core's `redactionMatchers` and `resetRedactionMarkerCache` + instead. `redactionMatchers()` returns `(string | RegExp)[]` rather than a `Set`, since a + `markerPattern` is a pattern. + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [59ace7a] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + ## 0.1.0 ### Minor Changes diff --git a/packages/sensitive-secrets/package.json b/packages/sensitive-secrets/package.json index cacd74f..3e9e654 100644 --- a/packages/sensitive-secrets/package.json +++ b/packages/sensitive-secrets/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-secrets", - "version": "0.1.0", + "version": "0.2.0", "description": "Shared layer under the secrets manager integrations - the SecretSource interface, classification, the decode guard, the instance cache, and the write guard", "main": "./dist/index.js", "module": "./dist/index.js", @@ -40,7 +40,7 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0" + "@platform-sec/sensitive-string-core": "^0.6.0" }, "devDependencies": { "@platform-sec/sensitive-string-core": "workspace:*", diff --git a/packages/sensitive-sentry/CHANGELOG.md b/packages/sensitive-sentry/CHANGELOG.md index 91effa8..38d0cf5 100644 --- a/packages/sensitive-sentry/CHANGELOG.md +++ b/packages/sensitive-sentry/CHANGELOG.md @@ -1,5 +1,55 @@ # @platform-sec/sensitive-string-sentry +## 0.1.1 + +### Patch Changes + +- 121c824: Stop tagging events with marker sightings this package wrote itself. + + `sensitive.marker_seen` is meant to be evidence that a _different_ integration sent a redaction + placeholder where a value belonged, which is worth an operator's attention. Every breadcrumb on an + event has already been through this package's `beforeBreadcrumb`, where structural redaction wrote a + marker wherever a wrapped value stood, and the scope event processor was counting those: measured, + two breadcrumbs each holding one wrapped value produced `sensitive.marker_seen: 2` on an event with + no marker of its own. An operator following the tag would have investigated an incident that did not + happen. + + The breadcrumbs are now walked separately with marker counting off, and their classes and denied keys + are folded back into the event's report. The pass itself is kept, because it is what catches a + breadcrumb an event processor registered before this one added straight to `event.breadcrumbs`. + + Also adds a transport-level case to the suite: one canary sweep driven through the SDK's own HTTP + transport to a server on loopback, asserting on the serialized envelope bytes rather than on the + envelope object a capturing transport receives. + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [59ace7a] +- Updated dependencies [f67efd7] +- Updated dependencies [48f6213] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + - @platform-sec/sensitive-string-scrub@0.2.0 + ## 0.1.0 ### Minor Changes diff --git a/packages/sensitive-sentry/package.json b/packages/sensitive-sentry/package.json index 32a675b..582e71c 100644 --- a/packages/sensitive-sentry/package.json +++ b/packages/sensitive-sentry/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-sentry", - "version": "0.1.0", + "version": "0.1.1", "description": "Sentry integration for SensitiveString - scrubs every before* hook and the scope event processor", "main": "./dist/index.js", "module": "./dist/index.js", @@ -32,8 +32,8 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0", - "@platform-sec/sensitive-string-scrub": "^0.1.0", + "@platform-sec/sensitive-string-core": "^0.6.0", + "@platform-sec/sensitive-string-scrub": "^0.2.0", "@sentry/node": "^10.0.0" }, "devDependencies": { diff --git a/packages/sensitive-sops/CHANGELOG.md b/packages/sensitive-sops/CHANGELOG.md index cb263c2..f720dd8 100644 --- a/packages/sensitive-sops/CHANGELOG.md +++ b/packages/sensitive-sops/CHANGELOG.md @@ -1,5 +1,35 @@ # @platform-sec/sensitive-string-sops +## 0.1.1 + +### Patch Changes + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [59ace7a] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + - @platform-sec/sensitive-string-secrets@0.2.0 + ## 0.1.0 ### Minor Changes diff --git a/packages/sensitive-sops/package.json b/packages/sensitive-sops/package.json index 8a486b8..945ee04 100644 --- a/packages/sensitive-sops/package.json +++ b/packages/sensitive-sops/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-sops", - "version": "0.1.0", + "version": "0.1.1", "description": "SOPS secret source - classification derived from the ciphertext, the age key kept out of the parent environment, plus in-process age decryption", "main": "./dist/index.js", "module": "./dist/index.js", @@ -36,8 +36,8 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0", - "@platform-sec/sensitive-string-secrets": "^0.1.0", + "@platform-sec/sensitive-string-core": "^0.6.0", + "@platform-sec/sensitive-string-secrets": "^0.2.0", "age-encryption": "^0.3.0", "yaml": "^2.0.0" }, diff --git a/packages/sensitive-testing/CHANGELOG.md b/packages/sensitive-testing/CHANGELOG.md index 079c2ec..3d511f2 100644 --- a/packages/sensitive-testing/CHANGELOG.md +++ b/packages/sensitive-testing/CHANGELOG.md @@ -1,5 +1,72 @@ # @platform-sec/sensitive-string-testing +## 0.2.0 + +### Minor Changes + +- 3e054e2: Hand `auditReveal` the context the operation ran under. + + The hook was called with the value and the operation and nothing else, so a sink could not tell which + unit of work a reveal belonged to. That is not a gap anyone works around cheaply: `sensitive-prisma` + tags each unwrap with the model, the operation and the argument path, and `sensitive-typeorm` tags + each column write with the table and the column, and both had to install their per-value context as + the ambient one around every single reveal so a sink could recover the tags from + `getCurrentRevealContext()` inside its own hook. One `AsyncLocalStorage` entry per sensitive value, + paid to move three strings. + + `auditReveal(value, details, context)` now carries it. The context is the one passed to the call, or + the ambient one when the call passed none, which is the same context the policy's + `checkRevealAllowed` was asked to authorize, so an audit record and an authorization decision are + comparable after the fact. It is not always the context the hook was installed on: a reveal under a + `createChildContext()` is audited under the child, which is exactly what makes an integration's tags + reachable. Both packages now reveal plainly, and the entry went with the workaround. + + The argument is typed as `AuditContext`: `name`, `reason` and `tags`, and no `auditReveal`, since the + sink reading it is that hook. The narrowing is in the type and not in a copy, so a sink may compare + the context by identity and an audited reveal allocates nothing; `tags` is the caller's own object, + shared by every reveal under the context, so a sink copies out what it means to keep rather than + writing to it. + + A hook written with two parameters is unaffected, in types and at runtime. Code that calls a + context's `auditReveal` itself must pass the third argument; the only such caller in this repository + was the worker handoff's hook forwarder, which now passes the receiving context through so a + forwarded sink sees the work the sender named. + + In `sensitive-string-testing`, `AuditCall` gains `context` and `AuditEntry.contextName` is read from + what core reports rather than from the log's own name, so an entry made under a nested child is + recorded under the child. The recorded context is a one-level copy: a record pointing at the live + mock context would close a cycle through `calls` that `JSON.stringify` could not walk. Both helpers + degrade rather than throw when a caller forwards the hook with two arguments, recording the mock's + own name; they run inside the reveal gate, where a `TypeError` would surface as a failing reveal in + somebody's suite rather than as a missing tag. + +### Patch Changes + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [59ace7a] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + ## 0.1.0 ### Minor Changes diff --git a/packages/sensitive-testing/package.json b/packages/sensitive-testing/package.json index a219df6..7175b21 100644 --- a/packages/sensitive-testing/package.json +++ b/packages/sensitive-testing/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-testing", - "version": "0.1.0", + "version": "0.2.0", "description": "Test secrets, leak assertions, matchers, and audit mocks for SensitiveString", "main": "./dist/index.js", "module": "./dist/index.js", @@ -42,7 +42,7 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0" + "@platform-sec/sensitive-string-core": "^0.6.0" }, "devDependencies": { "@platform-sec/sensitive-string-core": "workspace:*", diff --git a/packages/sensitive-typeorm/CHANGELOG.md b/packages/sensitive-typeorm/CHANGELOG.md index a623fb5..ff92183 100644 --- a/packages/sensitive-typeorm/CHANGELOG.md +++ b/packages/sensitive-typeorm/CHANGELOG.md @@ -1 +1,288 @@ # @platform-sec/sensitive-string-typeorm + +## 0.2.0 + +### Minor Changes + +- 1c87ee4: A shared layer under the ORM integrations, `sensitive-data`, extracted from the three that were + already built rather than designed ahead of them. It holds the bound-parameter walk, the query-log + redactor, the statement identifier scan, the driver error sanitizer and the write reveal context. + Nothing installs it directly; each ORM package takes it as a peer. + + Extracting from three consumers instead of one was the point, and it is a deliberate departure from + how `scrub` and `secrets` were built. Those shipped alongside a single first consumer and held up, + because their consumers are homogeneous. A `customType`, a `ValueTransformer` and a client extension + are not, and every piece of this layer differs from what any one of the three had. + + Two of those differences were gaps in shipped code, and neither was reachable by the suite of the + package that had it, because each package's development driver could not produce the shape the other + covered. + + **The bound-parameter walk missed a shape in each direction.** TypeORM's descended into nested + containers and terminated on cycles, but walked only arrays and objects whose prototype is + `Object.prototype`, so a `Map` or a bag from another realm passed the guard unchecked. Drizzle's + walked every object shape but stopped at one level, so an instance inside a nested filter object + passed. The shared walk does both, and both packages gain what they were missing. + + **The error sanitizer had the message and stack half in one package only.** `DrizzleQueryError` + interpolates the parameters into its message and V8 bakes that message into the stack header at + construction, so Drizzle's sanitizer rewrote all three copies. TypeORM's rewrote the structured + property alone, which is correct for `QueryFailedError` and leaves nothing for the next consumer + whose message does carry a value. The machinery now exists once and is exercised by both shapes. + + Two behaviour changes in the ORM packages follow from unifying them: + + `DrizzleParameterRefusedError.parameter` is now a `string` rather than `number | string`. A numeric + index cannot name a position inside a container, and the walk descends into one, so the field + carries a path such as `0.filters[1]`. + + The TypeORM write context now tags a reveal with `klass`, which the Drizzle one already did, and the + Prisma one now tags with `sink`, which neither of the others was missing. An audit sink receiving + records from an application using more than one of these packages can now tell them apart. + + Prisma keeps its own reveal context builder, deliberately. The shared one is per column and carries + one of six `WriteOperation` members; Prisma's is built once per operation, its `path` is a position + in a nested argument object rather than a column, and its `operation` is Prisma's own free-form + name. Filling the shared shape would mean reporting `unknown` for an operation that hook actually + knows. + + Three things were deliberately not extracted, because three consumers proved they are not shared: + the column specification, which is a path map in one, a model-keyed object in another and a + declaration on the column itself in the third; the drift check, which reads entity metadata, the + runtime data model and registered column objects respectively; and the `sensitive-scrub` fragments, + which name different properties on different errors. Each package also keeps its own error types, on + the same reasoning that kept `SecretWriteRefusedError` where it is. + +- acf3c68: Add the TypeORM integration: classified columns read back wrapped, writes unwrap through an audited + reveal, and the query builder path where TypeORM never calls the transformer is refused rather than + left to the driver. + + ROADMAP 3.3, which did not list TypeORM as a target. Reads and writes are one `ValueTransformer`, + built through `defineSchema()` so the class of a column is named once next to the entity rather than + repeated at each `@Column` and drifting from it. That hook is the right one, it is exactly what the + ORM advertises for intercepting column values, and it is sufficient for neither of the section's two + security constraints on its own. + + **TypeORM does not call the transformer for a query builder's named parameters.** Measured on 1.1.1: + `qb.where('u.email = :e', { e: value })` is a SQL fragment and a name, with no `ColumnMetadata` + connecting the two, so the parameter map is bound straight to the driver. What happens next belongs + to the driver and the three common ones disagree. `better-sqlite3` throws. `pg` 8.23.0 binds + `"[REDACTED:sensitive]"`, quotes included, because `prepareValue` falls through to `JSON.stringify` + for an object with no `toPostgres` and `toJSON()` returns the marker. `mysql2` 3.24.3 stringifies it + to the same marker, and with `SET ?` produces syntactically valid SQL assigning the instance's two + own enumerable properties, `klass` and `label`, to columns that do not exist. Only SQLite fails + loudly; on the two drivers TypeORM applications overwhelmingly run on, the query executes and matches + nothing, and the same fragment in an `UPDATE ... SET` stores the marker. This is the ORM's most-used + query API. `guardParameters()` refuses an instance or a marker at the query runner, which is the only + seam that sees the parameter map between the query builder and the driver, and it names the + find-options form as the fix because that one does run the transformer. + + The guard checks both parameter shapes TypeORM passes. The positional array is the usual one; the + named map is the other, because `ds.query(sql, { e: value })` hands the runner that object unchanged + and `better-sqlite3` answers a named-placeholder statement with `Your driver does not support named +placeholders`, so an array-only check looks correct on the driver most suites run against. The + logger redacts both shapes for the same reason: TypeORM's own `Logger` types the argument as an + array and a named map arrives as an object. + + The wrapper has to be idempotent per runner, and that is not tidiness. The SQLite family's driver + caches a single query runner and returns it from every `createQueryRunner()` call, while `pg` builds + a fresh one each time, so a wrapper without a marker would layer itself once per call and no test + written against `pg` would ever have shown it. The uninstall flips a flag those wrappers share + rather than restoring each runner, because a guard that remembered runners in order to restore them + would keep every query runner the process ever made alive, which on `pg` is one per query. + + Three more surfaces carry plaintext beside the column and none is reachable from a transformer. + `logging: ['query']`, which appears in most getting-started templates, prints every statement with + its bound parameters, and the transformer has already run by then, so what is printed is the revealed + value; `sensitiveLogger()` rewrites the parameters of any statement mentioning a classified column. + `QueryFailedError` carries `parameters` as an own enumerable array on every failing statement on + every driver, which is exactly what an error tracker serialises; + `sanitizeQueryFailedError()` removes it through property descriptors so a throwing getter is never + invoked, in place where the property allows it and by a prototype-preserving copy where it does not, + and `typeormScrubFragment` is the `sensitive-scrub` seam for a payload that already reached a sink. + And `cache: true` writes into a second table with none of the first table's grants: measured, it + stores the driver's rows before the read transformer runs **and** the statement with its bound + parameters as part of the cache key, so a cached lookup by a classified column leaks the predicate + even when it selects nothing classified. `schema.check()` refuses it unless + `allowCachedPlaintext: true` says otherwise in writing. + + Two measurements shape the README rather than the code. `save()` on an entity with N classified + columns of which k changed costs `2N + k` reveals, because `ColumnMetadata.getEntityValue` applies + the transformer unguarded and `SubjectChangedColumnsComputer` applies it again to answer "did this + change" and then discards the answer; at k = 0 that is 2N reveals and no `UPDATE` statement at all. + So `insert()` and `update()` are recommended over `save()` with a number behind it, and it is an + audit-volume recommendation before it is a performance one. And `operation` in the audit tags is + always `'unknown'`, permanently: a `ValueTransformer` receives one argument and is not told whether + the value is being inserted, updated, compared for change detection or used as a predicate, and the + only way to recover that is to read the stack, which is not something a security library does in a + hot path. + + Both directions of the transformer guard `value == null` rather than `value === null`, which is one + character and two ordinary paths: `IsNull()` on a nullable classified column routes through + `transformValue` with no operand, and `upsert` passes `undefined` for a column the row object omits. + A transformer that guards only `null` throws a `TypeError` from inside the ORM on both, and a test + asserts that so the correction cannot silently regress. + + The package imports no `typeorm` and no driver at runtime. Every ORM type it reads is restated + structurally, which keeps it correct in a process holding two copies of the ORM and is what lets the + guard be right about a driver nobody has measured. + + `schema.check()` catches configuration drift in four directions, and the fourth is the one worth + naming: an exact `fields` entry that no column on the `DataSource` matches. That is what a renamed + table or a mistyped column name produces, and it is the failure that looks most like success, + because the entry classifies nothing, no column is wrapped, every read returns a plain string where + the property type promises an instance, and every other check passes. `allowUnmatchedFields` is the + bypass for a specification deliberately shared across `DataSource`s. A `rules` entry cannot be + checked this way, since a pattern has no list of names behind it, and the README says so. The other + three are a classified column with no transformer, a transformer the specification no longer + classifies, and a transformer built for a different path than the column it sits on, which works and + audits every reveal under the wrong column. + + The DDL for a classified column is byte-identical to the DDL without it, measured, so adoption is a + one-line diff per column with no migration. + + Named as uncovered in the README rather than implied: `ds.query()` and `getRawMany()` return + plaintext, with `hydrateRaw()` as the audited bypass; `Raw()` builds SQL and binds nothing; + migrations and seed scripts bypass everything here; `softDelete()` leaves the plaintext row; there is + no read and write subpath split, because a `ValueTransformer` is one object holding both directions; + and there is no compile-time guarantee, because a TypeORM property is typed by the author's own + annotation and no transformer can make the compiler check it. The Mongo driver is not supported and + not measured. See ADR-004. + +### Patch Changes + +- 3e054e2: Hand `auditReveal` the context the operation ran under. + + The hook was called with the value and the operation and nothing else, so a sink could not tell which + unit of work a reveal belonged to. That is not a gap anyone works around cheaply: `sensitive-prisma` + tags each unwrap with the model, the operation and the argument path, and `sensitive-typeorm` tags + each column write with the table and the column, and both had to install their per-value context as + the ambient one around every single reveal so a sink could recover the tags from + `getCurrentRevealContext()` inside its own hook. One `AsyncLocalStorage` entry per sensitive value, + paid to move three strings. + + `auditReveal(value, details, context)` now carries it. The context is the one passed to the call, or + the ambient one when the call passed none, which is the same context the policy's + `checkRevealAllowed` was asked to authorize, so an audit record and an authorization decision are + comparable after the fact. It is not always the context the hook was installed on: a reveal under a + `createChildContext()` is audited under the child, which is exactly what makes an integration's tags + reachable. Both packages now reveal plainly, and the entry went with the workaround. + + The argument is typed as `AuditContext`: `name`, `reason` and `tags`, and no `auditReveal`, since the + sink reading it is that hook. The narrowing is in the type and not in a copy, so a sink may compare + the context by identity and an audited reveal allocates nothing; `tags` is the caller's own object, + shared by every reveal under the context, so a sink copies out what it means to keep rather than + writing to it. + + A hook written with two parameters is unaffected, in types and at runtime. Code that calls a + context's `auditReveal` itself must pass the third argument; the only such caller in this repository + was the worker handoff's hook forwarder, which now passes the receiving context through so a + forwarded sink sees the work the sender named. + + In `sensitive-string-testing`, `AuditCall` gains `context` and `AuditEntry.contextName` is read from + what core reports rather than from the log's own name, so an entry made under a nested child is + recorded under the child. The recorded context is a one-level copy: a record pointing at the live + mock context would close a cycle through `calls` that `JSON.stringify` could not walk. Both helpers + degrade rather than throw when a caller forwards the hook with two arguments, recording the mock's + own name; they run inside the reveal gate, where a `TypeError` would surface as a failing reveal in + somebody's suite rather than as a missing tag. + +- 1c87ee4: `containsRedactionMarker()` and `redactionMatchers()` in core, replacing four separate answers to + the same question: given a string about to be written to a column, sent in a request body or stored + in a secret, does it carry the text a `SensitiveString` renders as? `sensitive-http` derived its + answer from the policy registry, `sensitive-typeorm` and `sensitive-drizzle` each shipped a narrower + copy of that derivation, and `sensitive-secrets/write` used a hardcoded `[REDACTED:`. Core owns the + format, so the detector lives there and the four call it. + + Two gaps close, both in the database packages and both security-relevant. + + **`markerPattern` was ignored.** Core declares it on `SensitivityPolicy` for a policy whose redaction + varies with the value, `deterministicRedaction()` sets it, and core's own documentation for the field + names the http guard as its consumer. The two copies written for a database did not read it, so a + deployment using deterministic redaction had a marker check in front of its columns that could not + recognise its own markers. + + **A marker for an unregistered class went unrecognised.** Both copies enumerated the registry and + looked for `[REDACTED:${klass}]` per class. For a reader that is the ordinary case rather than an + edge one: the column was written by an older release, or by a service that registered a class this + process does not have. The detector matches the `[REDACTED:` prefix, which covers those, and also + catches a marker that a composed transformer altered without destroying. + + `policyRegistryVersion()` is new and exported. The memo is keyed on it rather than on the set of + registered class names, because an `overwrite` re-registration replaces a policy's `redact` while + leaving the names identical, and both copies kept answering from the previous policy until something + else changed. + + `sensitive-typeorm` and `sensitive-drizzle` no longer export `redactionMarkers` or + `resetMarkerCache`; they re-export core's `redactionMatchers` and `resetRedactionMarkerCache` + instead. `redactionMatchers()` returns `(string | RegExp)[]` rather than a `Set`, since a + `markerPattern` is a pattern. + +- 2c5bd7f: Three fixes from reviewing the extracted layer, one of them a leak that existed in + `sensitive-typeorm` before the extraction and was carried into the shared code. + + **A nested driver error that would not take the censor in place was returned with its plaintext.** + `sanitizeQueryError` censors the parameter property on the error and on any nested one a shape + names, and falls back to a copy of the outer error when a property will not move. That copy carried + the nested error across by descriptor, so a frozen `driverError`, or one whose parameter property is + a non-configurable accessor, ended up on the returned error with the original values still on it. + The outer error reads as sanitized, which makes this the worst of the three possible outcomes rather + than a partial fix. A censored copy of the nested error now goes onto the outer copy. Neither + package's fixtures had a nested error that resists a write, which is why neither suite saw it. + + **The total-failure path dropped a message that was safe to keep.** When everything else fails, + `sanitizeQueryError` returns a minimal error. For a shape that declares no `sanitizeMessage`, which + is a measurement that the ORM's message carries no value rather than a default, the original message + and stack are the diagnosis and are now carried across. This restores what `sensitive-typeorm` did + before the extraction. + + **The recovery path could throw from the function whose contract is that it does not.** The `catch` + that produces the minimal error read `message`, `name` and `stack` as plain properties, so a + throwing getter there escaped `sanitizeQueryError` entirely, at the one moment the contract is being + relied on. Every read in that path is now guarded individually, so a single hostile property costs + only that property rather than the whole recovery, and the path as a whole is guarded again beneath + that. Carried in from `sensitive-typeorm`, where the same three reads were unguarded. + + **The Drizzle logger scanned each statement twice.** `touchesClassifiedColumn` called the shared + predicate once per column set, and the shared predicate scans the statement for identifiers. It now + scans once and checks both sets against the one result, which is what that package's README already + claimed. This is on the path that runs for every query the process makes. + + Two README claims are corrected to match the code. The TypeORM composition section said the write + refusal "does not catch that and cannot" for a reversed transformer array; since the marker detector + moved to core it matches the `[REDACTED:` prefix, so it does catch a transform that leaves the prefix + intact and still cannot catch one that destroys it. Both halves are now asserted. The Drizzle cost + section described the marker memo as keyed on the registered class list, which is no longer how it + works. + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [2c5bd7f] +- Updated dependencies [1c87ee4] +- Updated dependencies [635b4c2] +- Updated dependencies [59ace7a] +- Updated dependencies [f67efd7] +- Updated dependencies [48f6213] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + - @platform-sec/sensitive-string-scrub@0.2.0 + - @platform-sec/sensitive-string-data@0.2.0 diff --git a/packages/sensitive-typeorm/package.json b/packages/sensitive-typeorm/package.json index e174417..4b95ade 100644 --- a/packages/sensitive-typeorm/package.json +++ b/packages/sensitive-typeorm/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-typeorm", - "version": "0.1.0", + "version": "0.2.0", "description": "TypeORM integration for SensitiveString - classified columns read back wrapped, writes unwrap through an audited reveal, and the query builder's parameter bypass is refused", "main": "./dist/index.js", "module": "./dist/index.js", @@ -33,9 +33,9 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0", - "@platform-sec/sensitive-string-data": "^0.1.0", - "@platform-sec/sensitive-string-scrub": "^0.1.0", + "@platform-sec/sensitive-string-core": "^0.6.0", + "@platform-sec/sensitive-string-data": "^0.2.0", + "@platform-sec/sensitive-string-scrub": "^0.2.0", "typeorm": ">=0.3.20 <2" }, "peerDependenciesMeta": { diff --git a/packages/sensitive-undici/CHANGELOG.md b/packages/sensitive-undici/CHANGELOG.md index 3c53e33..2576dc1 100644 --- a/packages/sensitive-undici/CHANGELOG.md +++ b/packages/sensitive-undici/CHANGELOG.md @@ -1,5 +1,126 @@ # @platform-sec/sensitive-string-undici +## 0.2.0 + +### Minor Changes + +- 256911b: `allowedHosts: 'strip'`: follow a redirect with the credential removed instead of refusing it. + + There were two answers to a credential-bearing request that gets redirected, and for one real shape + both are wrong. Refusing breaks a provider that redirects a release asset or a download to object + storage, which is the ordinary way private artifact hosting works. Naming the hosts is impossible for + exactly the same reason: the caller cannot enumerate a bucket endpoint the provider picks at request + time. `'strip'` is the third answer, and it is the one the upstream project behind + [GHSA-p2f4-r6v6-j797](https://github.com/advisories/GHSA-p2f4-r6v6-j797) reached for when it fixed + the same problem: drop the credential at the origin boundary and keep going. + + The difference is where the list of headers to drop comes from. That project had to guess which names + carry credentials, shipped nine of them, and added a registration function so publishers could add + their own. Here the list is derived: the scrub plan already records exactly which header names carried + a wrapped value, because the caller decided that when they wrapped it. A header the library has never + heard of is covered on the same terms as `Authorization`, and there is nothing to register. + + `'strip'` is never a default and cannot become one. Refusing is the answer that cannot go wrong + quietly: a request that stops is a bug report, while a credential dropped on a hop the caller did not + know was a boundary is a request that succeeds at doing less than it appears to. Same-origin hops keep + everything, since a redirect inside one origin discloses nothing to anyone new. + + `isCrossOrigin` decides the boundary by the scheme/host/port rule, normalizing implied ports. It makes + one exception, `http:` to `https:` on the same host where neither side names a non-default port, which + is an upgrade to the same server that every client in the measured set follows without stripping. The + reverse is never allowed, and the allowance does not extend to a non-default port, since a different + port is a different service as often as it is the same one. + + All five wrappers implement it and each is tested end to end against a real pair of loopback origins, + asserting both that the canary does not reach the second origin and that the request still completes. + got is the one that differs: it follows hops itself but hands the next request's options over through + `beforeRedirect`, so there it is a deletion rather than a rebuilt request. + + What every wrapper accumulates is the set of dropped header _names_, applied where the next request is + built, rather than a narrowed header object carried forward. The two are not equivalent and the + difference is a real leak: axios merges `defaults.headers` underneath the request's own, and the axios + wrapper deliberately supports a wrapped value there, so narrowing only the per-request headers would + strip the call site's credential and forward the instance's. Dropped names are also set to `null` in + the outgoing axios config, because removing a name from what is sent is not enough on its own when the + client refills it from its defaults. + + got tracks the previous hop rather than comparing every hop against the request's original URL. + Same-origin is not transitive once the http-to-https upgrade is allowed: A `http://x` to B `https://x` + is an upgrade, B to C `http://x` is a downgrade, and C is same-origin with A, so comparing against A + would forward a credential into plaintext. Distinguishing the two needs TLS and is not covered by a + test; the other four wrappers rebuild each hop and already hold the previous URL. + + New exports from the http package: `isCrossOrigin`, `headersWithout`, and the `RedirectPolicy` and + `ResolvedRedirectPolicy` types. `allowedHosts` accepts `'strip'` alongside the array and `false`. + Nothing changes for a caller who does not ask for it. + +### Patch Changes + +- e95bf0b: A redirect hop no longer carries the caller's query, and a cross-origin 307 or 308 that would + re-send a body carrying a wrapped value is refused under `allowedHosts: 'strip'`. + + Both were found by an independent verification of the corpus study that produced `'strip'`, by + measurement rather than by reading. With a wrapped credential passed as a query parameter and a 302 + whose `Location` carried no query, the axios, fetch, ky and undici wrappers sent the second origin a + URL containing the credential: each hop handed the caller's query to `prepareRequest` again, which + appended it to whatever URL it was given. got did not, because it follows hops itself. The corpus + had recorded exactly this gap against GHSA-p2f4-r6v6-j797 and then withdrawn it, on the reasoning + that a redirect target is the URL the server names; that reasoning was correct only for the delegated + mode, where the client follows and nothing is re-merged. + + `prepareRequest` now drops the query on any hop after the first, so no wrapper has to remember. A + redirect target is the URL the server named and nothing else, which is what every client in the + measured set does on its own. + + A 307 or 308 re-sends the body, and all five wrappers delivered a JSON body carrying a wrapped value + to the cross-origin target under `'strip'`. A header can be dropped at the origin boundary without + changing what the request means; a body cannot, since a body with one field removed is a different + request that succeeds at doing something else. So the hop is refused with + `RedirectWouldResendCredentialError`, before anything is revealed for it, and the message says to + name the destination in `allowedHosts` if it is trusted with the body. A 301, 302 or 303 drops the + body on its own and is followed as before. `PreparedRequest` gains `bodyCarriesCredential`, which + covers a form body as well as a structured one, and `assertBodyMayFollow` is exported for a wrapper + that follows hops itself. + + The got package now merges the instance's `defaults.options.headers` under the call's headers, as the + axios package already did for `defaults.headers`. Measured, a wrapped value placed in + `got.extend({ headers })` reached the wire as `[REDACTED:credential]` and an `apiKey()` descriptor as + `[object Object]`, on every hop, because the wrapper never saw either; they are now revealed, + recorded in the plan, scrubbed, and under `'strip'` dropped at the boundary. ky does not expose an + extended instance's defaults, so the ky README says to put a wrapped value in the call instead. + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [635b4c2] +- Updated dependencies [59d6a4b] +- Updated dependencies [612ccbd] +- Updated dependencies [aadfec7] +- Updated dependencies [e95bf0b] +- Updated dependencies [256911b] +- Updated dependencies [59ace7a] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + - @platform-sec/sensitive-string-http@0.2.0 + ## 0.1.0 ### Minor Changes diff --git a/packages/sensitive-undici/package.json b/packages/sensitive-undici/package.json index 90261f4..e412c45 100644 --- a/packages/sensitive-undici/package.json +++ b/packages/sensitive-undici/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-undici", - "version": "0.1.0", + "version": "0.2.0", "description": "undici dispatcher integration for SensitiveString - reveal, guard and audit for the one client that already fails closed, plus a dispatcher interceptor that refuses a marker", "main": "./dist/index.js", "module": "./dist/index.js", @@ -33,8 +33,8 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0", - "@platform-sec/sensitive-string-http": "^0.1.0", + "@platform-sec/sensitive-string-core": "^0.6.0", + "@platform-sec/sensitive-string-http": "^0.2.0", "undici": "^7.0.0 || ^8.0.0" }, "devDependencies": { diff --git a/packages/sensitive-vault/CHANGELOG.md b/packages/sensitive-vault/CHANGELOG.md index 9c60164..5fc2e8c 100644 --- a/packages/sensitive-vault/CHANGELOG.md +++ b/packages/sensitive-vault/CHANGELOG.md @@ -1,5 +1,35 @@ # @platform-sec/sensitive-string-vault +## 0.1.1 + +### Patch Changes + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [59ace7a] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + - @platform-sec/sensitive-string-secrets@0.2.0 + ## 0.1.0 ### Minor Changes diff --git a/packages/sensitive-vault/package.json b/packages/sensitive-vault/package.json index 9da5c0d..64ab126 100644 --- a/packages/sensitive-vault/package.json +++ b/packages/sensitive-vault/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-vault", - "version": "0.1.0", + "version": "0.1.1", "description": "HashiCorp Vault and OpenBao secret source - KV v1 and v2, dynamic secret leases, response wrapping, every value wrapped as a SensitiveString", "main": "./dist/index.js", "module": "./dist/index.js", @@ -33,8 +33,8 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0", - "@platform-sec/sensitive-string-secrets": "^0.1.0" + "@platform-sec/sensitive-string-core": "^0.6.0", + "@platform-sec/sensitive-string-secrets": "^0.2.0" }, "devDependencies": { "@platform-sec/sensitive-string-core": "workspace:*", diff --git a/packages/sensitive-zod/CHANGELOG.md b/packages/sensitive-zod/CHANGELOG.md index defb647..8544e08 100644 --- a/packages/sensitive-zod/CHANGELOG.md +++ b/packages/sensitive-zod/CHANGELOG.md @@ -1,5 +1,323 @@ # @platform-sec/sensitive-string-zod +## 0.2.0 + +### Minor Changes + +- b79d660: Move to Zod 4. The peer range is now `zod@^4.0.0`; Zod 3 is not supported, because a v3 schema and a + v4 schema cannot be composed in either direction and the failure is a raw `TypeError` at schema + construction rather than a type error. + + Three security-relevant changes come with the port: + + - `zSensitiveDefault` holds its fallback in a closure instead of a `z.string().default(...)`. + `z.toJSONSchema(schema, { io: 'input' })` reads a schema definition's `default` and emits it + verbatim, calling a function default to do so, which put the live value of a default such as + `() => process.env.API_KEY` into any generated OpenAPI document. Every documented behaviour of the + helper is unchanged. + - `error-map.ts` is re-derived from Zod 4's issue shapes rather than translated. The enum and literal + branches are gone, because v4 closed those leaks upstream. `redactZodError` now recurses into the + issue lists nested under `invalid_key` and `invalid_element` as well as `invalid_union`, redacts + `invalid_element.key`, and strips `input` from every issue. + - The three parse helpers use v4's `error` parse option in place of `errorMap`, which v4 accepts and + silently ignores, and force `reportInput` off. + + The suite now enumerates every issue code Zod 4 can emit, ties that table to `z.ZodIssueCode` so a + code added upstream fails the build rather than going unredacted, and asserts that a schema this + package builds emits a canary-free JSON Schema on the input side and refuses the output side. + + Two API changes, both accepting strictly more than before: `zSensitiveWithValidation` and the three + `*Preprocess` helpers take any string-to-string schema rather than a `z.ZodString`, so Zod 4's format + constructors (`z.email()`, `z.uuid()`) work without falling back to the deprecated + `z.string().email()` spelling. `SensitiveParseOptions` is exported for callers who name the parse + options type. + +### Patch Changes + +- b362710: Add destination binding: a value can say which sinks and hosts it may be revealed toward, and the + raw-value gate checks the reveal context against it. + + ```ts + const registryPassword = new SensitiveString(pw, 'credential', 'registry', { + destinations: { hosts: ['registry-1.docker.io'] }, + }); + + registryPassword.reveal(); // DestinationRestrictedError + registryPassword.reveal({ name: 'push', tags: { host: 'registry-1.docker.io.attacker.net' } }); + // DestinationRestrictedError + registryPassword.reveal({ name: 'push', tags: { sink: 'http', host: 'registry-1.docker.io' } }); + // the password + ``` + + Every other check in the gate answers a question about the value or about the process. None asked + where the plaintext was about to go, so a credential could be wrapped at entry, revealed at exactly + the call site the design says, audited, and still written to the wrong host. The adoption corpus + recorded that seven times, twice as the same substring host comparison from unrelated maintainers. + + The destination is the context's `tags.sink` and `tags.host`, which are the tags the integrations + already write on every reveal: the HTTP clients set both from the request URL per attempt, the data + layer sets the sink to its driver, and `sendToExternal` sets it to the sink it was given. So a bound + value works unchanged through `sensitive-axios`, `sensitive-fetch` and the rest, and refuses through + them when the URL the program computed points somewhere else. A context that states no destination + refuses, on the allowlist rule the environment restrictions follow; there is no denylist. + + Every operation that hands plaintext out is checked, `hash()`, `mask()`, `map()`, `serialize()` and + `toWorkerPayload()` included, so serializing a bound value is itself a reveal toward wherever the + payload goes. `equals()` is not checked: a comparison sends the value nowhere. Derived values carry + the binding, a value derived from two bound values carries both, and bindings cross the wire and the + worker handoff as data that can only tighten. `zSensitiveDeserialize()` declares the wire field so an + object schema does not strip it. + + A host entry is what `new URL(url).host` spells, compared case-insensitively, with the port when it + is not the scheme's default; `*.example.com` matches at least one label below and not the domain + itself, with a port as part of the suffix. A URL, a path, a userinfo, a bare `*`, a misplaced + wildcard, or any spelling the URL parser would change, a Unicode domain or a stated default port, + is refused at construction, because an entry that can never equal a host tag sits in the list + matching nothing. `sensitive-fetch`'s standalone `sensitiveHeaders` helper takes no URL and writes + no host, so a host-bound value refuses through it unless the caller's context names the host. + + Three additions on the same mechanism. `principals` is a third literal list, matched against + `tags.principal`, whatever identity the application tags its reveals with, so a credential fetched + for one caller and handed out of a shared cache to another refuses. A `proxy` tag is read the other + way round: absent means the request goes to its host directly, present means a second machine + receives the bytes first and has to match the same `hosts` list; `sensitive-http` tags it when a + client resolves one. And `releasedBy: Promise` holds every exposing read with + `UnreleasedSensitiveValueError` (code `unreleased-value`, `status` `'pending'` or `'refused'`) until + the promise resolves `true`, for good when it resolves anything else or rejects: a credential whose + recipient is trusted only after an asynchronous check, an SSH host key against a known-hosts store, + is constructed with the check in flight and cannot be sent until it says so. A plain boolean is + refused at construction, `equals()` is not held, and the promise never crosses the wire because an + unreleased value cannot be serialized and a released one has nothing to carry. + + `DestinationRestrictedError` extends `SensitiveStringError` with the code `destination-restricted` + and names the tag that refused, what the context stated, and the allowed list, none of which is any + part of the value. A process that binds nothing pays a length check on an empty frozen array. + +- 7de6338: Add environment restrictions: a value can say which deployment environment, region or datacenter it + may be read in, and the check runs in the raw-value gate rather than at the application layer. + + ```ts + setEnvironmentInfo({ environment: process.env.NODE_ENV, region: await instanceRegion() }); + lockEnvironmentInfo(); + + const euCustomer = new SensitiveString(pii, 'sensitive', 'eu_pii', { + restrictions: { allowedRegions: ['eu-*'] }, + }); + + euCustomer.reveal(); // in us-east-1: throws EnvironmentRestrictedError + ``` + + Six declarative lists, `allowed` and `denied` over environment, region and datacenter, plus a + `custom` predicate asked last and handed the frozen environment. Every field that is present has to + pass, so a name on both lists is denied. Region entries take a trailing `*` as a prefix and nothing + else; a `*` anywhere but the last character is refused at construction, because `*-east-1` matched + as a literal is a denylist that matches nothing. + + The check sits in the gate the TTL, revocation and reveal-budget checks sit in, which is what rule + R2 requires and what the design sketch did not do: the sketch put it at the top of `reveal()`, where + `secret.map(v => v).reveal()` walks past it. Every exposing read refuses, and so does `equals()`, + which R2 names explicitly: a value that may not be read in this region must not still be able to + answer whether a guess matches it here. Redaction and metadata keep working, because a value that + cannot be used still has to be safe to log. It is decided after the deadline and before the reveal + budget, so a read in the wrong region costs nothing and a revoked or expired value still reports the + condition that invites the right response. + + `renew()` checks it too, and needs its own check because it reads nothing and so does not pass the + gate at all. Revocation already had that check for the same reason; the reason transfers, because + extending the life of a value this deployment may not use is using it. Expiry is the one that does + not transfer: reviving a lapsed deadline is what `renew()` is for. + + Restrictions are read at each access, not at construction, and derived values share the state, so a + substring is restricted exactly as its parent is and a value derived from two restricted values + carries both. That makes a derivative at least as restricted as each source, which is R2's + combination rule with nothing left to decide: an intersection is what sharing already gives. + + Declarative restrictions cross the wire, as rule R3's default requires, and like the `revoked` flag + they can only tighten: a deserialized value starts with none and there is no spelling of "no + restriction". The wire field is a list, because a value derived from two restricted values carries + two, and carrying only the first would silently drop the other. A payload entry with a key this + receiver does not know is rejected rather than ignored, since enforcing part of a restriction and + reporting nothing about the rest is the failure the feature exists to prevent. The zod + `zSensitiveDeserialize()` schema declares the field for the same reason it declares `revoked`: an + object schema strips what it does not declare, and stripping this one turns a value restricted to + the EU into one readable anywhere. + + Four places the sketch had to change, all of them the same shape: it chose to read silence as + permission, and silence is where this feature fails. + + - **A custom restriction refuses to cross.** The sketch skipped it on serialize. A function has no + wire form, so skipping it hands the receiver a value it believes it has fully checked while the + half that named the actual condition is gone. `serialize()` now refuses such a value before + reading it, with `UnserializableRestrictionError`, and `toWorkerPayload()` refuses it with + `UnshareableConstraintError` alongside a reveal budget, which is the error for a constraint that + cannot cross an isolate. The serialize refusal is deliberately not an + `EnvironmentRestrictedError`: that one means "not here, try where the value allows" and a caller + may sensibly retry elsewhere on it, while this one means "not anywhere", and a caller who cannot + tell them apart retries in every region forever. + - **An empty list is refused at construction.** The sketch specified that `allowedRegions: []` + allows everything. Read literally it says the opposite, and a security control must not pick one + of those silently. A caller whose configuration came back empty finds out at the call that wrote + it. A restriction set that expresses nothing at all is refused for the same reason. + - **A field the environment does not state is refused by an allowlist.** The sketch skipped both + kinds of list, which makes `allowedRegions: ['eu-*']` inert on any deployment that never sets a + region, which is the deployment most likely to be the wrong one. An allowlist is the statement + "only here", and a process that cannot say where it is has not shown that it is here. A denylist + still passes: "not there" concludes nothing from silence, and refusing would break every laptop + and CI runner. The first read of a restricted value before `setEnvironmentInfo()` warns once, so + the quiet half of that asymmetry is audible. + - **A custom function that answers with anything but `true` denies.** A throw denies, for the reason + a revocation registry that throws does, and an `async` function returns a Promise, which is truthy + and would otherwise allow every read it was written to refuse. + + `EnvironmentRestrictedError` extends `SensitiveStringError` with the code `environment-restricted`, + `EnvironmentLockedError` with `environment-locked`, and `UnserializableRestrictionError` with + `unserializable-restriction`, so all three are catchable as library refusals and greppable in a log. The error names the restriction that refused, the environment's current + value for it, and the configured lists. All of that is configuration and none of it is any part of + the protected value. + + The environment itself is process-global state under rule R6: one `Symbol.for()` singleton so + duplicate installs of core declare one environment, with a test that reads it back through that key, + and documentation whose first paragraph says it defends against a deploy to the wrong region rather + than against a caller who wants the value. `lockEnvironmentInfo()` freezes it for the rest of the + process, and `resetEnvironmentInfo()` clears both it and the lock for test isolation, which is the + honest statement of what the lock is worth. Every field is copied and frozen when it is declared, so + neither a caller mutating the object it passed nor a custom restriction reading it can move the + environment after the fact, and the same applies to the lists a value is constructed with. + + A process that restricts nothing pays a length check on an empty frozen array, on the same shape the + reveal budget uses. + + 69 cases across two files. The security file drives the two tables the TTL, reveal-budget and + revocation suites drive, so an operation added later that forgets the gate fails there rather than + shipping as a bypass: 35 that reach the raw value, each refused in the wrong region and leaking + nothing in the message, the stack or the inspection, and 24 that derive a new protected value, each + inheriting the restriction of the value it came from. + +- f1d6d96: Add revocation: a way to stop a value being read after it is rotated or compromised, including + instances a caller no longer holds a reference to. + + After a rotation the old secret is still in memory, still held by whatever closed over it, and still + works. `revoke()` ends one value. `revokeByLabel()` and `revokeByPattern()` end every value in the + process built under a label, without needing a reference to any of them. A `revocationRegistry` + passed at construction is consulted on every read, which is where a store-backed list belongs. + + ```ts + apiKey.revoke('Rotated 2026-08-01'); + apiKey.reveal(); // throws RevokedSensitiveValueError + apiKey.isRevoked; // true + apiKey.revocationReason; // 'Rotated 2026-08-01' + + revokeByLabel('api_key', 'Emergency rotation'); + revokeByPattern(/^session:/, 'User logged out'); + ``` + + There is no way back. No `unrevoke`, no reset, no options object and no wire payload that lifts one. + `pruneRevocations()` shrinks the process-global lists, and it cannot resurrect anything that has + already been refused: a value latches its own revocation the first time it observes one, so pruning + only ever drops entries nothing has matched yet. That is also the rule for when pruning is safe, and + the only condition under which it is. + + The check lives in the raw-value gate, as rule R2 requires and as the design's sketch did not: the + sketch put it at the top of `reveal()`, where `secret.map(v => v).reveal()` walks past it. Every + exposing read refuses, and so does `equals()`, which R2 names explicitly: a comparison reads the + value, and a rotated credential must not still be able to answer whether a guess matches it. + Redaction and metadata keep working, because a value that can no longer be used still has to be safe + to log. `renew()` refuses too, though it reads nothing: a lifecycle control that keeps working on a + revoked value is one somebody will eventually mistake for the value working. + + Revocation runs along derivation edges in both directions, which is the decision R2 leaves to each + feature. A derivative is revoked by its sources, and revoking a derivative revokes its sources and + their other derivatives. That is the opposite of `renew()`, which refuses on a derivative, and the + reason for the asymmetry is which way each fails: refusing to extend a lifetime fails closed, + refusing to end one does not. An incident responder holding a substring, a `matchGroups()` result or + a template result has no way to know it is derived, and refusing them would leave the credential + alive. It is also the honest reading of what a derivative is: it carries its sources' material, so a + leaked substring is a leaked secret. + + Label and pattern revocation match the label a value was **constructed** with, and a derived value + is matched against its sources' labels rather than the one the derivation gave it. Revoking + `api_key` reaches every substring, match group and template built from that value; revoking the + combined label `a/b` that `concat()` produced matches nothing, because no value was ever constructed + under it. + + When both a revocation and a deadline would refuse, the revocation is reported. `ExpiredSensitiveValueError` + invites a renewal and a retry, and a revoked value must never look renewable. + + Nothing may refuse a revocation. No policy hook is consulted, and the audit record is reported after + the fact with anything the sink throws swallowed, which is the mirror image of `renew()`: that one + audits first so a sink _can_ stop it. A second `revoke()` on an already-revoked value changes + nothing and reports nothing, keeping the first reason and timestamp, because a revocation is a fact + that already happened. + + Three things the design sketch had to change. `RevocationRegistry` ships as the interface the option + accepts rather than a class to subclass, so a store-backed registry in another package satisfies it + structurally; the in-process implementation is `RevocationList`. A registry that throws denies the + read, as specified, and so does one that answers with anything but a boolean: an `async isRevoked` + returns a Promise, which is truthy and not `true`, so reading it either way silently revokes + everything or nothing. And `revoke()` does not clear the value, against the design's recommendation: + it would make `length` lie about a value that is still there, and it cannot actually erase a string + from the V8 heap, which is memory hygiene's problem rather than revocation's. + + `revokeByPattern` rejects a pattern that applies an unbounded quantifier to a group that can match + the same input more than one way. A stored pattern is tested against every label a value carries, on + a thread nothing can interrupt, so `(a+)+` in the list is a process that hangs on the first read + after somebody revokes something. The heuristic is deliberately strict and rejects some safe + patterns; the message says what to rewrite. Patterns are also copied out of the caller's RegExp with + `g` and `y` dropped, which would otherwise make a stored pattern match every other time it was + consulted. + + Instance revocation crosses the wire as a flag that can only tighten. `serialize()` never writes it, + because the gate refuses to read a revoked value at all; `deserialize()` honours it, and rejects + `revoked: false` rather than reading it as permission. The zod `zSensitiveDeserialize()` schema + declares both fields, because an object schema strips what it does not declare, and stripping these + would turn a payload that says "do not use this" into one that says nothing. + + The process-global lists follow rule R6: one `Symbol.for()` singleton so duplicate installs of core + share it, with a test that reads it back through that key, and documentation saying it defends + against a stale secret and a misconfiguration rather than against code already running in the + process. `pruneRevocations` is the only way to shrink them, in test builds or otherwise, which is + what a test uses to isolate itself. + + The cost to a process that revokes nothing is a generation counter. A value that has looked once + compares one integer instead of searching the label map and walking the pattern list, and both that + comparison and the registry guard are inline, so the common read reaches no call at all. Measured + against a build with the check removed, on Node 22.15.0: roughly 2 to 4ns on a read of about 20ns, + close enough to the machine's own drift between rounds that it takes the paired measurement to see. + Before the guards were inlined the same measurement showed 5 to 24ns. What the counter is avoiding + is in the benchmark: against 200 revoked labels and 20 patterns, a value reading for the first time, + which is the one case that has to search, is 2.5x slower than one with no label to look up. + + 84 cases. The security file drives the two tables the TTL and reveal-budget suites do, so an + operation added later that forgets the gate fails there rather than shipping as a bypass: 33 that + expose the raw value, each refused by an instance, a label and a pattern revocation and leaking + nothing in the message, the stack or the inspection, and 22 that derive a new protected value, each + inheriting the parent's revocation and each able to revoke the parent. + +- Updated dependencies [3e054e2] +- Updated dependencies [49ad21c] +- Updated dependencies [200a5c9] +- Updated dependencies [b362710] +- Updated dependencies [aa609af] +- Updated dependencies [14ed60e] +- Updated dependencies [7de6338] +- Updated dependencies [e721fbd] +- Updated dependencies [a90709f] +- Updated dependencies [785c101] +- Updated dependencies [f8377ff] +- Updated dependencies [1eb5030] +- Updated dependencies [dfe8ae4] +- Updated dependencies [342f7e4] +- Updated dependencies [1c87ee4] +- Updated dependencies [7de3033] +- Updated dependencies [98fd326] +- Updated dependencies [f1d6d96] +- Updated dependencies [5950b87] +- Updated dependencies [b92ff33] +- Updated dependencies [dbb643a] +- Updated dependencies [59ace7a] +- Updated dependencies [29c6f87] + - @platform-sec/sensitive-string-core@0.6.0 + ## 0.1.0 ### Minor Changes diff --git a/packages/sensitive-zod/package.json b/packages/sensitive-zod/package.json index 60c5962..f9c0e84 100644 --- a/packages/sensitive-zod/package.json +++ b/packages/sensitive-zod/package.json @@ -1,6 +1,6 @@ { "name": "@platform-sec/sensitive-string-zod", - "version": "0.1.0", + "version": "0.2.0", "description": "Zod integration for SensitiveString - parse input directly into sensitive types", "main": "./dist/index.js", "module": "./dist/index.js", @@ -31,7 +31,7 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@platform-sec/sensitive-string-core": "^0.5.0", + "@platform-sec/sensitive-string-core": "^0.6.0", "zod": "^4.0.0" }, "devDependencies": {