|
|
|
|
@@ -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)
|
|
|
|
|
|