diff --git a/.changeset/pii-hashing-polyglot.md b/.changeset/pii-hashing-polyglot.md new file mode 100644 index 0000000..56fec97 --- /dev/null +++ b/.changeset/pii-hashing-polyglot.md @@ -0,0 +1,50 @@ +--- +'@smooai/observability': minor +--- + +TypeScript, Go, Python and .NET: hash PII instead of leaking it, matching the Rust SDK. + +All four SDKs scrubbed **credentials only** — `Bearer`, `password=`, +`token`/`api_key`/`secret=`, `sk-…` — while their module docs claimed "PII +scrubbing". Emails, phone numbers and street addresses passed through to the +backend untouched. Rust fixed this in #82; this brings the other four to parity +with byte-identical output. + +Personal identifiers are now **hashed, not dropped**: `a@b.com` → +`[email:9f2a41c8]`. The type prefix stays visible, so "are these two spans the +same person?" stays answerable while nothing reversible is stored. The hash is +**HMAC-SHA256, keyed** — not a bare digest, which a rainbow table reverses in +seconds for a space as small as email addresses — and the org id is mixed into +the message, so identical PII hashes differently in different orgs. Phone +numbers normalize to digits and emails to lowercase before hashing, so +`(415) 555-0142` and `415-555-0142` correlate. + +Credentials are still **dropped**, never hashed, and are matched first: a hash of +a live token is a token oracle, and PII inside a secret (`token=a@b.com`) goes +with the secret. With no key configured (`SMOOAI_OBSERVABILITY_PII_HASH_KEY`, or +the per-SDK setter), personal identifiers are fully redacted (`[email:redacted]`) +rather than hashed under a guessable one — fail closed, never fail open. + +New API, same shape in every SDK. The org-less entry points keep working +unchanged (they hash under the empty org salt): + +- TypeScript: `setPiiHashKey`, `piiToken`, `scrubStringForOrg`, + `scrubHeadersForOrg`, `PiiKind` — now exported from the package entry +- Go: `SetPiiHashKey`, `PiiToken`, `ScrubStringForOrg`, `ScrubHeadersForOrg`, + `PiiKind`, `BootstrapEnv.PiiHashKey` +- Python: `set_pii_hash_key`, `pii_token`, `scrub_string_for_org`, + `scrub_headers_for_org`, `PiiKind`, `BootstrapEnv.pii_hash_key` +- .NET: `Pii.SetPiiHashKey`, `Pii.PiiToken`, `Pii.ScrubStringForOrg`, + `Pii.ScrubHeadersForOrg`, `PiiKind`, `BootstrapEnv.PiiHashKey` + +`piiToken(kind, raw, orgId)` is the search seam: hash a typed query term the same +way and match the stored token. + +⚠️ **The key is load-bearing — rotate never.** Rotating it silently forks +correlation with every hash already stored. Supply it once at startup; the +setters are set-once and refuse a second key. + +The TypeScript SDK ships a small synchronous SHA-256/HMAC (`hmac-sha256.ts`) +rather than taking a dependency: `scrubString` is sync and runs in the browser +bundle, where `node:crypto` is unavailable and WebCrypto is async-only. It is +pinned by the RFC 4231 and FIPS 180-4 vectors. diff --git a/README.md b/README.md index 1de2021..21dfbb2 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ - 🗺️ **Source maps** — uploaded to S3 at build time, applied lazily on view - 🚪 **Beacon flush** — events queued at `pagehide` ship via `navigator.sendBeacon` - 💾 **Offline queue** — events captured while offline persist in `IndexedDB` and retry on focus -- 🔐 **PII scrub** — `password`, `token`, `Bearer ...`, and friends are redacted before transport +- 🔐 **PII scrub** — credentials (`password`, `token`, `Bearer ...`) are dropped; emails / phones / addresses are HMAC-hashed per-org (`a@b.com` → `[email:9f2a41c8]`) so traces stay correlatable without storing the value **Node** @@ -42,7 +42,7 @@ - 🪢 **Hono middleware** — captures errors propagating to the global `onError` handler - 🧠 **AsyncLocalStorage scope** — per-request user, tags, breadcrumbs without leaking across requests - 📦 **Batched transport** — `undici` with retry / backoff -- 🔐 **Same PII scrub policy** as the browser +- 🔐 **Same PII scrub policy** as the browser — key from `SMOOAI_OBSERVABILITY_PII_HASH_KEY` **React / Next.js** @@ -55,7 +55,8 @@ - `console.log` / `console.info` / `console.warn` — only `console.error` is tapped, and that's opt-out - HTTP request **bodies** — only method, path, status, and duration appear in breadcrumbs -- Anything matching the PII scrub regex unless you explicitly allowlist it +- Credentials matching the PII scrub regex — dropped outright, never hashed +- Raw emails / phones / street addresses — replaced by a keyed per-org hash, never stored in the clear ## 📦 Install @@ -200,7 +201,7 @@ Known divergences: TypeScript, Python, Go and .NET emit `gen_ai.tool.names` as a ## 📖 Architecture -The SDK is intentionally thin. It captures, batches, redacts PII, and POSTs to a Smoo ingest endpoint. All of the heavy lifting — fingerprint grouping, source-map symbolication, dashboards, alerts, retention — lives in the Smoo platform. +The SDK is intentionally thin. It captures, batches, redacts credentials, hashes personal identifiers, and POSTs to a Smoo ingest endpoint. All of the heavy lifting — fingerprint grouping, source-map symbolication, dashboards, alerts, retention — lives in the Smoo platform. ```mermaid %%{init: {'theme':'base','themeVariables':{ @@ -236,7 +237,7 @@ This SDK is opinionated about privacy: - We never capture form bodies, request bodies, or response bodies by default - We never capture cookies - We never send anything to a third-party service — your events go to **your** Smoo backend only -- PII scrubbing is enabled by default and can be tuned per-tenant +- PII scrubbing is enabled by default and can be tuned per-tenant. Personal identifiers are hashed with HMAC-SHA256 under a key you supply (`SMOOAI_OBSERVABILITY_PII_HASH_KEY`), salted by org id — identical across the TypeScript, Rust, Go, Python and .NET SDKs. **With no key configured they are fully redacted, never hashed under a guessable one.** ## 📖 Status diff --git a/dotnet/src/SmooAI.Observability/Bootstrap.cs b/dotnet/src/SmooAI.Observability/Bootstrap.cs index 68d7ea0..c1c7675 100644 --- a/dotnet/src/SmooAI.Observability/Bootstrap.cs +++ b/dotnet/src/SmooAI.Observability/Bootstrap.cs @@ -47,6 +47,13 @@ public sealed class BootstrapEnv /// Skip bootstrap entirely. public bool? Disabled { get; set; } + + /// + /// HMAC key used to hash emails / phones / addresses in scrubbed strings + /// (see ). Unset means personal identifiers are fully + /// redacted rather than hashed. + /// + public string? PiiHashKey { get; set; } } /// @@ -109,6 +116,11 @@ public static async Task Run(BootstrapEnv? overrides = null) var env = ResolveEnv(overrides); + // Before anything can emit: a scrubbed string written without this key + // redacts PII outright, so installing it late would silently produce a + // window of uncorrelatable spans rather than an error. + Pii.SetPiiHashKey(env.PiiHashKey); + if (env.Disabled == true) { return Cache(new BootstrapResult { Installed = false, Exporting = false, Otel = null }); @@ -242,6 +254,7 @@ internal static BootstrapEnv ResolveEnv(BootstrapEnv? overrides) Release = overrides?.Release ?? Env("SMOOAI_OBSERVABILITY_RELEASE") ?? Env("GIT_SHA") ?? Env("LAMBDA_FUNCTION_VERSION") ?? "dev", Dsn = overrides?.Dsn ?? Env("SMOOAI_OBSERVABILITY_DSN") ?? Env("OBSERVABILITY_DSN"), Disabled = overrides?.Disabled ?? Truthy(Env("SMOOAI_OBSERVABILITY_DISABLED")), + PiiHashKey = overrides?.PiiHashKey ?? Env("SMOOAI_OBSERVABILITY_PII_HASH_KEY"), }; } diff --git a/dotnet/src/SmooAI.Observability/Pii.cs b/dotnet/src/SmooAI.Observability/Pii.cs index f8c3326..fad468b 100644 --- a/dotnet/src/SmooAI.Observability/Pii.cs +++ b/dotnet/src/SmooAI.Observability/Pii.cs @@ -1,15 +1,66 @@ +using System.Security.Cryptography; +using System.Text; using System.Text.RegularExpressions; namespace SmooAI.Observability; +/// +/// The class of personal identifier a match represents. Drives both the visible +/// prefix in the output token and the normalization applied before hashing, so +/// (415) 555-0142 and 415-555-0142 correlate. +/// +public enum PiiKind +{ + /// Email address. + Email, + + /// Telephone number. + Phone, + + /// Street address. + Address, +} + /// /// PII scrubbing — applied to message strings, breadcrumb messages, and headers -/// before transport. Direct port of the TS pii.ts patterns so both SDKs -/// redact identically. Stays opinionated and minimal; tenants can extend in -/// beforeSend. +/// before transport. Port of rust/observability/src/pii.rs; the semantics +/// are identical across the five SDKs. /// +/// +/// Two classes, handled differently on purpose: +/// +/// Credentials (Bearer …, password=, +/// token/api_key/secret=, sk-…) are dropped. +/// A hash of a live token is still a token oracle, and there is no correlation +/// value in a secret. +/// Personal identifiers (email, phone, street address) +/// are hashed, not dropped: a@b.com[email:9f2a41c8]. That +/// keeps the one question worth asking — "are these two spans the same person?" — +/// answerable while storing nothing reversible. +/// +/// +/// The hash is HMAC-SHA256, keyed, not a bare digest: emails and phone +/// numbers are a small enumerable space that a rainbow table reverses in seconds. +/// The org id is mixed into the HMAC message, so identical PII hashes +/// differently in different orgs. +/// +/// +/// The key and the org salt are load-bearing and must not rotate casually. +/// Rotating either silently breaks correlation with every previously stored hash. +/// Supply the key once at startup via SMOOAI_OBSERVABILITY_PII_HASH_KEY +/// (read by ) or . +/// With no key configured, personal identifiers are fully redacted +/// ([email:redacted]) rather than hashed under a guessable one — fail safe, +/// never fail open. +/// +/// public static partial class Pii { + // ---- credentials: matched FIRST, dropped entirely ---------------------- + // + // A personal identifier sitting inside a secret (token=a@b.com) is dropped + // with the secret rather than surviving as a hash. + // Bearer tokens. [GeneratedRegex(@"Bearer\s+[A-Za-z0-9._-]+", RegexOptions.IgnoreCase)] private static partial Regex BearerRegex(); @@ -26,6 +77,29 @@ public static partial class Pii [GeneratedRegex(@"\bsk-[A-Za-z0-9]{20,}")] private static partial Regex SkKeyRegex(); + // ---- personal identifiers: hashed, not dropped ------------------------- + + [GeneratedRegex(@"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+\b", RegexOptions.IgnoreCase)] + private static partial Regex EmailRegex(); + + // Phone: optional country code / area code, then the 3-4 local pair. A + // separator is REQUIRED so bare digit runs (ids, timestamps, amounts) don't + // get eaten. + [GeneratedRegex(@"(?:\+\d{1,3}[ .-]?)?(?:\(\d{3}\)[ .-]?|\b\d{3}[ .-])?\b\d{3}[ .-]\d{4}\b")] + private static partial Regex PhoneRegex(); + + // US-style street address: house number, 1-3 words, a street suffix. + [GeneratedRegex( + @"\b\d{1,6}\s+(?:[A-Za-z0-9.'-]+\s+){0,3}(?:street|st|avenue|ave|road|rd|boulevard|blvd|lane|ln|drive|dr|court|ct|way|terrace|ter|place|pl|circle|cir|highway|hwy|parkway|pkwy|square|sq)\b\.?", + RegexOptions.IgnoreCase)] + private static partial Regex AddressRegex(); + + [GeneratedRegex(@"[^0-9]")] + private static partial Regex NonDigitRegex(); + + [GeneratedRegex(@"\s+")] + private static partial Regex WhitespaceRunRegex(); + private static readonly HashSet SensitiveHeaders = new(StringComparer.OrdinalIgnoreCase) { "authorization", @@ -36,32 +110,72 @@ public static partial class Pii }; /// - /// Redact known sensitive substrings from a free-form string. + /// Hex characters kept from the HMAC. Long enough that collisions are rare + /// across an org's traces, short enough to read in a span attribute. + /// + internal const int HashHexLen = 8; + + private static byte[]? _piiHashKey; + + /// + /// Install the process-wide HMAC key used to hash personal identifiers. + /// Idempotent and set-once: returns false if a key was already + /// installed (the existing key is kept — a mid-process rotation would + /// silently fork every correlation) or if is empty. + /// calls this from + /// SMOOAI_OBSERVABILITY_PII_HASH_KEY. /// - /// - /// The token/apikey/secret pattern intentionally redacts only the value after - /// the delimiter (replacing key=value with key=[redacted]), - /// matching the TS port's effective behavior. - /// - public static string ScrubString(string? input) + public static bool SetPiiHashKey(byte[]? key) { - if (string.IsNullOrEmpty(input)) + if (key is null || key.Length == 0) { - return input ?? string.Empty; + return false; } - - var output = BearerRegex().Replace(input, "Bearer [redacted]"); - output = PasswordRegex().Replace(output, "password=[redacted]"); - output = SecretRegex().Replace(output, RedactAfterDelimiter); - output = SkKeyRegex().Replace(output, "sk-[redacted]"); - return output; + return Interlocked.CompareExchange(ref _piiHashKey, (byte[])key.Clone(), null) is null; } + /// + /// from a UTF-8 string. + /// + public static bool SetPiiHashKey(string? key) => + !string.IsNullOrEmpty(key) && SetPiiHashKey(Encoding.UTF8.GetBytes(key)); + + /// + /// Hash one known-personal value into its scrubbed token — the same token + /// would have written. This is how a UI + /// search box finds stored hashes: hash the typed term with the same org and + /// match. Returns [<kind>:redacted] when no key is installed. + /// + public static string PiiToken(PiiKind kind, string raw, string orgId) => + TokenWithKey(kind, raw, orgId, Volatile.Read(ref _piiHashKey)); + + /// + /// Scrub a free-form string with no org context — credentials dropped, + /// personal identifiers hashed under the empty org salt. Prefer + /// wherever an org id is in hand, so hashes + /// can't be correlated across tenants. + /// + public static string ScrubString(string? input) => ScrubWithKey(input, string.Empty, Volatile.Read(ref _piiHashKey)); + + /// + /// Scrub a free-form string, salting personal-identifier hashes with + /// . + /// + public static string ScrubStringForOrg(string? input, string orgId) => + ScrubWithKey(input, orgId, Volatile.Read(ref _piiHashKey)); + /// /// Scrub a header dictionary: sensitive header names are fully redacted, all /// other values are passed through . /// - public static Dictionary? ScrubHeaders(IReadOnlyDictionary? headers) + public static Dictionary? ScrubHeaders(IReadOnlyDictionary? headers) => + ScrubHeadersForOrg(headers, string.Empty); + + /// + /// with an org salt for the personal-identifier + /// hashes. + /// + public static Dictionary? ScrubHeadersForOrg(IReadOnlyDictionary? headers, string orgId) { if (headers is null) { @@ -71,17 +185,83 @@ public static string ScrubString(string? input) var output = new Dictionary(headers.Count, StringComparer.Ordinal); foreach (var (key, value) in headers) { - output[key] = SensitiveHeaders.Contains(key) ? "[redacted]" : ScrubString(value); + output[key] = SensitiveHeaders.Contains(key) ? "[redacted]" : ScrubStringForOrg(value, orgId); } return output; } + // Test seam: drives the scrub with an explicit key so the suite doesn't have + // to write the set-once process-wide key (xUnit runs the class in one + // process, which would make a global write order-dependent). + internal static string ScrubWithKey(string? input, string orgId, byte[]? key) + { + if (string.IsNullOrEmpty(input)) + { + return input ?? string.Empty; + } + + // Credentials first — dropped, never hashed. + var output = BearerRegex().Replace(input, "Bearer [redacted]"); + output = PasswordRegex().Replace(output, "password=[redacted]"); + output = SecretRegex().Replace(output, RedactAfterDelimiter); + output = SkKeyRegex().Replace(output, "sk-[redacted]"); + + // Personal identifiers — hashed, prefix preserved. + output = EmailRegex().Replace(output, m => TokenWithKey(PiiKind.Email, m.Value, orgId, key)); + output = PhoneRegex().Replace(output, m => TokenWithKey(PiiKind.Phone, m.Value, orgId, key)); + output = AddressRegex().Replace(output, m => TokenWithKey(PiiKind.Address, m.Value, orgId, key)); + return output; + } + + internal static string TokenWithKey(PiiKind kind, string raw, string orgId, byte[]? key) + { + var label = Label(kind); + if (key is null || key.Length == 0) + { + return $"[{label}:redacted]"; + } + + // orgId in the HMAC message IS the per-org salt. The kind is in there too + // so a phone and an address that normalize alike can't collide. + var message = new List(); + message.AddRange(Encoding.UTF8.GetBytes(orgId)); + message.Add(0); + message.AddRange(Encoding.UTF8.GetBytes(label)); + message.Add(0); + message.AddRange(Encoding.UTF8.GetBytes(Normalize(kind, raw))); + + var mac = HMACSHA256.HashData(key, message.ToArray()); + var hex = Convert.ToHexString(mac, 0, HashHexLen / 2).ToLowerInvariant(); + return $"[{label}:{hex}]"; + } + + // Test seam: clears the set-once key so the set-once behavior itself can be + // asserted. Not part of the public surface — rotation is never valid at runtime. + internal static void ResetPiiHashKeyForTests() => Volatile.Write(ref _piiHashKey, null); + + /// The prefix that stays visible in the scrubbed output. + internal static string Label(PiiKind kind) => kind switch + { + PiiKind.Email => "email", + PiiKind.Phone => "phone", + _ => "address", + }; + + private static string Normalize(PiiKind kind, string raw) => kind switch + { + // Digits only: formatting must not fork the hash. + PiiKind.Phone => NonDigitRegex().Replace(raw, string.Empty), + PiiKind.Email => raw.Trim().ToLowerInvariant(), + // Case-fold and collapse runs of whitespace. + _ => WhitespaceRunRegex().Replace(raw.Trim(), " ").ToLowerInvariant(), + }; + // Replace everything from the first delimiter onward with =[redacted], keeping // the key prefix intact (e.g. "token: abc" -> "token=[redacted]"). private static string RedactAfterDelimiter(Match match) { var value = match.Value; - var delimiterIndex = value.IndexOfAny(new[] { ':', '=' }); + var delimiterIndex = value.IndexOfAny([':', '=']); if (delimiterIndex < 0) { return value; diff --git a/dotnet/src/SmooAI.Observability/README.md b/dotnet/src/SmooAI.Observability/README.md index d863713..f15689b 100644 --- a/dotnet/src/SmooAI.Observability/README.md +++ b/dotnet/src/SmooAI.Observability/README.md @@ -116,6 +116,7 @@ GenAIActivity.SetAttributes(Activity.Current, new GenAIAttributes | `SMOOAI_OBSERVABILITY_RELEASE` | Release id | | `SMOOAI_OBSERVABILITY_DSN` | Error-webhook DSN (optional) | | `SMOOAI_OBSERVABILITY_DISABLED` | `1`/`true` to skip bootstrap | +| `SMOOAI_OBSERVABILITY_PII_HASH_KEY` | HMAC key for hashing emails / phones / addresses (unset = redacted) | ## Design notes @@ -128,3 +129,30 @@ GenAIActivity.SetAttributes(Activity.Current, new GenAIAttributes SDK (`System.Text.Json`, nulls omitted). Tracking: [SMOODEV-1159](https://smooai.atlassian.net/browse/SMOODEV-1159). + +## PII scrubbing + +Two classes, handled differently: + +- **Credentials** (`Bearer …`, `password=`, `token`/`api_key`/`secret=`, `sk-…`) + are **dropped**. A hash of a live token is still a token oracle. +- **Personal identifiers** (email, phone, street address) are **hashed**: + `a@b.com` → `[email:9f2a41c8]`. The type prefix stays visible, so you can see + *what kind* of value was there and that two spans carry the *same* one — + without ever seeing it. + +The hash is **HMAC-SHA256**, not a bare digest (emails and phones are a small +enumerable space a rainbow table reverses in seconds), and the org id is mixed +into the message so the same value hashes **differently in different orgs**. + +Set the key with `SMOOAI_OBSERVABILITY_PII_HASH_KEY` (read by the bootstrap) or +`Pii.SetPiiHashKey(...)`. **With no key, personal identifiers are fully redacted** +(`[email:redacted]`) rather than hashed under a guessable one. + +⚠️ **The key and the org id are load-bearing.** Rotating either silently breaks +correlation with every hash already stored — treat the key as permanent, and do +not reuse a secret that rotates on a schedule. + +All five SDKs (TypeScript, Rust, Go, Python, .NET) emit byte-identical tokens +for the same key/org/value; the shared vectors are asserted in each SDK's PII +test suite. diff --git a/dotnet/tests/SmooAI.Observability.Tests/PiiTests.cs b/dotnet/tests/SmooAI.Observability.Tests/PiiTests.cs index d27e66e..b3d0ec1 100644 --- a/dotnet/tests/SmooAI.Observability.Tests/PiiTests.cs +++ b/dotnet/tests/SmooAI.Observability.Tests/PiiTests.cs @@ -87,3 +87,193 @@ public void ScrubHeaders_ReturnsNullForNull() Assert.Null(Pii.ScrubHeaders(null)); } } + +/// +/// PII hashing parity with rust/observability/src/pii.rs. +/// +/// +/// These drive the internal ScrubWithKey / TokenWithKey rather than +/// installing the process-wide key: SetPiiHashKey is set-once and xUnit +/// runs the assembly in one process, so a global write would make the suite +/// order-dependent. +/// +public class PiiHashingTests +{ + private static readonly byte[] Key = "test-hmac-key-not-a-real-secret"u8.ToArray(); + private static readonly byte[] OtherKey = "a-different-test-hmac-key"u8.ToArray(); + + private static string Scrub(string input, string org = "org-1") => Pii.ScrubWithKey(input, org, Key); + + [Fact] + public void CredentialsAreDroppedNotHashed() + { + // A live token must never become a correlatable handle — hashing one still + // yields an oracle you can test candidate tokens against. + var result = Scrub("Bearer abc.def-ghi_123 password=hunter2 sk-ABCDEFGHIJKLMNOPQRSTUVWX"); + Assert.Contains("Bearer [redacted]", result); + Assert.Contains("password=[redacted]", result); + Assert.Contains("sk-[redacted]", result); + Assert.DoesNotContain("[token:", result); + Assert.DoesNotContain("[credential:", result); + Assert.DoesNotContain("[email:", result); + Assert.DoesNotContain("[phone:", result); + + // And a personal identifier hiding inside a secret goes with it. + var inSecret = Scrub("token=a@b.com"); + Assert.DoesNotContain("a@b.com", inSecret); + Assert.DoesNotContain("[email:", inSecret); + } + + [Fact] + public void HashesEmailsKeepingTheTypePrefix() + { + var result = Scrub("contact me at Alice@Example.com please"); + Assert.DoesNotContain("alice@example.com", result.ToLowerInvariant()); + Assert.StartsWith("contact me at [email:", result); + Assert.EndsWith("] please", result); + } + + [Theory] + [InlineData("555-0142")] + [InlineData("(415) 555-0142")] + [InlineData("+1 415-555-0142")] + public void HashesPhoneNumbers(string raw) + { + var result = Scrub($"call {raw} today"); + Assert.Contains("[phone:", result); + Assert.DoesNotContain("0142", result); + } + + [Fact] + public void HashesStreetAddresses() + { + var result = Scrub("ship to 1600 Pennsylvania Ave, Washington"); + Assert.Contains("[address:", result); + Assert.DoesNotContain("Pennsylvania", result); + } + + [Fact] + public void SameValueSameOrgIsStable() + { + Assert.Equal(Scrub("a@b.com"), Scrub("a@b.com")); + // …and correlation survives formatting differences in phones. + Assert.Equal(Scrub("(415) 555-0142"), Scrub("415-555-0142")); + } + + [Fact] + public void SameValueDifferentOrgHashesDifferently() + { + var a = Scrub("a@b.com", "org-1"); + var b = Scrub("a@b.com", "org-2"); + Assert.NotEqual(a, b); + Assert.StartsWith("[email:", a); + Assert.StartsWith("[email:", b); + } + + [Fact] + public void DifferentKeyHashesDifferently() + { + Assert.NotEqual( + Pii.ScrubWithKey("a@b.com", "org-1", Key), + Pii.ScrubWithKey("a@b.com", "org-1", OtherKey)); + } + + [Fact] + public void NoKeyRedactsRatherThanHashing() + { + // Fail safe: an unkeyed digest of an email is rainbow-tabled instantly. + var result = Pii.ScrubWithKey("a@b.com and 555-0142", "org-1", null); + Assert.Contains("[email:redacted]", result); + Assert.Contains("[phone:redacted]", result); + Assert.DoesNotContain("a@b.com", result); + Assert.DoesNotContain("0142", result); + } + + [Fact] + public void HashIsShortHex() + { + var token = Pii.TokenWithKey(PiiKind.Email, "a@b.com", "org-1", Key); + var hex = token["[email:".Length..^1]; + Assert.Equal(Pii.HashHexLen, hex.Length); + Assert.All(hex, c => Assert.Contains(c, "0123456789abcdef")); + } + + [Fact] + public void PiiTokenMatchesWhatScrubbingWrote() + { + // The searchability contract: hashing a typed query term must produce + // exactly the token stored in the span. + var scrubbed = Pii.ScrubWithKey("mail a@b.com now", "org-7", Key); + var term = Pii.TokenWithKey(PiiKind.Email, "A@B.com ", "org-7", Key); + Assert.Contains(term, scrubbed); + } + + [Theory] + [InlineData("took 1234 ms")] + [InlineData("version 1.2.3")] + [InlineData("2026-08-15T12:00:00Z")] + [InlineData("total 1234.5678")] + [InlineData("trace 0af7651916cd43dd8448eb211c80319c")] + public void OrdinaryNumbersAreNotMistakenForPhones(string value) + { + // The phone pattern requires a separator before the last 4 digits; ids, + // versions, dates and amounts must survive intact. + Assert.Equal(value, Scrub(value)); + } + + [Fact] + public void SetPiiHashKeyIsSetOnceAndRejectsEmpty() + { + Assert.False(Pii.SetPiiHashKey((byte[]?)null)); + Assert.False(Pii.SetPiiHashKey(Array.Empty())); + Assert.False(Pii.SetPiiHashKey((string?)null)); + + Pii.ResetPiiHashKeyForTests(); + try + { + Assert.True(Pii.SetPiiHashKey("first-key")); + Assert.False(Pii.SetPiiHashKey("second-key")); + // The first key is what is still in force. + Assert.Equal( + Pii.TokenWithKey(PiiKind.Email, "a@b.com", "o", "first-key"u8.ToArray()), + Pii.PiiToken(PiiKind.Email, "a@b.com", "o")); + } + finally + { + Pii.ResetPiiHashKeyForTests(); + } + } + + [Fact] + public void ScrubHeadersForOrgNeverLeaksRawEmail() + { + var headers = new Dictionary { ["X-Note"] = "reply to a@b.com" }; + var scrubbed = Pii.ScrubHeadersForOrg(headers, "org-1")!; + Assert.DoesNotContain("a@b.com", scrubbed["X-Note"]); + Assert.Contains("[email:", scrubbed["X-Note"]); + } +} + +/// +/// Pins the exact bytes every SDK must produce. Computed independently +/// (python hmac.new(key, org\0kind\0normalized, "sha256")) and asserted +/// verbatim in all five SDKs. If any SDK's message framing, normalization or +/// truncation drifts, exactly one of these breaks. +/// +public class PiiCrossSdkParityTests +{ + private static readonly byte[] Key = "test-hmac-key-not-a-real-secret"u8.ToArray(); + + [Theory] + [InlineData(PiiKind.Email, "a@b.com", "org-1", "[email:02ea437f]")] + [InlineData(PiiKind.Email, "A@B.COM ", "org-1", "[email:02ea437f]")] + [InlineData(PiiKind.Email, "a@b.com", "org-2", "[email:fd96f7dc]")] + [InlineData(PiiKind.Email, "a@b.com", "", "[email:453b154f]")] + [InlineData(PiiKind.Phone, "(415) 555-0142", "org-1", "[phone:415a9aea]")] + [InlineData(PiiKind.Phone, "415-555-0142", "org-1", "[phone:415a9aea]")] + [InlineData(PiiKind.Address, "1600 Pennsylvania Ave", "org-1", "[address:c5351f4a]")] + public void MatchesTheSharedVectors(PiiKind kind, string raw, string orgId, string expected) + { + Assert.Equal(expected, Pii.TokenWithKey(kind, raw, orgId, Key)); + } +} diff --git a/go/README.md b/go/README.md index 0877405..6167561 100644 --- a/go/README.md +++ b/go/README.md @@ -202,3 +202,30 @@ so one backend ingest endpoint (`type: "error"`) serves both SDKs. The SDK name The plain `CaptureException` still records via transport + a synthetic span. - **`sendBeacon` / page-unload** — browser-only; N/A for Go. - **Source-map / symbolication** — Go stacks are already symbolicated. + +## PII scrubbing + +Two classes, handled differently: + +- **Credentials** (`Bearer …`, `password=`, `token`/`api_key`/`secret=`, `sk-…`) + are **dropped**. A hash of a live token is still a token oracle. +- **Personal identifiers** (email, phone, street address) are **hashed**: + `a@b.com` → `[email:9f2a41c8]`. The type prefix stays visible, so you can see + *what kind* of value was there and that two spans carry the *same* one — + without ever seeing it. + +The hash is **HMAC-SHA256**, not a bare digest (emails and phones are a small +enumerable space a rainbow table reverses in seconds), and the org id is mixed +into the message so the same value hashes **differently in different orgs**. + +Set the key with `SMOOAI_OBSERVABILITY_PII_HASH_KEY` (read by the bootstrap) or +`observability.SetPiiHashKey(...)`. **With no key, personal identifiers are fully redacted** +(`[email:redacted]`) rather than hashed under a guessable one. + +⚠️ **The key and the org id are load-bearing.** Rotating either silently breaks +correlation with every hash already stored — treat the key as permanent, and do +not reuse a secret that rotates on a schedule. + +All five SDKs (TypeScript, Rust, Go, Python, .NET) emit byte-identical tokens +for the same key/org/value; the shared vectors are asserted in each SDK's PII +test suite. diff --git a/go/bootstrap.go b/go/bootstrap.go index 421dbbf..17516ab 100644 --- a/go/bootstrap.go +++ b/go/bootstrap.go @@ -23,7 +23,8 @@ import ( // // Optional: _SERVICE_NAME (default "smoo-service"), _ENVIRONMENT // (default STAGE / GO_ENV / "unknown"), _RELEASE (default GIT_SHA / "dev"), -// _DISABLED ("1"/"true" to skip). +// _DISABLED ("1"/"true" to skip), _PII_HASH_KEY (HMAC key for hashing emails / +// phones / addresses — unset means they are fully redacted; see pii.go). // BootstrapResult reports what the bootstrap did. type BootstrapResult struct { @@ -61,6 +62,11 @@ type BootstrapEnv struct { // to both OTel and the Errors dashboard (SMOODEV-1148 parity). Resolved // from OBSERVABILITY_DSN when not overridden. DSN string + // PiiHashKey is the HMAC key used to hash emails / phones / addresses in + // scrubbed strings (see pii.go). Unset means personal identifiers are fully + // redacted rather than hashed. Resolved from + // SMOOAI_OBSERVABILITY_PII_HASH_KEY. + PiiHashKey string } var ( @@ -88,6 +94,13 @@ func Bootstrap(ctx context.Context, overrides *BootstrapEnv) BootstrapResult { env := resolveEnv(overrides) + // Before anything can emit: a scrubbed string written without this key + // redacts PII outright, so installing it late would silently produce a + // window of uncorrelatable spans rather than an error. + if env.PiiHashKey != "" { + SetPiiHashKey([]byte(env.PiiHashKey)) + } + if env.Disabled { result = BootstrapResult{Installed: false} bootstrapResult = &result @@ -211,6 +224,7 @@ func resolveEnv(o *BootstrapEnv) BootstrapEnv { Release: firstNonEmpty(o.Release, os.Getenv("SMOOAI_OBSERVABILITY_RELEASE"), os.Getenv("GIT_SHA"), "dev"), Disabled: o.Disabled || truthy(os.Getenv("SMOOAI_OBSERVABILITY_DISABLED")), DSN: firstNonEmpty(o.DSN, os.Getenv("OBSERVABILITY_DSN")), + PiiHashKey: pick(o.PiiHashKey, "SMOOAI_OBSERVABILITY_PII_HASH_KEY"), } } diff --git a/go/bootstrap_test.go b/go/bootstrap_test.go index 6d42161..f00b7dc 100644 --- a/go/bootstrap_test.go +++ b/go/bootstrap_test.go @@ -158,3 +158,14 @@ func TestBootstrapExportingWhenEndpointConfigured(t *testing.T) { t.Error("expected a tracer provider when an endpoint is configured") } } + +func TestResolveEnvReadsPiiHashKey(t *testing.T) { + t.Setenv("SMOOAI_OBSERVABILITY_PII_HASH_KEY", "env-supplied-key") + if got := resolveEnv(nil).PiiHashKey; got != "env-supplied-key" { + t.Errorf("PiiHashKey = %q, want the env value", got) + } + // An explicit override still wins. + if got := resolveEnv(&BootstrapEnv{PiiHashKey: "override"}).PiiHashKey; got != "override" { + t.Errorf("override ignored: %q", got) + } +} diff --git a/go/pii.go b/go/pii.go index 970880a..36cfe02 100644 --- a/go/pii.go +++ b/go/pii.go @@ -1,14 +1,39 @@ package observability import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "regexp" "strings" + "sync" ) // PII scrubbing — applied to message strings, breadcrumb messages, and headers -// before transport. Ports the exact patterns from the TS reference SDK -// (packages/core/src/pii.ts). Stays opinionated and minimal; callers can extend -// in a BeforeSend hook. +// before transport. Port of rust/observability/src/pii.rs; the semantics are +// identical across the five SDKs. +// +// Two classes, handled differently on purpose: +// +// - Credentials (Bearer …, password=, token/api_key/secret=, sk-…) are +// DROPPED. A hash of a live token is still a token oracle, and there is no +// correlation value in a secret. +// - Personal identifiers (email, phone, street address) are HASHED, not +// dropped: a@b.com -> [email:9f2a41c8]. That keeps the one question worth +// asking — "are these two spans the same person?" — answerable while +// storing nothing reversible. +// +// The hash is HMAC-SHA256, keyed, not a bare digest: emails and phone numbers +// are a small enumerable space that a rainbow table reverses in seconds. The +// org id is mixed into the HMAC message, so identical PII hashes DIFFERENTLY in +// different orgs. +// +// The key and the org salt are load-bearing and must not rotate casually. +// Rotating either silently breaks correlation with every previously stored +// hash. Supply the key once at startup via SMOOAI_OBSERVABILITY_PII_HASH_KEY +// (read by Bootstrap) or SetPiiHashKey. With no key configured, personal +// identifiers are fully redacted ([email:redacted]) rather than hashed under a +// guessable one — fail safe, never fail open. // Go's regexp (RE2) has no backreferences, so the TS pattern that reused `$&` // with a JS `.replace` callback is reimplemented with a capture group + @@ -19,7 +44,10 @@ type piiPattern struct { replacement string } -var piiPatterns = []piiPattern{ +// credentialPatterns are matched FIRST so a personal identifier sitting inside +// a secret (token=a@b.com) is dropped with the secret rather than surviving as +// a hash. +var credentialPatterns = []piiPattern{ // Bearer tokens. {regexp.MustCompile(`(?i)Bearer\s+[A-Za-z0-9._-]+`), "Bearer [redacted]"}, // password=... / passwd: ... / pwd = ... @@ -32,6 +60,52 @@ var piiPatterns = []piiPattern{ {regexp.MustCompile(`sk-[A-Za-z0-9]{20,}`), "sk-[redacted]"}, } +// PiiKind is the class of personal identifier a match represents. It drives both +// the visible prefix in the output token and the normalization applied before +// hashing, so "(415) 555-0142" and "415-555-0142" correlate. +type PiiKind string + +const ( + PiiEmail PiiKind = "email" + PiiPhone PiiKind = "phone" + PiiAddress PiiKind = "address" +) + +// Label is the prefix that stays visible in the scrubbed output. +func (k PiiKind) Label() string { return string(k) } + +var nonDigit = regexp.MustCompile(`[^0-9]`) + +func (k PiiKind) normalize(raw string) string { + switch k { + case PiiEmail: + return strings.ToLower(strings.TrimSpace(raw)) + case PiiPhone: + // Digits only: formatting must not fork the hash. + return nonDigit.ReplaceAllString(raw, "") + case PiiAddress: + // Case-fold and collapse runs of whitespace. + return strings.ToLower(strings.Join(strings.Fields(raw), " ")) + default: + return raw + } +} + +// personalPatterns are hashed, not dropped. Order matters only in that these +// all run after credentialPatterns. +var personalPatterns = []struct { + kind PiiKind + re *regexp.Regexp +}{ + {PiiEmail, regexp.MustCompile(`(?i)\b[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+\b`)}, + // Phone: optional country code / area code, then the 3-4 local pair. A + // separator is REQUIRED so bare digit runs (ids, timestamps, amounts) don't + // get eaten. + {PiiPhone, regexp.MustCompile(`(?:\+\d{1,3}[ .-]?)?(?:\(\d{3}\)[ .-]?|\b\d{3}[ .-])?\b\d{3}[ .-]\d{4}\b`)}, + // US-style street address: house number, 1-3 words, a street suffix. + {PiiAddress, regexp.MustCompile(`(?i)\b\d{1,6}\s+(?:[A-Za-z0-9.'-]+\s+){0,3}(?:street|st|avenue|ave|road|rd|boulevard|blvd|lane|ln|drive|dr|court|ct|way|terrace|ter|place|pl|circle|cir|highway|hwy|parkway|pkwy|square|sq)\b\.?`)}, +} + // sensitiveHeaders are header names whose values are fully redacted. var sensitiveHeaders = map[string]struct{}{ "authorization": {}, @@ -41,18 +115,101 @@ var sensitiveHeaders = map[string]struct{}{ "x-auth-token": {}, } -// ScrubString applies the PII patterns to a single string. +// hashHexLen is how many hex characters are kept from the HMAC. Long enough +// that collisions are rare across an org's traces, short enough to read in a +// span attribute. +const hashHexLen = 8 + +var ( + piiKeyMu sync.RWMutex + piiKey []byte +) + +// SetPiiHashKey installs the process-wide HMAC key used to hash personal +// identifiers. Idempotent and set-once: returns false if a key was already +// installed (the existing key is kept — a mid-process rotation would silently +// fork every correlation) or if key is empty. Bootstrap calls this from +// SMOOAI_OBSERVABILITY_PII_HASH_KEY. +func SetPiiHashKey(key []byte) bool { + if len(key) == 0 { + return false + } + piiKeyMu.Lock() + defer piiKeyMu.Unlock() + if piiKey != nil { + return false + } + piiKey = append([]byte(nil), key...) + return true +} + +func piiHashKey() []byte { + piiKeyMu.RLock() + defer piiKeyMu.RUnlock() + return piiKey +} + +// PiiToken hashes one known-personal value into its scrubbed token — the same +// token ScrubStringForOrg would have written. This is how a UI search box finds +// stored hashes: hash the typed term with the same org and match. +// +// Returns "[:redacted]" when no key is installed. +func PiiToken(kind PiiKind, raw, orgID string) string { + return tokenWithKey(kind, raw, orgID, piiHashKey()) +} + +func tokenWithKey(kind PiiKind, raw, orgID string, key []byte) string { + if len(key) == 0 { + return "[" + kind.Label() + ":redacted]" + } + // orgID in the HMAC message IS the per-org salt. The kind is in there too so + // a phone and an address that normalize alike can't collide. + mac := hmac.New(sha256.New, key) + mac.Write([]byte(orgID)) + mac.Write([]byte{0}) + mac.Write([]byte(kind.Label())) + mac.Write([]byte{0}) + mac.Write([]byte(kind.normalize(raw))) + return "[" + kind.Label() + ":" + hex.EncodeToString(mac.Sum(nil))[:hashHexLen] + "]" +} + +// ScrubString scrubs a free-form string with no org context — credentials +// dropped, personal identifiers hashed under the empty org salt. Prefer +// ScrubStringForOrg wherever an org id is in hand, so hashes can't be +// correlated across tenants. func ScrubString(input string) string { + return scrubWithKey(input, "", piiHashKey()) +} + +// ScrubStringForOrg scrubs a free-form string, salting personal-identifier +// hashes with orgID. +func ScrubStringForOrg(input, orgID string) string { + return scrubWithKey(input, orgID, piiHashKey()) +} + +func scrubWithKey(input, orgID string, key []byte) string { out := input - for _, p := range piiPatterns { + for _, p := range credentialPatterns { out = p.re.ReplaceAllString(out, p.replacement) } + for _, p := range personalPatterns { + kind := p.kind + out = p.re.ReplaceAllStringFunc(out, func(match string) string { + return tokenWithKey(kind, match, orgID, key) + }) + } return out } // ScrubHeaders fully redacts sensitive header values and scrubs the rest. // Returns nil for a nil map (matches the TS undefined passthrough). func ScrubHeaders(headers map[string]string) map[string]string { + return ScrubHeadersForOrg(headers, "") +} + +// ScrubHeadersForOrg is ScrubHeaders with an org salt for the +// personal-identifier hashes. +func ScrubHeadersForOrg(headers map[string]string, orgID string) map[string]string { if headers == nil { return nil } @@ -61,7 +218,7 @@ func ScrubHeaders(headers map[string]string) map[string]string { if _, ok := sensitiveHeaders[strings.ToLower(k)]; ok { out[k] = "[redacted]" } else { - out[k] = ScrubString(v) + out[k] = ScrubStringForOrg(v, orgID) } } return out diff --git a/go/pii_test.go b/go/pii_test.go index 6a1ebe6..4447700 100644 --- a/go/pii_test.go +++ b/go/pii_test.go @@ -1,6 +1,9 @@ package observability -import "testing" +import ( + "strings" + "testing" +) func TestScrubString(t *testing.T) { cases := []struct { @@ -55,3 +58,210 @@ func TestScrubHeaders(t *testing.T) { t.Errorf("Content-Type mangled: %q", out["Content-Type"]) } } + +// ---- PII hashing parity with rust/observability/src/pii.rs ----------------- +// +// These drive scrubWithKey / tokenWithKey rather than installing the +// process-wide key: SetPiiHashKey is set-once and `go test` runs the package in +// one process, so a global write would make the suite order-dependent. + +const ( + testKey = "test-hmac-key-not-a-real-secret" + testOtherKey = "a-different-test-hmac-key" +) + +func scrubT(input, org string) string { + return scrubWithKey(input, org, []byte(testKey)) +} + +func TestCredentialsAreDroppedNotHashed(t *testing.T) { + // A live token must never become a correlatable handle — hashing one still + // yields an oracle you can test candidate tokens against. + s := scrubT("Bearer abc.def-ghi_123 password=hunter2 sk-ABCDEFGHIJKLMNOPQRSTUVWX", "org-1") + for _, want := range []string{"Bearer [redacted]", "password=[redacted]", "sk-[redacted]"} { + if !strings.Contains(s, want) { + t.Errorf("missing %q in %q", want, s) + } + } + for _, bad := range []string{"[token:", "[credential:", "[email:", "[phone:"} { + if strings.Contains(s, bad) { + t.Errorf("credential was hashed (%q) in %q", bad, s) + } + } + // And a personal identifier hiding inside a secret goes with it. + s2 := scrubT("token=a@b.com", "org-1") + if strings.Contains(s2, "a@b.com") || strings.Contains(s2, "[email:") { + t.Errorf("PII inside a secret survived: %q", s2) + } +} + +func TestHashesEmailsKeepingTheTypePrefix(t *testing.T) { + s := scrubT("contact me at Alice@Example.com please", "org-1") + if strings.Contains(strings.ToLower(s), "alice@example.com") { + t.Fatalf("raw email survived: %q", s) + } + if !strings.HasPrefix(s, "contact me at [email:") || !strings.HasSuffix(s, "] please") { + t.Errorf("type prefix not preserved in place: %q", s) + } +} + +func TestHashesPhoneNumbers(t *testing.T) { + for _, raw := range []string{"555-0142", "(415) 555-0142", "+1 415-555-0142"} { + s := scrubT("call "+raw+" today", "org-1") + if !strings.Contains(s, "[phone:") { + t.Errorf("%q not hashed: %q", raw, s) + } + if strings.Contains(s, "0142") { + t.Errorf("%q leaked digits: %q", raw, s) + } + } +} + +func TestHashesStreetAddresses(t *testing.T) { + s := scrubT("ship to 1600 Pennsylvania Ave, Washington", "org-1") + if !strings.Contains(s, "[address:") || strings.Contains(s, "Pennsylvania") { + t.Errorf("address not hashed: %q", s) + } +} + +func TestSameValueSameOrgIsStable(t *testing.T) { + if a, b := scrubT("a@b.com", "org-1"), scrubT("a@b.com", "org-1"); a != b { + t.Errorf("not deterministic: %q != %q", a, b) + } + // …and correlation survives formatting differences in phones. + if a, b := scrubT("(415) 555-0142", "org-1"), scrubT("415-555-0142", "org-1"); a != b { + t.Errorf("normalization missing: %q != %q", a, b) + } +} + +func TestSameValueDifferentOrgHashesDifferently(t *testing.T) { + a, b := scrubT("a@b.com", "org-1"), scrubT("a@b.com", "org-2") + if a == b { + t.Errorf("per-org salt missing: %s == %s", a, b) + } + if !strings.HasPrefix(a, "[email:") || !strings.HasPrefix(b, "[email:") { + t.Errorf("prefix lost: %q %q", a, b) + } +} + +func TestDifferentKeyHashesDifferently(t *testing.T) { + a := scrubWithKey("a@b.com", "org-1", []byte(testKey)) + b := scrubWithKey("a@b.com", "org-1", []byte(testOtherKey)) + if a == b { + t.Errorf("hash is not keyed: %s == %s", a, b) + } +} + +func TestNoKeyRedactsRatherThanHashing(t *testing.T) { + // Fail safe: an unkeyed digest of an email is rainbow-tabled instantly. + s := scrubWithKey("a@b.com and 555-0142", "org-1", nil) + if !strings.Contains(s, "[email:redacted]") || !strings.Contains(s, "[phone:redacted]") { + t.Errorf("no-key fallback did not redact: %q", s) + } + if strings.Contains(s, "a@b.com") || strings.Contains(s, "0142") { + t.Errorf("no-key fallback leaked the raw value: %q", s) + } +} + +func TestHashIsShortHex(t *testing.T) { + tok := tokenWithKey(PiiEmail, "a@b.com", "org-1", []byte(testKey)) + hexPart := strings.TrimSuffix(strings.TrimPrefix(tok, "[email:"), "]") + if len(hexPart) != hashHexLen { + t.Errorf("hash length %d, want %d: %q", len(hexPart), hashHexLen, tok) + } + for _, c := range hexPart { + if !strings.ContainsRune("0123456789abcdef", c) { + t.Errorf("non-hex char %q in %q", c, tok) + } + } +} + +func TestPiiTokenMatchesWhatScrubbingWrote(t *testing.T) { + // The searchability contract: hashing a typed query term must produce + // exactly the token stored in the span. + scrubbed := scrubT("mail a@b.com now", "org-7") + term := tokenWithKey(PiiEmail, "A@B.com ", "org-7", []byte(testKey)) + if !strings.Contains(scrubbed, term) { + t.Errorf("search seam broken: %q does not contain %q", scrubbed, term) + } +} + +func TestOrdinaryNumbersAreNotMistakenForPhones(t *testing.T) { + // The phone pattern requires a separator before the last 4 digits; ids, + // versions, dates and amounts must survive intact. + for _, s := range []string{ + "took 1234 ms", + "version 1.2.3", + "2026-08-15T12:00:00Z", + "total 1234.5678", + "trace 0af7651916cd43dd8448eb211c80319c", + } { + if got := scrubT(s, "org-1"); got != s { + t.Errorf("false positive on %q -> %q", s, got) + } + } +} + +func TestSetPiiHashKeyIsSetOnceAndRejectsEmpty(t *testing.T) { + if SetPiiHashKey(nil) { + t.Error("empty key should be rejected") + } + piiKeyMu.Lock() + saved := piiKey + piiKey = nil + piiKeyMu.Unlock() + defer func() { + piiKeyMu.Lock() + piiKey = saved + piiKeyMu.Unlock() + }() + + if !SetPiiHashKey([]byte("first-key")) { + t.Fatal("first install should succeed") + } + if SetPiiHashKey([]byte("second-key")) { + t.Error("rotation should be refused") + } + if string(piiHashKey()) != "first-key" { + t.Errorf("key was rotated: %q", piiHashKey()) + } +} + +func TestScrubHeadersForOrgSaltsValues(t *testing.T) { + in := map[string]string{"X-Note": "reply to a@b.com"} + a := ScrubHeadersForOrg(in, "org-1")["X-Note"] + b := ScrubHeadersForOrg(in, "org-2")["X-Note"] + if strings.Contains(a, "a@b.com") || strings.Contains(b, "a@b.com") { + t.Fatalf("raw email in header output: %q / %q", a, b) + } + // With no key installed both redact; with one installed they must differ. + if piiHashKey() != nil && a == b { + t.Errorf("header hashes not org-salted: %q == %q", a, b) + } +} + +// TestCrossSDKParityVectors pins the exact bytes every SDK must produce. +// Computed independently (python `hmac.new(key, org\0kind\0normalized, +// "sha256")`) and asserted verbatim in all five SDKs. If any SDK's message +// framing, normalization or truncation drifts, exactly one of these breaks. +func TestCrossSDKParityVectors(t *testing.T) { + cases := []struct { + kind PiiKind + raw string + org string + want string + }{ + {PiiEmail, "a@b.com", "org-1", "[email:02ea437f]"}, + {PiiEmail, "A@B.COM ", "org-1", "[email:02ea437f]"}, + {PiiEmail, "a@b.com", "org-2", "[email:fd96f7dc]"}, + {PiiEmail, "a@b.com", "", "[email:453b154f]"}, + {PiiPhone, "(415) 555-0142", "org-1", "[phone:415a9aea]"}, + {PiiPhone, "415-555-0142", "org-1", "[phone:415a9aea]"}, + {PiiAddress, "1600 Pennsylvania Ave", "org-1", "[address:c5351f4a]"}, + } + for _, c := range cases { + if got := tokenWithKey(c.kind, c.raw, c.org, []byte(testKey)); got != c.want { + t.Errorf("tokenWithKey(%s, %q, %q) = %s, want %s", c.kind, c.raw, c.org, got, c.want) + } + } +} diff --git a/packages/core/README.md b/packages/core/README.md index 25c6687..4fcab9c 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -141,3 +141,30 @@ wrapper covers Groq / DeepSeek / Azure / any OpenAI-compatible gateway via ## License MIT + +## PII scrubbing + +Two classes, handled differently: + +- **Credentials** (`Bearer …`, `password=`, `token`/`api_key`/`secret=`, `sk-…`) + are **dropped**. A hash of a live token is still a token oracle. +- **Personal identifiers** (email, phone, street address) are **hashed**: + `a@b.com` → `[email:9f2a41c8]`. The type prefix stays visible, so you can see + _what kind_ of value was there and that two spans carry the _same_ one — + without ever seeing it. + +The hash is **HMAC-SHA256**, not a bare digest (emails and phones are a small +enumerable space a rainbow table reverses in seconds), and the org id is mixed +into the message so the same value hashes **differently in different orgs**. + +Set the key with `SMOOAI_OBSERVABILITY_PII_HASH_KEY` (read by the bootstrap) or +`setPiiHashKey(...)` (the browser bundle has no env — call it explicitly). **With no key, personal identifiers are fully redacted** +(`[email:redacted]`) rather than hashed under a guessable one. + +⚠️ **The key and the org id are load-bearing.** Rotating either silently breaks +correlation with every hash already stored — treat the key as permanent, and do +not reuse a secret that rotates on a schedule. + +All five SDKs (TypeScript, Rust, Go, Python, .NET) emit byte-identical tokens +for the same key/org/value; the shared vectors are asserted in each SDK's PII +test suite. diff --git a/packages/core/src/__tests__/bootstrap.test.ts b/packages/core/src/__tests__/bootstrap.test.ts index 62e8f3d..98c96f2 100644 --- a/packages/core/src/__tests__/bootstrap.test.ts +++ b/packages/core/src/__tests__/bootstrap.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { _resetBootstrapForTests, bootstrapObservability } from '../bootstrap'; import { _resetOtelSdkForTests } from '../otel'; +import { _resetPiiHashKeyForTests, _tokenWithKey, piiToken, scrubString } from '../pii'; // Capture stderr writes so we can assert on the bootstrap warning paths // without polluting test output. @@ -143,3 +144,34 @@ describe('bootstrapObservability', () => { ).resolves.toBeDefined(); }); }); + +describe('bootstrapObservability — PII hash key', () => { + it('installs the key from SMOOAI_OBSERVABILITY_PII_HASH_KEY before anything can emit', async () => { + const previous = process.env.SMOOAI_OBSERVABILITY_PII_HASH_KEY; + process.env.SMOOAI_OBSERVABILITY_PII_HASH_KEY = 'env-supplied-key'; + _resetPiiHashKeyForTests(); + try { + // Disabled bootstrap still installs the key — the scrubber runs + // whether or not there is an exporter behind it. + await bootstrapObservability({ disabled: true }); + expect(piiToken('email', 'a@b.com', 'org-1')).toBe(_tokenWithKey('email', 'a@b.com', 'org-1', new TextEncoder().encode('env-supplied-key'))); + } finally { + _resetPiiHashKeyForTests(); + if (previous === undefined) delete process.env.SMOOAI_OBSERVABILITY_PII_HASH_KEY; + else process.env.SMOOAI_OBSERVABILITY_PII_HASH_KEY = previous; + } + }); + + it('leaves PII redacted when no key is configured', async () => { + const previous = process.env.SMOOAI_OBSERVABILITY_PII_HASH_KEY; + delete process.env.SMOOAI_OBSERVABILITY_PII_HASH_KEY; + _resetPiiHashKeyForTests(); + try { + await bootstrapObservability({ disabled: true }); + expect(scrubString('mail a@b.com')).toBe('mail [email:redacted]'); + } finally { + _resetPiiHashKeyForTests(); + if (previous !== undefined) process.env.SMOOAI_OBSERVABILITY_PII_HASH_KEY = previous; + } + }); +}); diff --git a/packages/core/src/__tests__/hmac-sha256.test.ts b/packages/core/src/__tests__/hmac-sha256.test.ts new file mode 100644 index 0000000..b189bb7 --- /dev/null +++ b/packages/core/src/__tests__/hmac-sha256.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { hmacSha256, sha256, toHex } from '../hmac-sha256'; + +/** + * The hand-rolled sync primitive `pii.ts` depends on. These are the published + * FIPS 180-4 / RFC 4231 vectors — if this file is green the implementation is + * the real algorithm, not something that merely looks like a hash. + */ + +const enc = new TextEncoder(); +const hex = (b: Uint8Array) => toHex(b); + +describe('sha256', () => { + it('matches the FIPS 180-4 vectors', () => { + expect(hex(sha256(enc.encode('')))).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'); + expect(hex(sha256(enc.encode('abc')))).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'); + expect(hex(sha256(enc.encode('abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq')))).toBe( + '248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1', + ); + }); + + it('handles the 55/56/64-byte padding boundaries', () => { + // 56 bytes is where the length field no longer fits in the same block — + // the classic off-by-one in a hand-rolled pad. + expect(hex(sha256(enc.encode('a'.repeat(55))))).toBe('9f4390f8d30c2dd92ec9f095b65e2b9ae9b0a925a5258e241c9f1e910f734318'); + expect(hex(sha256(enc.encode('a'.repeat(56))))).toBe('b35439a4ac6f0948b6d6f9e3c6af0f5f590ce20f1bde7090ef7970686ec6738a'); + expect(hex(sha256(enc.encode('a'.repeat(64))))).toBe('ffe054fe7ae0cb6dc65c3af9b61d5209f439851db43d0ba5997337df154668eb'); + }); + + it('hashes a million a-s (FIPS long vector)', () => { + expect(hex(sha256(enc.encode('a'.repeat(1_000_000))))).toBe('cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0'); + }); +}); + +describe('hmacSha256', () => { + it('matches RFC 4231 test case 1', () => { + const key = new Uint8Array(20).fill(0x0b); + expect(hex(hmacSha256(key, enc.encode('Hi There')))).toBe('b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7'); + }); + + it('matches RFC 4231 test case 2', () => { + expect(hex(hmacSha256(enc.encode('Jefe'), enc.encode('what do ya want for nothing?')))).toBe( + '5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843', + ); + }); + + it('matches RFC 4231 test case 3', () => { + const key = new Uint8Array(20).fill(0xaa); + const data = new Uint8Array(50).fill(0xdd); + expect(hex(hmacSha256(key, data))).toBe('773ea91e36800e46854db8ebd09181a72959098b3ef8c122d9635514ced565fe'); + }); + + it('matches RFC 4231 test case 6 — key longer than one block', () => { + const key = new Uint8Array(131).fill(0xaa); + expect(hex(hmacSha256(key, enc.encode('Test Using Larger Than Block-Size Key - Hash Key First')))).toBe( + '60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54', + ); + }); +}); + +describe('toHex', () => { + it('truncates to the requested length', () => { + const bytes = new Uint8Array([0x00, 0xab, 0xff, 0x10]); + expect(toHex(bytes)).toBe('00abff10'); + expect(toHex(bytes, 4)).toBe('00ab'); + // Leading zeroes must survive — a naive toString(16) drops them. + expect(toHex(new Uint8Array([0x01, 0x02]))).toBe('0102'); + }); +}); diff --git a/packages/core/src/__tests__/pii.test.ts b/packages/core/src/__tests__/pii.test.ts index d92cb90..2da3a27 100644 --- a/packages/core/src/__tests__/pii.test.ts +++ b/packages/core/src/__tests__/pii.test.ts @@ -1,18 +1,179 @@ -import { describe, expect, it } from 'vitest'; -import { scrubHeaders, scrubString } from '../pii'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { _resetPiiHashKeyForTests, _scrubWithKey, _tokenWithKey, piiToken, scrubHeaders, scrubHeadersForOrg, scrubString, setPiiHashKey } from '../pii'; -describe('scrubString', () => { +/** + * Mirrors `rust/observability/src/pii.rs`'s test suite so parity across the + * five SDKs is auditable case-by-case. + * + * The hashing tests drive `_scrubWithKey` / `_tokenWithKey` rather than + * installing the process-wide key: `setPiiHashKey` is set-once and vitest runs + * the file in one module instance, so a global write would make the suite + * order-dependent. + */ + +const enc = new TextEncoder(); +const KEY = enc.encode('test-hmac-key-not-a-real-secret'); +const OTHER_KEY = enc.encode('a-different-test-hmac-key'); + +const scrub = (input: string, org = 'org-1') => _scrubWithKey(input, org, KEY); + +// ---- credentials: dropped, never hashed ---------------------------------- + +describe('scrubString — credentials', () => { it('redacts Bearer tokens', () => { expect(scrubString('Authorization: Bearer abc.def.ghi')).toBe('Authorization: Bearer [redacted]'); }); it('redacts password=', () => { expect(scrubString('?password=hunter2&x=1')).toBe('?password=[redacted]&x=1'); }); + it('redacts token= while keeping the key', () => { + const out = scrubString('api_key=supersecretvalue'); + expect(out).toBe('api_key=[redacted]'); + expect(out).not.toContain('supersecretvalue'); + }); it('redacts sk-... API keys', () => { expect(scrubString('key=sk-AAAAAAAAAAAAAAAAAAAAAAAAAAAA')).toContain('sk-[redacted]'); }); + + it('drops credentials rather than hashing them', () => { + // A live token must never become a correlatable handle — hashing one + // still yields an oracle you can test candidate tokens against. + const out = scrub('Bearer abc.def-ghi_123 password=hunter2 sk-ABCDEFGHIJKLMNOPQRSTUVWX'); + expect(out).toContain('Bearer [redacted]'); + expect(out).toContain('password=[redacted]'); + expect(out).toContain('sk-[redacted]'); + for (const shape of ['[token:', '[credential:', '[email:', '[phone:']) { + expect(out).not.toContain(shape); + } + // And a personal identifier hiding inside a secret goes with it. + const inSecret = scrub('token=a@b.com'); + expect(inSecret).not.toContain('a@b.com'); + expect(inSecret).not.toContain('[email:'); + }); +}); + +// ---- personal identifiers: hashed, prefix preserved ---------------------- + +describe('scrubString — personal identifiers', () => { + it('hashes emails keeping the type prefix in place', () => { + const out = scrub('contact me at Alice@Example.com please'); + expect(out.toLowerCase()).not.toContain('alice@example.com'); + expect(out.startsWith('contact me at [email:')).toBe(true); + expect(out.endsWith('] please')).toBe(true); + }); + + it.each(['555-0142', '(415) 555-0142', '+1 415-555-0142'])('hashes phone number %s', (raw) => { + const out = scrub(`call ${raw} today`); + expect(out).toContain('[phone:'); + expect(out).not.toContain('0142'); + }); + + it('hashes street addresses', () => { + const out = scrub('ship to 1600 Pennsylvania Ave, Washington'); + expect(out).toContain('[address:'); + expect(out).not.toContain('Pennsylvania'); + }); + + it('is stable for the same value in the same org', () => { + expect(scrub('a@b.com')).toBe(scrub('a@b.com')); + // …and correlation survives formatting differences in phones. + expect(scrub('(415) 555-0142')).toBe(scrub('415-555-0142')); + }); + + it('hashes the same value differently in a different org', () => { + const a = scrub('a@b.com', 'org-1'); + const b = scrub('a@b.com', 'org-2'); + expect(a).not.toBe(b); + expect(a.startsWith('[email:')).toBe(true); + expect(b.startsWith('[email:')).toBe(true); + }); + + it('hashes differently under a different key', () => { + expect(_scrubWithKey('a@b.com', 'org-1', KEY)).not.toBe(_scrubWithKey('a@b.com', 'org-1', OTHER_KEY)); + }); + + it('redacts rather than hashing when no key is installed', () => { + // Fail safe: an unkeyed digest of an email is rainbow-tabled instantly. + const out = _scrubWithKey('a@b.com and 555-0142', 'org-1', null); + expect(out).toContain('[email:redacted]'); + expect(out).toContain('[phone:redacted]'); + expect(out).not.toContain('a@b.com'); + expect(out).not.toContain('0142'); + }); + + it('emits a short lowercase hex hash', () => { + const token = _tokenWithKey('email', 'a@b.com', 'org-1', KEY); + const hex = token.slice('[email:'.length, -1); + expect(hex).toHaveLength(8); + expect(hex).toMatch(/^[0-9a-f]{8}$/); + }); + + it('leaves ordinary numbers alone', () => { + // The phone pattern requires a separator before the last 4 digits; ids, + // versions, dates and amounts must survive intact. + for (const value of ['took 1234 ms', 'version 1.2.3', '2026-08-15T12:00:00Z', 'total 1234.5678', 'trace 0af7651916cd43dd8448eb211c80319c']) { + expect(scrub(value)).toBe(value); + } + }); +}); + +// ---- the search seam ------------------------------------------------------ + +describe('piiToken', () => { + beforeEach(() => { + _resetPiiHashKeyForTests(); + }); + + it('produces exactly what scrubbing wrote', () => { + // The searchability contract: hashing a typed query term must produce + // exactly the token stored in the span. + const scrubbed = _scrubWithKey('mail a@b.com now', 'org-7', KEY); + expect(scrubbed).toContain(_tokenWithKey('email', 'A@B.com ', 'org-7', KEY)); + }); + + it('redacts when no key is installed', () => { + expect(piiToken('email', 'a@b.com', 'org-1')).toBe('[email:redacted]'); + }); +}); + +// ---- cross-SDK byte parity ----------------------------------------------- + +describe('cross-SDK parity vectors', () => { + // Computed independently (python `hmac.new(key, org\0kind\0normalized, + // "sha256")`) and asserted verbatim in all five SDKs. If any SDK's message + // framing, normalization or truncation drifts, exactly one of these breaks. + it.each([ + ['email', 'a@b.com', 'org-1', '[email:02ea437f]'], + ['email', 'A@B.COM ', 'org-1', '[email:02ea437f]'], + ['email', 'a@b.com', 'org-2', '[email:fd96f7dc]'], + ['email', 'a@b.com', '', '[email:453b154f]'], + ['phone', '(415) 555-0142', 'org-1', '[phone:415a9aea]'], + ['phone', '415-555-0142', 'org-1', '[phone:415a9aea]'], + ['address', '1600 Pennsylvania Ave', 'org-1', '[address:c5351f4a]'], + ] as const)('%s %s in %s', (kind, raw, org, expected) => { + expect(_tokenWithKey(kind, raw, org, KEY)).toBe(expected); + }); +}); + +// ---- set-once key --------------------------------------------------------- + +describe('setPiiHashKey', () => { + beforeEach(() => { + _resetPiiHashKeyForTests(); + }); + + it('rejects an empty key and refuses rotation', () => { + expect(setPiiHashKey('')).toBe(false); + expect(setPiiHashKey('first-key')).toBe(true); + expect(setPiiHashKey('second-key')).toBe(false); + // The first key is what is still in force. + expect(piiToken('email', 'a@b.com', 'o')).toBe(_tokenWithKey('email', 'a@b.com', 'o', enc.encode('first-key'))); + _resetPiiHashKeyForTests(); + }); }); +// ---- headers -------------------------------------------------------------- + describe('scrubHeaders', () => { it('redacts known sensitive headers', () => { const out = scrubHeaders({ authorization: 'Bearer abc', 'x-api-key': '12345', accept: 'application/json' })!; @@ -20,4 +181,10 @@ describe('scrubHeaders', () => { expect(out['x-api-key']).toBe('[redacted]'); expect(out.accept).toBe('application/json'); }); + + it('never leaks a raw email through a non-sensitive header', () => { + const out = scrubHeadersForOrg({ 'X-Note': 'reply to a@b.com' }, 'org-1')!; + expect(out['X-Note']).not.toContain('a@b.com'); + expect(out['X-Note']).toContain('[email:'); + }); }); diff --git a/packages/core/src/bootstrap/index.ts b/packages/core/src/bootstrap/index.ts index 9844ae2..8c9f82f 100644 --- a/packages/core/src/bootstrap/index.ts +++ b/packages/core/src/bootstrap/index.ts @@ -58,6 +58,13 @@ * SMOOAI_OBSERVABILITY_DISABLED — set to "1"/"true" to skip * bootstrap entirely (useful in * tests). + * SMOOAI_OBSERVABILITY_PII_HASH_KEY — HMAC key used to hash emails / + * phones / addresses in scrubbed + * strings (see `../pii`). Unset + * means personal identifiers are + * fully redacted rather than hashed. + * Set once; rotating it forks every + * stored correlation. * * ## Behavior * @@ -74,6 +81,7 @@ import { TokenProvider } from '../auth/token-provider'; import { Client } from '../node'; import { setupOtelSdk, type OtelSdkHandle, type SetupOtelOptions } from '../otel'; +import { setPiiHashKey } from '../pii'; const TOKEN_REFRESH_INTERVAL_MS = 55 * 60 * 1000; // < openauth's 1h JWT TTL @@ -134,8 +142,14 @@ export async function bootstrapObservability(overrides: Partial = environment: overrides.environment ?? process.env.SMOOAI_OBSERVABILITY_ENVIRONMENT ?? process.env.STAGE ?? process.env.NODE_ENV, release: overrides.release ?? process.env.SMOOAI_OBSERVABILITY_RELEASE ?? process.env.GIT_SHA ?? process.env.LAMBDA_FUNCTION_VERSION ?? 'dev', disabled: overrides.disabled ?? truthy(process.env.SMOOAI_OBSERVABILITY_DISABLED), + piiHashKey: overrides.piiHashKey ?? process.env.SMOOAI_OBSERVABILITY_PII_HASH_KEY, }; + // Before anything can emit: a scrubbed string written without this key + // redacts PII outright, so installing it late would silently produce a + // window of uncorrelatable spans rather than an error. + if (env.piiHashKey) setPiiHashKey(env.piiHashKey); + if (env.disabled) { bootstrapped = { installed: false, exporting: false, otel: null, stopRefresh: () => {} }; return bootstrapped; @@ -263,6 +277,12 @@ export interface BootstrapEnv { environment?: string; release?: string; disabled?: boolean; + /** + * HMAC key used to hash emails / phones / addresses in scrubbed strings + * (see `../pii`). Unset means personal identifiers are fully redacted + * rather than hashed. + */ + piiHashKey?: string; } interface RefreshConfig { diff --git a/packages/core/src/gen-ai-attributes.ts b/packages/core/src/gen-ai-attributes.ts index 3a8feaa..d28182f 100644 --- a/packages/core/src/gen-ai-attributes.ts +++ b/packages/core/src/gen-ai-attributes.ts @@ -108,11 +108,14 @@ export function setGenAIAttributes(span: Span, attrs: GenAIAttributes): void { * prompts and tool arguments are the single most PII-dense payload this SDK can * touch, so raw content never reaches the wire. * - * ponytail: `scrubString` in TS is credentials-only today (Bearer tokens, api - * keys, `password=`). Keyed per-org hashing of names / emails / phones exists in - * Rust (`rust/observability/src/pii.rs`) and is being ported to TS in a parallel - * PR — when it lands, this call site inherits it for free because it already - * routes through the SDK's one scrub entry point. Do not scrub inline here. + * That covers both classes: credentials (Bearer tokens, api keys, `password=`) + * are dropped, and emails / phones / addresses are hashed per-org. This call + * site inherited the hashing for free by routing through the SDK's one scrub + * entry point. Do not scrub inline here. + * + * ponytail: uses the org-less `scrubString`, so hashes are salted with the + * empty org — there is no org id in hand at this call site. Switch to + * `scrubStringForOrg` if one ever reaches here. */ export function recordGenAIMessage( span: Span, diff --git a/packages/core/src/hmac-sha256.ts b/packages/core/src/hmac-sha256.ts new file mode 100644 index 0000000..eb542fe --- /dev/null +++ b/packages/core/src/hmac-sha256.ts @@ -0,0 +1,130 @@ +/** + * Minimal synchronous SHA-256 + HMAC-SHA256. + * + * Why hand-rolled rather than `node:crypto` or WebCrypto: `scrubString` is + * synchronous and runs in **both** the browser and Node bundles. `node:crypto` + * breaks the browser build, and WebCrypto's `subtle.sign` is async-only — it + * cannot be called from a sync scrubber. This is the only sync primitive that + * works in both runtimes without adding a dependency to a published SDK. + * + * Correctness is pinned by the RFC 4231 / FIPS 180-4 vectors in + * `__tests__/hmac-sha256.test.ts`. Do not "optimize" this file without + * re-running them. + * + * Not constant-time, and not intended for verifying MACs against attacker- + * supplied values — the only consumer is `pii.ts`, which hashes values it + * already holds. + */ + +const K = /* @__PURE__ */ new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, + 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, + 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, + 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, + 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); + +const BLOCK_BYTES = 64; + +function rotr(x: number, n: number): number { + return (x >>> n) | (x << (32 - n)); +} + +/** FIPS 180-4 SHA-256. Returns the 32-byte digest. */ +export function sha256(message: Uint8Array): Uint8Array { + const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]); + + // Pad to a multiple of 64 bytes: 0x80, zeroes, then the 64-bit big-endian + // bit length. + const paddedLength = Math.ceil((message.length + 9) / BLOCK_BYTES) * BLOCK_BYTES; + const padded = new Uint8Array(paddedLength); + padded.set(message); + padded[message.length] = 0x80; + const view = new DataView(padded.buffer); + const bitLength = message.length * 8; + view.setUint32(paddedLength - 8, Math.floor(bitLength / 0x100000000), false); + view.setUint32(paddedLength - 4, bitLength >>> 0, false); + + const w = new Uint32Array(64); + for (let offset = 0; offset < paddedLength; offset += BLOCK_BYTES) { + for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4, false); + for (let i = 16; i < 64; i++) { + const x = w[i - 15]!; + const y = w[i - 2]!; + const s0 = rotr(x, 7) ^ rotr(x, 18) ^ (x >>> 3); + const s1 = rotr(y, 17) ^ rotr(y, 19) ^ (y >>> 10); + w[i] = (w[i - 16]! + s0 + w[i - 7]! + s1) >>> 0; + } + + let a = h[0]!; + let b = h[1]!; + let c = h[2]!; + let d = h[3]!; + let e = h[4]!; + let f = h[5]!; + let g = h[6]!; + let hh = h[7]!; + + for (let i = 0; i < 64; i++) { + const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); + const ch = (e & f) ^ (~e & g); + const temp1 = (hh + s1 + ch + K[i]! + w[i]!) >>> 0; + const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); + const maj = (a & b) ^ (a & c) ^ (b & c); + const temp2 = (s0 + maj) >>> 0; + + hh = g; + g = f; + f = e; + e = (d + temp1) >>> 0; + d = c; + c = b; + b = a; + a = (temp1 + temp2) >>> 0; + } + + h[0] = (h[0]! + a) >>> 0; + h[1] = (h[1]! + b) >>> 0; + h[2] = (h[2]! + c) >>> 0; + h[3] = (h[3]! + d) >>> 0; + h[4] = (h[4]! + e) >>> 0; + h[5] = (h[5]! + f) >>> 0; + h[6] = (h[6]! + g) >>> 0; + h[7] = (h[7]! + hh) >>> 0; + } + + const digest = new Uint8Array(32); + const digestView = new DataView(digest.buffer); + for (let i = 0; i < 8; i++) digestView.setUint32(i * 4, h[i]!, false); + return digest; +} + +function concat(a: Uint8Array, b: Uint8Array): Uint8Array { + const out = new Uint8Array(a.length + b.length); + out.set(a); + out.set(b, a.length); + return out; +} + +/** RFC 2104 HMAC-SHA256. Returns the 32-byte MAC. */ +export function hmacSha256(key: Uint8Array, message: Uint8Array): Uint8Array { + const block = new Uint8Array(BLOCK_BYTES); + block.set(key.length > BLOCK_BYTES ? sha256(key) : key); + + const inner = new Uint8Array(BLOCK_BYTES); + const outer = new Uint8Array(BLOCK_BYTES); + for (let i = 0; i < BLOCK_BYTES; i++) { + inner[i] = block[i]! ^ 0x36; + outer[i] = block[i]! ^ 0x5c; + } + return sha256(concat(outer, sha256(concat(inner, message)))); +} + +/** Lowercase hex of `bytes`, truncated to `length` characters. */ +export function toHex(bytes: Uint8Array, length = bytes.length * 2): string { + let out = ''; + for (let i = 0; i < bytes.length && out.length < length; i++) { + out += bytes[i]!.toString(16).padStart(2, '0'); + } + return out.slice(0, length); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7ac0175..31cc163 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -38,3 +38,8 @@ export { type TelemetrySettingsProvider, } from './telemetry-settings'; export { parseTraceparent, formatTraceparent, type TraceContext } from './traceparent'; +// PII scrubbing / hashing. `setPiiHashKey` MUST be called before anything is +// captured — without a key, emails / phones / addresses are redacted outright +// rather than hashed. On Node `bootstrapObservability` does it from +// SMOOAI_OBSERVABILITY_PII_HASH_KEY; the browser has no env, so call it here. +export { scrubString, scrubStringForOrg, scrubHeaders, scrubHeadersForOrg, setPiiHashKey, piiToken, type PiiKind } from './pii'; diff --git a/packages/core/src/pii.ts b/packages/core/src/pii.ts index 2393f6e..236ebb0 100644 --- a/packages/core/src/pii.ts +++ b/packages/core/src/pii.ts @@ -1,31 +1,192 @@ /** * PII scrubbing — applied to message strings, breadcrumb messages, and headers - * before transport. Stays opinionated and minimal; tenants can extend in - * `beforeSend`. + * before transport. Mirrors `rust/observability/src/pii.rs`; the semantics are + * identical across the five SDKs. + * + * Two classes, handled differently on purpose: + * + * - **Credentials** (`Bearer …`, `password=`, `token`/`api_key`/`secret=`, + * `sk-…`) are **dropped**. A hash of a live token is still a token oracle, + * and there is no correlation value in a secret. + * - **Personal identifiers** (email, phone, street address) are **hashed**, not + * dropped: `a@b.com` → `[email:9f2a41c8]`. That keeps the one question worth + * asking — "are these two spans the same person?" — answerable while storing + * nothing reversible. + * + * The hash is **HMAC-SHA256, keyed**, not a bare digest: emails and phone + * numbers are a small enumerable space that a rainbow table reverses in + * seconds. The org id is mixed into the HMAC message, so identical PII hashes + * **differently in different orgs**. + * + * **The key and the org salt are load-bearing and must not rotate casually.** + * Rotating either silently breaks correlation with every previously stored + * hash. Supply the key once at startup via `SMOOAI_OBSERVABILITY_PII_HASH_KEY` + * (read by `bootstrapObservability`) or {@link setPiiHashKey} — the browser + * bundle has no env, so it must call `setPiiHashKey` explicitly. **With no key + * configured, personal identifiers are fully redacted** (`[email:redacted]`) + * rather than hashed under a guessable one — fail safe, never fail open. + * + * Pattern matching never catches everything. It fails safe, which is the right + * default for data we persist from end-user conversations; tenants can extend + * in `beforeSend`. */ -const PII_PATTERNS: Array<{ re: RegExp; replacement: string }> = [ +import { hmacSha256, toHex } from './hmac-sha256'; + +/** + * Credentials. Matched FIRST so a personal identifier sitting inside a secret + * (`token=a@b.com`) is dropped with the secret rather than surviving as a hash. + */ +const CREDENTIAL_PATTERNS: Array<{ re: RegExp; replacement: string | ((match: string) => string) }> = [ { re: /Bearer\s+[A-Za-z0-9._-]+/gi, replacement: 'Bearer [redacted]' }, { re: /\b(?:password|passwd|pwd)["']?\s*[:=]\s*["']?[^"'&\s]+/gi, replacement: 'password=[redacted]' }, - { re: /\b(?:token|api[-_]?key|apikey|secret)["']?\s*[:=]\s*["']?[^"'&\s]+/gi, replacement: '$&'.replace(/=.*/, '=[redacted]') }, + // Key-preserving: keep everything up to and including the `=`/`:` + // separator, redact the rest. + { + re: /\b(?:token|api[-_]?key|apikey|secret)["']?\s*[:=]\s*["']?[^"'&\s]+/gi, + replacement: (match: string) => match.replace(/^(.*?[:=]).*$/, '$1[redacted]'), + }, { re: /\bsk-[A-Za-z0-9]{20,}/g, replacement: 'sk-[redacted]' }, ]; +/** + * The class of personal identifier a match represents. Drives both the visible + * prefix in the output token and the normalization applied before hashing, so + * `(415) 555-0142` and `415-555-0142` correlate. + */ +export type PiiKind = 'email' | 'phone' | 'address'; + +function normalize(kind: PiiKind, raw: string): string { + switch (kind) { + case 'email': + return raw.trim().toLowerCase(); + case 'phone': + // Digits only: formatting must not fork the hash. + return raw.replace(/[^0-9]/g, ''); + case 'address': + // Case-fold and collapse runs of whitespace. + return raw.trim().replace(/\s+/g, ' ').toLowerCase(); + } +} + +/** + * Personal identifiers — hashed, not dropped. Order matters only in that these + * all run after {@link CREDENTIAL_PATTERNS}. + */ +const PERSONAL_PATTERNS: Array<{ kind: PiiKind; re: RegExp }> = [ + { kind: 'email', re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+\b/gi }, + // Phone: optional country code / area code, then the 3-4 local pair. A + // separator is REQUIRED so bare digit runs (ids, timestamps, amounts) don't + // get eaten. + { kind: 'phone', re: /(?:\+\d{1,3}[ .-]?)?(?:\(\d{3}\)[ .-]?|\b\d{3}[ .-])?\b\d{3}[ .-]\d{4}\b/g }, + // US-style street address: house number, 1-3 words, a street suffix. + { + kind: 'address', + re: /\b\d{1,6}\s+(?:[A-Za-z0-9.'-]+\s+){0,3}(?:street|st|avenue|ave|road|rd|boulevard|blvd|lane|ln|drive|dr|court|ct|way|terrace|ter|place|pl|circle|cir|highway|hwy|parkway|pkwy|square|sq)\b\.?/gi, + }, +]; + const SENSITIVE_HEADERS = new Set(['authorization', 'cookie', 'set-cookie', 'x-api-key', 'x-auth-token']); +/** + * Hex characters kept from the HMAC. Long enough that collisions are rare + * across an org's traces, short enough to read in a span attribute. + */ +const HASH_HEX_LEN = 8; + +const encoder = new TextEncoder(); + +let piiHashKey: Uint8Array | null = null; + +/** + * Install the process-wide HMAC key used to hash personal identifiers. + * Idempotent and set-once: returns `false` if a key was already installed (the + * existing key is kept — a mid-process rotation would silently fork every + * correlation) or if `key` is empty. `bootstrapObservability` calls this from + * `SMOOAI_OBSERVABILITY_PII_HASH_KEY`. + */ +export function setPiiHashKey(key: string | Uint8Array): boolean { + const bytes = typeof key === 'string' ? encoder.encode(key) : key; + if (bytes.length === 0 || piiHashKey !== null) return false; + piiHashKey = bytes; + return true; +} + +/** Test seam — clears the set-once key so set-once itself can be asserted. */ +export function _resetPiiHashKeyForTests(): void { + piiHashKey = null; +} + +/** + * Hash one known-personal value into its scrubbed token — the same token + * {@link scrubStringForOrg} would have written. This is how a UI search box + * finds stored hashes: hash the typed term with the same org and match. + * + * Returns `[:redacted]` when no key is installed. + */ +export function piiToken(kind: PiiKind, raw: string, orgId: string): string { + return tokenWithKey(kind, raw, orgId, piiHashKey); +} + +function tokenWithKey(kind: PiiKind, raw: string, orgId: string, key: Uint8Array | null): string { + if (!key || key.length === 0) return `[${kind}:redacted]`; + // orgId in the HMAC message IS the per-org salt. The kind is in there too so + // a phone and an address that normalize alike can't collide. + // NUL separators (written as explicit escapes — a literal NUL in source is + // invisible and formatter-fragile) so no field can impersonate a boundary. + const message = encoder.encode(`${orgId}\u0000${kind}\u0000${normalize(kind, raw)}`); + return `[${kind}:${toHex(hmacSha256(key, message), HASH_HEX_LEN)}]`; +} + +/** + * Scrub a free-form string with no org context — credentials dropped, personal + * identifiers hashed under the empty org salt. Prefer {@link scrubStringForOrg} + * wherever an org id is in hand, so hashes can't be correlated across tenants. + */ export function scrubString(input: string): string { + return scrubWithKey(input, '', piiHashKey); +} + +/** Scrub a free-form string, salting personal-identifier hashes with `orgId`. */ +export function scrubStringForOrg(input: string, orgId: string): string { + return scrubWithKey(input, orgId, piiHashKey); +} + +/** @internal Test seam — scrub with an explicit key instead of the global one. */ +export function _scrubWithKey(input: string, orgId: string, key: Uint8Array | null): string { + return scrubWithKey(input, orgId, key); +} + +/** @internal Test seam — token with an explicit key instead of the global one. */ +export function _tokenWithKey(kind: PiiKind, raw: string, orgId: string, key: Uint8Array | null): string { + return tokenWithKey(kind, raw, orgId, key); +} + +function scrubWithKey(input: string, orgId: string, key: Uint8Array | null): string { let out = input; - for (const { re, replacement } of PII_PATTERNS) { - out = out.replace(re, replacement); + for (const { re, replacement } of CREDENTIAL_PATTERNS) { + out = typeof replacement === 'string' ? out.replace(re, replacement) : out.replace(re, (match: string) => replacement(match)); + } + for (const { kind, re } of PERSONAL_PATTERNS) { + out = out.replace(re, (match) => tokenWithKey(kind, match, orgId, key)); } return out; } +/** + * Scrub a header map: sensitive header names are fully redacted, all other + * values are run through {@link scrubString}. + */ export function scrubHeaders(headers: Record | undefined): Record | undefined { + return scrubHeadersForOrg(headers, ''); +} + +/** {@link scrubHeaders} with an org salt for the personal-identifier hashes. */ +export function scrubHeadersForOrg(headers: Record | undefined, orgId: string): Record | undefined { if (!headers) return headers; const out: Record = {}; for (const [k, v] of Object.entries(headers)) { - out[k] = SENSITIVE_HEADERS.has(k.toLowerCase()) ? '[redacted]' : scrubString(v); + out[k] = SENSITIVE_HEADERS.has(k.toLowerCase()) ? '[redacted]' : scrubStringForOrg(v, orgId); } return out; } diff --git a/python/README.md b/python/README.md index d5425a3..e5fbaf5 100644 --- a/python/README.md +++ b/python/README.md @@ -118,6 +118,7 @@ Same names as the TS bootstrap: | `SMOOAI_OBSERVABILITY_SERVICE_NAME` | OTel `service.name` (default `smoo-service`) | | `SMOOAI_OBSERVABILITY_ENVIRONMENT` / `_RELEASE` | Deployment env / release id | | `SMOOAI_OBSERVABILITY_DISABLED` | `1`/`true` to skip bootstrap | +| `SMOOAI_OBSERVABILITY_PII_HASH_KEY` | HMAC key for hashing emails / phones / addresses (unset = redacted) | ## Development @@ -126,3 +127,30 @@ uv sync --all-extras --dev uv run ruff check . && uv run ruff format --check . uv run pytest ``` + +## PII scrubbing + +Two classes, handled differently: + +- **Credentials** (`Bearer …`, `password=`, `token`/`api_key`/`secret=`, `sk-…`) + are **dropped**. A hash of a live token is still a token oracle. +- **Personal identifiers** (email, phone, street address) are **hashed**: + `a@b.com` → `[email:9f2a41c8]`. The type prefix stays visible, so you can see + *what kind* of value was there and that two spans carry the *same* one — + without ever seeing it. + +The hash is **HMAC-SHA256**, not a bare digest (emails and phones are a small +enumerable space a rainbow table reverses in seconds), and the org id is mixed +into the message so the same value hashes **differently in different orgs**. + +Set the key with `SMOOAI_OBSERVABILITY_PII_HASH_KEY` (read by the bootstrap) or +`pii.set_pii_hash_key(...)`. **With no key, personal identifiers are fully redacted** +(`[email:redacted]`) rather than hashed under a guessable one. + +⚠️ **The key and the org id are load-bearing.** Rotating either silently breaks +correlation with every hash already stored — treat the key as permanent, and do +not reuse a secret that rotates on a schedule. + +All five SDKs (TypeScript, Rust, Go, Python, .NET) emit byte-identical tokens +for the same key/org/value; the shared vectors are asserted in each SDK's PII +test suite. diff --git a/python/src/smooai_observability/bootstrap/__init__.py b/python/src/smooai_observability/bootstrap/__init__.py index 45a2f98..e8a3dea 100644 --- a/python/src/smooai_observability/bootstrap/__init__.py +++ b/python/src/smooai_observability/bootstrap/__init__.py @@ -26,6 +26,10 @@ SMOOAI_OBSERVABILITY_RELEASE — default GIT_SHA / "dev". SMOOAI_OBSERVABILITY_DSN — webhook DSN for the Errors dashboard. SMOOAI_OBSERVABILITY_DISABLED — "1"/"true" to skip bootstrap entirely. +SMOOAI_OBSERVABILITY_PII_HASH_KEY — HMAC key used to hash emails / phones / + addresses in scrubbed strings (see + ``smooai_observability.pii``). Unset means + personal identifiers are fully redacted. """ from __future__ import annotations @@ -36,6 +40,7 @@ from ..auth.token_provider import TokenProvider, TokenProviderError from ..client import Client, ClientOptions +from ..pii import set_pii_hash_key from ..transport import Transport @@ -54,6 +59,7 @@ class BootstrapEnv: environment: str | None = None release: str | None = None disabled: bool = False + pii_hash_key: str | None = None @dataclass @@ -129,8 +135,15 @@ def bootstrap_observability( or os.environ.get("NODE_ENV"), release=o.release or os.environ.get("SMOOAI_OBSERVABILITY_RELEASE") or os.environ.get("GIT_SHA") or "dev", disabled=o.disabled or _truthy(os.environ.get("SMOOAI_OBSERVABILITY_DISABLED")), + pii_hash_key=o.pii_hash_key or os.environ.get("SMOOAI_OBSERVABILITY_PII_HASH_KEY"), ) + # Before anything can emit: a scrubbed string written without this key + # redacts PII outright, so installing it late would silently produce a + # window of uncorrelatable spans rather than an error. + if env.pii_hash_key: + set_pii_hash_key(env.pii_hash_key) + if env.disabled: _bootstrapped = BootstrapResult(installed=False) return _bootstrapped diff --git a/python/src/smooai_observability/pii.py b/python/src/smooai_observability/pii.py index 1ec5298..c90520a 100644 --- a/python/src/smooai_observability/pii.py +++ b/python/src/smooai_observability/pii.py @@ -1,29 +1,54 @@ """PII scrubbing — applied to message strings, breadcrumb messages, and headers -before transport. Stays opinionated and minimal; tenants can extend in -``before_send``. +before transport. Port of ``rust/observability/src/pii.rs``; the semantics are +identical across the five SDKs. + +Two classes, handled differently on purpose: + +- **Credentials** (``Bearer …``, ``password=``, ``token``/``api_key``/``secret=``, + ``sk-…``) are **dropped**. A hash of a live token is still a token oracle, and + there is no correlation value in a secret. +- **Personal identifiers** (email, phone, street address) are **hashed**, not + dropped: ``a@b.com`` → ``[email:9f2a41c8]``. That keeps the one question worth + asking — "are these two spans the same person?" — answerable while storing + nothing reversible. + +The hash is **HMAC-SHA256, keyed**, not a bare digest: emails and phone numbers +are a small enumerable space that a rainbow table reverses in seconds. The org +id is mixed into the HMAC message, so identical PII hashes **differently in +different orgs**. + +**The key and the org salt are load-bearing and must not rotate casually.** +Rotating either silently breaks correlation with every previously stored hash. +Supply the key once at startup via ``SMOOAI_OBSERVABILITY_PII_HASH_KEY`` (read +by :mod:`smooai_observability.bootstrap`) or :func:`set_pii_hash_key`. **With no +key configured, personal identifiers are fully redacted** (``[email:redacted]``) +rather than hashed under a guessable one — fail safe, never fail open. -Direct port of ``packages/core/src/pii.ts``. The regex patterns mirror the TS -source. One deliberate deviation: the TS ``token|api_key|secret`` pattern ships -a latent no-op replacement (``'$&'.replace(/=.*/, ...)`` evaluates to the -literal ``$&`` at module load, so the match is replaced with itself). A PII -scrubber that doesn't redact secrets is worse than useless, so the Python port -actually redacts the value while keeping the key — the clearly-intended -behavior. See the inline note on ``_TOKEN_RE``. +Pattern matching never catches everything. It fails safe, which is the right +default for data we persist from end-user conversations; tenants can extend in +``before_send``. """ from __future__ import annotations +import hmac import re +import threading +from enum import StrEnum + +# --- credentials: matched FIRST, dropped entirely -------------------------- +# +# A personal identifier sitting inside a secret (``token=a@b.com``) is dropped +# with the secret rather than surviving as a hash. -# Mirrors the TS PII_PATTERNS. Python's `re` uses `(?i)` / re.IGNORECASE for -# the `i` flag and re.sub iterates globally by default (no `g` flag needed). _BEARER_RE = re.compile(r"Bearer\s+[A-Za-z0-9._-]+", re.IGNORECASE) _PASSWORD_RE = re.compile( r"""\b(?:password|passwd|pwd)["']?\s*[:=]\s*["']?[^"'&\s]+""", re.IGNORECASE, ) -# TS source: the replacement string resolves to a no-op (`$&`). Python keeps -# the leading `key=`/`key:` and redacts only the value — the intended effect. +# The TS source's replacement string resolves to a no-op (`$&`). Python keeps +# the leading `key=`/`key:` and redacts only the value — the intended effect, +# and what the Rust reference does. _TOKEN_RE = re.compile( r"""\b(?P(?:token|api[-_]?key|apikey|secret)["']?\s*[:=]\s*["']?)[^"'&\s]+""", re.IGNORECASE, @@ -32,13 +57,138 @@ _SENSITIVE_HEADERS = frozenset({"authorization", "cookie", "set-cookie", "x-api-key", "x-auth-token"}) +# Hex characters kept from the HMAC. Long enough that collisions are rare across +# an org's traces, short enough to read in a span attribute. +_HASH_HEX_LEN = 8 + +_NON_DIGIT_RE = re.compile(r"[^0-9]") + + +class PiiKind(StrEnum): + """The class of personal identifier a match represents. + + Drives both the visible prefix in the output token and the normalization + applied before hashing, so ``(415) 555-0142`` and ``415-555-0142`` + correlate. + """ + + EMAIL = "email" + PHONE = "phone" + ADDRESS = "address" + + @property + def label(self) -> str: + """The prefix that stays visible in the scrubbed output.""" + return self.value + + def normalize(self, raw: str) -> str: + if self is PiiKind.EMAIL: + return raw.strip().lower() + if self is PiiKind.PHONE: + # Digits only: formatting must not fork the hash. + return _NON_DIGIT_RE.sub("", raw) + # Case-fold and collapse runs of whitespace. + return " ".join(raw.split()).lower() + + +# --- personal identifiers: hashed, not dropped ----------------------------- +# +# These all run after the credential patterns above. +_PERSONAL_PATTERNS: list[tuple[PiiKind, re.Pattern[str]]] = [ + ( + PiiKind.EMAIL, + re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+\b", re.IGNORECASE), + ), + # Phone: optional country code / area code, then the 3-4 local pair. A + # separator is REQUIRED so bare digit runs (ids, timestamps, amounts) don't + # get eaten. + ( + PiiKind.PHONE, + re.compile(r"(?:\+\d{1,3}[ .-]?)?(?:\(\d{3}\)[ .-]?|\b\d{3}[ .-])?\b\d{3}[ .-]\d{4}\b"), + ), + # US-style street address: house number, 1-3 words, a street suffix. + ( + PiiKind.ADDRESS, + re.compile( + r"\b\d{1,6}\s+(?:[A-Za-z0-9.'-]+\s+){0,3}" + r"(?:street|st|avenue|ave|road|rd|boulevard|blvd|lane|ln|drive|dr|court|ct|way|" + r"terrace|ter|place|pl|circle|cir|highway|hwy|parkway|pkwy|square|sq)\b\.?", + re.IGNORECASE, + ), + ), +] + +_key_lock = threading.Lock() +_pii_hash_key: bytes | None = None + + +def set_pii_hash_key(key: bytes | str) -> bool: + """Install the process-wide HMAC key used to hash personal identifiers. + + Idempotent and set-once: returns ``False`` if a key was already installed + (the existing key is kept — a mid-process rotation would silently fork every + correlation) or if ``key`` is empty. The bootstrap calls this from + ``SMOOAI_OBSERVABILITY_PII_HASH_KEY``. + """ + global _pii_hash_key + raw = key.encode() if isinstance(key, str) else key + if not raw: + return False + with _key_lock: + if _pii_hash_key is not None: + return False + _pii_hash_key = raw + return True + + +def _current_key() -> bytes | None: + return _pii_hash_key + + +def pii_token(kind: PiiKind, raw: str, org_id: str) -> str: + """Hash one known-personal value into its scrubbed token. + + Produces exactly the token :func:`scrub_string_for_org` would have written, + which is how a UI search box finds stored hashes: hash the typed term with + the same org and match. + + Returns ``[:redacted]`` when no key is installed. + """ + return _token_with_key(kind, raw, org_id, _current_key()) + + +def _token_with_key(kind: PiiKind, raw: str, org_id: str, key: bytes | None) -> str: + if not key: + return f"[{kind.label}:redacted]" + # org_id in the HMAC message IS the per-org salt. The kind is in there too so + # a phone and an address that normalize alike can't collide. + msg = b"\x00".join((org_id.encode(), kind.label.encode(), kind.normalize(raw).encode())) + digest = hmac.new(key, msg, "sha256").hexdigest()[:_HASH_HEX_LEN] + return f"[{kind.label}:{digest}]" + def scrub_string(value: str) -> str: - """Redact known secret shapes from a free-form string.""" + """Scrub a free-form string with no org context. + + Credentials dropped, personal identifiers hashed under the empty org salt. + Prefer :func:`scrub_string_for_org` wherever an org id is in hand, so hashes + can't be correlated across tenants. + """ + return _scrub_with_key(value, "", _current_key()) + + +def scrub_string_for_org(value: str, org_id: str) -> str: + """Scrub a free-form string, salting personal-identifier hashes with ``org_id``.""" + return _scrub_with_key(value, org_id, _current_key()) + + +def _scrub_with_key(value: str, org_id: str, key: bytes | None) -> str: out = _BEARER_RE.sub("Bearer [redacted]", value) out = _PASSWORD_RE.sub("password=[redacted]", out) out = _TOKEN_RE.sub(lambda m: f"{m.group('key')}[redacted]", out) out = _SK_RE.sub("sk-[redacted]", out) + for kind, pattern in _PERSONAL_PATTERNS: + out = pattern.sub(lambda m, k=kind: _token_with_key(k, m.group(0), org_id, key), out) return out @@ -46,9 +196,17 @@ def scrub_headers( headers: dict[str, str] | None, ) -> dict[str, str] | None: """Redact sensitive headers wholesale; scrub remaining header values.""" + return scrub_headers_for_org(headers, "") + + +def scrub_headers_for_org( + headers: dict[str, str] | None, + org_id: str, +) -> dict[str, str] | None: + """:func:`scrub_headers` with an org salt for the personal-identifier hashes.""" if not headers: return headers out: dict[str, str] = {} for k, v in headers.items(): - out[k] = "[redacted]" if k.lower() in _SENSITIVE_HEADERS else scrub_string(v) + out[k] = "[redacted]" if k.lower() in _SENSITIVE_HEADERS else scrub_string_for_org(v, org_id) return out diff --git a/python/tests/test_pii.py b/python/tests/test_pii.py index 3f256ea..5c13bf3 100644 --- a/python/tests/test_pii.py +++ b/python/tests/test_pii.py @@ -1,3 +1,5 @@ +import pytest + from smooai_observability import pii @@ -46,3 +48,164 @@ def test_scrub_headers_none_passthrough(): def test_scrub_headers_scrubs_nonsensitive_values(): out = pii.scrub_headers({"X-Note": "Bearer leaked.token"}) assert out["X-Note"] == "Bearer [redacted]" + + +# ---- PII hashing parity with rust/observability/src/pii.rs ----------------- +# +# These drive the private _scrub_with_key / _token_with_key rather than +# installing the process-wide key: set_pii_hash_key is set-once and pytest runs +# the suite in one process, so a global write would make it order-dependent. + +KEY = b"test-hmac-key-not-a-real-secret" +OTHER_KEY = b"a-different-test-hmac-key" + + +def scrub(value: str, org: str = "org-1") -> str: + return pii._scrub_with_key(value, org, KEY) + + +def test_credentials_are_dropped_not_hashed(): + # A live token must never become a correlatable handle — hashing one still + # yields an oracle you can test candidate tokens against. + out = scrub("Bearer abc.def-ghi_123 password=hunter2 sk-ABCDEFGHIJKLMNOPQRSTUVWX") + assert "Bearer [redacted]" in out + assert "password=[redacted]" in out + assert "sk-[redacted]" in out + for shape in ("[token:", "[credential:", "[email:", "[phone:"): + assert shape not in out, out + # And a personal identifier hiding inside a secret goes with it. + out2 = scrub("token=a@b.com") + assert "a@b.com" not in out2 + assert "[email:" not in out2 + + +def test_hashes_emails_keeping_the_type_prefix(): + out = scrub("contact me at Alice@Example.com please") + assert "alice@example.com" not in out.lower() + assert out.startswith("contact me at [email:") + assert out.endswith("] please") + + +def test_hashes_phone_numbers(): + for raw in ("555-0142", "(415) 555-0142", "+1 415-555-0142"): + out = scrub(f"call {raw} today") + assert "[phone:" in out, out + assert "0142" not in out, out + + +def test_hashes_street_addresses(): + out = scrub("ship to 1600 Pennsylvania Ave, Washington") + assert "[address:" in out, out + assert "Pennsylvania" not in out, out + + +def test_same_value_same_org_is_stable(): + assert scrub("a@b.com") == scrub("a@b.com") + # …and correlation survives formatting differences in phones. + assert scrub("(415) 555-0142") == scrub("415-555-0142") + + +def test_same_value_different_org_hashes_differently(): + a = scrub("a@b.com", "org-1") + b = scrub("a@b.com", "org-2") + assert a != b, f"per-org salt missing: {a} == {b}" + assert a.startswith("[email:") and b.startswith("[email:") + + +def test_different_key_hashes_differently(): + a = pii._scrub_with_key("a@b.com", "org-1", KEY) + b = pii._scrub_with_key("a@b.com", "org-1", OTHER_KEY) + assert a != b, f"hash is not keyed: {a} == {b}" + + +def test_no_key_redacts_rather_than_hashing(): + # Fail safe: an unkeyed digest of an email is rainbow-tabled instantly. + out = pii._scrub_with_key("a@b.com and 555-0142", "org-1", None) + assert "[email:redacted]" in out, out + assert "[phone:redacted]" in out, out + assert "a@b.com" not in out + assert "0142" not in out + + +def test_hash_is_short_hex(): + token = pii._token_with_key(pii.PiiKind.EMAIL, "a@b.com", "org-1", KEY) + digest = token.removeprefix("[email:").removesuffix("]") + assert len(digest) == pii._HASH_HEX_LEN, token + assert all(c in "0123456789abcdef" for c in digest), token + + +def test_pii_token_matches_what_scrubbing_wrote(): + # The searchability contract: hashing a typed query term must produce + # exactly the token stored in the span. + scrubbed = pii._scrub_with_key("mail a@b.com now", "org-7", KEY) + term = pii._token_with_key(pii.PiiKind.EMAIL, "A@B.com ", "org-7", KEY) + assert term in scrubbed, f"{scrubbed} vs {term}" + + +def test_ordinary_numbers_are_not_mistaken_for_phones(): + # The phone pattern requires a separator before the last 4 digits; ids, + # versions, dates and amounts must survive intact. + for value in ( + "took 1234 ms", + "version 1.2.3", + "2026-08-15T12:00:00Z", + "total 1234.5678", + "trace 0af7651916cd43dd8448eb211c80319c", + ): + assert scrub(value) == value, f"false positive on {value}" + + +def test_set_pii_hash_key_is_set_once_and_rejects_empty(): + assert pii.set_pii_hash_key(b"") is False + saved = pii._pii_hash_key + pii._pii_hash_key = None + try: + assert pii.set_pii_hash_key("first-key") is True + assert pii.set_pii_hash_key("second-key") is False, "rotation should be refused" + assert pii._pii_hash_key == b"first-key" + finally: + pii._pii_hash_key = saved + + +def test_scrub_headers_for_org_never_leaks_raw_email(): + out = pii.scrub_headers_for_org({"X-Note": "reply to a@b.com"}, "org-1") + assert "a@b.com" not in out["X-Note"] + assert "[email:" in out["X-Note"] + + +def test_bootstrap_reads_pii_hash_key_from_env(monkeypatch): + from smooai_observability import bootstrap as bootstrap_mod + + monkeypatch.setenv("SMOOAI_OBSERVABILITY_PII_HASH_KEY", "env-supplied-key") + monkeypatch.setenv("SMOOAI_OBSERVABILITY_DISABLED", "1") + monkeypatch.setattr(bootstrap_mod, "_bootstrapped", None) + saved = pii._pii_hash_key + pii._pii_hash_key = None + try: + bootstrap_mod.bootstrap_observability() + assert pii._pii_hash_key == b"env-supplied-key" + finally: + pii._pii_hash_key = saved + bootstrap_mod._bootstrapped = None + + +@pytest.mark.parametrize( + ("kind", "raw", "org", "expected"), + [ + (pii.PiiKind.EMAIL, "a@b.com", "org-1", "[email:02ea437f]"), + (pii.PiiKind.EMAIL, "A@B.COM ", "org-1", "[email:02ea437f]"), + (pii.PiiKind.EMAIL, "a@b.com", "org-2", "[email:fd96f7dc]"), + (pii.PiiKind.EMAIL, "a@b.com", "", "[email:453b154f]"), + (pii.PiiKind.PHONE, "(415) 555-0142", "org-1", "[phone:415a9aea]"), + (pii.PiiKind.PHONE, "415-555-0142", "org-1", "[phone:415a9aea]"), + (pii.PiiKind.ADDRESS, "1600 Pennsylvania Ave", "org-1", "[address:c5351f4a]"), + ], +) +def test_cross_sdk_parity_vectors(kind, raw, org, expected): + """Pins the exact bytes every SDK must produce. + + Computed independently and asserted verbatim in all five SDKs. If any SDK's + message framing, normalization or truncation drifts, exactly one of these + breaks. + """ + assert pii._token_with_key(kind, raw, org, KEY) == expected diff --git a/rust/observability/README.md b/rust/observability/README.md index bd86d60..a7bda2d 100644 --- a/rust/observability/README.md +++ b/rust/observability/README.md @@ -19,7 +19,7 @@ error-safe and degrades to a no-op (plus one stderr line) rather than panicking. | Scope / context (per-task) | `scope.ts` | ✅ | | Breadcrumb buffer (max 100) | `scope.ts` | ✅ | | PII scrubbing (credentials) | `pii.ts` | ✅ | -| PII **hashing** (email/phone/addr) | — (Rust only) | ✅ | +| PII **hashing** (email/phone/addr) | `pii.ts` (all 5 SDKs) | ✅ | | Batched webhook transport + retry | `transport.ts` | ✅ | | OTLP traces + metrics export | `otel/setup-otel-sdk.ts` | ✅ | | Per-request M2M auth (no staleness)| `otel/auth-injecting-exporter.ts` | ✅ | @@ -91,6 +91,10 @@ Set the key with `SMOOAI_OBSERVABILITY_PII_HASH_KEY` (read by `bootstrap()`) or `pii::set_pii_hash_key`. **With no key, personal identifiers are fully redacted** (`[email:redacted]`) rather than hashed under a guessable one. +The TypeScript, Go, Python and .NET SDKs implement the identical scheme and emit +byte-identical tokens; the shared vectors are asserted in every SDK's PII test +suite (`cross_sdk_parity_vectors`). + ⚠️ **The key and the org id are load-bearing.** Rotating either silently breaks correlation with every hash already stored — treat the key as permanent, and do not reuse a secret that rotates on a schedule. diff --git a/rust/observability/src/bootstrap.rs b/rust/observability/src/bootstrap.rs index 0d31c8e..2535465 100644 --- a/rust/observability/src/bootstrap.rs +++ b/rust/observability/src/bootstrap.rs @@ -243,7 +243,6 @@ async fn build(env: BootstrapEnv) -> BootstrapResult { } } - #[cfg(test)] mod exporting_status_tests { use super::*; diff --git a/rust/observability/src/pii.rs b/rust/observability/src/pii.rs index f856bf7..99b53d3 100644 --- a/rust/observability/src/pii.rs +++ b/rust/observability/src/pii.rs @@ -29,8 +29,9 @@ //! in `before_send`. //! //! Credential patterns are a direct port of the TS `pii.ts` patterns. The -//! hashing layer is Rust-only today — the other four SDKs still scrub -//! credentials only. +//! hashing layer is implemented identically in all five SDKs (TypeScript, Rust, +//! Go, Python, .NET) — see `cross_sdk_parity_vectors` for the shared vectors +//! every port asserts verbatim. use once_cell::sync::{Lazy, OnceCell}; use regex::Regex; @@ -411,6 +412,40 @@ mod tests { assert!(hex.chars().all(|c| c.is_ascii_hexdigit()), "{t}"); } + /// Pins the exact bytes every SDK must produce. Computed independently + /// (python `hmac.new(key, org\0kind\0normalized, "sha256")`) and asserted + /// verbatim in the TS, Go, Python and .NET ports too. If any SDK's message + /// framing, normalization or truncation drifts, exactly one of these breaks. + #[test] + fn cross_sdk_parity_vectors() { + let cases = [ + (PiiKind::Email, "a@b.com", "org-1", "[email:02ea437f]"), + (PiiKind::Email, "A@B.COM ", "org-1", "[email:02ea437f]"), + (PiiKind::Email, "a@b.com", "org-2", "[email:fd96f7dc]"), + (PiiKind::Email, "a@b.com", "", "[email:453b154f]"), + ( + PiiKind::Phone, + "(415) 555-0142", + "org-1", + "[phone:415a9aea]", + ), + (PiiKind::Phone, "415-555-0142", "org-1", "[phone:415a9aea]"), + ( + PiiKind::Address, + "1600 Pennsylvania Ave", + "org-1", + "[address:c5351f4a]", + ), + ]; + for (kind, raw, org, want) in cases { + assert_eq!( + token_with_key(kind, raw, org, Some(KEY)), + want, + "{kind:?} {raw:?} {org:?}" + ); + } + } + #[test] fn pii_token_matches_what_scrubbing_wrote() { // The searchability contract: hashing a typed query term must produce