/* * Copyright (c) 2025 Proton AG * * This file is part of ProtonVPN. * * ProtonVPN is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * ProtonVPN is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with ProtonVPN. If not, see . */ using FluentAssertions; using NSubstitute; using ProtonVPN.IssueReporting.DataScrubbing; using ProtonVPN.Logging.Contracts; using Sentry.Extensibility; using Sentry.Protocol.Envelopes; namespace ProtonVPN.IssueReporting.Tests; [TestClass] public class SentryScrubbingIntegrationTests { private const string FAKE_DSN = "https://fakekey@fake-host.ingest.sentry.io/1234567"; private SentryEvent? _capturedEvent; private ManualResetEventSlim _capturedSignal = null!; private ILogger _mockLogger = null!; [TestInitialize] public void TestInitialize() { _capturedSignal = new ManualResetEventSlim(false); _capturedEvent = null; _mockLogger = Substitute.For(); _mockLogger.GetRecentLogs().Returns(new List { "[2025-01-30 10:00:00] User testuser@protonvpn.com connected", @"[2025-01-30 10:01:00] Config at C:\Users\JohnDoe\AppData\Local\ProtonVPN\config.json" }); SentrySdk.Close(); } [TestCleanup] public void TestCleanup() { SentrySdk.Close(); _capturedSignal.Dispose(); } [TestMethod] public void IntegrationTest_MessageWithEmailAndPath_ShouldBeScrubbed() { InitializeSentry(); SentrySdk.CaptureMessage("User admin@proton.me accessed C:\\Users\\AdminUser\\Documents\\secret.txt"); WaitForCapture(); _capturedEvent.Should().NotBeNull(); _capturedEvent!.Message!.Message.Should().NotContain("admin@proton.me"); _capturedEvent.Message.Message.Should().Contain("[REDACTED_EMAIL]"); _capturedEvent.Message.Message.Should().NotContain("AdminUser"); _capturedEvent.Message.Message.Should().Contain(@"C:\Users\[REDACTED_USER]"); } [TestMethod] public void IntegrationTest_TagsAndExtras_ShouldBeScrubbed() { InitializeSentry(); SentrySdk.ConfigureScope(scope => { scope.SetTag("user_email", "sensitive@protonmail.com"); scope.SetExtra("file_path", @"C:\Users\TestUser\Desktop\data.txt"); scope.SetExtra("safe_value", 42); }); SentrySdk.CaptureMessage("Test event"); WaitForCapture(); _capturedEvent.Should().NotBeNull(); _capturedEvent!.Tags!["user_email"].Should().Be("[REDACTED_EMAIL]"); _capturedEvent.Tags["user_email"].Should().NotContain("sensitive@protonmail.com"); string filePath = _capturedEvent.Extra["file_path"] as string; filePath.Should().Contain("[REDACTED_USER]"); filePath.Should().NotContain("TestUser"); _capturedEvent.Extra["safe_value"].Should().Be(42); } [TestMethod] public void IntegrationTest_LogsInExtras_ShouldBeScrubbed() { InitializeSentry(); SentrySdk.CaptureMessage("Test - logs should be scrubbed"); WaitForCapture(); _capturedEvent.Should().NotBeNull(); _capturedEvent!.Extra.Should().ContainKey("logs"); string logs = _capturedEvent.Extra["logs"] as string; logs.Should().NotBeNull(); logs!.Should().NotContain("testuser@protonvpn.com"); logs.Should().Contain("[REDACTED_EMAIL]"); logs.Should().NotContain("JohnDoe"); logs.Should().Contain("[REDACTED_USER]"); logs.Should().Contain(@"C:\Users\[REDACTED_USER]\AppData\Local"); } private void InitializeSentry() { SentryOptions options = new() { Dsn = FAKE_DSN, Debug = false, AttachStacktrace = false, AutoSessionTracking = false, SampleRate = 1.0f, Transport = new NoOpTransport() }; options.AddEventProcessor(new SentryEventScrubberProcessor()); options.SetBeforeSend(e => { SentryDataScrubber scrubber = new(); e.SetTag("ProcessName", "TestProcess"); e.User.Id = "test-device-id"; string logs = string.Join("\n", _mockLogger.GetRecentLogs()); e.SetExtra("logs", scrubber.Scrub(logs)); _capturedEvent = e; _capturedSignal.Set(); return e; }); SentrySdk.Init(options); } private void WaitForCapture() { _capturedSignal.Wait(2000).Should().BeTrue("BeforeSend should be called"); } private sealed class NoOpTransport : ITransport { public Task SendEnvelopeAsync(Envelope envelope, CancellationToken cancellationToken = default) { return Task.CompletedTask; } } }