feat(audit): M5.3 response-capture increments — request headers, ceiling-hits counter, per-method body opt-out (T7)

1. Request headers in Extra JSON (AuditWriteMiddleware): adds a `requestHeaders`
   object to the existing Extra JSON alongside remoteIp/userAgent; headers whose
   names appear in AuditLogOptions.HeaderRedactList (Authorization, X-Api-Key,
   Cookie, Set-Cookie by default) are replaced with "<redacted>" using
   OrdinalIgnoreCase matching — same policy as ScadaBridgeAuditRedactor.

2. AuditInboundCeilingHits counter: new IAuditInboundCeilingHitsCounter interface
   + NoOpAuditInboundCeilingHitsCounter default; AuditCentralHealthSnapshot
   implements the interface (Interlocked field, thread-safe) and exposes
   AuditInboundCeilingHits on IAuditCentralHealthSnapshot; AddAuditLog registers
   the NoOp default, AddAuditLogCentralMaintenance forwards to the snapshot;
   AuditWriteMiddleware accepts the counter as an optional ctor arg and increments
   it once per request where either the request or response body hit the cap.

3. Per-method SkipBodyCapture opt-out: adds SkipBodyCapture bool to
   PerTargetRedactionOverride; AuditWriteMiddleware consults the per-target
   override map at the start of InvokeAsync (before EnableBuffering) and, when
   set, skips body read + capture entirely — the audit row still emits with
   headers/metadata but null RequestSummary/ResponseSummary; truncation flags
   are also cleared so the ceiling-hits counter is not bumped for opted-out methods.
