feat(audit): ScadaBridge C2 — ScadaBridgeAuditRedactor/SafeDefaultAuditRedactor : IAuditRedactor on canonical record (Task 2.5)
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical-record analogue of <see cref="SafeDefaultAuditPayloadFilter"/> for
|
||||
/// stage C2 (Task 2.5): a minimal always-safe <see cref="IAuditRedactor"/>
|
||||
/// fallback for composition roots that bypass the full
|
||||
/// <see cref="ScadaBridgeAuditRedactor"/>. Performs line-oriented HTTP header
|
||||
/// redaction for the always-sensitive defaults (Authorization, X-Api-Key,
|
||||
/// Cookie, Set-Cookie) on the <c>RequestSummary</c> / <c>ResponseSummary</c>
|
||||
/// fields carried inside <c>ZB.MOM.WW.Audit.AuditEvent.DetailsJson</c>. Does NOT
|
||||
/// perform body-regex redaction, SQL-parameter redaction, or truncation — those
|
||||
/// need <see cref="ScadaBridgeAuditRedactor"/> with live options. Contract:
|
||||
/// over-redact safely, never throw, never miss a header on the default
|
||||
/// sensitive list.
|
||||
/// </summary>
|
||||
public sealed class SafeDefaultAuditRedactor : IAuditRedactor
|
||||
{
|
||||
/// <summary>Singleton instance — the redactor is stateless and side-effect-free.</summary>
|
||||
public static SafeDefaultAuditRedactor Instance { get; } = new SafeDefaultAuditRedactor();
|
||||
|
||||
private static readonly string[] DefaultHeaderRedactList =
|
||||
{
|
||||
"Authorization",
|
||||
"X-Api-Key",
|
||||
"Cookie",
|
||||
"Set-Cookie",
|
||||
};
|
||||
|
||||
private static readonly Regex HeaderRegex = new(
|
||||
@"(?<name>[A-Za-z][A-Za-z0-9\-_]*)\s*:\s*(?<value>[^\r\n]*)",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
private SafeDefaultAuditRedactor() { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public AuditEvent Apply(AuditEvent rawEvent)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rawEvent);
|
||||
|
||||
// Fast path: no DetailsJson means no summaries to scrub.
|
||||
if (string.IsNullOrEmpty(rawEvent.DetailsJson))
|
||||
{
|
||||
return rawEvent;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var d = AuditDetailsCodec.Deserialize(rawEvent.DetailsJson);
|
||||
var scrubbed = d with
|
||||
{
|
||||
RequestSummary = RedactHeaders(d.RequestSummary),
|
||||
ResponseSummary = RedactHeaders(d.ResponseSummary),
|
||||
};
|
||||
return rawEvent with { DetailsJson = AuditDetailsCodec.Serialize(scrubbed) };
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Over-redact: drop both summaries entirely so a malformed parse
|
||||
// path never leaks the original. The contract is "never throw."
|
||||
var safe = new AuditDetails
|
||||
{
|
||||
RequestSummary = "[redacted by SafeDefaultAuditRedactor]",
|
||||
ResponseSummary = "[redacted by SafeDefaultAuditRedactor]",
|
||||
};
|
||||
return rawEvent with { DetailsJson = AuditDetailsCodec.Serialize(safe) };
|
||||
}
|
||||
}
|
||||
|
||||
private static string? RedactHeaders(string? summary)
|
||||
{
|
||||
if (string.IsNullOrEmpty(summary)) return summary;
|
||||
|
||||
return HeaderRegex.Replace(summary, m =>
|
||||
{
|
||||
var name = m.Groups["name"].Value;
|
||||
foreach (var sensitive in DefaultHeaderRedactList)
|
||||
{
|
||||
if (string.Equals(name, sensitive, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return $"{name}: [REDACTED]";
|
||||
}
|
||||
}
|
||||
return m.Value;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical <see cref="IAuditRedactor"/> implementation for ScadaBridge — the
|
||||
/// stage-C2 port of <see cref="DefaultAuditPayloadFilter"/> onto
|
||||
/// <c>ZB.MOM.WW.Audit.AuditEvent</c> and its <see cref="AuditEvent.DetailsJson"/>
|
||||
/// payload bag. The ScadaBridge request/response/error/extra summaries travel
|
||||
/// inside <c>DetailsJson</c> as a <see cref="AuditDetails"/> record (serialized
|
||||
/// by <see cref="AuditDetailsCodec"/>); this redactor deserializes them, applies
|
||||
/// the SAME header → body-regex → SQL-parameter → byte-safe truncation pipeline
|
||||
/// the legacy filter applies, re-serializes, and returns a filtered COPY.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Additive only: the legacy <see cref="IAuditPayloadFilter"/> pipeline stays in
|
||||
/// place and wired until stage C3 swaps the record type; this redactor is the
|
||||
/// canonical-record analogue exercised in isolation by the C2 unit tests.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Cap selection is faithful to the legacy filter, translated onto canonical
|
||||
/// fields:
|
||||
/// <list type="bullet">
|
||||
/// <item>The <c>ApiInbound</c> branch keys on <see cref="AuditEvent.Category"/>
|
||||
/// (= <c>AuditChannel.ToString()</c> per <see cref="AuditFieldBuilders.BuildCategory"/>)
|
||||
/// → <see cref="AuditLogOptions.InboundMaxBytes"/>.</item>
|
||||
/// <item>The "error row" branch reproduces the legacy
|
||||
/// <c>IsErrorStatus(Status)</c> rule — Status NOT IN (<c>Delivered</c>,
|
||||
/// <c>Submitted</c>, <c>Forwarded</c>) → <see cref="AuditLogOptions.ErrorCapBytes"/>.
|
||||
/// The fine-grained status is read from <see cref="AuditDetails.Status"/>
|
||||
/// when present (it must be — <see cref="AuditOutcome"/> alone cannot
|
||||
/// reproduce <c>IsErrorStatus</c>, since <c>Attempted</c>/<c>Skipped</c>
|
||||
/// project to <see cref="AuditOutcome.Success"/> yet take the error cap).
|
||||
/// When <see cref="AuditDetails.Status"/> is absent/unparseable the
|
||||
/// canonical <see cref="AuditEvent.Outcome"/> is the fallback:
|
||||
/// <see cref="AuditOutcome.Failure"/>/<see cref="AuditOutcome.Denied"/>
|
||||
/// → error cap.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// MUST NOT throw — wrapped in try/catch; over-redacts (drops the summaries to a
|
||||
/// safe marker) on any internal failure, mirroring
|
||||
/// <see cref="SafeDefaultAuditPayloadFilter"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ScadaBridgeAuditRedactor : IAuditRedactor
|
||||
{
|
||||
private const string OverRedactedMarker = "[redacted by ScadaBridgeAuditRedactor]";
|
||||
|
||||
private readonly IOptionsMonitor<AuditLogOptions> _options;
|
||||
private readonly ILogger<ScadaBridgeAuditRedactor> _logger;
|
||||
private readonly IAuditRedactionFailureCounter _failureCounter;
|
||||
private readonly AuditRegexCache _regexCache;
|
||||
|
||||
/// <summary>
|
||||
/// Primary constructor used by DI — pulls the optional redaction-failure
|
||||
/// counter from the container; a NoOp default is used when none is supplied.
|
||||
/// </summary>
|
||||
/// <param name="options">Live-reloadable audit log options.</param>
|
||||
/// <param name="logger">Logger for redaction diagnostics.</param>
|
||||
/// <param name="failureCounter">Optional counter incremented when a redaction operation fails; defaults to a no-op.</param>
|
||||
public ScadaBridgeAuditRedactor(
|
||||
IOptionsMonitor<AuditLogOptions> options,
|
||||
ILogger<ScadaBridgeAuditRedactor> logger,
|
||||
IAuditRedactionFailureCounter? failureCounter = null)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_failureCounter = failureCounter ?? new NoOpAuditRedactionFailureCounter();
|
||||
_regexCache = new AuditRegexCache(_logger);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public AuditEvent Apply(AuditEvent rawEvent)
|
||||
{
|
||||
try
|
||||
{
|
||||
var opts = _options.CurrentValue;
|
||||
|
||||
// --- Fast path -------------------------------------------------
|
||||
// Mirror the legacy filter's non-JSON pre-check: when there is no
|
||||
// DetailsJson payload to scrub AND the Target is within the cap,
|
||||
// there is nothing to redact or truncate. Return the input
|
||||
// unchanged so the common case stays cheap (no Deserialize, no
|
||||
// re-Serialize, same instance back).
|
||||
var detailsEmpty = string.IsNullOrEmpty(rawEvent.DetailsJson);
|
||||
var targetWithinCap = rawEvent.Target is null
|
||||
|| Encoding.UTF8.GetByteCount(rawEvent.Target) <= opts.DefaultCapBytes;
|
||||
if (detailsEmpty && targetWithinCap)
|
||||
{
|
||||
return rawEvent;
|
||||
}
|
||||
|
||||
// --- Slow path -------------------------------------------------
|
||||
var d = AuditDetailsCodec.Deserialize(rawEvent.DetailsJson);
|
||||
|
||||
// Cap selection. Channel = canonical Category (the ApiInbound
|
||||
// branch); error-cap selection reproduces the legacy
|
||||
// IsErrorStatus(Status) — read from d.Status when present, else
|
||||
// fall back to the canonical Outcome.
|
||||
var cap = SelectCap(opts, rawEvent.Category, d.Status, rawEvent.Outcome);
|
||||
|
||||
// --- Header-redaction stage (runs BEFORE truncation) ----------
|
||||
var request = RedactHeaders(d.RequestSummary, opts.HeaderRedactList);
|
||||
var response = RedactHeaders(d.ResponseSummary, opts.HeaderRedactList);
|
||||
var errorDetail = d.ErrorDetail;
|
||||
var extra = d.Extra;
|
||||
|
||||
// --- Body-regex stage (also runs BEFORE truncation) -----------
|
||||
// Per-target additions key on the canonical Target.
|
||||
var bodyRegexes = ResolveBodyRegexes(opts, rawEvent.Target);
|
||||
if (bodyRegexes.Count > 0)
|
||||
{
|
||||
request = RedactBody(request, bodyRegexes);
|
||||
response = RedactBody(response, bodyRegexes);
|
||||
errorDetail = RedactBody(errorDetail, bodyRegexes);
|
||||
extra = RedactBody(extra, bodyRegexes);
|
||||
}
|
||||
|
||||
// --- SQL parameter redaction stage (DbOutbound only) ----------
|
||||
// Channel-guarded on the canonical Category; connection key is the
|
||||
// Target prefix before the first '.'.
|
||||
if (string.Equals(rawEvent.Category, nameof(AuditChannel.DbOutbound), StringComparison.Ordinal)
|
||||
&& TryGetSqlParamRedactor(opts, rawEvent.Target, out var sqlParamRegex))
|
||||
{
|
||||
request = RedactSqlParameters(request, sqlParamRegex!);
|
||||
}
|
||||
|
||||
// --- Truncation stage -----------------------------------------
|
||||
var truncated = false;
|
||||
request = TruncateField(request, cap, ref truncated);
|
||||
response = TruncateField(response, cap, ref truncated);
|
||||
errorDetail = TruncateField(errorDetail, cap, ref truncated);
|
||||
extra = TruncateField(extra, cap, ref truncated);
|
||||
|
||||
var rewritten = d with
|
||||
{
|
||||
RequestSummary = request,
|
||||
ResponseSummary = response,
|
||||
ErrorDetail = errorDetail,
|
||||
Extra = extra,
|
||||
PayloadTruncated = d.PayloadTruncated || truncated,
|
||||
};
|
||||
|
||||
// Target length cap (canonical top-level field). Cap at the default
|
||||
// byte ceiling so an absurd Target cannot blow the storage column.
|
||||
var cappedTarget = TruncateTarget(rawEvent.Target, opts.DefaultCapBytes);
|
||||
|
||||
return rawEvent with
|
||||
{
|
||||
DetailsJson = AuditDetailsCodec.Serialize(rewritten),
|
||||
Target = cappedTarget,
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Audit is best-effort: over-redact rather than fail the caller.
|
||||
// Drop the summaries entirely (mirroring SafeDefault's catch path)
|
||||
// and flag PayloadTruncated so downstream readers know the row was
|
||||
// scrubbed defensively.
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Canonical audit redactor failed; over-redacting DetailsJson and flagging PayloadTruncated");
|
||||
IncrementFailureCounter();
|
||||
return OverRedact(rawEvent);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pick the truncation cap. <paramref name="category"/> = canonical Category
|
||||
/// (= channel name): <c>ApiInbound</c> → <see cref="AuditLogOptions.InboundMaxBytes"/>.
|
||||
/// Otherwise the legacy <c>IsErrorStatus</c> rule decides between the error
|
||||
/// and default caps, preferring the fine-grained <paramref name="detailsStatus"/>
|
||||
/// (from <c>DetailsJson</c>) and falling back to the canonical
|
||||
/// <paramref name="outcome"/> when status is absent/unparseable.
|
||||
/// </summary>
|
||||
private static int SelectCap(
|
||||
AuditLogOptions opts,
|
||||
string? category,
|
||||
string? detailsStatus,
|
||||
AuditOutcome outcome)
|
||||
{
|
||||
if (string.Equals(category, nameof(AuditChannel.ApiInbound), StringComparison.Ordinal))
|
||||
{
|
||||
return opts.InboundMaxBytes;
|
||||
}
|
||||
return IsErrorRow(detailsStatus, outcome) ? opts.ErrorCapBytes : opts.DefaultCapBytes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reproduce the legacy <c>IsErrorStatus(Status)</c> error-cap predicate on
|
||||
/// the canonical record: Status NOT IN (<c>Delivered</c>, <c>Submitted</c>,
|
||||
/// <c>Forwarded</c>) → error row. When the fine-grained status is present in
|
||||
/// <c>DetailsJson</c> it is authoritative; otherwise the canonical
|
||||
/// <see cref="AuditOutcome"/> is the fallback
|
||||
/// (<see cref="AuditOutcome.Failure"/>/<see cref="AuditOutcome.Denied"/>
|
||||
/// → error row).
|
||||
/// </summary>
|
||||
private static bool IsErrorRow(string? detailsStatus, AuditOutcome outcome)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(detailsStatus)
|
||||
&& Enum.TryParse<AuditStatus>(detailsStatus, ignoreCase: false, out var status))
|
||||
{
|
||||
return status switch
|
||||
{
|
||||
AuditStatus.Delivered or AuditStatus.Submitted or AuditStatus.Forwarded => false,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
// No usable status — fall back to the canonical outcome.
|
||||
return outcome != AuditOutcome.Success;
|
||||
}
|
||||
|
||||
private string? RedactHeaders(string? json, IList<string> redactList)
|
||||
=> AuditRedactionPrimitives.RedactHeaders(json, redactList, _logger, IncrementFailureCounter);
|
||||
|
||||
private string? RedactBody(string? value, IReadOnlyList<Regex> regexes)
|
||||
=> AuditRedactionPrimitives.RedactBody(value, regexes, _logger, IncrementFailureCounter);
|
||||
|
||||
private string? RedactSqlParameters(string? json, Regex paramNameRegex)
|
||||
=> AuditRedactionPrimitives.RedactSqlParameters(json, paramNameRegex, _logger, IncrementFailureCounter);
|
||||
|
||||
private static string? TruncateField(string? value, int cap, ref bool truncated)
|
||||
=> AuditRedactionPrimitives.TruncateField(value, cap, ref truncated);
|
||||
|
||||
private static string? TruncateTarget(string? target, int cap)
|
||||
=> target is null ? null : AuditRedactionPrimitives.TruncateUtf8(target, cap);
|
||||
|
||||
/// <summary>
|
||||
/// Combine the global and per-target body-redactor lists, returning the
|
||||
/// compiled-regex set to apply. Patterns that failed compilation are
|
||||
/// silently skipped. Identical resolution to
|
||||
/// <see cref="DefaultAuditPayloadFilter"/>.
|
||||
/// </summary>
|
||||
private IReadOnlyList<Regex> ResolveBodyRegexes(AuditLogOptions opts, string? target)
|
||||
{
|
||||
var hasGlobal = opts.GlobalBodyRedactors is { Count: > 0 };
|
||||
var perTargetAdditions = (target != null
|
||||
&& opts.PerTargetOverrides.TryGetValue(target, out var over)
|
||||
&& over.AdditionalBodyRedactors is { Count: > 0 })
|
||||
? over.AdditionalBodyRedactors
|
||||
: null;
|
||||
|
||||
if (!hasGlobal && perTargetAdditions == null)
|
||||
{
|
||||
return Array.Empty<Regex>();
|
||||
}
|
||||
|
||||
var result = new List<Regex>();
|
||||
if (hasGlobal)
|
||||
{
|
||||
foreach (var pattern in opts.GlobalBodyRedactors)
|
||||
{
|
||||
if (_regexCache.TryGet(pattern, out var rx))
|
||||
{
|
||||
result.Add(rx!);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (perTargetAdditions != null)
|
||||
{
|
||||
foreach (var pattern in perTargetAdditions)
|
||||
{
|
||||
if (_regexCache.TryGet(pattern, out var rx))
|
||||
{
|
||||
result.Add(rx!);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the per-connection SQL parameter redaction regex for the given
|
||||
/// target. Connection key = everything before the first <c>.</c> in
|
||||
/// <paramref name="target"/>. Patterns are forced case-insensitive.
|
||||
/// Identical resolution to <see cref="DefaultAuditPayloadFilter"/>.
|
||||
/// </summary>
|
||||
private bool TryGetSqlParamRedactor(AuditLogOptions opts, string? target, out Regex? regex)
|
||||
{
|
||||
regex = null;
|
||||
if (string.IsNullOrEmpty(target))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var dot = target.IndexOf('.');
|
||||
var connectionKey = dot < 0 ? target : target[..dot];
|
||||
|
||||
if (!opts.PerTargetOverrides.TryGetValue(connectionKey, out var over)
|
||||
|| string.IsNullOrEmpty(over.RedactSqlParamsMatching))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var cacheKey = "(?i)" + over.RedactSqlParamsMatching;
|
||||
return _regexCache.TryGet(cacheKey, out regex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Over-redaction copy returned from the never-throws catch: drop the
|
||||
/// request/response summaries inside <c>DetailsJson</c> to a safe marker and
|
||||
/// flag <see cref="AuditDetails.PayloadTruncated"/>. Best-effort re-serialise;
|
||||
/// if even that fails, return the input with no summaries via an empty
|
||||
/// details bag.
|
||||
/// </summary>
|
||||
private static AuditEvent OverRedact(AuditEvent rawEvent)
|
||||
{
|
||||
try
|
||||
{
|
||||
var d = AuditDetailsCodec.Deserialize(rawEvent.DetailsJson) with
|
||||
{
|
||||
RequestSummary = OverRedactedMarker,
|
||||
ResponseSummary = OverRedactedMarker,
|
||||
PayloadTruncated = true,
|
||||
};
|
||||
return rawEvent with { DetailsJson = AuditDetailsCodec.Serialize(d) };
|
||||
}
|
||||
catch
|
||||
{
|
||||
var safe = new AuditDetails
|
||||
{
|
||||
RequestSummary = OverRedactedMarker,
|
||||
ResponseSummary = OverRedactedMarker,
|
||||
PayloadTruncated = true,
|
||||
};
|
||||
return rawEvent with { DetailsJson = AuditDetailsCodec.Serialize(safe) };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bumps the injected redaction-failure counter, swallowing any fault per
|
||||
/// alog.md §7. Passed as the <c>onFailure</c> callback to the shared
|
||||
/// primitives and called from the top-level catch.
|
||||
/// </summary>
|
||||
private void IncrementFailureCounter()
|
||||
{
|
||||
try { _failureCounter.Increment(); } catch { /* swallow per §7 */ }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user