feat(auditlog): HTTP header redaction stage (#23 M5)
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IAuditPayloadFilter"/>. M5 Bundle A scope: payload
|
||||
/// truncation only (RequestSummary / ResponseSummary / ErrorDetail / Extra),
|
||||
/// capped at <see cref="AuditLogOptions.DefaultCapBytes"/> on success rows and
|
||||
/// <see cref="AuditLogOptions.ErrorCapBytes"/> on error rows. Bundle B layers
|
||||
/// header / body / SQL-parameter redaction on top.
|
||||
/// Default <see cref="IAuditPayloadFilter"/>. 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -30,20 +32,54 @@ namespace ScadaLink.AuditLog.Payload;
|
||||
/// <para>
|
||||
/// Apply MUST NOT throw — on internal failure the filter over-redacts by
|
||||
/// returning the input with <see cref="AuditEvent.PayloadTruncated"/> set and
|
||||
/// (Bundle C) increments the <c>AuditRedactionFailure</c> health metric.
|
||||
/// increments the <c>AuditRedactionFailure</c> health metric via the injected
|
||||
/// <see cref="IAuditRedactionFailureCounter"/>. 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DefaultAuditPayloadFilter : IAuditPayloadFilter
|
||||
{
|
||||
private const string RedactedMarker = "<redacted>";
|
||||
private const string RedactorErrorMarker = "<redacted: redactor error>";
|
||||
|
||||
/// <summary>
|
||||
/// JSON serializer options used to re-emit redacted summaries. The
|
||||
/// UnsafeRelaxedJsonEscaping encoder is required so the redaction marker
|
||||
/// (which contains <c><</c> / <c>></c>) survives unescaped — the
|
||||
/// header-redaction tests grep for the literal marker, and the downstream
|
||||
/// UI / log readers would rather see <c><redacted></c> than
|
||||
/// <c><redacted></c>. 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.
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerOptions RedactedSummaryJsonOptions = new()
|
||||
{
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
|
||||
private readonly IOptionsMonitor<AuditLogOptions> _options;
|
||||
private readonly ILogger<DefaultAuditPayloadFilter> _logger;
|
||||
private readonly IAuditRedactionFailureCounter _failureCounter;
|
||||
|
||||
/// <summary>
|
||||
/// Primary constructor used by DI — pulls the optional redaction-failure
|
||||
/// counter from the container; a NoOp default is registered in
|
||||
/// <see cref="ServiceCollectionExtensions.AddAuditLog"/>.
|
||||
/// </summary>
|
||||
public DefaultAuditPayloadFilter(
|
||||
IOptionsMonitor<AuditLogOptions> options,
|
||||
ILogger<DefaultAuditPayloadFilter> logger)
|
||||
ILogger<DefaultAuditPayloadFilter> 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 };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse <paramref name="json"/> as the documented
|
||||
/// <c>{"headers": {...}, "body": ...}</c> shape and replace values whose
|
||||
/// header NAME (case-insensitive) is in <paramref name="redactList"/> with
|
||||
/// <see cref="RedactedMarker"/>. Re-serialises and returns the result.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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 <see cref="RedactorErrorMarker"/> and bump the failure counter.
|
||||
/// </remarks>
|
||||
private string? RedactHeaders(string? json, IList<string> 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<string>(redactList, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Take a snapshot of names first — we cannot mutate while
|
||||
// enumerating the JsonObject.
|
||||
var names = new List<string>(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)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace ScadaLink.AuditLog.Payload;
|
||||
|
||||
/// <summary>
|
||||
/// Counter sink invoked by <see cref="DefaultAuditPayloadFilter"/> every time
|
||||
/// a redactor (header / body regex / SQL parameter) throws and the filter has
|
||||
/// to over-redact the offending field with the
|
||||
/// <c><redacted: redactor error></c> marker. Bundle C bridges this into
|
||||
/// the Site Health Monitoring report payload as <c>AuditRedactionFailure</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public interface IAuditRedactionFailureCounter
|
||||
{
|
||||
/// <summary>Increment the audit-redaction failure counter by one.</summary>
|
||||
void Increment();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace ScadaLink.AuditLog.Payload;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IAuditRedactionFailureCounter"/> 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 <c>AuditRedactionFailure</c>.
|
||||
/// </summary>
|
||||
public sealed class NoOpAuditRedactionFailureCounter : IAuditRedactionFailureCounter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public void Increment()
|
||||
{
|
||||
// Intentionally empty — Bundle C overrides this binding with the real
|
||||
// health-metric counter.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user