|
|
|
@@ -0,0 +1,587 @@
|
|
|
|
|
using System.Collections.Concurrent;
|
|
|
|
|
using System.Text;
|
|
|
|
|
using System.Text.Encodings.Web;
|
|
|
|
|
using System.Text.Json;
|
|
|
|
|
using System.Text.Json.Nodes;
|
|
|
|
|
using System.Text.RegularExpressions;
|
|
|
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
|
using Microsoft.Extensions.Options;
|
|
|
|
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
|
|
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
|
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
|
|
|
|
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// 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>
|
|
|
|
|
/// Uses <see cref="IOptionsMonitor{TOptions}"/> (not <see cref="IOptions{TOptions}"/>)
|
|
|
|
|
/// so the M5-T8 hot-reload path sees fresh values without re-resolving the
|
|
|
|
|
/// singleton. <see cref="Apply"/> reads <see cref="IOptionsMonitor{T}.CurrentValue"/>
|
|
|
|
|
/// on every call, and the regex cache is keyed by pattern string — patterns
|
|
|
|
|
/// added via a live config change compile on first use of the next event;
|
|
|
|
|
/// patterns removed simply stop being looked up. No <c>OnChange</c> subscription
|
|
|
|
|
/// or explicit cache invalidation is required (the
|
|
|
|
|
/// <c>AuditLogOptionsBindingTests</c> fixture in <c>ZB.MOM.WW.ScadaBridge.AuditLog.Tests</c>
|
|
|
|
|
/// pins this behaviour).
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// "Error row" = <see cref="AuditEvent.Status"/> NOT IN (<c>Delivered</c>,
|
|
|
|
|
/// <c>Submitted</c>, <c>Forwarded</c>) — every other status, including the
|
|
|
|
|
/// non-terminal <c>Attempted</c>, the parked/discarded terminals, and the
|
|
|
|
|
/// short-circuit <c>Skipped</c>, receives the larger error cap so a verbose
|
|
|
|
|
/// error body survives.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// Apply MUST NOT throw — on internal failure the filter over-redacts by
|
|
|
|
|
/// returning the input with <see cref="AuditEvent.PayloadTruncated"/> set and
|
|
|
|
|
/// 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 → body regex redaction → truncation. The SQL-parameter
|
|
|
|
|
/// stage piggybacks on the body-redactor path; both run BEFORE truncation so
|
|
|
|
|
/// the cap trims the redacted result, never bytes the redactor intended to
|
|
|
|
|
/// hide.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// </remarks>
|
|
|
|
|
public sealed class DefaultAuditPayloadFilter : IAuditPayloadFilter
|
|
|
|
|
{
|
|
|
|
|
private const string RedactedMarker = "<redacted>";
|
|
|
|
|
private const string RedactorErrorMarker = "<redacted: redactor error>";
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Per-match regex timeout. Catastrophic-backtracking patterns trip a
|
|
|
|
|
/// <see cref="RegexMatchTimeoutException"/> when a single match takes
|
|
|
|
|
/// longer than this; the offending field is then over-redacted with
|
|
|
|
|
/// <see cref="RedactorErrorMarker"/> and the failure counter is bumped.
|
|
|
|
|
/// 50 ms is generous for normal patterns yet short enough that the
|
|
|
|
|
/// audit hot-path isn't held up by a misconfigured regex.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private static readonly TimeSpan RegexMatchTimeout = TimeSpan.FromMilliseconds(50);
|
|
|
|
|
|
|
|
|
|
/// <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>
|
|
|
|
|
/// Compiled-regex cache keyed by pattern string. Lazy population: each
|
|
|
|
|
/// pattern is compiled on first use and cached forever (the entry's
|
|
|
|
|
/// <see cref="CompiledRegex"/> carries either the working <see cref="Regex"/>
|
|
|
|
|
/// or a sentinel marking the pattern as invalid so we don't retry the
|
|
|
|
|
/// failing compile on every call). ConcurrentDictionary is the right
|
|
|
|
|
/// thread-safety primitive here because the filter is a DI singleton
|
|
|
|
|
/// shared across the audit hot-path.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private readonly ConcurrentDictionary<string, CompiledRegex> _regexCache = new();
|
|
|
|
|
|
|
|
|
|
/// <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>
|
|
|
|
|
/// <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 DefaultAuditPayloadFilter(
|
|
|
|
|
IOptionsMonitor<AuditLogOptions> options,
|
|
|
|
|
ILogger<DefaultAuditPayloadFilter> logger,
|
|
|
|
|
IAuditRedactionFailureCounter? failureCounter = null)
|
|
|
|
|
{
|
|
|
|
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
|
|
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
|
|
|
_failureCounter = failureCounter ?? new NoOpAuditRedactionFailureCounter();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <inheritdoc />
|
|
|
|
|
public AuditEvent Apply(AuditEvent rawEvent)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var opts = _options.CurrentValue;
|
|
|
|
|
// Inbound API gets a dedicated, larger ceiling — request/response bodies are
|
|
|
|
|
// captured verbatim up to InboundMaxBytes (default 1 MiB) so support can
|
|
|
|
|
// replay exactly what the caller sent and what we returned. Other channels
|
|
|
|
|
// keep the global 8 KiB / 64 KiB policy.
|
|
|
|
|
// See docs/plans/2026-05-23-inbound-api-full-response-audit-design.md.
|
|
|
|
|
var cap = rawEvent.Channel == AuditChannel.ApiInbound
|
|
|
|
|
? opts.InboundMaxBytes
|
|
|
|
|
: (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;
|
|
|
|
|
|
|
|
|
|
// --- Body-regex stage (also runs BEFORE truncation) -----------
|
|
|
|
|
// Resolves the active regex set per event so per-target overrides
|
|
|
|
|
// bound to AuditEvent.Target are picked up; effectively a no-op
|
|
|
|
|
// when neither GlobalBodyRedactors nor the per-target additions
|
|
|
|
|
// are configured.
|
|
|
|
|
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) ----------
|
|
|
|
|
// Parses the M4 AuditingDbCommand RequestSummary shape
|
|
|
|
|
// {"sql":"...","parameters":{...}} and redacts parameter VALUES
|
|
|
|
|
// whose NAME matches the per-connection regex. Opt-in: no
|
|
|
|
|
// PerTargetOverrides[connectionName].RedactSqlParamsMatching =>
|
|
|
|
|
// no-op. Channel-guarded so the same regex can never accidentally
|
|
|
|
|
// touch an ApiOutbound row.
|
|
|
|
|
if (rawEvent.Channel == AuditChannel.DbOutbound
|
|
|
|
|
&& 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);
|
|
|
|
|
|
|
|
|
|
return rawEvent with
|
|
|
|
|
{
|
|
|
|
|
RequestSummary = request,
|
|
|
|
|
ResponseSummary = response,
|
|
|
|
|
ErrorDetail = errorDetail,
|
|
|
|
|
Extra = extra,
|
|
|
|
|
PayloadTruncated = rawEvent.PayloadTruncated || truncated,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
// Audit is best-effort: over-redact rather than fail the caller.
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Combine the global and per-target body-redactor lists for a single
|
|
|
|
|
/// event, returning the compiled-regex set to apply. Patterns that failed
|
|
|
|
|
/// compilation are silently skipped — the compile-time failure was logged
|
|
|
|
|
/// once on first encounter; we never let one bad pattern starve the rest.
|
|
|
|
|
/// </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 (TryGetCompiledRegex(pattern, out var rx))
|
|
|
|
|
{
|
|
|
|
|
result.Add(rx!);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (perTargetAdditions != null)
|
|
|
|
|
{
|
|
|
|
|
foreach (var pattern in perTargetAdditions)
|
|
|
|
|
{
|
|
|
|
|
if (TryGetCompiledRegex(pattern, out var rx))
|
|
|
|
|
{
|
|
|
|
|
result.Add(rx!);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Resolve a compiled regex from the cache, compiling it on first use.
|
|
|
|
|
/// Returns <c>false</c> for patterns that are invalid OR whose compile
|
|
|
|
|
/// took longer than 100 ms (the spec calls catastrophic-backtracking
|
|
|
|
|
/// guesses at compile time "invalid"); the failure is logged once and
|
|
|
|
|
/// the sentinel cache entry prevents repeat compile attempts.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private bool TryGetCompiledRegex(string pattern, out Regex? regex)
|
|
|
|
|
{
|
|
|
|
|
var entry = _regexCache.GetOrAdd(pattern, CompileRegex);
|
|
|
|
|
regex = entry.Regex;
|
|
|
|
|
return entry.Regex != null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private CompiledRegex CompileRegex(string pattern)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var swStart = System.Diagnostics.Stopwatch.GetTimestamp();
|
|
|
|
|
var rx = new Regex(pattern, RegexOptions.Compiled, RegexMatchTimeout);
|
|
|
|
|
var elapsedMs = (System.Diagnostics.Stopwatch.GetTimestamp() - swStart)
|
|
|
|
|
* 1000d / System.Diagnostics.Stopwatch.Frequency;
|
|
|
|
|
if (elapsedMs > 100)
|
|
|
|
|
{
|
|
|
|
|
_logger.LogWarning(
|
|
|
|
|
"Body redactor pattern compiled in {Elapsed}ms (> 100ms cap); rejecting '{Pattern}'",
|
|
|
|
|
elapsedMs, pattern);
|
|
|
|
|
return CompiledRegex.Invalid;
|
|
|
|
|
}
|
|
|
|
|
return new CompiledRegex(rx);
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
_logger.LogWarning(
|
|
|
|
|
ex,
|
|
|
|
|
"Body redactor pattern '{Pattern}' failed to compile; skipping",
|
|
|
|
|
pattern);
|
|
|
|
|
return CompiledRegex.Invalid;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Apply each compiled body-redactor regex to <paramref name="value"/> in
|
|
|
|
|
/// turn, replacing every match with <see cref="RedactedMarker"/>. If any
|
|
|
|
|
/// single regex match throws (most commonly
|
|
|
|
|
/// <see cref="RegexMatchTimeoutException"/>) the field is over-redacted
|
|
|
|
|
/// with <see cref="RedactorErrorMarker"/> and the failure counter is
|
|
|
|
|
/// incremented — the user-facing action is never aborted.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private string? RedactBody(string? value, IReadOnlyList<Regex> regexes)
|
|
|
|
|
{
|
|
|
|
|
if (value is null)
|
|
|
|
|
{
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
var current = value;
|
|
|
|
|
foreach (var rx in regexes)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
current = rx.Replace(current, RedactedMarker);
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
_logger.LogWarning(
|
|
|
|
|
ex,
|
|
|
|
|
"Body redactor '{Pattern}' faulted; over-redacting field with '{Marker}'",
|
|
|
|
|
rx.ToString(), RedactorErrorMarker);
|
|
|
|
|
try { _failureCounter.Increment(); } catch { /* swallow per §7 */ }
|
|
|
|
|
return RedactorErrorMarker;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return current;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Resolve the per-connection SQL parameter redaction regex for the given
|
|
|
|
|
/// DbOutbound event target. Target shape (M4 AuditingDbCommand): the
|
|
|
|
|
/// connection name optionally followed by <c>.<sql-snippet></c> for
|
|
|
|
|
/// disambiguation; the per-target dictionary is keyed by the connection
|
|
|
|
|
/// name alone, so we strip the snippet suffix before lookup. Patterns are
|
|
|
|
|
/// compiled with case-insensitive matching to match the documented
|
|
|
|
|
/// behaviour.
|
|
|
|
|
/// </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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Force case-insensitivity per the spec — even if the operator wrote
|
|
|
|
|
// the pattern without an IgnoreCase flag. The compile cache key folds
|
|
|
|
|
// the option to keep the entries unambiguous.
|
|
|
|
|
var cacheKey = "(?i)" + over.RedactSqlParamsMatching;
|
|
|
|
|
if (!TryGetCompiledRegex(cacheKey, out regex))
|
|
|
|
|
{
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Walk the M4 <c>{"sql":"...","parameters":{...}}</c> RequestSummary
|
|
|
|
|
/// shape; for each parameter whose NAME matches
|
|
|
|
|
/// <paramref name="paramNameRegex"/>, replace its value with
|
|
|
|
|
/// <see cref="RedactedMarker"/>. Re-serialise.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// No-op pass-through when the input isn't parseable JSON, isn't a JSON
|
|
|
|
|
/// object, or doesn't carry a top-level <c>"parameters"</c> object. On
|
|
|
|
|
/// any unexpected fault the field is over-redacted with
|
|
|
|
|
/// <see cref="RedactorErrorMarker"/> and the failure counter is bumped.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
private string? RedactSqlParameters(string? json, Regex paramNameRegex)
|
|
|
|
|
{
|
|
|
|
|
if (json is null)
|
|
|
|
|
{
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var trimmed = json.AsSpan().TrimStart();
|
|
|
|
|
if (trimmed.Length == 0 || trimmed[0] != '{')
|
|
|
|
|
{
|
|
|
|
|
return json;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
JsonNode? root;
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
root = JsonNode.Parse(json);
|
|
|
|
|
}
|
|
|
|
|
catch (JsonException)
|
|
|
|
|
{
|
|
|
|
|
return json;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (root is not JsonObject obj || obj["parameters"] is not JsonObject parameters)
|
|
|
|
|
{
|
|
|
|
|
return json;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Snapshot the names — mutating during enumeration is unsupported.
|
|
|
|
|
var names = new List<string>(parameters.Count);
|
|
|
|
|
foreach (var kvp in parameters)
|
|
|
|
|
{
|
|
|
|
|
names.Add(kvp.Key);
|
|
|
|
|
}
|
|
|
|
|
var anyChanged = false;
|
|
|
|
|
foreach (var name in names)
|
|
|
|
|
{
|
|
|
|
|
bool matched;
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
matched = paramNameRegex.IsMatch(name);
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
_logger.LogWarning(
|
|
|
|
|
ex,
|
|
|
|
|
"SQL parameter redactor faulted; over-redacting field with '{Marker}'",
|
|
|
|
|
RedactorErrorMarker);
|
|
|
|
|
try { _failureCounter.Increment(); } catch { /* swallow per §7 */ }
|
|
|
|
|
return RedactorErrorMarker;
|
|
|
|
|
}
|
|
|
|
|
if (matched)
|
|
|
|
|
{
|
|
|
|
|
parameters[name] = JsonValue.Create(RedactedMarker);
|
|
|
|
|
anyChanged = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Avoid re-serialising (which would normalise whitespace / order)
|
|
|
|
|
// when no parameter matched — keeps the on-disk row byte-identical
|
|
|
|
|
// to the emitter's output on the no-match path.
|
|
|
|
|
return anyChanged ? obj.ToJsonString(RedactedSummaryJsonOptions) : json;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
_logger.LogWarning(
|
|
|
|
|
ex,
|
|
|
|
|
"SQL parameter 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)
|
|
|
|
|
{
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
var result = TruncateUtf8(value, cap);
|
|
|
|
|
if (result.Length != value.Length)
|
|
|
|
|
{
|
|
|
|
|
truncated = true;
|
|
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// UTF-8 byte-safe truncation. Encodes the input to UTF-8, walks back from
|
|
|
|
|
/// the cap position until the byte is NOT a continuation byte
|
|
|
|
|
/// (<c>byte & 0xC0 == 0x80</c>), and decodes the resulting prefix —
|
|
|
|
|
/// guaranteeing the returned string never splits a multi-byte sequence.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private static string TruncateUtf8(string value, int capBytes)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrEmpty(value))
|
|
|
|
|
{
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
var bytes = Encoding.UTF8.GetBytes(value);
|
|
|
|
|
if (bytes.Length <= capBytes)
|
|
|
|
|
{
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
var boundary = capBytes;
|
|
|
|
|
while (boundary > 0 && (bytes[boundary] & 0xC0) == 0x80)
|
|
|
|
|
{
|
|
|
|
|
boundary--;
|
|
|
|
|
}
|
|
|
|
|
return Encoding.UTF8.GetString(bytes, 0, boundary);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static bool IsErrorStatus(AuditStatus status) => status switch
|
|
|
|
|
{
|
|
|
|
|
AuditStatus.Delivered or AuditStatus.Submitted or AuditStatus.Forwarded => false,
|
|
|
|
|
_ => true,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Cache entry for a body-redactor pattern. Carries the working
|
|
|
|
|
/// <see cref="Regex"/> on the success path, or the
|
|
|
|
|
/// <see cref="Invalid"/> sentinel for patterns that failed to compile
|
|
|
|
|
/// (or exceeded the 100 ms compile budget). The sentinel lets us skip
|
|
|
|
|
/// repeat compile attempts on every event without re-throwing on the
|
|
|
|
|
/// hot-path.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private readonly struct CompiledRegex
|
|
|
|
|
{
|
|
|
|
|
public static readonly CompiledRegex Invalid = new(null);
|
|
|
|
|
|
|
|
|
|
/// <summary>Gets the compiled <see cref="System.Text.RegularExpressions.Regex"/>, or <c>null</c> when the pattern was invalid.</summary>
|
|
|
|
|
public Regex? Regex { get; }
|
|
|
|
|
|
|
|
|
|
/// <summary>Initializes a new <see cref="CompiledRegex"/> wrapping the given compiled regex instance.</summary>
|
|
|
|
|
/// <param name="regex">The pre-compiled regex, or <c>null</c> to represent an invalid pattern.</param>
|
|
|
|
|
public CompiledRegex(Regex? regex) => Regex = regex;
|
|
|
|
|
}
|
|
|
|
|
}
|