diff --git a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs index aa53e915..0b3f4a0e 100644 --- a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs @@ -11,6 +11,7 @@ using Orbit.Api.Authorization; using Orbit.Api.Idempotency; using Orbit.Api.OAuth; +using Orbit.Api.Observability; using Orbit.Application.Behaviors; using Orbit.Application.Common; using Orbit.Application.Gamification.Services; @@ -259,28 +260,9 @@ public static WebApplicationBuilder AddOrbitObservability(this WebApplicationBui options.SendDefaultPii = false; options.AddExceptionFilterForType(); options.AddExceptionFilterForType(); - options.SetBeforeSend(ScrubSensitiveData); + options.SetBeforeSend(SentryEventScrubber.Scrub); }); return builder; } - - private static SentryEvent ScrubSensitiveData(SentryEvent sentryEvent, SentryHint hint) - { - if (sentryEvent.User is { } user) - { - user.Email = null; - user.Username = null; - user.IpAddress = null; - } - - if (sentryEvent.Request is { } request) - { - request.Headers?.Clear(); - request.Cookies = null; - request.Data = null; - } - - return sentryEvent; - } } diff --git a/src/Orbit.Api/Observability/SentryEventScrubber.cs b/src/Orbit.Api/Observability/SentryEventScrubber.cs new file mode 100644 index 00000000..6616b188 --- /dev/null +++ b/src/Orbit.Api/Observability/SentryEventScrubber.cs @@ -0,0 +1,76 @@ +using Sentry; +using Sentry.Protocol; + +namespace Orbit.Api.Observability; + +internal static class SentryEventScrubber +{ + private const string RedactedMarker = "[Filtered]"; + + private static readonly string[] SensitiveKeyFragments = + [ + "password", "passwd", "secret", "token", "apikey", "api_key", + "authorization", "auth", "cookie", "session", "credential", + "email", "phone", "ssn", "card", "cvv", "otp", "signature", + "bearer", "jwt", "refresh", + ]; + + public static SentryEvent Scrub(SentryEvent sentryEvent, SentryHint hint) + { + ScrubUser(sentryEvent); + ScrubRequest(sentryEvent); + ScrubResponse(sentryEvent); + ScrubExtra(sentryEvent); + return sentryEvent; + } + + private static void ScrubUser(SentryEvent sentryEvent) + { + if (sentryEvent.User is not { } user) + return; + + user.Email = null; + user.Username = null; + user.IpAddress = null; + user.Other.Clear(); + } + + private static void ScrubRequest(SentryEvent sentryEvent) + { + if (sentryEvent.Request is not { } request) + return; + + request.Headers.Clear(); + request.Cookies = null; + request.Data = null; + request.QueryString = null; + } + + private static void ScrubResponse(SentryEvent sentryEvent) + { + foreach (var context in sentryEvent.Contexts.Values) + { + if (context is not Response response) + continue; + + response.Data = null; + response.Cookies = null; + response.Headers.Clear(); + } + } + + private static void ScrubExtra(SentryEvent sentryEvent) + { + var sensitiveKeys = sentryEvent.Extra + .Where(entry => entry.Value is not null && IsSensitiveKey(entry.Key)) + .Select(entry => entry.Key) + .ToList(); + + foreach (var key in sensitiveKeys) + sentryEvent.SetExtra(key, RedactedMarker); + } + + private static bool IsSensitiveKey(string key) => + SensitiveKeyFragments.Any(fragment => + key.Contains(fragment, StringComparison.OrdinalIgnoreCase)); +} diff --git a/tests/Orbit.Infrastructure.Tests/Observability/SentryEventScrubberTests.cs b/tests/Orbit.Infrastructure.Tests/Observability/SentryEventScrubberTests.cs new file mode 100644 index 00000000..9a75b4d5 --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Observability/SentryEventScrubberTests.cs @@ -0,0 +1,105 @@ +using FluentAssertions; +using Orbit.Api.Observability; +using Sentry; +using Sentry.Protocol; + +namespace Orbit.Infrastructure.Tests.Observability; + +public class SentryEventScrubberTests +{ + [Fact] + public void Scrub_RemovesResponseBodyHeadersAndCookies_WhenResponseContextPresent() + { + var sentryEvent = new SentryEvent(); + var response = sentryEvent.Contexts.Response; + response.Data = "{\"token\":\"live-secret\"}"; + response.Cookies = "session=abc"; + response.Headers["Set-Cookie"] = "session=abc"; + + SentryEventScrubber.Scrub(sentryEvent, new SentryHint()); + + var scrubbed = sentryEvent.Contexts.Response; + scrubbed.Data.Should().BeNull(); + scrubbed.Cookies.Should().BeNull(); + scrubbed.Headers.Should().BeEmpty(); + } + + [Fact] + public void Scrub_DoesNotAddResponseContext_WhenNonePresent() + { + var sentryEvent = new SentryEvent(); + + SentryEventScrubber.Scrub(sentryEvent, new SentryHint()); + + sentryEvent.Contexts.ContainsKey(Response.Type).Should().BeFalse(); + } + + [Fact] + public void Scrub_ClearsUserPii_IncludingOtherDictionary() + { + var sentryEvent = new SentryEvent + { + User = new SentryUser + { + Email = "alice@example.com", + Username = "alice", + IpAddress = "203.0.113.7", + }, + }; + sentryEvent.User.Other["ssn"] = "123-45-6789"; + + SentryEventScrubber.Scrub(sentryEvent, new SentryHint()); + + sentryEvent.User.Email.Should().BeNull(); + sentryEvent.User.Username.Should().BeNull(); + sentryEvent.User.IpAddress.Should().BeNull(); + sentryEvent.User.Other.Should().BeEmpty(); + } + + [Fact] + public void Scrub_ClearsRequestBodyCookiesHeadersAndQueryString() + { + var sentryEvent = new SentryEvent + { + Request = new SentryRequest + { + Data = "{\"password\":\"hunter2\"}", + Cookies = "session=abc", + QueryString = "token=live-secret&email=alice@example.com", + }, + }; + sentryEvent.Request.Headers["Authorization"] = "Bearer live-secret"; + + SentryEventScrubber.Scrub(sentryEvent, new SentryHint()); + + sentryEvent.Request.Data.Should().BeNull(); + sentryEvent.Request.Cookies.Should().BeNull(); + sentryEvent.Request.QueryString.Should().BeNull(); + sentryEvent.Request.Headers.Should().BeEmpty(); + } + + [Fact] + public void Scrub_RedactsKnownPiiKeysInExtra_ButKeepsBenignKeys() + { + var sentryEvent = new SentryEvent(); + sentryEvent.SetExtra("authToken", "live-secret"); + sentryEvent.SetExtra("userEmail", "alice@example.com"); + sentryEvent.SetExtra("habitCount", 7); + + SentryEventScrubber.Scrub(sentryEvent, new SentryHint()); + + sentryEvent.Extra["authToken"].Should().Be("[Filtered]"); + sentryEvent.Extra["userEmail"].Should().Be("[Filtered]"); + sentryEvent.Extra["habitCount"].Should().Be(7); + } + + [Fact] + public void Scrub_ReturnsSameEvent_WhenNoSensitiveDataPresent() + { + var sentryEvent = new SentryEvent(); + + var result = SentryEventScrubber.Scrub(sentryEvent, new SentryHint()); + + result.Should().BeSameAs(sentryEvent); + } +}