Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 2 additions & 20 deletions src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -259,28 +260,9 @@ public static WebApplicationBuilder AddOrbitObservability(this WebApplicationBui
options.SendDefaultPii = false;
options.AddExceptionFilterForType<FluentValidation.ValidationException>();
options.AddExceptionFilterForType<OperationCanceledException>();
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;
}
}
76 changes: 76 additions & 0 deletions src/Orbit.Api/Observability/SentryEventScrubber.cs
Original file line number Diff line number Diff line change
@@ -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));
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading