Skip to content

Hash PII instead of leaking it (Rust SDK) - #82

Merged
brentrager merged 1 commit into
mainfrom
pii-hash-scrubbing
Aug 15, 2026
Merged

Hash PII instead of leaking it (Rust SDK)#82
brentrager merged 1 commit into
mainfrom
pii-hash-scrubbing

Conversation

@brentrager

Copy link
Copy Markdown
Contributor

Problem

rust/observability/src/pii.rs's scrub_string handled credentials onlyBearer, 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's rust/chat-ws/src/traced_tool.rs has 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:

a@b.com   ->  [email:9f2a41c8]
555-0142  ->  [phone:3b7e0d92]
  • HMAC-SHA256, not a bare digest. Emails and phone numbers are a small enumerable space; an unkeyed digest is rainbow-tabled in seconds.
  • Per-org salt — 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.
  • Type prefix stays visible. You can see what kind of value was there, and that two spans carry the same one, without ever seeing it.
  • 8 hex chars — readable in a span attribute, collisions acceptable for correlation.
  • No key configured → fully redacted ([email:redacted]), never hashed under a guessable one. Fail safe, not open.
  • Credentials stay DROPPED, never hashed. A hash of a live token is still a token oracle. 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.

Phones normalize to digits and emails 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.

API

scrub_string / scrub_headers unchanged signatures. Now scrub personal identifiers too, under the empty org salt — strictly more redaction, no caller breakage. The four client.rs call sites have no org in hand.
scrub_string_for_org(&str, org_id) new — the org-salted form.
scrub_headers_for_org(&map, org_id) new.
pii_token(PiiKind, raw, org_id) new — the searchability seam. Hash a typed query term the same way and match the stored token.
set_pii_hash_key(key) new — direct injection. Set-once.
BootstrapEnv::pii_hash_key new — SMOOAI_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 the hmac + sha2 + digest trait stack, since this crate needs exactly one primitive.

Verification

87 tests pass, clippy -D warnings clean, cargo fmt --check clean, pnpm format:check (oxfmt) clean.

Every guard is mutation-checked — removed, confirmed a specific named test fails, restored:

Mutation Test that caught it
email pattern removed hashes_emails_keeping_the_type_prefix, tool_result_shape_from_traced_tool_is_scrubbed (+5)
phone pattern removed hashes_phone_numbers (+2)
address pattern removed hashes_street_addresses
per-org salt removed from HMAC message same_value_different_org_hashes_differently
credential drop removed credentials_are_dropped_not_hashed, scrubs_bearer_tokens, scrubs_passwords, scrubs_sk_keys (+4)
keying removed (constant key) different_key_hashes_differently
no-key fallback hashes instead of redacting no_key_redacts_rather_than_hashing

Tests drive the private key-taking core rather than installing the process-wide key: OnceCell is 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 clean origin/main tree with this work stashed; passes in isolation. It now counts only the observability.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 call scrub_string_for_org with 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.
  • The other four SDKs (TS/Go/Python/.NET) still scrub credentials only. The parity corpus does not cover PII, so nothing gates this; the README parity table now says so explicitly.
  • The operator's second redactorsmooth-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

`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-bot

changeset-bot Bot commented Aug 15, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 111161b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@smooai/observability Minor

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
brentrager merged commit 5648be2 into main Aug 15, 2026
6 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant