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;
+ }
+}