208 lines
8.2 KiB
C#
208 lines
8.2 KiB
C#
using System.Text;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using ScadaLink.AuditLog.Configuration;
|
|
using ScadaLink.AuditLog.Payload;
|
|
using ScadaLink.Commons.Entities.Audit;
|
|
using ScadaLink.Commons.Types.Enums;
|
|
|
|
namespace ScadaLink.AuditLog.Tests.Payload;
|
|
|
|
/// <summary>
|
|
/// Bundle B (M5-T4) tests for body regex redaction in
|
|
/// <see cref="DefaultAuditPayloadFilter"/>. The body-redactor stage runs
|
|
/// regex replace against RequestSummary / ResponseSummary / ErrorDetail /
|
|
/// Extra, replacing every match with <c><redacted></c>. Regexes come
|
|
/// from <see cref="AuditLogOptions.GlobalBodyRedactors"/> plus the per-target
|
|
/// <see cref="PerTargetRedactionOverride.AdditionalBodyRedactors"/>. Each
|
|
/// regex is compiled with a 50 ms timeout so catastrophic-backtracking
|
|
/// patterns trip a <see cref="System.Text.RegularExpressions.RegexMatchTimeoutException"/>;
|
|
/// when that happens the offending field is over-redacted with
|
|
/// <c><redacted: redactor error></c> and the
|
|
/// <see cref="IAuditRedactionFailureCounter"/> is incremented. The stage runs
|
|
/// BEFORE truncation.
|
|
/// </summary>
|
|
public class BodyRegexRedactionTests
|
|
{
|
|
private static IOptionsMonitor<AuditLogOptions> Monitor(AuditLogOptions? opts = null) =>
|
|
new StaticMonitor(opts ?? new AuditLogOptions());
|
|
|
|
private static DefaultAuditPayloadFilter Filter(
|
|
AuditLogOptions? opts = null,
|
|
IAuditRedactionFailureCounter? counter = null) =>
|
|
new(Monitor(opts), NullLogger<DefaultAuditPayloadFilter>.Instance, counter);
|
|
|
|
private static AuditEvent NewEvent(
|
|
AuditStatus status = AuditStatus.Delivered,
|
|
string? request = null,
|
|
string? response = null,
|
|
string? errorDetail = null,
|
|
string? extra = null,
|
|
string? target = null) => new()
|
|
{
|
|
EventId = Guid.NewGuid(),
|
|
OccurredAtUtc = DateTime.UtcNow,
|
|
Channel = AuditChannel.ApiOutbound,
|
|
Kind = AuditKind.ApiCall,
|
|
Status = status,
|
|
Target = target,
|
|
RequestSummary = request,
|
|
ResponseSummary = response,
|
|
ErrorDetail = errorDetail,
|
|
Extra = extra,
|
|
};
|
|
|
|
[Fact]
|
|
public void GlobalRegex_HunterPassword_Redacted()
|
|
{
|
|
var opts = new AuditLogOptions
|
|
{
|
|
GlobalBodyRedactors = new List<string> { "\"password\":\\s*\"[^\"]*\"" },
|
|
};
|
|
const string input = "{\"user\":\"alice\",\"password\":\"hunter2\"}";
|
|
var evt = NewEvent(request: input);
|
|
|
|
var result = Filter(opts).Apply(evt);
|
|
|
|
Assert.NotNull(result.RequestSummary);
|
|
Assert.Contains("<redacted>", result.RequestSummary);
|
|
Assert.DoesNotContain("hunter2", result.RequestSummary);
|
|
Assert.Contains("alice", result.RequestSummary);
|
|
}
|
|
|
|
[Fact]
|
|
public void PerTargetRegex_OnlyAppliedToMatchingTarget()
|
|
{
|
|
var opts = new AuditLogOptions
|
|
{
|
|
PerTargetOverrides = new Dictionary<string, PerTargetRedactionOverride>
|
|
{
|
|
["esg.A"] = new PerTargetRedactionOverride
|
|
{
|
|
AdditionalBodyRedactors = new List<string> { "SECRET-[A-Z0-9]+" },
|
|
},
|
|
},
|
|
};
|
|
|
|
const string input = "token=SECRET-XYZ123 normal-text";
|
|
|
|
var matchedEvt = NewEvent(request: input, target: "esg.A");
|
|
var matchedResult = Filter(opts).Apply(matchedEvt);
|
|
Assert.Contains("<redacted>", matchedResult.RequestSummary!);
|
|
Assert.DoesNotContain("SECRET-XYZ123", matchedResult.RequestSummary!);
|
|
|
|
var unmatchedEvt = NewEvent(request: input, target: "esg.B");
|
|
var unmatchedResult = Filter(opts).Apply(unmatchedEvt);
|
|
Assert.Equal(input, unmatchedResult.RequestSummary);
|
|
}
|
|
|
|
[Fact]
|
|
public void RegexThrowsTimeout_FieldBecomesRedactedMarker_CounterIncrements()
|
|
{
|
|
// Catastrophic backtracking pattern: alternation with overlapping
|
|
// groups + non-matching suffix forces the engine into exponential
|
|
// work that blows past the 50 ms timeout. Append a non-'a' character
|
|
// so the suffix anchor fails and the engine has to exhaust every
|
|
// permutation.
|
|
var opts = new AuditLogOptions
|
|
{
|
|
GlobalBodyRedactors = new List<string> { "^(a+)+$" },
|
|
};
|
|
// 30 'a's followed by '!' — small enough to keep the test fast, big
|
|
// enough to overflow the 50 ms regex timeout on every machine the CI
|
|
// grid runs on.
|
|
var input = new string('a', 30) + "!";
|
|
var counter = new CountingRedactionFailureCounter();
|
|
var evt = NewEvent(request: input);
|
|
|
|
var result = Filter(opts, counter).Apply(evt);
|
|
|
|
Assert.Equal("<redacted: redactor error>", result.RequestSummary);
|
|
Assert.True(counter.Count >= 1, $"expected counter >= 1, got {counter.Count}");
|
|
}
|
|
|
|
[Fact]
|
|
public void NoRegexConfigured_FieldUnchanged()
|
|
{
|
|
var opts = new AuditLogOptions(); // no GlobalBodyRedactors, no per-target
|
|
const string input = "{\"password\":\"hunter2\"}";
|
|
var evt = NewEvent(request: input);
|
|
|
|
var result = Filter(opts).Apply(evt);
|
|
|
|
Assert.Equal(input, result.RequestSummary);
|
|
}
|
|
|
|
[Fact]
|
|
public void RedactionAppliedBeforeTruncation()
|
|
{
|
|
// A pattern that matches a long secret in the body. The full input is
|
|
// > 8 KB so truncation must run. After redaction:
|
|
// * the marker survives the cap (redaction ran first),
|
|
// * the original secret bytes do NOT survive,
|
|
// * PayloadTruncated is set.
|
|
var opts = new AuditLogOptions
|
|
{
|
|
GlobalBodyRedactors = new List<string> { "SECRET-[A-Z0-9]+" },
|
|
};
|
|
var secret = "SECRET-ABCDEF123";
|
|
var padding = new string('x', 9 * 1024);
|
|
var input = secret + padding;
|
|
Assert.True(Encoding.UTF8.GetByteCount(input) > 8192);
|
|
|
|
var evt = NewEvent(AuditStatus.Delivered, request: input);
|
|
|
|
var result = Filter(opts).Apply(evt);
|
|
|
|
Assert.NotNull(result.RequestSummary);
|
|
Assert.True(Encoding.UTF8.GetByteCount(result.RequestSummary!) <= 8192);
|
|
Assert.Contains("<redacted>", result.RequestSummary);
|
|
Assert.DoesNotContain(secret, result.RequestSummary);
|
|
Assert.True(result.PayloadTruncated);
|
|
}
|
|
|
|
[Fact]
|
|
public void CatastrophicBacktrackingRegex_AtCompileTime_RejectedAtStartup()
|
|
{
|
|
// .NET's regex engine has no compile-time detection for catastrophic
|
|
// backtracking (only structural validation), so the filter's
|
|
// protection is RUNTIME — the 50 ms per-match timeout. We assert the
|
|
// safety net behaviour: a known evil pattern compiles cleanly but
|
|
// matches time out at runtime, the field is over-redacted, and the
|
|
// failure counter is incremented. Future engines that DO support
|
|
// compile-time analysis can tighten this further; the contract here
|
|
// is that the user-facing action is never aborted.
|
|
var evilPattern = "^(a+)+$";
|
|
var opts = new AuditLogOptions
|
|
{
|
|
GlobalBodyRedactors = new List<string> { evilPattern },
|
|
};
|
|
var input = new string('a', 30) + "!";
|
|
var counter = new CountingRedactionFailureCounter();
|
|
var evt = NewEvent(request: input);
|
|
|
|
var result = Filter(opts, counter).Apply(evt);
|
|
|
|
Assert.Equal("<redacted: redactor error>", result.RequestSummary);
|
|
Assert.True(counter.Count >= 1);
|
|
}
|
|
|
|
/// <summary>Test double that counts increments.</summary>
|
|
private sealed class CountingRedactionFailureCounter : IAuditRedactionFailureCounter
|
|
{
|
|
private int _count;
|
|
public int Count => _count;
|
|
public void Increment() => System.Threading.Interlocked.Increment(ref _count);
|
|
}
|
|
|
|
/// <summary>IOptionsMonitor test double — returns the same snapshot on every read.</summary>
|
|
private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions>
|
|
{
|
|
private readonly AuditLogOptions _value;
|
|
public StaticMonitor(AuditLogOptions value) => _value = value;
|
|
public AuditLogOptions CurrentValue => _value;
|
|
public AuditLogOptions Get(string? name) => _value;
|
|
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
|
|
}
|
|
}
|