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; /// /// Bundle B (M5-T4) tests for body regex redaction in /// . The body-redactor stage runs /// regex replace against RequestSummary / ResponseSummary / ErrorDetail / /// Extra, replacing every match with <redacted>. Regexes come /// from plus the per-target /// . Each /// regex is compiled with a 50 ms timeout so catastrophic-backtracking /// patterns trip a ; /// when that happens the offending field is over-redacted with /// <redacted: redactor error> and the /// is incremented. The stage runs /// BEFORE truncation. /// public class BodyRegexRedactionTests { private static IOptionsMonitor Monitor(AuditLogOptions? opts = null) => new StaticMonitor(opts ?? new AuditLogOptions()); private static DefaultAuditPayloadFilter Filter( AuditLogOptions? opts = null, IAuditRedactionFailureCounter? counter = null) => new(Monitor(opts), NullLogger.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 { "\"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("", result.RequestSummary); Assert.DoesNotContain("hunter2", result.RequestSummary); Assert.Contains("alice", result.RequestSummary); } [Fact] public void PerTargetRegex_OnlyAppliedToMatchingTarget() { var opts = new AuditLogOptions { PerTargetOverrides = new Dictionary { ["esg.A"] = new PerTargetRedactionOverride { AdditionalBodyRedactors = new List { "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("", 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 { "^(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("", 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 { "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("", 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 { 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("", result.RequestSummary); Assert.True(counter.Count >= 1); } /// Test double that counts increments. private sealed class CountingRedactionFailureCounter : IAuditRedactionFailureCounter { private int _count; public int Count => _count; public void Increment() => System.Threading.Interlocked.Increment(ref _count); } /// IOptionsMonitor test double — returns the same snapshot on every read. private sealed class StaticMonitor : IOptionsMonitor { private readonly AuditLogOptions _value; public StaticMonitor(AuditLogOptions value) => _value = value; public AuditLogOptions CurrentValue => _value; public AuditLogOptions Get(string? name) => _value; public IDisposable? OnChange(Action listener) => null; } }