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
156 changes: 156 additions & 0 deletions Core/Resgrid.Framework/SentryTransactionFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
using System;
using Sentry;

namespace Resgrid.Framework
{
/// <summary>
/// Removes expected internet-scanner noise from Sentry while retaining application 404s.
/// </summary>
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"
};

/// <summary>
/// Returns <see langword="null"/> only for a confirmed 404 whose request path matches a
/// technology or sensitive-file probe that cannot be served by Resgrid.
/// </summary>
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;
}
}
}
86 changes: 86 additions & 0 deletions Tests/Resgrid.Tests/Framework/SentryTransactionFilterTests.cs
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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("<Play>");
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<VoiceResponse>(), "We couldn't complete your verification call. Please request a new code and try again. Goodbye.", It.IsAny<CancellationToken>(), It.IsAny<string>()), Times.Once);
}

Expand Down Expand Up @@ -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<CryptographicException> ReportedDecryptionFailures { get; } = new List<CryptographicException>();

protected override void ReportVoiceVerificationDecryptionFailure(CryptographicException exception)
{
ReportedDecryptionFailures.Add(exception);
}
}

}
}
Loading
Loading