This commit is contained in:
Joseph Doherty
2026-06-16 21:23:07 -04:00
parent 0569c5ff23
commit a07ff28f10
9 changed files with 643 additions and 8 deletions
@@ -1022,4 +1022,429 @@ public class AuditWriteMiddlewareTests
var evt = Assert.Single(writer.Events);
Assert.Equal(requestJson, evt.RequestSummary);
}
// ---------------------------------------------------------------------
// M5.3 (T7) Increment 1: Request headers in Extra JSON
// Request headers are captured into the Extra JSON object alongside the
// existing remoteIp / userAgent fields. Sensitive headers (e.g.
// Authorization, X-Api-Key) are redacted to "<redacted>" using the same
// HeaderRedactList as ScadaBridgeAuditRedactor.
// ---------------------------------------------------------------------
[Fact]
public async Task RequestHeaders_AppearInExtra_UnderRequestHeadersKey()
{
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
ctx.Request.Headers["X-Custom-Header"] = "custom-value";
var mw = CreateMiddleware(_ =>
{
ctx.Response.StatusCode = 200;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(ctx);
var evt = Assert.Single(writer.Events);
Assert.NotNull(evt.Extra);
using var doc = JsonDocument.Parse(evt.Extra!);
var root = doc.RootElement;
// Extra must carry a requestHeaders object.
Assert.True(root.TryGetProperty("requestHeaders", out var headers),
"Extra JSON must contain a 'requestHeaders' property");
Assert.Equal(JsonValueKind.Object, headers.ValueKind);
// The non-sensitive custom header must appear unredacted.
Assert.True(headers.TryGetProperty("X-Custom-Header", out var customVal),
"requestHeaders must contain 'X-Custom-Header'");
Assert.Equal("custom-value", customVal.GetString());
}
[Fact]
public async Task RequestHeaders_AuthorizationHeader_IsRedacted()
{
// Authorization is in the default HeaderRedactList and must appear as
// "<redacted>" rather than the real token value.
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
ctx.Request.Headers["Authorization"] = "Bearer secret-token-abc";
var mw = CreateMiddleware(_ =>
{
ctx.Response.StatusCode = 200;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(ctx);
var evt = Assert.Single(writer.Events);
Assert.NotNull(evt.Extra);
using var doc = JsonDocument.Parse(evt.Extra!);
var root = doc.RootElement;
var headers = root.GetProperty("requestHeaders");
Assert.True(headers.TryGetProperty("Authorization", out var authVal),
"requestHeaders must contain 'Authorization'");
Assert.Equal("<redacted>", authVal.GetString());
}
[Fact]
public async Task RequestHeaders_XApiKeyHeader_IsRedacted()
{
// X-Api-Key is in the default HeaderRedactList and must be redacted.
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
ctx.Request.Headers["X-Api-Key"] = "sbk_12345_secretkey";
var mw = CreateMiddleware(_ =>
{
ctx.Response.StatusCode = 200;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(ctx);
var evt = Assert.Single(writer.Events);
Assert.NotNull(evt.Extra);
using var doc = JsonDocument.Parse(evt.Extra!);
var root = doc.RootElement;
var headers = root.GetProperty("requestHeaders");
Assert.True(headers.TryGetProperty("X-Api-Key", out var keyVal));
Assert.Equal("<redacted>", keyVal.GetString());
}
[Fact]
public async Task RequestHeaders_CustomRedactListEntry_IsRedacted()
{
// A non-default entry added to HeaderRedactList must also be redacted.
var opts = new AuditLogOptions
{
HeaderRedactList = new List<string>
{
"Authorization", "X-Api-Key", "Cookie", "Set-Cookie",
"X-Internal-Secret", // custom addition
},
};
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
ctx.Request.Headers["X-Internal-Secret"] = "my-secret-value";
ctx.Request.Headers["X-Safe-Header"] = "safe-value";
var mw = CreateMiddleware(
_ =>
{
ctx.Response.StatusCode = 200;
return Task.CompletedTask;
},
writer,
options: opts);
await mw.InvokeAsync(ctx);
var evt = Assert.Single(writer.Events);
using var doc = JsonDocument.Parse(evt.Extra!);
var headers = doc.RootElement.GetProperty("requestHeaders");
Assert.Equal("<redacted>", headers.GetProperty("X-Internal-Secret").GetString());
Assert.Equal("safe-value", headers.GetProperty("X-Safe-Header").GetString());
}
[Fact]
public async Task RequestHeaders_Redaction_IsCaseInsensitive()
{
// HeaderRedactList match must be case-insensitive (mirrors the
// ScadaBridgeAuditRedactor behaviour — the redact set uses
// OrdinalIgnoreCase).
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
// Vary the casing from the list entry ("Authorization").
ctx.Request.Headers["authorization"] = "Bearer lower-case-token";
var mw = CreateMiddleware(_ =>
{
ctx.Response.StatusCode = 200;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(ctx);
var evt = Assert.Single(writer.Events);
using var doc = JsonDocument.Parse(evt.Extra!);
var headers = doc.RootElement.GetProperty("requestHeaders");
// ASP.NET Core normalises the header name to "authorization" in the dict;
// the redact set (OrdinalIgnoreCase) must still match it.
Assert.Equal("<redacted>", headers.GetProperty("authorization").GetString());
}
// ---------------------------------------------------------------------
// M5.3 (T7) Increment 2: AuditInboundCeilingHits counter
// When request OR response exceeds InboundMaxBytes, the middleware
// increments IAuditInboundCeilingHitsCounter once per request.
// ---------------------------------------------------------------------
/// <summary>
/// In-memory <see cref="IAuditInboundCeilingHitsCounter"/> that records
/// every <see cref="Increment"/> call.
/// </summary>
private sealed class RecordingCeilingHitsCounter : ZB.MOM.WW.ScadaBridge.AuditLog.Central.IAuditInboundCeilingHitsCounter
{
private int _count;
public int Count => Volatile.Read(ref _count);
public void Increment() => Interlocked.Increment(ref _count);
}
private static AuditWriteMiddleware CreateMiddlewareWithCounter(
RequestDelegate next,
ICentralAuditWriter writer,
AuditLogOptions? options,
ZB.MOM.WW.ScadaBridge.AuditLog.Central.IAuditInboundCeilingHitsCounter counter) =>
new(
next,
writer,
NullLogger<AuditWriteMiddleware>.Instance,
new StaticAuditLogOptionsMonitor(options ?? new AuditLogOptions()),
actorAccessor: null,
ceilingHitsCounter: counter);
[Fact]
public async Task RequestBody_AboveInboundMaxBytes_IncrementsCeilingHitsCounter()
{
const int cap = 1024;
var bigBody = new string('x', cap + 100);
var writer = new RecordingAuditWriter();
var counter = new RecordingCeilingHitsCounter();
var ctx = BuildContext(body: bigBody);
var mw = CreateMiddlewareWithCounter(
hc =>
{
hc.Response.StatusCode = 200;
return Task.CompletedTask;
},
writer,
options: new AuditLogOptions { InboundMaxBytes = cap },
counter: counter);
await mw.InvokeAsync(ctx);
Assert.Equal(1, counter.Count);
// Verify the truncation did happen to confirm ceiling was hit.
var evt = Assert.Single(writer.Events);
Assert.True(evt.PayloadTruncated);
}
[Fact]
public async Task ResponseBody_AboveInboundMaxBytes_IncrementsCeilingHitsCounter()
{
const int cap = 1024;
var bigResponse = new string('y', cap + 100);
var writer = new RecordingAuditWriter();
var counter = new RecordingCeilingHitsCounter();
var ctx = BuildContext();
ctx.Response.Body = new MemoryStream();
var mw = CreateMiddlewareWithCounter(
async hc =>
{
hc.Response.StatusCode = 200;
await hc.Response.WriteAsync(bigResponse);
},
writer,
options: new AuditLogOptions { InboundMaxBytes = cap },
counter: counter);
await mw.InvokeAsync(ctx);
Assert.Equal(1, counter.Count);
var evt = Assert.Single(writer.Events);
Assert.True(evt.PayloadTruncated);
}
[Fact]
public async Task NormalRequest_WithinCap_DoesNotIncrementCeilingHitsCounter()
{
var writer = new RecordingAuditWriter();
var counter = new RecordingCeilingHitsCounter();
var smallBody = "{\"ok\":true}";
var ctx = BuildContext(body: smallBody);
// Cap is well above the body size.
var mw = CreateMiddlewareWithCounter(
hc =>
{
hc.Response.StatusCode = 200;
return Task.CompletedTask;
},
writer,
options: new AuditLogOptions { InboundMaxBytes = 8192 },
counter: counter);
await mw.InvokeAsync(ctx);
Assert.Equal(0, counter.Count);
}
// ---------------------------------------------------------------------
// M5.3 (T7) Increment 3: SkipBodyCapture per-method opt-out
// A target with SkipBodyCapture=true produces an audit row with
// headers/metadata but empty/omitted body. A normal target still captures.
// ---------------------------------------------------------------------
private static DefaultHttpContext BuildContextWithRoute(
string methodName,
string? body = null)
{
var ctx = new DefaultHttpContext();
ctx.Request.Method = "POST";
ctx.Request.Path = $"/api/{methodName}";
ctx.Request.RouteValues["methodName"] = methodName;
ctx.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("10.0.0.1");
if (body is not null)
{
var bytes = Encoding.UTF8.GetBytes(body);
ctx.Request.Body = new MemoryStream(bytes);
ctx.Request.ContentLength = bytes.Length;
ctx.Request.ContentType = "application/json";
}
return ctx;
}
[Fact]
public async Task SkipBodyCapture_True_AuditRowEmitted_ButBodyIsNull()
{
// A target with SkipBodyCapture=true must produce an audit row (the
// row must not be suppressed entirely) but RequestSummary and
// ResponseSummary must both be null — only the body is omitted.
var writer = new RecordingAuditWriter();
var opts = new AuditLogOptions
{
PerTargetOverrides = new Dictionary<string, ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride>
{
["secret-method"] = new ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride
{
SkipBodyCapture = true,
},
},
};
var ctx = BuildContextWithRoute("secret-method", body: "{\"sensitive\":\"data\"}");
var mw = CreateMiddleware(
async hc =>
{
hc.Response.StatusCode = 200;
await hc.Response.WriteAsync("{\"result\":\"secret\"}");
},
writer,
options: opts);
await mw.InvokeAsync(ctx);
var evt = Assert.Single(writer.Events);
// Row IS emitted — only the body content is suppressed.
Assert.Equal("secret-method", evt.Target);
Assert.Equal(AuditStatus.Delivered, evt.Status);
// Bodies are null — SkipBodyCapture stripped them.
Assert.Null(evt.RequestSummary);
Assert.Null(evt.ResponseSummary);
// Headers / metadata are still present.
Assert.NotNull(evt.Extra);
using var doc = JsonDocument.Parse(evt.Extra!);
Assert.True(doc.RootElement.TryGetProperty("requestHeaders", out _),
"Headers must be present even when body capture is skipped");
Assert.Equal(200, evt.HttpStatus);
}
[Fact]
public async Task SkipBodyCapture_True_CeilingHitsCounter_NotIncremented()
{
// When SkipBodyCapture=true the body is never measured against the cap;
// the counter must NOT be bumped even if the body would have exceeded it.
var writer = new RecordingAuditWriter();
var counter = new RecordingCeilingHitsCounter();
const int cap = 64;
var bigBody = new string('z', cap + 1000);
var opts = new AuditLogOptions
{
InboundMaxBytes = cap,
PerTargetOverrides = new Dictionary<string, ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride>
{
["large-method"] = new ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride
{
SkipBodyCapture = true,
},
},
};
var ctx = BuildContextWithRoute("large-method", body: bigBody);
var mw = CreateMiddlewareWithCounter(
hc =>
{
hc.Response.StatusCode = 200;
return Task.CompletedTask;
},
writer,
options: opts,
counter: counter);
await mw.InvokeAsync(ctx);
Assert.Equal(0, counter.Count);
}
[Fact]
public async Task SkipBodyCapture_False_NormalTarget_StillCapturesBody()
{
// Regression: a target WITHOUT SkipBodyCapture (or with SkipBodyCapture=false)
// must still capture the body normally.
var writer = new RecordingAuditWriter();
var opts = new AuditLogOptions
{
PerTargetOverrides = new Dictionary<string, ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride>
{
["normal-method"] = new ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride
{
SkipBodyCapture = false,
},
},
};
var requestJson = "{\"a\":1}";
var ctx = BuildContextWithRoute("normal-method", body: requestJson);
var mw = CreateMiddleware(
async hc =>
{
hc.Response.StatusCode = 200;
await hc.Response.WriteAsync("{\"result\":1}");
},
writer,
options: opts);
await mw.InvokeAsync(ctx);
var evt = Assert.Single(writer.Events);
Assert.Equal(requestJson, evt.RequestSummary);
Assert.Equal("{\"result\":1}", evt.ResponseSummary);
}
[Fact]
public async Task SkipBodyCapture_NoOverride_DefaultTarget_StillCapturesBody()
{
// A target with no per-target override at all must still capture the body —
// SkipBodyCapture defaults to false and must not suppress capture.
var writer = new RecordingAuditWriter();
var requestJson = "{\"x\":99}";
var ctx = BuildContext(body: requestJson);
var mw = CreateMiddleware(
async hc =>
{
hc.Response.StatusCode = 200;
await hc.Response.WriteAsync("{\"y\":99}");
},
writer);
await mw.InvokeAsync(ctx);
var evt = Assert.Single(writer.Events);
Assert.Equal(requestJson, evt.RequestSummary);
Assert.Equal("{\"y\":99}", evt.ResponseSummary);
}
}