Skip to content
Open
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
166 changes: 166 additions & 0 deletions Rasp.Core.Tests/Guard/PathTraversalGuardTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Unit tests for <see cref="PathTraversalGuard"/> in isolation (no runtime patching).
/// Construction pattern matches <see cref="SqlSinkGuardTaintTests"/>.
/// </summary>
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<string>? allowedRoots = null)
{
var options = Options.Create(new RaspOptions
{
BlockOnRuntimePatchingDetection = blockOnRuntimePatchingDetection,
AllowedFileRoots = new List<string>(allowedRoots ?? new[] { AllowedRoot() })
});

return new PathTraversalGuard(
new PathTraversalDetectionEngine(options),
bus,
metrics,
options,
NullLogger<PathTraversalGuard>.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<RaspSecurityException>()
.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);
}
}