diff --git a/src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs b/src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs index 8682a9f..a191542 100644 --- a/src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs +++ b/src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs @@ -1,4 +1,7 @@ using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ScadaLink.AuditLog.Configuration; @@ -8,11 +11,10 @@ using ScadaLink.Commons.Types.Enums; namespace ScadaLink.AuditLog.Payload; /// -/// Default . M5 Bundle A scope: payload -/// truncation only (RequestSummary / ResponseSummary / ErrorDetail / Extra), -/// capped at on success rows and -/// on error rows. Bundle B layers -/// header / body / SQL-parameter redaction on top. +/// Default . Bundle A established the +/// truncation backbone; Bundle B chains HTTP header redaction (M5-T3) BEFORE +/// truncation so redactors operate on the full payload and the cap then trims +/// the redacted result. /// /// /// @@ -30,20 +32,54 @@ namespace ScadaLink.AuditLog.Payload; /// /// Apply MUST NOT throw — on internal failure the filter over-redacts by /// returning the input with set and -/// (Bundle C) increments the AuditRedactionFailure health metric. +/// increments the AuditRedactionFailure health metric via the injected +/// . Each redactor stage runs in +/// its own try/catch — a failure in (say) the header redactor still lets the +/// SQL parameter redactor and the truncator run on the remaining fields. +/// +/// +/// Stage order (each runs on every applicable field): +/// header redaction → truncation. Bundle B will append body-regex and +/// SQL-parameter stages after header redaction and before truncation. /// /// public sealed class DefaultAuditPayloadFilter : IAuditPayloadFilter { + private const string RedactedMarker = ""; + private const string RedactorErrorMarker = ""; + + /// + /// JSON serializer options used to re-emit redacted summaries. The + /// UnsafeRelaxedJsonEscaping encoder is required so the redaction marker + /// (which contains < / >) survives unescaped — the + /// header-redaction tests grep for the literal marker, and the downstream + /// UI / log readers would rather see <redacted> than + /// . The summaries are persisted to the audit + /// table and rendered in trusted-internal contexts only, so the relaxed + /// HTML-escaping rules do not introduce an XSS surface. + /// + private static readonly JsonSerializerOptions RedactedSummaryJsonOptions = new() + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + private readonly IOptionsMonitor _options; private readonly ILogger _logger; + private readonly IAuditRedactionFailureCounter _failureCounter; + /// + /// Primary constructor used by DI — pulls the optional redaction-failure + /// counter from the container; a NoOp default is registered in + /// . + /// public DefaultAuditPayloadFilter( IOptionsMonitor options, - ILogger logger) + ILogger logger, + IAuditRedactionFailureCounter? failureCounter = null) { _options = options ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _failureCounter = failureCounter ?? new NoOpAuditRedactionFailureCounter(); } public AuditEvent Apply(AuditEvent rawEvent) @@ -52,11 +88,20 @@ public sealed class DefaultAuditPayloadFilter : IAuditPayloadFilter { var opts = _options.CurrentValue; var cap = IsErrorStatus(rawEvent.Status) ? opts.ErrorCapBytes : opts.DefaultCapBytes; + + // --- Header-redaction stage (runs BEFORE truncation) ---------- + var request = RedactHeaders(rawEvent.RequestSummary, opts.HeaderRedactList); + var response = RedactHeaders(rawEvent.ResponseSummary, opts.HeaderRedactList); + var errorDetail = rawEvent.ErrorDetail; + var extra = rawEvent.Extra; + + // --- Truncation stage ----------------------------------------- var truncated = false; - var request = TruncateField(rawEvent.RequestSummary, cap, ref truncated); - var response = TruncateField(rawEvent.ResponseSummary, cap, ref truncated); - var errorDetail = TruncateField(rawEvent.ErrorDetail, cap, ref truncated); - var extra = TruncateField(rawEvent.Extra, cap, ref truncated); + request = TruncateField(request, cap, ref truncated); + response = TruncateField(response, cap, ref truncated); + errorDetail = TruncateField(errorDetail, cap, ref truncated); + extra = TruncateField(extra, cap, ref truncated); + return rawEvent with { RequestSummary = request, @@ -69,14 +114,99 @@ public sealed class DefaultAuditPayloadFilter : IAuditPayloadFilter catch (Exception ex) { // Audit is best-effort: over-redact rather than fail the caller. - // Bundle C wires the AuditRedactionFailure health metric here. + // The per-stage try/catches above already handle redactor faults + // and increment the counter; this catch covers any unexpected + // surprise in the surrounding orchestration code. _logger.LogWarning( ex, "Payload filter failed; returning raw event with PayloadTruncated=true"); + try { _failureCounter.Increment(); } catch { /* swallow per §7 */ } return rawEvent with { PayloadTruncated = true }; } } + /// + /// Parse as the documented + /// {"headers": {...}, "body": ...} shape and replace values whose + /// header NAME (case-insensitive) is in with + /// . Re-serialises and returns the result. + /// + /// + /// No-op pass-through for inputs that aren't JSON-shaped — emitters that + /// have not yet adopted the convention (the M2 site emitters today, which + /// leave RequestSummary null on outbound API calls) get a transparent + /// pass. If the redactor itself throws, we over-redact the whole field + /// with and bump the failure counter. + /// + private string? RedactHeaders(string? json, IList redactList) + { + if (json is null) + { + return null; + } + + // Cheap structural pre-check: only attempt JSON parsing when the input + // actually looks like a JSON object. Saves the JsonDocument allocation + // on the (very common) non-JSON ErrorDetail / Extra fields. + var trimmed = json.AsSpan().TrimStart(); + if (trimmed.Length == 0 || trimmed[0] != '{') + { + return json; + } + + try + { + JsonNode? root; + try + { + root = JsonNode.Parse(json); + } + catch (JsonException) + { + // Not parseable JSON — leave the field alone (no error, no + // redaction). Emitters not yet using the documented shape get + // a transparent pass; Bundle C will update them. + return json; + } + + if (root is not JsonObject obj || obj["headers"] is not JsonObject headers) + { + // No "headers" object at the top level — nothing to redact. + return json; + } + + // Build a case-insensitive lookup of the redact list so we can do + // one O(1) check per header name without an inner Any() loop. + var redactSet = new HashSet(redactList, StringComparer.OrdinalIgnoreCase); + + // Take a snapshot of names first — we cannot mutate while + // enumerating the JsonObject. + var names = new List(headers.Count); + foreach (var kvp in headers) + { + names.Add(kvp.Key); + } + foreach (var name in names) + { + if (redactSet.Contains(name)) + { + headers[name] = JsonValue.Create(RedactedMarker); + } + } + + return obj.ToJsonString(RedactedSummaryJsonOptions); + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Header redactor faulted; over-redacting field with '{Marker}'", + RedactorErrorMarker); + try { _failureCounter.Increment(); } catch { /* swallow per §7 */ } + return RedactorErrorMarker; + } + } + private static string? TruncateField(string? value, int cap, ref bool truncated) { if (value is null) diff --git a/src/ScadaLink.AuditLog/Payload/IAuditRedactionFailureCounter.cs b/src/ScadaLink.AuditLog/Payload/IAuditRedactionFailureCounter.cs new file mode 100644 index 0000000..42543ef --- /dev/null +++ b/src/ScadaLink.AuditLog/Payload/IAuditRedactionFailureCounter.cs @@ -0,0 +1,20 @@ +namespace ScadaLink.AuditLog.Payload; + +/// +/// Counter sink invoked by every time +/// a redactor (header / body regex / SQL parameter) throws and the filter has +/// to over-redact the offending field with the +/// <redacted: redactor error> marker. Bundle C bridges this into +/// the Site Health Monitoring report payload as AuditRedactionFailure. +/// +/// +/// Redaction failures must NEVER abort the user-facing action (alog.md §7) — +/// the filter over-redacts the field and surfaces the failure via this counter +/// instead. A NoOp default is the correct safe fallback while the health +/// metric is being wired in. +/// +public interface IAuditRedactionFailureCounter +{ + /// Increment the audit-redaction failure counter by one. + void Increment(); +} diff --git a/src/ScadaLink.AuditLog/Payload/NoOpAuditRedactionFailureCounter.cs b/src/ScadaLink.AuditLog/Payload/NoOpAuditRedactionFailureCounter.cs new file mode 100644 index 0000000..affeaab --- /dev/null +++ b/src/ScadaLink.AuditLog/Payload/NoOpAuditRedactionFailureCounter.cs @@ -0,0 +1,17 @@ +namespace ScadaLink.AuditLog.Payload; + +/// +/// Default binding used when the +/// Site Health Monitoring bridge has not been wired yet. Bundle C replaces +/// this registration with the real counter that surfaces in the site health +/// report payload as AuditRedactionFailure. +/// +public sealed class NoOpAuditRedactionFailureCounter : IAuditRedactionFailureCounter +{ + /// + public void Increment() + { + // Intentionally empty — Bundle C overrides this binding with the real + // health-metric counter. + } +} diff --git a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs index d42f299..2200e72 100644 --- a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs @@ -69,6 +69,12 @@ public static class ServiceCollectionExtensions // dependency picks up M5-T8 hot reloads on its own. services.AddSingleton(); + // M5 Bundle B: per-stage redactor-failure counter. NoOp default; + // Bundle C replaces this binding with the Site Health Monitoring + // bridge that surfaces failures as AuditRedactionFailure on the site + // health report. + services.TryAddSingleton(); + // M2 Bundle E: site writer + telemetry options bindings. // BindConfiguration is not used because the configuration root supplied // by the caller may not be the application root — we go through the diff --git a/tests/ScadaLink.AuditLog.Tests/Payload/HeaderRedactionTests.cs b/tests/ScadaLink.AuditLog.Tests/Payload/HeaderRedactionTests.cs new file mode 100644 index 0000000..01784f6 --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/Payload/HeaderRedactionTests.cs @@ -0,0 +1,217 @@ +using System.Text; +using System.Text.Json; +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-T3) tests for HTTP header +/// redaction. Redaction parses / +/// as JSON of shape +/// {"headers": {"name": "value", ...}, "body": "..."}, replaces values +/// whose header NAME (case-insensitive) is in +/// with "<redacted>", +/// and re-serialises. Non-JSON inputs pass through unchanged (no-op for +/// emitters that have not yet adopted the convention). The stage runs BEFORE +/// truncation so the redaction marker survives the cap. +/// +public class HeaderRedactionTests +{ + private static IOptionsMonitor Monitor(AuditLogOptions? opts = null) => + new StaticMonitor(opts ?? new AuditLogOptions()); + + private static DefaultAuditPayloadFilter Filter(AuditLogOptions? opts = null) => + new(Monitor(opts), NullLogger.Instance); + + private static AuditEvent NewEvent( + AuditStatus status = AuditStatus.Delivered, + string? request = null, + string? response = null) => new() + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTime.UtcNow, + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCall, + Status = status, + RequestSummary = request, + ResponseSummary = response, + }; + + private static string BuildSummary(IDictionary headers, string body) + { + // Serialize via System.Text.Json so we get a representative shape. + return JsonSerializer.Serialize(new + { + headers = headers, + body = body, + }); + } + + private static IDictionary ParseSummary(string? summary) + { + Assert.NotNull(summary); + using var doc = JsonDocument.Parse(summary!); + var dict = new Dictionary(); + foreach (var property in doc.RootElement.EnumerateObject()) + { + dict[property.Name] = property.Value.Clone(); + } + return dict; + } + + [Fact] + public void HeaderRedaction_AuthorizationBearer_Redacted() + { + var headers = new Dictionary + { + ["Authorization"] = "Bearer secret-token-xyz", + ["Content-Type"] = "application/json", + }; + var input = BuildSummary(headers, "hello"); + var evt = NewEvent(request: input); + + var result = Filter().Apply(evt); + + var parsed = ParseSummary(result.RequestSummary); + var resultHeaders = parsed["headers"]; + Assert.Equal("", resultHeaders.GetProperty("Authorization").GetString()); + } + + [Fact] + public void HeaderRedaction_CaseInsensitive_LowercaseAuthorization_Redacted() + { + var headers = new Dictionary + { + ["authorization"] = "Bearer secret-token-xyz", + }; + var input = BuildSummary(headers, "hello"); + var evt = NewEvent(request: input); + + var result = Filter().Apply(evt); + + var parsed = ParseSummary(result.RequestSummary); + var resultHeaders = parsed["headers"]; + Assert.Equal("", resultHeaders.GetProperty("authorization").GetString()); + } + + [Fact] + public void HeaderRedaction_CustomRedactList_RedactsCustomHeaderName() + { + var opts = new AuditLogOptions + { + HeaderRedactList = new List { "X-Custom-Secret" }, + }; + var headers = new Dictionary + { + ["X-Custom-Secret"] = "topsecret", + ["Authorization"] = "Bearer keep-me", // not in list anymore + }; + var input = BuildSummary(headers, "hi"); + var evt = NewEvent(request: input); + + var result = Filter(opts).Apply(evt); + + var parsed = ParseSummary(result.RequestSummary); + var resultHeaders = parsed["headers"]; + Assert.Equal("", resultHeaders.GetProperty("X-Custom-Secret").GetString()); + // Authorization no longer listed -> preserved verbatim. + Assert.Equal("Bearer keep-me", resultHeaders.GetProperty("Authorization").GetString()); + } + + [Fact] + public void HeaderRedaction_NonJson_RequestSummary_Unchanged() + { + const string input = "this is not JSON at all"; + var evt = NewEvent(request: input); + + var result = Filter().Apply(evt); + + Assert.Equal(input, result.RequestSummary); + } + + [Fact] + public void HeaderRedaction_NoHeadersField_Unchanged() + { + var input = JsonSerializer.Serialize(new { body = "only a body, no headers" }); + var evt = NewEvent(request: input); + + var result = Filter().Apply(evt); + + // The stage may re-serialise but the content must be semantically identical. + var parsed = ParseSummary(result.RequestSummary); + Assert.Equal("only a body, no headers", parsed["body"].GetString()); + Assert.False(parsed.ContainsKey("headers")); + } + + [Fact] + public void HeaderRedaction_Other_Headers_Preserved() + { + var headers = new Dictionary + { + ["Authorization"] = "Bearer secret", + ["Content-Type"] = "application/json", + ["X-Request-Id"] = "abc-123", + ["Accept"] = "application/json", + }; + var input = BuildSummary(headers, "payload"); + var evt = NewEvent(request: input); + + var result = Filter().Apply(evt); + + var parsed = ParseSummary(result.RequestSummary); + var resultHeaders = parsed["headers"]; + Assert.Equal("", resultHeaders.GetProperty("Authorization").GetString()); + Assert.Equal("application/json", resultHeaders.GetProperty("Content-Type").GetString()); + Assert.Equal("abc-123", resultHeaders.GetProperty("X-Request-Id").GetString()); + Assert.Equal("application/json", resultHeaders.GetProperty("Accept").GetString()); + } + + [Fact] + public void HeaderRedaction_AppliedBeforeTruncation() + { + // Build a summary whose Authorization header value is enormous AND whose + // body padding pushes the total beyond the 8 KB cap. After redaction the + // Authorization value becomes "" — then truncation caps the + // re-serialised string. Result must: + // * carry "" (header redaction ran first), + // * NOT carry the original secret bytes (proves redaction won, not order swap), + // * be capped at the configured DefaultCapBytes, + // * have PayloadTruncated == true. + const string secret = "SUPER-SECRET-TOKEN-DO-NOT-LEAK"; + var headers = new Dictionary + { + ["Authorization"] = "Bearer " + secret, + }; + var body = new string('x', 9 * 1024); + var input = BuildSummary(headers, body); + Assert.True(Encoding.UTF8.GetByteCount(input) > 8192); + + var evt = NewEvent(AuditStatus.Delivered, request: input); + + var result = Filter().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); + } + + /// + /// IOptionsMonitor test double — returns the same snapshot on every read, + /// no change-token plumbing required for these tests. + /// + 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; + } +}