diff --git a/Rasp.Core.Tests/Guard/PathTraversalGuardTests.cs b/Rasp.Core.Tests/Guard/PathTraversalGuardTests.cs new file mode 100644 index 0000000..e6c90c5 --- /dev/null +++ b/Rasp.Core.Tests/Guard/PathTraversalGuardTests.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Rasp.Core.Abstractions; +using Rasp.Core.Configuration; +using Rasp.Core.Engine; +using Rasp.Core.Exceptions; +using Rasp.Core.Guard; +using Rasp.Core.Infrastructure; +using Xunit; + +namespace Rasp.Core.Tests.Guard; + +/// +/// Unit tests for in isolation (no runtime patching). +/// Construction pattern matches . +/// +public class PathTraversalGuardTests +{ + private sealed class RecordingRaspMetrics : IRaspMetrics + { + public int InspectionCount { get; private set; } + public int ThreatCount { get; private set; } + public bool? LastBlocked { get; private set; } + + public void RecordInspection(string layer, double durationMs) + { + InspectionCount++; + } + + public void ReportThreat(string layer, string threatType, bool blocked) + { + ThreatCount++; + LastBlocked = blocked; + } + } + + private static string AllowedRoot() => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? @"C:\App\Data" : "/app/data"; + + private static string OutsidePath() => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? @"C:\Windows\System32\sam" : "/etc/passwd"; + + private static PathTraversalGuard CreateGuard( + RaspAlertBus bus, + RecordingRaspMetrics metrics, + bool blockOnRuntimePatchingDetection, + IReadOnlyList? allowedRoots = null) + { + var options = Options.Create(new RaspOptions + { + BlockOnRuntimePatchingDetection = blockOnRuntimePatchingDetection, + AllowedFileRoots = new List(allowedRoots ?? new[] { AllowedRoot() }) + }); + + return new PathTraversalGuard( + new PathTraversalDetectionEngine(options), + bus, + metrics, + options, + NullLogger.Instance); + } + + private static bool TryReadAlert(RaspAlertBus bus, out RaspAlertEvent alert, int timeoutMs = 500) + { + using var cts = new CancellationTokenSource(timeoutMs); + var enumerator = bus.ReadAlertsAsync(cts.Token).GetAsyncEnumerator(cts.Token); + try + { + if (enumerator.MoveNextAsync().AsTask().GetAwaiter().GetResult()) + { + alert = enumerator.Current; + return true; + } + } + catch (OperationCanceledException) + { + // No alert within timeout. + } + finally + { + enumerator.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + + alert = default; + return false; + } + + [Fact] + public void AnalyzePath_ThreatDetected_AuditMode_PushesAlertWithoutThrowing() + { + var bus = new RaspAlertBus(); + var metrics = new RecordingRaspMetrics(); + var guard = CreateGuard(bus, metrics, blockOnRuntimePatchingDetection: false); + + var act = () => guard.AnalyzePath(OutsidePath(), "File Access"); + + act.Should().NotThrow(); + TryReadAlert(bus, out var alert).Should().BeTrue(); + alert.ThreatType.Should().Be("Path Traversal"); + alert.Context.Should().Contain("File Access"); + metrics.InspectionCount.Should().Be(1); + metrics.ThreatCount.Should().Be(1); + metrics.LastBlocked.Should().BeFalse(); + } + + [Fact] + public void AnalyzePath_ThreatDetected_BlockMode_ThrowsAfterPushingAlert() + { + var bus = new RaspAlertBus(); + var metrics = new RecordingRaspMetrics(); + var guard = CreateGuard(bus, metrics, blockOnRuntimePatchingDetection: true); + + var act = () => guard.AnalyzePath(OutsidePath(), "File Access"); + + act.Should().Throw() + .Which.ThreatType.Should().Be("Path Traversal"); + + // Alert must still be published before the throw. + TryReadAlert(bus, out var alert).Should().BeTrue(); + alert.ThreatType.Should().Be("Path Traversal"); + metrics.InspectionCount.Should().Be(1); + metrics.ThreatCount.Should().Be(1); + metrics.LastBlocked.Should().BeTrue(); + } + + [Fact] + public void AnalyzePath_NoThreat_DoesNotAlertOrThrow() + { + var bus = new RaspAlertBus(); + var metrics = new RecordingRaspMetrics(); + var guard = CreateGuard(bus, metrics, blockOnRuntimePatchingDetection: true); + + var safePath = Path.Combine(AllowedRoot(), "file.txt"); + var act = () => guard.AnalyzePath(safePath, "File Access"); + + act.Should().NotThrow(); + TryReadAlert(bus, out _, timeoutMs: 200).Should().BeFalse(); + metrics.InspectionCount.Should().Be(1); + metrics.ThreatCount.Should().Be(0); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void AnalyzePath_NullOrWhitespace_IsNoOp(string? path) + { + var bus = new RaspAlertBus(); + var metrics = new RecordingRaspMetrics(); + var guard = CreateGuard(bus, metrics, blockOnRuntimePatchingDetection: true); + + var act = () => guard.AnalyzePath(path!, "File Access"); + + act.Should().NotThrow(); + TryReadAlert(bus, out _, timeoutMs: 200).Should().BeFalse(); + // Guard returns before engine/metrics when path is empty. + metrics.InspectionCount.Should().Be(0); + metrics.ThreatCount.Should().Be(0); + } +} \ No newline at end of file