Hash PII instead of leaking it (Rust SDK) - #82
Merged
Conversation
`pii::scrub_string` handled credentials only — `Bearer`, `password=`, `token`/`api_key`/`secret=`, `sk-…` — while the module doc claimed PII scrubbing. An email or phone in a message, a breadcrumb, or a GenAI tool argument reached the wire intact. The sibling repo's `traced_tool.rs` has a test whose tool returns `"did the thing; email=a@b.com"` and asserts it passes through untouched; that string is now a test case here that asserts it does not. This is latent only because chat-ws telemetry never exports today. The export fix is the next phase, so the scrubber goes first: the first rows that ever land are already clean and there is nothing to backfill or purge. Hash rather than drop. `[redacted]` destroys the one question worth asking of this data — "are these two spans the same person?" — so emails, phones and street addresses become a keyed token instead: `a@b.com` → `[email:9f2a41c8]`. The type prefix stays visible; the value never does. - HMAC-SHA256, not a bare digest. Emails and phone numbers are a small enumerable space; an unkeyed digest is rainbow-tabled in seconds. - The org id is mixed into the HMAC message, so identical PII hashes differently in different orgs — no cross-tenant correlation, matching the ClickHouse org-isolation posture. - No key configured → fully redacted (`[email:redacted]`), never hashed under a guessable one. Fail safe, not open. - Credentials stay DROPPED. A hash of a live token is still a token oracle, and there is no correlation value in a secret. Credential patterns run FIRST so a personal identifier inside a secret (`token=a@b.com`) goes with the secret rather than surviving as a hash. - Phone normalizes to digits and email to lowercase before hashing, so `(415) 555-0142` and `415-555-0142` correlate. The phone pattern REQUIRES a separator, so ids, versions, ISO timestamps and amounts are not eaten — there is a test pinning that. `scrub_string` / `scrub_headers` keep their signatures (four call sites in `client.rs` have no org in hand) and now scrub personal identifiers under the empty org salt — strictly more redaction, no caller breakage. `scrub_string_for_org` / `scrub_headers_for_org` take the org. `pii_token` is the searchability seam: hash a typed query term the same way and match the stored token. The key arrives the way every other setting in this crate does — an env var read by `bootstrap` (`SMOOAI_OBSERVABILITY_PII_HASH_KEY`), with `set_pii_hash_key` as the direct-injection seam. It is set-once: a mid-process rotation would silently fork every correlation. Same reason the docs say rotate never — a new key breaks correlation with every hash already stored. Tests drive the private key-taking core rather than installing the global one: `OnceCell` is set-once and cargo runs the suite in one process, so a global write would make it racy. Every guard is mutation-checked — removing each pattern, the per-org salt, the keying, the no-key fallback, or the credential-drop each fails a specific named test. Also fixes a pre-existing failure on main, unrelated to this change but in the way of a green lane: `otel_capture`'s test counted EVERY span in the in-memory exporter, but the tracer provider is process-wide, so it collected the whole suite's spans (8, not 2). It now counts only the `observability.capture_*` spans it produces — a double-report still fails it, which is what the assertion was for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 111161b The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
brentrager
added a commit
that referenced
this pull request
Aug 15, 2026
All four scrubbed credentials only — `Bearer`, `password=`, `token`/`api_key`/`secret=`, `sk-…` — while their module docs claimed "PII scrubbing". Emails, phone numbers and street addresses went to the backend untouched. Rust fixed this in #82; this brings the other four to parity with byte-identical output, which matters now that telemetry is actually being exported (chat-ws was just wired up). Personal identifiers are hashed, not dropped: `a@b.com` -> `[email:9f2a41c8]`. `[redacted]` destroys the ability to ask "is this the same user as that other trace?"; a keyed hash keeps correlation while storing nothing reversible, and the type prefix stays visible so you can still see what kind of value was there. - HMAC-SHA256, keyed — a bare digest of an email is rainbow-tabled in seconds. - Org id inside the HMAC message, so identical PII hashes differently per org. The kind is in there too, NUL-separated, so values that normalize alike can't collide. - Fail closed: no key => `[email:redacted]`, never plaintext and never a hash under a guessable key. - Credentials matched FIRST and dropped entirely, never hashed — a hash of a live token is still a token oracle, and PII inside a secret (`token=a@b.com`) goes with the secret. - Normalized by kind (phone -> digits, email -> lowercase) so `(415) 555-0142` and `415-555-0142` correlate. Key comes from `SMOOAI_OBSERVABILITY_PII_HASH_KEY` at bootstrap, or the per-SDK setter; set-once, and the setters refuse a second key — rotating it silently forks every correlation already stored. The browser TS bundle has no env, so it calls `setPiiHashKey` explicitly (now exported from the entry). `piiToken(kind, raw, orgId)` is the search seam: hash a typed query term the same way and match the stored token. Existing org-less signatures keep working (they hash under the empty salt); `*ForOrg` variants are additive. The TS SDK ships a small sync SHA-256/HMAC rather than a dependency: `scrubString` is sync and runs in the browser bundle, where `node:crypto` is unavailable and WebCrypto is async-only. Pinned by the RFC 4231 / FIPS 180-4 vectors. All five SDKs assert the same `cross_sdk_parity_vectors` — computed independently — so any drift in message framing, normalization or truncation breaks exactly one SDK's suite. Two unrelated pre-existing format failures are fixed here because this PR touches those lanes and would otherwise be red: `CrashChild.cs` whitespace (`dotnet format`) and a stray blank line in `bootstrap.rs` (`cargo fmt`). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
brentrager
added a commit
that referenced
this pull request
Aug 15, 2026
* Hash PII in the TS, Go, Python and .NET SDKs, not just Rust All four scrubbed credentials only — `Bearer`, `password=`, `token`/`api_key`/`secret=`, `sk-…` — while their module docs claimed "PII scrubbing". Emails, phone numbers and street addresses went to the backend untouched. Rust fixed this in #82; this brings the other four to parity with byte-identical output, which matters now that telemetry is actually being exported (chat-ws was just wired up). Personal identifiers are hashed, not dropped: `a@b.com` -> `[email:9f2a41c8]`. `[redacted]` destroys the ability to ask "is this the same user as that other trace?"; a keyed hash keeps correlation while storing nothing reversible, and the type prefix stays visible so you can still see what kind of value was there. - HMAC-SHA256, keyed — a bare digest of an email is rainbow-tabled in seconds. - Org id inside the HMAC message, so identical PII hashes differently per org. The kind is in there too, NUL-separated, so values that normalize alike can't collide. - Fail closed: no key => `[email:redacted]`, never plaintext and never a hash under a guessable key. - Credentials matched FIRST and dropped entirely, never hashed — a hash of a live token is still a token oracle, and PII inside a secret (`token=a@b.com`) goes with the secret. - Normalized by kind (phone -> digits, email -> lowercase) so `(415) 555-0142` and `415-555-0142` correlate. Key comes from `SMOOAI_OBSERVABILITY_PII_HASH_KEY` at bootstrap, or the per-SDK setter; set-once, and the setters refuse a second key — rotating it silently forks every correlation already stored. The browser TS bundle has no env, so it calls `setPiiHashKey` explicitly (now exported from the entry). `piiToken(kind, raw, orgId)` is the search seam: hash a typed query term the same way and match the stored token. Existing org-less signatures keep working (they hash under the empty salt); `*ForOrg` variants are additive. The TS SDK ships a small sync SHA-256/HMAC rather than a dependency: `scrubString` is sync and runs in the browser bundle, where `node:crypto` is unavailable and WebCrypto is async-only. Pinned by the RFC 4231 / FIPS 180-4 vectors. All five SDKs assert the same `cross_sdk_parity_vectors` — computed independently — so any drift in message framing, normalization or truncation breaks exactly one SDK's suite. Two unrelated pre-existing format failures are fixed here because this PR touches those lanes and would otherwise be red: `CrashChild.cs` whitespace (`dotnet format`) and a stray blank line in `bootstrap.rs` (`cargo fmt`). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Correct the gen_ai scrub comment that PR #86 left pointing at a future #86 routed prompt content through `scrubString` and noted the TS scrub was credentials-only "until keyed per-org hashing lands". It has landed in this PR, and that call site inherited it exactly as predicted — no second redactor. The comment now describes what the code does instead of what it will do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
rust/observability/src/pii.rs'sscrub_stringhandled credentials only —Bearer,password=,token/api_key/secret=,sk-…— while the module doc claimed PII scrubbing. An email, phone or address in a message, breadcrumb, header value, or GenAI tool argument reached the wire intact. The sibling repo'srust/chat-ws/src/traced_tool.rshas a test whose tool returns"did the thing; email=a@b.com"and asserts it passes straight through.That is latent only because chat-ws telemetry never exports today. The export fix is the next phase, so the scrubber goes first — the first rows that ever land are already clean, and there is nothing to backfill or purge later.
Solution — hash, don't drop
[redacted]destroys the one question worth asking of this data: are these two spans the same person? Emails, phones and street addresses now become a keyed token instead:[email:redacted]), never hashed under a guessable one. Fail safe, not open.token=a@b.com) goes with the secret rather than surviving as a hash.Phones normalize to digits and emails to lowercase before hashing, so
(415) 555-0142and415-555-0142correlate. The phone pattern requires a separator, so ids, versions, ISO timestamps and amounts are not eaten — there is a test pinning that.API
scrub_string/scrub_headersclient.rscall sites have no org in hand.scrub_string_for_org(&str, org_id)scrub_headers_for_org(&map, org_id)pii_token(PiiKind, raw, org_id)set_pii_hash_key(key)BootstrapEnv::pii_hash_keySMOOAI_OBSERVABILITY_PII_HASH_KEY.The key arrives the way every other setting in this crate does: an env var read by
bootstrap(), which is this crate's config seam (it deliberately has no Smoo-internal coupling). It is set-once — a mid-process rotation would silently fork every correlation — and the docs say rotate never, for the same reason.One new dependency:
hmac-sha256(zero-dep, single-purpose) rather than thehmac+sha2+digesttrait stack, since this crate needs exactly one primitive.Verification
87 tests pass,
clippy -D warningsclean,cargo fmt --checkclean,pnpm format:check(oxfmt) clean.Every guard is mutation-checked — removed, confirmed a specific named test fails, restored:
hashes_emails_keeping_the_type_prefix,tool_result_shape_from_traced_tool_is_scrubbed(+5)hashes_phone_numbers(+2)hashes_street_addressessame_value_different_org_hashes_differentlycredentials_are_dropped_not_hashed,scrubs_bearer_tokens,scrubs_passwords,scrubs_sk_keys(+4)different_key_hashes_differentlyno_key_redacts_rather_than_hashingTests drive the private key-taking core rather than installing the process-wide key:
OnceCellis set-once and cargo runs the suite in one process, so a global write would make it racy.Also in here
Fixes a pre-existing failure on
main, unrelated to this change but in the way of a green Rust lane:otel_capture's test counted every span in the in-memory exporter, but the tracer provider is process-wide, so it collected the whole suite's spans (8, not 2). Confirmed failing on a cleanorigin/maintree with this work stashed; passes in isolation. It now counts only theobservability.capture_*spans it produces — a double-report still fails it, which is what the assertion was for.Follow-ups (not in this PR)
traced_tool.rs(smooai monorepo,rust/chat-ws/) should callscrub_string_for_orgwith the conversation's org id, and its leaky test assertion should be inverted. Today it gets the empty-salt behaviour by default — redacted/hashed, but not org-isolated.smooth-operator/rust/smooth-operator/src/telemetry.rs::redact_tool_arguments, key-name-based JSON walking with a 2048-byte cap — is a different algorithm on the same data class. Converge or document.🤖 Generated with Claude Code