From c5002e8bf8f23fabeeabda1ff40556552a9e85e5 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Wed, 22 Jul 2026 22:04:52 +1200 Subject: [PATCH] ref: Add internal sensitive-data denylist and key-value filter utility First step of the DataCollection migration (#5420, #5421). Internal only, nothing wired up yet. Mirrors filterKeyValueData/filtering-snippets from the JS reference implementation. Co-Authored-By: Claude Fable 5 --- .../DataCollection/KeyValueFilterBehavior.cs | 49 ++++ .../DataCollection/SensitiveDataFilter.cs | 148 ++++++++++ .../SensitiveDataFilterTests.cs | 254 ++++++++++++++++++ 3 files changed, 451 insertions(+) create mode 100644 src/Sentry/Internal/DataCollection/KeyValueFilterBehavior.cs create mode 100644 src/Sentry/Internal/DataCollection/SensitiveDataFilter.cs create mode 100644 test/Sentry.Tests/Internals/DataCollection/SensitiveDataFilterTests.cs diff --git a/src/Sentry/Internal/DataCollection/KeyValueFilterBehavior.cs b/src/Sentry/Internal/DataCollection/KeyValueFilterBehavior.cs new file mode 100644 index 0000000000..72d71748d6 --- /dev/null +++ b/src/Sentry/Internal/DataCollection/KeyValueFilterBehavior.cs @@ -0,0 +1,49 @@ +namespace Sentry.Internal.DataCollection; + +/// +/// How a key-value collection (HTTP headers, cookies, URL query parameters) should be collected, per the +/// DataCollection spec: https://develop.sentry.dev/sdk/foundations/client/data-collection/ +/// +internal enum KeyValueFilterMode +{ + /// Neither keys nor values are collected. + Off, + + /// All keys are collected; values of sensitive keys are replaced with "[Filtered]". + DenyList, + + /// All keys are collected; only values of allow-listed, non-sensitive keys are kept. + AllowList +} + +/// +/// A plus the terms that parameterize it: extra deny terms in +/// mode, allowed key terms in +/// mode. Terms match key names as case-insensitive substrings. Internal precursor to the public behavior type that +/// ships with the DataCollection option. +/// +internal readonly struct KeyValueFilterBehavior +{ + private readonly string[]? _terms; + + private KeyValueFilterBehavior(KeyValueFilterMode mode, string[]? terms) + { + Mode = mode; + _terms = terms; + } + + public KeyValueFilterMode Mode { get; } + + public string[] Terms => _terms ?? []; + + public static KeyValueFilterBehavior Off => new(KeyValueFilterMode.Off, null); + + /// The default behavior: collect everything, replacing values of sensitive keys. + public static KeyValueFilterBehavior DenyList => new(KeyValueFilterMode.DenyList, null); + + public static KeyValueFilterBehavior DenyListWith(params string[] extraDenyTerms) => + new(KeyValueFilterMode.DenyList, extraDenyTerms); + + public static KeyValueFilterBehavior AllowList(params string[] allowedTerms) => + new(KeyValueFilterMode.AllowList, allowedTerms); +} diff --git a/src/Sentry/Internal/DataCollection/SensitiveDataFilter.cs b/src/Sentry/Internal/DataCollection/SensitiveDataFilter.cs new file mode 100644 index 0000000000..fd40ffc669 --- /dev/null +++ b/src/Sentry/Internal/DataCollection/SensitiveDataFilter.cs @@ -0,0 +1,148 @@ +namespace Sentry.Internal.DataCollection; + +/// +/// Filters automatically collected key-value data (HTTP headers, cookies, URL query parameters) so that values of +/// sensitive keys are replaced with "[Filtered]" before being sent to Sentry, per the DataCollection spec: +/// https://develop.sentry.dev/sdk/foundations/client/data-collection/ +/// Key names are always preserved; only values are replaced. Only applies to automatically collected data — +/// data explicitly set by the user is never filtered. +/// +internal static class SensitiveDataFilter +{ + internal const string FilteredValue = PiiExtensions.RedactedText; + + /// + /// Case-insensitive substrings identifying sensitive key names, from the canonical denylist in the spec, + /// plus "set-cookie"/"cookie" so cookie headers are treated as sensitive wherever individual cookie + /// pairs cannot be extracted. + /// + internal static readonly string[] SensitiveKeyTerms = + [ + "auth", + "token", + "secret", + "session", + "password", + "passwd", + "pwd", + "key", + "jwt", + "bearer", + "sso", + "saml", + "csrf", + "xsrf", + "credentials", + "sid", + "identity", + "set-cookie", + "cookie", + ]; + + /// + /// Extra substrings matched only against individual Cookie / Set-Cookie names (not header names), covering + /// common session secrets that do not match (e.g. "connect.sid") without + /// false positives on arbitrary HTTP headers. Cookie-only terms already implied by a + /// match (e.g. "oauth", "id_token") are omitted. + /// + internal static readonly string[] SensitiveCookieNameTerms = + [ + // Express / Connect default session cookie + ".sid", + // Opaque session ids (PHPSESSID, ASPSESSIONID*, *sessid*, ...) + "sessid", + // "Remember me" tokens + "remember", + // OIDC / OAuth auxiliary ("oauth*" is covered by "auth") + "oidc", + "pkce", + "nonce", + // RFC 6265bis high-security cookie name prefixes + "__secure-", + "__host-", + // Load balancer / CDN sticky-session cookies (opaque routing tokens) + "awsalb", + "awselb", + "akamai", + // BaaS / IdP session cookies (names often omit "session") + "__stripe", + "cognito", + "firebase", + "supabase", + "sb-", + // Step-up / MFA cookies + "mfa", + "2fa", + ]; + + /// + /// Key-name substrings that identify end users (IP addresses, forwarding chains). Not filtered by default; + /// used to extend deny lists when replicating SendDefaultPii = false behavior (GDPR terms per the spec). + /// + internal static readonly string[] PiiKeyTerms = ["forwarded", "-ip", "remote-", "via", "-user"]; + + internal static bool IsSensitiveKey(string key) => MatchesAny(key, SensitiveKeyTerms); + + /// + /// Filters a single value according to . Returns the value unchanged, or + /// in its place. Must not be called in mode + /// (in off mode nothing should be collected at all). + /// + internal static string FilterValue(string key, string value, KeyValueFilterBehavior behavior, + string[]? additionalDenyTerms = null) + { + Debug.Assert(behavior.Mode != KeyValueFilterMode.Off, + "FilterValue must not be called in Off mode - collect nothing instead."); + + if (MatchesAny(key, SensitiveKeyTerms) || + (additionalDenyTerms is not null && MatchesAny(key, additionalDenyTerms))) + { + return FilteredValue; + } + + return behavior.Mode switch + { + KeyValueFilterMode.DenyList => MatchesAny(key, behavior.Terms) ? FilteredValue : value, + KeyValueFilterMode.AllowList => MatchesAny(key, behavior.Terms) ? value : FilteredValue, + _ => value + }; + } + + /// + /// Filters key-value data according to . Key names are always preserved; values are + /// either kept or replaced with . In mode an + /// empty dictionary is returned. are extra sensitive terms applied in + /// every mode, beyond the built-in denylist (e.g. when filtering cookies). + /// + internal static Dictionary FilterKeyValueData( + IEnumerable> data, + KeyValueFilterBehavior behavior, + string[]? additionalDenyTerms = null) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (behavior.Mode == KeyValueFilterMode.Off) + { + return result; + } + + foreach (var (key, value) in data) + { + result[key] = FilterValue(key, value, behavior, additionalDenyTerms); + } + + return result; + } + + private static bool MatchesAny(string key, string[] terms) + { + foreach (var term in terms) + { + if (key.Contains(term, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } +} diff --git a/test/Sentry.Tests/Internals/DataCollection/SensitiveDataFilterTests.cs b/test/Sentry.Tests/Internals/DataCollection/SensitiveDataFilterTests.cs new file mode 100644 index 0000000000..35e2fb65be --- /dev/null +++ b/test/Sentry.Tests/Internals/DataCollection/SensitiveDataFilterTests.cs @@ -0,0 +1,254 @@ +using Sentry.Internal.DataCollection; + +namespace Sentry.Tests.Internals.DataCollection; + +public class SensitiveDataFilterTests +{ + [Theory] + [InlineData("auth")] + [InlineData("token")] + [InlineData("secret")] + [InlineData("session")] + [InlineData("password")] + [InlineData("passwd")] + [InlineData("pwd")] + [InlineData("key")] + [InlineData("jwt")] + [InlineData("bearer")] + [InlineData("sso")] + [InlineData("saml")] + [InlineData("csrf")] + [InlineData("xsrf")] + [InlineData("credentials")] + [InlineData("sid")] + [InlineData("identity")] + [InlineData("set-cookie")] + [InlineData("cookie")] + public void IsSensitiveKey_CanonicalDenylistTerm_ReturnsTrue(string term) + { + SensitiveDataFilter.IsSensitiveKey(term).Should().BeTrue(); + } + + [Theory] + [InlineData("Authorization")] // contains "auth" + [InlineData("X-Api-Key")] // contains "key" + [InlineData("PHPSESSID")] // contains "sid" + [InlineData("X-CSRF-TOKEN")] + [InlineData("Set-Cookie")] + public void IsSensitiveKey_KeyContainingTermInAnyCase_ReturnsTrue(string key) + { + SensitiveDataFilter.IsSensitiveKey(key).Should().BeTrue(); + } + + [Theory] + [InlineData("User-Agent")] + [InlineData("Accept")] + [InlineData("Content-Type")] + [InlineData("traceparent")] + public void IsSensitiveKey_BenignKey_ReturnsFalse(string key) + { + SensitiveDataFilter.IsSensitiveKey(key).Should().BeFalse(); + } + + [Fact] + public void FilterKeyValueData_OffMode_ReturnsEmpty() + { + // Arrange + var data = new Dictionary + { + ["User-Agent"] = "TestAgent", + ["Authorization"] = "Bearer 123" + }; + + // Act + var result = SensitiveDataFilter.FilterKeyValueData(data, KeyValueFilterBehavior.Off); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public void FilterKeyValueData_DefaultBehavior_IsOff() + { + // The default struct value must be the most conservative mode + default(KeyValueFilterBehavior).Mode.Should().Be(KeyValueFilterMode.Off); + default(KeyValueFilterBehavior).Terms.Should().BeEmpty(); + } + + [Fact] + public void FilterKeyValueData_DenyListMode_FiltersSensitiveValuesAndPreservesKeys() + { + // Arrange + var data = new Dictionary + { + ["User-Agent"] = "TestAgent", + ["Authorization"] = "Bearer 123", + ["X-API-KEY"] = "abc123" + }; + + // Act + var result = SensitiveDataFilter.FilterKeyValueData(data, KeyValueFilterBehavior.DenyList); + + // Assert + result.Should().BeEquivalentTo(new Dictionary + { + ["User-Agent"] = "TestAgent", + ["Authorization"] = "[Filtered]", + ["X-API-KEY"] = "[Filtered]" + }); + } + + [Fact] + public void FilterKeyValueData_DenyListModeWithExtraTerms_FiltersExtraTerms() + { + // Arrange + var data = new Dictionary + { + ["X-Forwarded-For"] = "203.0.113.7", + ["Accept"] = "application/json" + }; + + // Act + var result = SensitiveDataFilter.FilterKeyValueData(data, KeyValueFilterBehavior.DenyListWith("forwarded")); + + // Assert + result["X-Forwarded-For"].Should().Be("[Filtered]"); + result["Accept"].Should().Be("application/json"); + } + + [Fact] + public void FilterKeyValueData_AllowListMode_OnlyAllowedKeysKeepValues() + { + // Arrange + var data = new Dictionary + { + ["User-Agent"] = "TestAgent", + ["Accept"] = "application/json", + ["X-Custom"] = "value" + }; + + // Act + var result = SensitiveDataFilter.FilterKeyValueData(data, KeyValueFilterBehavior.AllowList("user-agent")); + + // Assert + result.Should().BeEquivalentTo(new Dictionary + { + ["User-Agent"] = "TestAgent", + ["Accept"] = "[Filtered]", + ["X-Custom"] = "[Filtered]" + }); + } + + [Fact] + public void FilterKeyValueData_AllowListMode_BuiltInDenylistWins() + { + // Arrange: allow-listing a sensitive key must not expose its value + var data = new Dictionary + { + ["Authorization"] = "Bearer 123" + }; + + // Act + var result = SensitiveDataFilter.FilterKeyValueData(data, KeyValueFilterBehavior.AllowList("authorization")); + + // Assert + result["Authorization"].Should().Be("[Filtered]"); + } + + [Fact] + public void FilterKeyValueData_AllowListMode_MatchesSubstrings() + { + // Arrange + var data = new Dictionary + { + ["X-Tenant-Id"] = "acme", + ["X-Request-Id"] = "42" + }; + + // Act + var result = SensitiveDataFilter.FilterKeyValueData(data, KeyValueFilterBehavior.AllowList("tenant")); + + // Assert + result["X-Tenant-Id"].Should().Be("acme"); + result["X-Request-Id"].Should().Be("[Filtered]"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void FilterKeyValueData_AdditionalDenyTerms_AppliedInEveryMode(bool useAllowList) + { + // Arrange: "remember" is a cookie-name term, not part of the built-in denylist + var data = new Dictionary + { + ["remember_me"] = "yes" + }; + var behavior = useAllowList + ? KeyValueFilterBehavior.AllowList("remember_me") + : KeyValueFilterBehavior.DenyList; + + // Act + var result = SensitiveDataFilter.FilterKeyValueData( + data, behavior, SensitiveDataFilter.SensitiveCookieNameTerms); + + // Assert + result["remember_me"].Should().Be("[Filtered]"); + } + + [Theory] + [InlineData("connect.sid")] + [InlineData("PHPSESSID")] + [InlineData("remember_token")] + [InlineData("__Secure-next-auth.session-token")] + [InlineData("__Host-csrf")] + [InlineData("AWSALB")] + [InlineData("__stripe_mid")] + [InlineData("CognitoIdentityServiceProvider.foo")] + [InlineData("sb-access-token")] + [InlineData("mfa_pending")] + public void FilterKeyValueData_CommonSessionCookieNames_FilteredWithCookieTerms(string cookieName) + { + // Arrange + var data = new Dictionary { [cookieName] = "value" }; + + // Act + var result = SensitiveDataFilter.FilterKeyValueData( + data, KeyValueFilterBehavior.DenyList, SensitiveDataFilter.SensitiveCookieNameTerms); + + // Assert + result[cookieName].Should().Be("[Filtered]"); + } + + [Fact] + public void FilterValue_BenignKeyDenyListMode_ReturnsValueUnchanged() + { + SensitiveDataFilter.FilterValue("Accept", "text/html", KeyValueFilterBehavior.DenyList) + .Should().Be("text/html"); + } + + [Theory] + [InlineData("X-Forwarded-For")] + [InlineData("Client-IP")] + [InlineData("Remote-Addr")] + [InlineData("Via")] + [InlineData("X-Real-User")] + public void FilterValue_PiiKeyTermsAsDenyTerms_Filtered(string key) + { + // The GDPR terms are not filtered by default but must work as extra deny terms (SendDefaultPii=false bridge) + SensitiveDataFilter.FilterValue(key, "value", KeyValueFilterBehavior.DenyList, SensitiveDataFilter.PiiKeyTerms) + .Should().Be("[Filtered]"); + } + + [Fact] + public void FilterValue_PiiKeyTermsNotPassed_NotFilteredByDefault() + { + SensitiveDataFilter.FilterValue("X-Forwarded-For", "203.0.113.7", KeyValueFilterBehavior.DenyList) + .Should().Be("203.0.113.7"); + } + + [Fact] + public void FilterKeyValueData_EmptyData_ReturnsEmpty() + { + SensitiveDataFilter.FilterKeyValueData([], KeyValueFilterBehavior.DenyList).Should().BeEmpty(); + } +}