diff --git a/Core/Resgrid.Framework/SentryTransactionFilter.cs b/Core/Resgrid.Framework/SentryTransactionFilter.cs new file mode 100644 index 000000000..d0e1ede9e --- /dev/null +++ b/Core/Resgrid.Framework/SentryTransactionFilter.cs @@ -0,0 +1,156 @@ +using System; +using Sentry; + +namespace Resgrid.Framework +{ + /// + /// Removes expected internet-scanner noise from Sentry while retaining application 404s. + /// + public static class SentryTransactionFilter + { + private static readonly string[] ScannerPathPrefixes = + { + "/.git", + "/.hg", + "/.svn", + "/actuator", + "/boaform", + "/cgi-bin", + "/phpmyadmin", + "/pma", + "/server-status", + "/vendor/phpunit", + "/wordpress", + "/wp-admin", + "/wp-content", + "/wp-includes", + "/wp-json" + }; + + private static readonly string[] ScannerFileNames = + { + ".env", + "appsettings.json", + "composer.json", + "composer.lock", + "web.config" + }; + + private static readonly string[] ScannerFileExtensions = + { + ".asp", + ".aspx", + ".cfm", + ".cgi", + ".jsp", + ".jspx", + ".phar", + ".php", + ".php3", + ".php4", + ".php5", + ".php7", + ".php8", + ".phtml", + ".pl" + }; + + /// + /// Returns only for a confirmed 404 whose request path matches a + /// technology or sensitive-file probe that cannot be served by Resgrid. + /// + public static SentryTransaction Filter(SentryTransaction transaction) + { + if (transaction == null || transaction.Status != SpanStatus.NotFound) + return transaction; + + var requestTarget = transaction.Request?.Url; + if (string.IsNullOrWhiteSpace(requestTarget)) + requestTarget = GetRequestTargetFromTransactionName(transaction.Name); + + return ShouldDrop(transaction.Status, requestTarget) ? null : transaction; + } + + public static bool ShouldDrop(SpanStatus? status, string requestTarget) + { + return status == SpanStatus.NotFound && IsKnownScannerPath(requestTarget); + } + + public static bool IsKnownScannerPath(string requestTarget) + { + var path = GetPath(requestTarget); + if (string.IsNullOrWhiteSpace(path)) + return false; + + foreach (var prefix in ScannerPathPrefixes) + { + if (path.Equals(prefix, StringComparison.OrdinalIgnoreCase) || + path.StartsWith(prefix + "/", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + foreach (var segment in segments) + { + foreach (var fileName in ScannerFileNames) + { + if (segment.Equals(fileName, StringComparison.OrdinalIgnoreCase)) + return true; + } + + foreach (var extension in ScannerFileExtensions) + { + if (segment.EndsWith(extension, StringComparison.OrdinalIgnoreCase)) + return true; + } + } + + return false; + } + + private static string GetRequestTargetFromTransactionName(string transactionName) + { + if (string.IsNullOrWhiteSpace(transactionName)) + return null; + + var separatorIndex = transactionName.IndexOf(' '); + return separatorIndex >= 0 && separatorIndex < transactionName.Length - 1 + ? transactionName.Substring(separatorIndex + 1) + : transactionName; + } + + private static string GetPath(string requestTarget) + { + if (string.IsNullOrWhiteSpace(requestTarget)) + return null; + + var value = requestTarget.Trim(); + if (Uri.TryCreate(value, UriKind.Absolute, out var uri) && + (uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) || + uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))) + { + value = uri.AbsolutePath; + } + else + { + var suffixIndex = value.IndexOfAny(new[] { '?', '#' }); + if (suffixIndex >= 0) + value = value.Substring(0, suffixIndex); + } + + try + { + value = Uri.UnescapeDataString(value); + } + catch (UriFormatException) + { + // Keep the original path when a scanner sends malformed escaping. + } + + value = value.Replace('\\', '/'); + return value.StartsWith('/') ? value : "/" + value; + } + } +} diff --git a/Tests/Resgrid.Tests/Framework/SentryTransactionFilterTests.cs b/Tests/Resgrid.Tests/Framework/SentryTransactionFilterTests.cs new file mode 100644 index 000000000..026e67e9e --- /dev/null +++ b/Tests/Resgrid.Tests/Framework/SentryTransactionFilterTests.cs @@ -0,0 +1,86 @@ +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Framework; +using Sentry; + +namespace Resgrid.Tests.Framework +{ + [TestFixture] + public class SentryTransactionFilterTests + { + [TestCase("https://resgrid.example/wp-login.php")] + [TestCase("https://resgrid.example/wp-login.php?redirect=/User/Home")] + [TestCase("/wp-admin/install.php")] + [TestCase("/wordpress/wp-content/plugins/example/readme.txt")] + [TestCase("/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php")] + [TestCase("/.git/config")] + [TestCase("/%2Eenv")] + [TestCase("/cgi-bin/status")] + [TestCase("/legacy/default.aspx")] + public void Known_scanner_path_is_recognized(string path) + { + // Act + var result = SentryTransactionFilter.IsKnownScannerPath(path); + + // Assert + result.Should().BeTrue(); + } + + [TestCase("/User/Home/RemovedView")] + [TestCase("/api/v4/Calls/RemovedAction")] + [TestCase("/events/RemovedWebhook")] + [TestCase("/User/WordpressSettings")] + [TestCase("/.well-known/acme-challenge/token")] + [TestCase("/Search?q=wp-login.php")] + [TestCase("/Search#wp-login.php")] + [TestCase("https://resgrid.example/Search?q=wp-login.php")] + [TestCase("https://resgrid.example/Search#wp-login.php")] + public void Plausible_resgrid_path_is_not_recognized_as_scanner_noise(string path) + { + // Act + var result = SentryTransactionFilter.IsKnownScannerPath(path); + + // Assert + result.Should().BeFalse(); + } + + [Test] + public void ShouldDrop_Scanner404_ReturnsTrue() + { + // Arrange + const string requestTarget = "https://resgrid.example/wp-login.php"; + + // Act + var result = SentryTransactionFilter.ShouldDrop(SpanStatus.NotFound, requestTarget); + + // Assert + result.Should().BeTrue(); + } + + [Test] + public void ShouldDrop_ScannerFailureOtherThan404_ReturnsFalse() + { + // Arrange + const string requestTarget = "https://resgrid.example/wp-login.php"; + + // Act + var result = SentryTransactionFilter.ShouldDrop(SpanStatus.InternalError, requestTarget); + + // Assert + result.Should().BeFalse(); + } + + [Test] + public void ShouldDrop_Resgrid404_ReturnsFalse() + { + // Arrange + const string requestTarget = "https://resgrid.example/api/v4/Calls/RemovedAction"; + + // Act + var result = SentryTransactionFilter.ShouldDrop(SpanStatus.NotFound, requestTarget); + + // Assert + result.Should().BeFalse(); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs b/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs index 2216903a7..9be4c29bd 100644 --- a/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs +++ b/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs @@ -125,9 +125,9 @@ protected override void Before_all_tests() .Returns(System.Threading.Tasks.Task.CompletedTask); } - private TwilioController BuildController() + private TestableTwilioController BuildController() { - return new TwilioController( + return new TestableTwilioController( _departmentSettingsServiceMock.Object, _numbersServiceMock.Object, _limitsServiceMock.Object, @@ -221,22 +221,28 @@ public async System.Threading.Tasks.Task should_return_generic_message_when_home [Test] public async System.Threading.Tasks.Task should_return_generic_message_when_decryption_fails() { + // Arrange var profile = new UserProfile { UserId = "user1", HomeVerificationCode = "ENC:broken", HomeVerificationCodeExpiry = DateTime.UtcNow.AddMinutes(10) }; + var failure = new CryptographicException("bad"); _userProfileServiceMock.Setup(x => x.GetProfileByUserIdAsync("user1", true)).ReturnsAsync(profile); - _encryptionServiceMock.Setup(x => x.Decrypt("ENC:broken")).Throws(new CryptographicException("bad")); + _encryptionServiceMock.Setup(x => x.Decrypt("ENC:broken")).Throws(failure); + var controller = BuildController(); - var result = await BuildController().VoiceVerification("user1", (int)ContactVerificationType.HomeNumber); + // Act + var result = await controller.VoiceVerification("user1", (int)ContactVerificationType.HomeNumber); + // Assert var content = ((ContentResult)result).Content; content.Should().Contain(""); content.Should().Contain(Uri.EscapeDataString("We couldn't complete your verification call. Please request a new code and try again. Goodbye.")); content.Should().NotContain("broken"); + controller.ReportedDecryptionFailures.Should().ContainSingle().Which.Should().BeSameAs(failure); _twilioVoiceResponseServiceMock.Verify(x => x.AppendPromptAsync(It.IsAny(), "We couldn't complete your verification call. Please request a new code and try again. Goodbye.", It.IsAny(), It.IsAny()), Times.Once); } @@ -476,5 +482,64 @@ public void voice_prompt_catalog_should_use_sentence_punctuation_for_tts_playbac TwilioVoicePromptCatalog.StatusMarked("Available").Should().Be("You have been marked as Available. Goodbye."); } + private sealed class TestableTwilioController : TwilioController + { + public TestableTwilioController( + IDepartmentSettingsService departmentSettingsService, + INumbersService numbersService, + ILimitsService limitsService, + ICallsService callsService, + IQueueService queueService, + IDepartmentsService departmentsService, + IUserProfileService userProfileService, + ITextCommandService textCommandService, + IActionLogsService actionLogsService, + IUserStateService userStateService, + ICommunicationService communicationService, + IGeoLocationProvider geoLocationProvider, + IDepartmentGroupsService departmentGroupsService, + ICustomStateService customStateService, + IUnitsService unitsService, + IUsersService usersService, + ICalendarService calendarService, + ICommunicationTestService communicationTestService, + IEncryptionService encryptionService, + ITwilioVoiceResponseService twilioVoiceResponseService, + IFeatureToggleService featureToggleService, + ITextDepartmentSwitchService textDepartmentSwitchService) + : base( + departmentSettingsService, + numbersService, + limitsService, + callsService, + queueService, + departmentsService, + userProfileService, + textCommandService, + actionLogsService, + userStateService, + communicationService, + geoLocationProvider, + departmentGroupsService, + customStateService, + unitsService, + usersService, + calendarService, + communicationTestService, + encryptionService, + twilioVoiceResponseService, + featureToggleService, + textDepartmentSwitchService) + { + } + + public List ReportedDecryptionFailures { get; } = new List(); + + protected override void ReportVoiceVerificationDecryptionFailure(CryptographicException exception) + { + ReportedDecryptionFailures.Add(exception); + } + } + } } diff --git a/Tests/Resgrid.Tests/Workers/Console/Tasks/TtsStaticPromptRefreshTaskTests.cs b/Tests/Resgrid.Tests/Workers/Console/Tasks/TtsStaticPromptRefreshTaskTests.cs index d1911c88b..14bd47cee 100644 --- a/Tests/Resgrid.Tests/Workers/Console/Tasks/TtsStaticPromptRefreshTaskTests.cs +++ b/Tests/Resgrid.Tests/Workers/Console/Tasks/TtsStaticPromptRefreshTaskTests.cs @@ -55,6 +55,7 @@ public void TearDown() [Test] public async Task process_async_should_throw_after_all_retries_exhausted() { + // Arrange var failure = new InvalidOperationException("refresh failed"); var ttsAudioService = new Mock(MockBehavior.Strict); ttsAudioService @@ -62,9 +63,10 @@ public async Task process_async_should_throw_after_all_retries_exhausted() .ThrowsAsync(failure); SetWorkerContainer(ttsAudioService.Object); - var task = new TtsStaticPromptRefreshTask(Mock.Of()); + var task = new TestableTtsStaticPromptRefreshTask(Mock.Of()); var progress = new Mock(MockBehavior.Loose); + // Act / Assert await FluentActions .Awaiting(() => task.ProcessAsync(new TtsStaticPromptRefreshCommand(1), progress.Object, CancellationToken.None)) .Should() @@ -74,11 +76,13 @@ await FluentActions ttsAudioService.Verify( x => x.RegenerateStaticPromptsAsync(It.IsAny>(), It.IsAny()), Times.Exactly(3)); + task.ReportedExceptions.Should().ContainSingle().Which.Should().BeSameAs(failure); } [Test] public async Task process_async_should_rethrow_cancellation() { + // Arrange using var cancellationTokenSource = new CancellationTokenSource(); var ttsAudioService = new Mock(MockBehavior.Strict); ttsAudioService @@ -86,9 +90,10 @@ public async Task process_async_should_rethrow_cancellation() .ThrowsAsync(new OperationCanceledException(cancellationTokenSource.Token)); SetWorkerContainer(ttsAudioService.Object); - var task = new TtsStaticPromptRefreshTask(Mock.Of()); + var task = new TestableTtsStaticPromptRefreshTask(Mock.Of()); var progress = new Mock(MockBehavior.Loose); + // Act / Assert await FluentActions .Awaiting(() => task.ProcessAsync(new TtsStaticPromptRefreshCommand(1), progress.Object, cancellationTokenSource.Token)) .Should() @@ -97,11 +102,13 @@ await FluentActions ttsAudioService.Verify( x => x.RegenerateStaticPromptsAsync(It.IsAny>(), It.IsAny()), Times.Once); + task.ReportedExceptions.Should().BeEmpty(); } [Test] public async Task process_async_should_succeed_on_retry_after_initial_failure() { + // Arrange var ttsAudioService = new Mock(MockBehavior.Strict); var callCount = 0; ttsAudioService @@ -116,9 +123,10 @@ public async Task process_async_should_succeed_on_retry_after_initial_failure() }); SetWorkerContainer(ttsAudioService.Object); - var task = new TtsStaticPromptRefreshTask(Mock.Of()); + var task = new TestableTtsStaticPromptRefreshTask(Mock.Of()); var progress = new Mock(MockBehavior.Loose); + // Act / Assert await FluentActions .Awaiting(() => task.ProcessAsync(new TtsStaticPromptRefreshCommand(1), progress.Object, CancellationToken.None)) .Should() @@ -127,6 +135,7 @@ await FluentActions ttsAudioService.Verify( x => x.RegenerateStaticPromptsAsync(It.IsAny>(), It.IsAny()), Times.Exactly(2)); + task.ReportedExceptions.Should().BeEmpty(); } private void SetWorkerContainer(ITtsAudioService ttsAudioService) @@ -139,5 +148,20 @@ private void SetWorkerContainer(ITtsAudioService ttsAudioService) WorkerBootstrapperContainerField.SetValue(null, _testWorkerContainer); } + + private sealed class TestableTtsStaticPromptRefreshTask : TtsStaticPromptRefreshTask + { + public TestableTtsStaticPromptRefreshTask(ILogger logger) + : base(logger) + { + } + + public List ReportedExceptions { get; } = new List(); + + protected override void ReportTerminalFailure(Exception exception) + { + ReportedExceptions.Add(exception); + } + } } } diff --git a/Web/Resgrid.Web.Eventing/Program.cs b/Web/Resgrid.Web.Eventing/Program.cs index 85dd75b6b..17d65bdc7 100644 --- a/Web/Resgrid.Web.Eventing/Program.cs +++ b/Web/Resgrid.Web.Eventing/Program.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Resgrid.Config; +using Resgrid.Framework; using Sentry.Profiling; namespace Resgrid.Web.Eventing @@ -56,6 +57,7 @@ public static IHostBuilder CreateHostBuilder(string[] args) => options.AutoSessionTracking = true; options.Release = Assembly.GetEntryAssembly().GetName().Version.ToString(); options.ProfilesSampleRate = ExternalErrorConfig.SentryProfilingSampleRate; + options.SetBeforeSendTransaction(SentryTransactionFilter.Filter); // Requires NuGet package: Sentry.Profiling // Note: By default, the profiler is initialized asynchronously. This can be tuned by passing a desired initialization timeout to the constructor. diff --git a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs index e459ce818..d5e1d05c4 100644 --- a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs +++ b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs @@ -921,7 +921,7 @@ public async Task VoiceVerification(string userId, int contactType } catch (CryptographicException ex) { - Framework.Logging.LogException(ex); + ReportVoiceVerificationDecryptionFailure(ex); return await GetVoiceVerificationErrorResult(); } @@ -1021,6 +1021,13 @@ public async Task InboundVoice([FromQuery] TwilioGatherRequest req return CreateVoiceContentResult(response); } + protected virtual void ReportVoiceVerificationDecryptionFailure(CryptographicException exception) + { + Framework.Logging.LogException( + exception, + "Twilio VoiceVerification failed to decrypt a verification code"); + } + private async Task GetVoiceVerificationErrorResult() { var response = new VoiceResponse(); diff --git a/Web/Resgrid.Web.Services/Program.cs b/Web/Resgrid.Web.Services/Program.cs index ef2b40df9..a15612837 100644 --- a/Web/Resgrid.Web.Services/Program.cs +++ b/Web/Resgrid.Web.Services/Program.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Resgrid.Config; +using Resgrid.Framework; using Sentry.Profiling; namespace Resgrid.Web.ServicesCore @@ -57,6 +58,7 @@ public static IHostBuilder CreateHostBuilder(string[] args) => options.Environment = ExternalErrorConfig.Environment; options.Release = Assembly.GetEntryAssembly().GetName().Version.ToString(); options.ProfilesSampleRate = ExternalErrorConfig.SentryProfilingSampleRate; + options.SetBeforeSendTransaction(SentryTransactionFilter.Filter); // Requires NuGet package: Sentry.Profiling // Note: By default, the profiler is initialized asynchronously. This can be tuned by passing a desired initialization timeout to the constructor. diff --git a/Web/Resgrid.Web/Program.cs b/Web/Resgrid.Web/Program.cs index e44824324..889743ae5 100644 --- a/Web/Resgrid.Web/Program.cs +++ b/Web/Resgrid.Web/Program.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Resgrid.Config; +using Resgrid.Framework; using Sentry.Profiling; namespace Resgrid.Web { @@ -56,6 +57,7 @@ public static IHostBuilder CreateHostBuilder(string[] args) => options.AutoSessionTracking = true; options.Release = Assembly.GetEntryAssembly().GetName().Version.ToString(); options.ProfilesSampleRate = ExternalErrorConfig.SentryProfilingSampleRate; + options.SetBeforeSendTransaction(SentryTransactionFilter.Filter); // Requires NuGet package: Sentry.Profiling // Note: By default, the profiler is initialized asynchronously. This can be tuned by passing a desired initialization timeout to the constructor. diff --git a/Workers/Resgrid.Workers.Console/Tasks/TtsStaticPromptRefreshTask.cs b/Workers/Resgrid.Workers.Console/Tasks/TtsStaticPromptRefreshTask.cs index 9121155ec..a7682cc01 100644 --- a/Workers/Resgrid.Workers.Console/Tasks/TtsStaticPromptRefreshTask.cs +++ b/Workers/Resgrid.Workers.Console/Tasks/TtsStaticPromptRefreshTask.cs @@ -73,8 +73,6 @@ public async Task ProcessAsync(TtsStaticPromptRefreshCommand command, IQuidjiboP } } - Resgrid.Framework.Logging.LogException(lastException); - _logger.LogError(lastException, "TtsStaticPromptRefresh::Failed to refresh static prompts after {MaxRetries} attempts", MaxRetries); throw lastException!; } catch (OperationCanceledException) @@ -83,10 +81,19 @@ public async Task ProcessAsync(TtsStaticPromptRefreshCommand command, IQuidjiboP } catch (Exception ex) { - Resgrid.Framework.Logging.LogException(ex); - _logger.LogError(ex, "TtsStaticPromptRefresh::Failed to refresh static prompts"); + ReportTerminalFailure(ex); throw; } } + + protected virtual void ReportTerminalFailure(Exception exception) + { + Resgrid.Framework.Logging.LogException( + exception, + "TtsStaticPromptRefresh::Failed to refresh static prompts"); + _logger.LogError( + exception, + "TtsStaticPromptRefresh::Failed to refresh static prompts"); + } } }