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");
+ }
}
}