diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Payload/AuditRedactionPrimitives.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Payload/AuditRedactionPrimitives.cs
new file mode 100644
index 00000000..663b21e5
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Payload/AuditRedactionPrimitives.cs
@@ -0,0 +1,310 @@
+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;
+
+namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
+
+///
+/// Pure, stateless redaction + truncation primitives shared by the legacy
+/// (which operates on the
+/// entity)
+/// and the canonical (which operates on
+/// ZB.MOM.WW.Audit.AuditEvent + its DetailsJson). Extracted in
+/// ScadaBridge audit re-architecture stage C2 (Task 2.5) so the byte-exact
+/// redaction logic lives in ONE place and the two code paths can never drift.
+///
+///
+///
+/// Each stage method is a pure function of its inputs (no instance state). The
+/// only side effects are diagnostics-only: a warning log line and an
+/// callback invocation when a redactor faults, so
+/// the caller can bump its redaction-failure health counter. The callbacks are
+/// passed in (rather than the counter interface) to keep this helper free of
+/// any DI / health-metric coupling.
+///
+///
+/// The regex CACHE and per-call options resolution deliberately stay in
+/// — they carry per-instance state
+/// (lazy compile, 100 ms compile budget, sentinel entries) that the safety-net
+/// tests pin to that class. This helper only holds the stateless stages that
+/// operate once the compiled regex set / redact list / cap has already been
+/// resolved.
+///
+///
+internal static class AuditRedactionPrimitives
+{
+ /// Marker replacing redacted header values, body matches, and SQL parameter values.
+ public const string RedactedMarker = "";
+
+ /// Over-redaction marker emitted when a redactor stage itself faults.
+ public const string RedactorErrorMarker = "";
+
+ ///
+ /// JSON serializer options used to re-emit redacted summaries. The
+ /// UnsafeRelaxedJsonEscaping encoder is required so the redaction marker
+ /// (which contains < / >) survives unescaped — matching
+ /// the legacy filter's output byte-for-byte.
+ ///
+ public static readonly JsonSerializerOptions RedactedSummaryJsonOptions = new()
+ {
+ Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
+ };
+
+ ///
+ /// Parse as the documented
+ /// {"headers": {...}, "body": ...} shape and replace values whose
+ /// header NAME (case-insensitive) is in with
+ /// . Re-serialises and returns the result.
+ /// No-op pass-through for inputs that are not JSON-object-shaped or do not
+ /// carry a top-level headers object. On any unexpected fault the
+ /// field is over-redacted with and
+ /// is invoked.
+ ///
+ public static string? RedactHeaders(
+ string? json,
+ IList redactList,
+ ILogger logger,
+ Action onFailure)
+ {
+ 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.
+ 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(redactList, StringComparer.OrdinalIgnoreCase);
+
+ // Take a snapshot of names first — we cannot mutate while
+ // enumerating the JsonObject.
+ var names = new List(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 { onFailure(); } catch { /* swallow per §7 */ }
+ return RedactorErrorMarker;
+ }
+ }
+
+ ///
+ /// Apply each compiled body-redactor regex to in
+ /// turn, replacing every match with . If any
+ /// single regex match throws (most commonly
+ /// ) the field is over-redacted
+ /// with and
+ /// is invoked — the user-facing action is never aborted.
+ ///
+ public static string? RedactBody(
+ string? value,
+ IReadOnlyList regexes,
+ ILogger logger,
+ Action onFailure)
+ {
+ 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 { onFailure(); } catch { /* swallow per §7 */ }
+ return RedactorErrorMarker;
+ }
+ }
+ return current;
+ }
+
+ ///
+ /// Walk the M4 {"sql":"...","parameters":{...}} RequestSummary
+ /// shape; for each parameter whose NAME matches
+ /// , replace its value with
+ /// . Re-serialise. No-op pass-through when the
+ /// input is not parseable JSON, is not a JSON object, or does not carry a
+ /// top-level "parameters" object. On any unexpected fault the field
+ /// is over-redacted with and
+ /// is invoked.
+ ///
+ public static string? RedactSqlParameters(
+ string? json,
+ Regex paramNameRegex,
+ ILogger logger,
+ Action onFailure)
+ {
+ 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(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 { onFailure(); } 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 { onFailure(); } catch { /* swallow per §7 */ }
+ return RedactorErrorMarker;
+ }
+ }
+
+ ///
+ /// Truncate to UTF-8 bytes,
+ /// setting to true when the value was
+ /// shortened. Null passes through as null.
+ ///
+ public 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;
+ }
+
+ ///
+ /// 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
+ /// (byte & 0xC0 == 0x80), and decodes the resulting prefix —
+ /// guaranteeing the returned string never splits a multi-byte sequence.
+ ///
+ public 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);
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Payload/AuditRegexCache.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Payload/AuditRegexCache.cs
new file mode 100644
index 00000000..6d5f0646
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Payload/AuditRegexCache.cs
@@ -0,0 +1,96 @@
+using System.Collections.Concurrent;
+using System.Text.RegularExpressions;
+using Microsoft.Extensions.Logging;
+
+namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
+
+///
+/// Per-instance compiled-regex cache for audit body / SQL-parameter redactors.
+/// Extracted in ScadaBridge audit re-architecture stage C2 (Task 2.5) so the
+/// legacy and the canonical
+///
+/// share the SAME compile rules (50 ms per-match timeout, 100 ms compile budget,
+/// invalid-pattern sentinel) rather than duplicating the logic.
+///
+///
+///
+/// Lazy population keyed by pattern string: each pattern is compiled on first
+/// use and cached forever. A failed compile (or a compile slower than 100 ms)
+/// caches a sentinel so the failing compile is not retried on every event. The
+/// failure is logged once on first encounter.
+/// is the right primitive because the owning redactor is a DI singleton on the
+/// audit hot-path.
+///
+///
+internal sealed class AuditRegexCache
+{
+ ///
+ /// Per-match regex timeout. Catastrophic-backtracking patterns trip a
+ /// when a single match takes longer
+ /// than this; the caller then over-redacts the offending field. 50 ms is
+ /// generous for normal patterns yet short enough that the audit hot-path is
+ /// not held up by a misconfigured regex.
+ ///
+ private static readonly TimeSpan RegexMatchTimeout = TimeSpan.FromMilliseconds(50);
+
+ private readonly ConcurrentDictionary _cache = new();
+ private readonly ILogger _logger;
+
+ public AuditRegexCache(ILogger logger) => _logger = logger;
+
+ ///
+ /// Resolve a compiled regex from the cache, compiling it on first use.
+ /// Returns false 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.
+ ///
+ public bool TryGet(string pattern, out Regex? regex)
+ {
+ var entry = _cache.GetOrAdd(pattern, Compile);
+ regex = entry.Regex;
+ return entry.Regex != null;
+ }
+
+ private CompiledRegex Compile(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;
+ }
+ }
+
+ ///
+ /// Cache entry for a body-redactor pattern. Carries the working
+ /// on the success path, or the
+ /// sentinel for patterns that failed to compile (or exceeded the 100 ms
+ /// compile budget).
+ ///
+ private readonly struct CompiledRegex
+ {
+ public static readonly CompiledRegex Invalid = new(null);
+
+ public Regex? Regex { get; }
+
+ public CompiledRegex(Regex? regex) => Regex = regex;
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Payload/DefaultAuditPayloadFilter.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Payload/DefaultAuditPayloadFilter.cs
index e6f1d5d9..5d36730d 100644
--- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Payload/DefaultAuditPayloadFilter.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Payload/DefaultAuditPayloadFilter.cs
@@ -1,8 +1,3 @@
-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;
@@ -55,48 +50,15 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
///
public sealed class DefaultAuditPayloadFilter : IAuditPayloadFilter
{
- private const string RedactedMarker = "";
- private const string RedactorErrorMarker = "";
-
- ///
- /// Per-match regex timeout. Catastrophic-backtracking patterns trip a
- /// when a single match takes
- /// longer than this; the offending field is then over-redacted with
- /// 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.
- ///
- private static readonly TimeSpan RegexMatchTimeout = TimeSpan.FromMilliseconds(50);
-
- ///
- /// JSON serializer options used to re-emit redacted summaries. The
- /// UnsafeRelaxedJsonEscaping encoder is required so the redaction marker
- /// (which contains < / >) survives unescaped — the
- /// header-redaction tests grep for the literal marker, and the downstream
- /// UI / log readers would rather see <redacted> than
- /// . 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.
- ///
- private static readonly JsonSerializerOptions RedactedSummaryJsonOptions = new()
- {
- Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
- };
-
+ // Redaction markers + the relaxed-escaping JSON options live in
+ // AuditRedactionPrimitives, and the compiled-regex cache (50 ms match
+ // timeout, 100 ms compile budget, invalid-pattern sentinel) lives in
+ // AuditRegexCache — both shared C2 helpers so the legacy filter and the
+ // canonical ScadaBridgeAuditRedactor emit byte-identical output.
private readonly IOptionsMonitor _options;
private readonly ILogger _logger;
private readonly IAuditRedactionFailureCounter _failureCounter;
-
- ///
- /// Compiled-regex cache keyed by pattern string. Lazy population: each
- /// pattern is compiled on first use and cached forever (the entry's
- /// carries either the working
- /// 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.
- ///
- private readonly ConcurrentDictionary _regexCache = new();
+ private readonly AuditRegexCache _regexCache;
///
/// Primary constructor used by DI — pulls the optional redaction-failure
@@ -114,6 +76,7 @@ public sealed class DefaultAuditPayloadFilter : IAuditPayloadFilter
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_failureCounter = failureCounter ?? new NoOpAuditRedactionFailureCounter();
+ _regexCache = new AuditRegexCache(_logger);
}
///
@@ -198,83 +161,18 @@ public sealed class DefaultAuditPayloadFilter : IAuditPayloadFilter
/// Parse as the documented
/// {"headers": {...}, "body": ...} shape and replace values whose
/// header NAME (case-insensitive) is in with
- /// . Re-serialises and returns the result.
+ /// the redaction marker. Re-serialises and returns the result. Delegates to
+ /// .
///
///
/// 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 and bump the failure counter.
+ /// with the redactor-error marker and bump the failure counter.
///
private string? RedactHeaders(string? json, IList 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(redactList, StringComparer.OrdinalIgnoreCase);
-
- // Take a snapshot of names first — we cannot mutate while
- // enumerating the JsonObject.
- var names = new List(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;
- }
- }
+ => AuditRedactionPrimitives.RedactHeaders(json, redactList, _logger, IncrementFailureCounter);
///
/// Combine the global and per-target body-redactor lists for a single
@@ -301,7 +199,7 @@ public sealed class DefaultAuditPayloadFilter : IAuditPayloadFilter
{
foreach (var pattern in opts.GlobalBodyRedactors)
{
- if (TryGetCompiledRegex(pattern, out var rx))
+ if (_regexCache.TryGet(pattern, out var rx))
{
result.Add(rx!);
}
@@ -311,7 +209,7 @@ public sealed class DefaultAuditPayloadFilter : IAuditPayloadFilter
{
foreach (var pattern in perTargetAdditions)
{
- if (TryGetCompiledRegex(pattern, out var rx))
+ if (_regexCache.TryGet(pattern, out var rx))
{
result.Add(rx!);
}
@@ -320,80 +218,17 @@ public sealed class DefaultAuditPayloadFilter : IAuditPayloadFilter
return result;
}
- ///
- /// Resolve a compiled regex from the cache, compiling it on first use.
- /// Returns false 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.
- ///
- 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;
- }
- }
-
///
/// Apply each compiled body-redactor regex to in
- /// turn, replacing every match with . If any
- /// single regex match throws (most commonly
- /// ) the field is over-redacted
- /// with and the failure counter is
- /// incremented — the user-facing action is never aborted.
+ /// turn, replacing every match with the redaction marker. If any single
+ /// regex match throws (most commonly
+ /// ) the field is over-redacted with
+ /// the redactor-error marker and the failure counter is incremented — the
+ /// user-facing action is never aborted. Delegates to
+ /// .
///
private string? RedactBody(string? value, IReadOnlyList 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;
- }
+ => AuditRedactionPrimitives.RedactBody(value, regexes, _logger, IncrementFailureCounter);
///
/// Resolve the per-connection SQL parameter redaction regex for the given
@@ -425,7 +260,7 @@ public sealed class DefaultAuditPayloadFilter : IAuditPayloadFilter
// 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))
+ if (!_regexCache.TryGet(cacheKey, out regex))
{
return false;
}
@@ -435,128 +270,30 @@ public sealed class DefaultAuditPayloadFilter : IAuditPayloadFilter
///
/// Walk the M4 {"sql":"...","parameters":{...}} RequestSummary
/// shape; for each parameter whose NAME matches
- /// , replace its value with
- /// . Re-serialise.
+ /// , replace its value with the redaction
+ /// marker. Re-serialise. Delegates to
+ /// .
///
///
/// No-op pass-through when the input isn't parseable JSON, isn't a JSON
/// object, or doesn't carry a top-level "parameters" object. On
- /// any unexpected fault the field is over-redacted with
- /// and the failure counter is bumped.
+ /// any unexpected fault the field is over-redacted and the failure counter
+ /// is bumped.
///
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(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;
- }
- }
+ => AuditRedactionPrimitives.RedactSqlParameters(json, paramNameRegex, _logger, IncrementFailureCounter);
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;
- }
+ => AuditRedactionPrimitives.TruncateField(value, cap, ref truncated);
///
- /// 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
- /// (byte & 0xC0 == 0x80), and decodes the resulting prefix —
- /// guaranteeing the returned string never splits a multi-byte sequence.
+ /// Bumps the injected redaction-failure counter, swallowing any fault per
+ /// alog.md §7 (a counter failure must never abort the audited action).
+ /// Passed as the onFailure callback to the shared primitives.
///
- private static string TruncateUtf8(string value, int capBytes)
+ private void IncrementFailureCounter()
{
- 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);
+ try { _failureCounter.Increment(); } catch { /* swallow per §7 */ }
}
private static bool IsErrorStatus(AuditStatus status) => status switch
@@ -564,24 +301,4 @@ public sealed class DefaultAuditPayloadFilter : IAuditPayloadFilter
AuditStatus.Delivered or AuditStatus.Submitted or AuditStatus.Forwarded => false,
_ => true,
};
-
- ///
- /// Cache entry for a body-redactor pattern. Carries the working
- /// on the success path, or the
- /// 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.
- ///
- private readonly struct CompiledRegex
- {
- public static readonly CompiledRegex Invalid = new(null);
-
- /// Gets the compiled , or null when the pattern was invalid.
- public Regex? Regex { get; }
-
- /// Initializes a new wrapping the given compiled regex instance.
- /// The pre-compiled regex, or null to represent an invalid pattern.
- public CompiledRegex(Regex? regex) => Regex = regex;
- }
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Redaction/SafeDefaultAuditRedactor.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Redaction/SafeDefaultAuditRedactor.cs
new file mode 100644
index 00000000..29940916
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Redaction/SafeDefaultAuditRedactor.cs
@@ -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;
+
+///
+/// Canonical-record analogue of for
+/// stage C2 (Task 2.5): a minimal always-safe
+/// fallback for composition roots that bypass the full
+/// . Performs line-oriented HTTP header
+/// redaction for the always-sensitive defaults (Authorization, X-Api-Key,
+/// Cookie, Set-Cookie) on the RequestSummary / ResponseSummary
+/// fields carried inside ZB.MOM.WW.Audit.AuditEvent.DetailsJson. Does NOT
+/// perform body-regex redaction, SQL-parameter redaction, or truncation — those
+/// need with live options. Contract:
+/// over-redact safely, never throw, never miss a header on the default
+/// sensitive list.
+///
+public sealed class SafeDefaultAuditRedactor : IAuditRedactor
+{
+ /// Singleton instance — the redactor is stateless and side-effect-free.
+ 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(
+ @"(?[A-Za-z][A-Za-z0-9\-_]*)\s*:\s*(?[^\r\n]*)",
+ RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+ private SafeDefaultAuditRedactor() { }
+
+ ///
+ 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;
+ });
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Redaction/ScadaBridgeAuditRedactor.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Redaction/ScadaBridgeAuditRedactor.cs
new file mode 100644
index 00000000..2db12e7b
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Redaction/ScadaBridgeAuditRedactor.cs
@@ -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;
+
+///
+/// Canonical implementation for ScadaBridge — the
+/// stage-C2 port of onto
+/// ZB.MOM.WW.Audit.AuditEvent and its
+/// payload bag. The ScadaBridge request/response/error/extra summaries travel
+/// inside DetailsJson as a record (serialized
+/// by ); 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.
+///
+///
+///
+/// Additive only: the legacy 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.
+///
+///
+/// Cap selection is faithful to the legacy filter, translated onto canonical
+/// fields:
+///
+/// - The ApiInbound branch keys on
+/// (= AuditChannel.ToString() per )
+/// → .
+/// - The "error row" branch reproduces the legacy
+/// IsErrorStatus(Status) rule — Status NOT IN (Delivered,
+/// Submitted, Forwarded) → .
+/// The fine-grained status is read from
+/// when present (it must be — alone cannot
+/// reproduce IsErrorStatus, since Attempted/Skipped
+/// project to yet take the error cap).
+/// When is absent/unparseable the
+/// canonical is the fallback:
+/// /
+/// → error cap.
+///
+///
+///
+/// MUST NOT throw — wrapped in try/catch; over-redacts (drops the summaries to a
+/// safe marker) on any internal failure, mirroring
+/// .
+///
+///
+public sealed class ScadaBridgeAuditRedactor : IAuditRedactor
+{
+ private const string OverRedactedMarker = "[redacted by ScadaBridgeAuditRedactor]";
+
+ private readonly IOptionsMonitor _options;
+ private readonly ILogger _logger;
+ private readonly IAuditRedactionFailureCounter _failureCounter;
+ private readonly AuditRegexCache _regexCache;
+
+ ///
+ /// Primary constructor used by DI — pulls the optional redaction-failure
+ /// counter from the container; a NoOp default is used when none is supplied.
+ ///
+ /// Live-reloadable audit log options.
+ /// Logger for redaction diagnostics.
+ /// Optional counter incremented when a redaction operation fails; defaults to a no-op.
+ public ScadaBridgeAuditRedactor(
+ IOptionsMonitor options,
+ ILogger 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);
+ }
+
+ ///
+ 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);
+ }
+ }
+
+ ///
+ /// Pick the truncation cap. = canonical Category
+ /// (= channel name): ApiInbound → .
+ /// Otherwise the legacy IsErrorStatus rule decides between the error
+ /// and default caps, preferring the fine-grained
+ /// (from DetailsJson) and falling back to the canonical
+ /// when status is absent/unparseable.
+ ///
+ 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;
+ }
+
+ ///
+ /// Reproduce the legacy IsErrorStatus(Status) error-cap predicate on
+ /// the canonical record: Status NOT IN (Delivered, Submitted,
+ /// Forwarded) → error row. When the fine-grained status is present in
+ /// DetailsJson it is authoritative; otherwise the canonical
+ /// is the fallback
+ /// (/
+ /// → error row).
+ ///
+ private static bool IsErrorRow(string? detailsStatus, AuditOutcome outcome)
+ {
+ if (!string.IsNullOrEmpty(detailsStatus)
+ && Enum.TryParse(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 redactList)
+ => AuditRedactionPrimitives.RedactHeaders(json, redactList, _logger, IncrementFailureCounter);
+
+ private string? RedactBody(string? value, IReadOnlyList 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);
+
+ ///
+ /// 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
+ /// .
+ ///
+ private IReadOnlyList 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();
+ }
+
+ var result = new List();
+ 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;
+ }
+
+ ///
+ /// Resolve the per-connection SQL parameter redaction regex for the given
+ /// target. Connection key = everything before the first . in
+ /// . Patterns are forced case-insensitive.
+ /// Identical resolution to .
+ ///
+ 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);
+ }
+
+ ///
+ /// Over-redaction copy returned from the never-throws catch: drop the
+ /// request/response summaries inside DetailsJson to a safe marker and
+ /// flag . Best-effort re-serialise;
+ /// if even that fails, return the input with no summaries via an empty
+ /// details bag.
+ ///
+ 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) };
+ }
+ }
+
+ ///
+ /// Bumps the injected redaction-failure counter, swallowing any fault per
+ /// alog.md §7. Passed as the onFailure callback to the shared
+ /// primitives and called from the top-level catch.
+ ///
+ private void IncrementFailureCounter()
+ {
+ try { _failureCounter.Increment(); } catch { /* swallow per §7 */ }
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Redaction/SafeDefaultAuditRedactorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Redaction/SafeDefaultAuditRedactorTests.cs
new file mode 100644
index 00000000..cb74fc8b
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Redaction/SafeDefaultAuditRedactorTests.cs
@@ -0,0 +1,122 @@
+using ZB.MOM.WW.Audit;
+using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
+
+namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Redaction;
+
+///
+/// ScadaBridge audit re-architecture stage C2 (Task 2.5) tests for
+/// — the canonical-record analogue of
+/// .
+/// Header-only scrub of the always-sensitive default headers inside
+/// DetailsJson's RequestSummary / ResponseSummary; never throws, never
+/// performs body / SQL / truncation work.
+///
+public class SafeDefaultAuditRedactorTests
+{
+ private static AuditEvent EventWith(string? request = null, string? response = null)
+ {
+ var details = new AuditDetails
+ {
+ RequestSummary = request,
+ ResponseSummary = response,
+ };
+ return new AuditEvent
+ {
+ EventId = Guid.NewGuid(),
+ OccurredAtUtc = DateTimeOffset.UtcNow,
+ Actor = "tester",
+ Action = "ApiOutbound.ApiCall",
+ Outcome = AuditOutcome.Success,
+ DetailsJson = AuditDetailsCodec.Serialize(details),
+ };
+ }
+
+ private static AuditDetails Details(AuditEvent evt) =>
+ AuditDetailsCodec.Deserialize(evt.DetailsJson);
+
+ [Fact]
+ public void Redacts_DefaultSensitiveHeaders_InRequestSummary()
+ {
+ var evt = EventWith(request: "Authorization: Bearer secret-token\nContent-Type: application/json");
+
+ var result = SafeDefaultAuditRedactor.Instance.Apply(evt);
+
+ var d = Details(result);
+ Assert.Contains("Authorization: [REDACTED]", d.RequestSummary!);
+ Assert.DoesNotContain("secret-token", d.RequestSummary!);
+ Assert.Contains("Content-Type: application/json", d.RequestSummary!);
+ }
+
+ [Fact]
+ public void Redacts_DefaultSensitiveHeaders_InResponseSummary()
+ {
+ var evt = EventWith(response: "Set-Cookie: sessionid=abc123\nX-Other: ok");
+
+ var result = SafeDefaultAuditRedactor.Instance.Apply(evt);
+
+ var d = Details(result);
+ Assert.Contains("Set-Cookie: [REDACTED]", d.ResponseSummary!);
+ Assert.DoesNotContain("abc123", d.ResponseSummary!);
+ Assert.Contains("X-Other: ok", d.ResponseSummary!);
+ }
+
+ [Fact]
+ public void CaseInsensitive_HeaderName_Redacted()
+ {
+ var evt = EventWith(request: "authorization: Bearer x-y-z");
+
+ var result = SafeDefaultAuditRedactor.Instance.Apply(evt);
+
+ Assert.Contains("[REDACTED]", Details(result).RequestSummary!);
+ Assert.DoesNotContain("x-y-z", Details(result).RequestSummary!);
+ }
+
+ [Fact]
+ public void NonSensitiveHeader_Preserved()
+ {
+ var evt = EventWith(request: "X-Request-Id: abc-123\nAccept: application/json");
+
+ var result = SafeDefaultAuditRedactor.Instance.Apply(evt);
+
+ var d = Details(result);
+ Assert.Contains("X-Request-Id: abc-123", d.RequestSummary!);
+ Assert.Contains("Accept: application/json", d.RequestSummary!);
+ }
+
+ [Fact]
+ public void NullDetails_FastPath_ReturnsSameInstance()
+ {
+ var evt = new AuditEvent
+ {
+ EventId = Guid.NewGuid(),
+ OccurredAtUtc = DateTimeOffset.UtcNow,
+ Actor = "tester",
+ Action = "ApiOutbound.ApiCall",
+ Outcome = AuditOutcome.Success,
+ DetailsJson = null,
+ };
+
+ var result = SafeDefaultAuditRedactor.Instance.Apply(evt);
+
+ Assert.Same(evt, result);
+ }
+
+ [Fact]
+ public void MalformedDetailsJson_NeverThrows()
+ {
+ var evt = new AuditEvent
+ {
+ EventId = Guid.NewGuid(),
+ OccurredAtUtc = DateTimeOffset.UtcNow,
+ Actor = "tester",
+ Action = "ApiOutbound.ApiCall",
+ Outcome = AuditOutcome.Success,
+ DetailsJson = "{not valid json]",
+ };
+
+ var ex = Record.Exception(() => SafeDefaultAuditRedactor.Instance.Apply(evt));
+
+ Assert.Null(ex);
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Redaction/ScadaBridgeAuditRedactorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Redaction/ScadaBridgeAuditRedactorTests.cs
new file mode 100644
index 00000000..ce271f7f
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Redaction/ScadaBridgeAuditRedactorTests.cs
@@ -0,0 +1,540 @@
+using System.Text;
+using Microsoft.Extensions.Logging.Abstractions;
+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.AuditLog.Redaction;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
+
+namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Redaction;
+
+///
+/// ScadaBridge audit re-architecture stage C2 (Task 2.5) tests for
+/// — the canonical
+/// implementation that ports the
+/// redaction + truncation behaviour onto
+/// the canonical ZB.MOM.WW.Audit.AuditEvent record and its
+/// payload bag. These mirror the legacy
+/// Payload fixtures (HeaderRedaction / BodyRegex / SqlParam / RedactionSafetyNet
+/// / Truncation) but operate on canonical events built via
+/// .
+///
+public class ScadaBridgeAuditRedactorTests
+{
+ private static ScadaBridgeAuditRedactor Redactor(
+ AuditLogOptions? opts = null,
+ IAuditRedactionFailureCounter? counter = null) =>
+ new(new StaticMonitor(opts ?? new AuditLogOptions()),
+ NullLogger.Instance,
+ counter);
+
+ ///
+ /// Build a canonical event whose carries the
+ /// supplied summaries + channel/status, mirroring what the C3 emit boundary
+ /// will produce. Category = channel name (so the ApiInbound branch is keyed
+ /// correctly); Status travels inside DetailsJson for the fine-grained error
+ /// cap selection.
+ ///
+ private static AuditEvent NewEvent(
+ AuditChannel channel = AuditChannel.ApiOutbound,
+ AuditStatus status = AuditStatus.Delivered,
+ AuditOutcome outcome = AuditOutcome.Success,
+ string? request = null,
+ string? response = null,
+ string? errorDetail = null,
+ string? extra = null,
+ string? target = null,
+ bool detailsPayloadTruncated = false)
+ {
+ var details = new AuditDetails
+ {
+ Channel = channel.ToString(),
+ Status = status.ToString(),
+ RequestSummary = request,
+ ResponseSummary = response,
+ ErrorDetail = errorDetail,
+ Extra = extra,
+ PayloadTruncated = detailsPayloadTruncated,
+ };
+ return new AuditEvent
+ {
+ EventId = Guid.NewGuid(),
+ OccurredAtUtc = DateTimeOffset.UtcNow,
+ Actor = "tester",
+ Action = AuditFieldBuilders.BuildAction(channel, AuditKind.ApiCall),
+ Category = AuditFieldBuilders.BuildCategory(channel),
+ Outcome = outcome,
+ Target = target,
+ DetailsJson = AuditDetailsCodec.Serialize(details),
+ };
+ }
+
+ private static AuditDetails Details(AuditEvent evt) =>
+ AuditDetailsCodec.Deserialize(evt.DetailsJson);
+
+ // ---- Header redaction (ports HeaderRedactionTests) ---------------------
+
+ [Fact]
+ public void HeaderRedaction_AuthorizationBearer_Redacted()
+ {
+ var request = "{\"headers\":{\"Authorization\":\"Bearer secret-token-xyz\",\"Content-Type\":\"application/json\"},\"body\":\"hello\"}";
+ var evt = NewEvent(request: request);
+
+ var result = Redactor().Apply(evt);
+
+ var d = Details(result);
+ Assert.NotNull(d.RequestSummary);
+ Assert.Contains("\"Authorization\":\"\"", d.RequestSummary);
+ Assert.DoesNotContain("secret-token-xyz", d.RequestSummary);
+ Assert.Contains("application/json", d.RequestSummary);
+ }
+
+ [Fact]
+ public void HeaderRedaction_CaseInsensitive_LowercaseAuthorization_Redacted()
+ {
+ var request = "{\"headers\":{\"authorization\":\"Bearer secret-token-xyz\"},\"body\":\"hi\"}";
+ var evt = NewEvent(request: request);
+
+ var result = Redactor().Apply(evt);
+
+ Assert.Contains("\"authorization\":\"\"", Details(result).RequestSummary!);
+ }
+
+ [Fact]
+ public void HeaderRedaction_CustomRedactList_RedactsCustomHeaderName()
+ {
+ var opts = new AuditLogOptions { HeaderRedactList = new List { "X-Custom-Secret" } };
+ var request = "{\"headers\":{\"X-Custom-Secret\":\"topsecret\",\"Authorization\":\"Bearer keep-me\"},\"body\":\"hi\"}";
+ var evt = NewEvent(request: request);
+
+ var result = Redactor(opts).Apply(evt);
+
+ var d = Details(result);
+ Assert.Contains("\"X-Custom-Secret\":\"\"", d.RequestSummary!);
+ Assert.Contains("Bearer keep-me", d.RequestSummary!);
+ }
+
+ [Fact]
+ public void HeaderRedaction_NonJson_RequestSummary_Unchanged()
+ {
+ var evt = NewEvent(request: "this is not JSON at all");
+
+ var result = Redactor().Apply(evt);
+
+ Assert.Equal("this is not JSON at all", Details(result).RequestSummary);
+ }
+
+ // ---- Body regex redaction (ports BodyRegexRedactionTests) --------------
+
+ [Fact]
+ public void GlobalRegex_HunterPassword_Redacted()
+ {
+ var opts = new AuditLogOptions
+ {
+ GlobalBodyRedactors = new List { "\"password\":\\s*\"[^\"]*\"" },
+ };
+ var evt = NewEvent(request: "{\"user\":\"alice\",\"password\":\"hunter2\"}");
+
+ var result = Redactor(opts).Apply(evt);
+
+ var d = Details(result);
+ Assert.Contains("", d.RequestSummary!);
+ Assert.DoesNotContain("hunter2", d.RequestSummary!);
+ Assert.Contains("alice", d.RequestSummary!);
+ }
+
+ [Fact]
+ public void BodyRegex_AppliesToErrorDetailAndExtra()
+ {
+ var opts = new AuditLogOptions
+ {
+ GlobalBodyRedactors = new List { "SECRET-[A-Z0-9]+" },
+ };
+ var evt = NewEvent(
+ errorDetail: "boom SECRET-AAA111 boom",
+ extra: "ctx SECRET-BBB222 ctx");
+
+ var result = Redactor(opts).Apply(evt);
+
+ var d = Details(result);
+ Assert.DoesNotContain("SECRET-AAA111", d.ErrorDetail!);
+ Assert.DoesNotContain("SECRET-BBB222", d.Extra!);
+ Assert.Contains("", d.ErrorDetail!);
+ Assert.Contains("", d.Extra!);
+ }
+
+ [Fact]
+ public void PerTargetRegex_OnlyAppliedToMatchingTarget()
+ {
+ var opts = new AuditLogOptions
+ {
+ PerTargetOverrides = new Dictionary
+ {
+ ["esg.A"] = new PerTargetRedactionOverride
+ {
+ AdditionalBodyRedactors = new List { "SECRET-[A-Z0-9]+" },
+ },
+ },
+ };
+ const string input = "token=SECRET-XYZ123 normal-text";
+
+ var matched = Redactor(opts).Apply(NewEvent(request: input, target: "esg.A"));
+ Assert.Contains("", Details(matched).RequestSummary!);
+ Assert.DoesNotContain("SECRET-XYZ123", Details(matched).RequestSummary!);
+
+ var unmatched = Redactor(opts).Apply(NewEvent(request: input, target: "esg.B"));
+ Assert.Equal(input, Details(unmatched).RequestSummary);
+ }
+
+ [Fact]
+ public void NoRegexConfigured_FieldUnchanged()
+ {
+ var evt = NewEvent(request: "{\"password\":\"hunter2\"}");
+
+ var result = Redactor().Apply(evt);
+
+ Assert.Equal("{\"password\":\"hunter2\"}", Details(result).RequestSummary);
+ }
+
+ [Fact]
+ public void RegexThrowsTimeout_FieldBecomesRedactedMarker_CounterIncrements()
+ {
+ var opts = new AuditLogOptions { GlobalBodyRedactors = new List { "^(a+)+$" } };
+ var counter = new CountingRedactionFailureCounter();
+ var evt = NewEvent(request: new string('a', 30) + "!");
+
+ var result = Redactor(opts, counter).Apply(evt);
+
+ Assert.Equal("", Details(result).RequestSummary);
+ Assert.True(counter.Count >= 1, $"expected counter >= 1, got {counter.Count}");
+ }
+
+ // ---- SQL parameter redaction (ports SqlParamRedactionTests) ------------
+
+ private static string DbRequestSummary(string sql, params (string name, string value)[] parameters)
+ {
+ var sb = new StringBuilder();
+ sb.Append("{\"sql\":\"").Append(sql).Append('"');
+ if (parameters.Length > 0)
+ {
+ sb.Append(",\"parameters\":{");
+ for (var i = 0; i < parameters.Length; i++)
+ {
+ if (i > 0) sb.Append(',');
+ sb.Append('"').Append(parameters[i].name).Append("\":\"")
+ .Append(parameters[i].value).Append('"');
+ }
+ sb.Append('}');
+ }
+ sb.Append('}');
+ return sb.ToString();
+ }
+
+ [Fact]
+ public void NoOptIn_ParamsVerbatim_Unchanged()
+ {
+ var input = DbRequestSummary(
+ "INSERT INTO Users (Name, Token) VALUES (@name, @token)",
+ ("@name", "Alice"), ("@token", "secret-xyz"));
+ var evt = NewEvent(
+ channel: AuditChannel.DbOutbound, status: AuditStatus.Delivered,
+ request: input, target: "PrimaryDb.INSERT INTO Users");
+
+ var result = Redactor().Apply(evt);
+
+ Assert.Equal(input, Details(result).RequestSummary);
+ }
+
+ [Fact]
+ public void OptInRegex_AtToken_RedactsThoseValues_KeepsOthers()
+ {
+ var opts = new AuditLogOptions
+ {
+ PerTargetOverrides = new Dictionary
+ {
+ ["PrimaryDb"] = new PerTargetRedactionOverride { RedactSqlParamsMatching = "^@(token|apikey)$" },
+ },
+ };
+ var input = DbRequestSummary(
+ "INSERT INTO Users (Name, Token, ApiKey) VALUES (@name, @token, @apikey)",
+ ("@name", "Alice"), ("@token", "secret-xyz"), ("@apikey", "k-987"));
+ var evt = NewEvent(
+ channel: AuditChannel.DbOutbound, request: input, target: "PrimaryDb.INSERT INTO Users");
+
+ var result = Redactor(opts).Apply(evt);
+
+ var d = Details(result);
+ Assert.Contains("\"@name\":\"Alice\"", d.RequestSummary!);
+ Assert.Contains("\"@token\":\"\"", d.RequestSummary!);
+ Assert.Contains("\"@apikey\":\"\"", d.RequestSummary!);
+ Assert.DoesNotContain("secret-xyz", d.RequestSummary!);
+ Assert.DoesNotContain("k-987", d.RequestSummary!);
+ }
+
+ [Fact]
+ public void NonDbOutboundChannel_NotAffected()
+ {
+ var opts = new AuditLogOptions
+ {
+ PerTargetOverrides = new Dictionary
+ {
+ ["PrimaryDb"] = new PerTargetRedactionOverride { RedactSqlParamsMatching = "^@token$" },
+ },
+ };
+ var input = DbRequestSummary("SELECT @token", ("@token", "should-survive"));
+ // ApiOutbound channel whose summary *looks* like the DbOutbound shape.
+ var evt = NewEvent(
+ channel: AuditChannel.ApiOutbound, request: input, target: "PrimaryDb.SELECT");
+
+ var result = Redactor(opts).Apply(evt);
+
+ Assert.Equal(input, Details(result).RequestSummary);
+ }
+
+ // ---- Truncation + cap selection (ports TruncationTests) ----------------
+
+ [Fact]
+ public void SuccessRow_10KB_RequestSummary_TruncatedTo8KB_PayloadTruncatedTrue()
+ {
+ var evt = NewEvent(
+ channel: AuditChannel.ApiOutbound, status: AuditStatus.Delivered,
+ outcome: AuditOutcome.Success, request: new string('a', 10 * 1024));
+
+ var result = Redactor().Apply(evt);
+
+ var d = Details(result);
+ Assert.Equal(8192, Encoding.UTF8.GetByteCount(d.RequestSummary!));
+ Assert.True(d.PayloadTruncated);
+ }
+
+ [Fact]
+ public void ErrorRow_Failed_10KB_RequestSummary_NotTruncated_UnderErrorCap()
+ {
+ var evt = NewEvent(
+ channel: AuditChannel.ApiOutbound, status: AuditStatus.Failed,
+ outcome: AuditOutcome.Failure, request: new string('b', 10 * 1024));
+
+ var result = Redactor().Apply(evt);
+
+ var d = Details(result);
+ Assert.Equal(new string('b', 10 * 1024), d.RequestSummary);
+ Assert.False(d.PayloadTruncated);
+ }
+
+ [Fact]
+ public void ErrorRow_Failed_70KB_RequestSummary_TruncatedTo64KB()
+ {
+ var evt = NewEvent(
+ channel: AuditChannel.ApiOutbound, status: AuditStatus.Failed,
+ outcome: AuditOutcome.Failure, request: new string('c', 70 * 1024));
+
+ var result = Redactor().Apply(evt);
+
+ var d = Details(result);
+ Assert.Equal(65536, Encoding.UTF8.GetByteCount(d.RequestSummary!));
+ Assert.True(d.PayloadTruncated);
+ }
+
+ [Fact]
+ public void StatusAttempted_TreatedAsError_UsesErrorCap_EvenThoughOutcomeSuccess()
+ {
+ // Attempted projects to Outcome.Success, yet IsErrorStatus(Attempted)==true.
+ // Faithful port must read d.Status and pick the error cap — a 10 KB body
+ // must survive (under the 64 KiB error cap), NOT truncate to 8 KiB.
+ var evt = NewEvent(
+ channel: AuditChannel.ApiOutbound, status: AuditStatus.Attempted,
+ outcome: AuditOutcome.Success, request: new string('d', 10 * 1024));
+
+ var result = Redactor().Apply(evt);
+
+ var d = Details(result);
+ Assert.Equal(new string('d', 10 * 1024), d.RequestSummary);
+ Assert.False(d.PayloadTruncated);
+ }
+
+ [Fact]
+ public void StatusSkipped_TreatedAsError_UsesErrorCap_EvenThoughOutcomeSuccess()
+ {
+ var evt = NewEvent(
+ channel: AuditChannel.ApiOutbound, status: AuditStatus.Skipped,
+ outcome: AuditOutcome.Success, request: new string('f', 10 * 1024));
+
+ var result = Redactor().Apply(evt);
+
+ Assert.False(Details(result).PayloadTruncated);
+ }
+
+ [Fact]
+ public void StatusSubmitted_TreatedAsSuccess_UsesDefaultCap()
+ {
+ // Submitted is NOT an error status (IsErrorStatus==false) → default 8 KiB cap.
+ var evt = NewEvent(
+ channel: AuditChannel.ApiOutbound, status: AuditStatus.Submitted,
+ outcome: AuditOutcome.Success, request: new string('g', 10 * 1024));
+
+ var result = Redactor().Apply(evt);
+
+ var d = Details(result);
+ Assert.Equal(8192, Encoding.UTF8.GetByteCount(d.RequestSummary!));
+ Assert.True(d.PayloadTruncated);
+ }
+
+ [Fact]
+ public void ApiInbound_LargeBody_UsesInboundCap_NotDefault()
+ {
+ var evt = NewEvent(
+ channel: AuditChannel.ApiInbound, status: AuditStatus.Delivered,
+ outcome: AuditOutcome.Success, request: new string('a', 100_000));
+
+ var result = Redactor().Apply(evt);
+
+ var d = Details(result);
+ Assert.False(d.PayloadTruncated);
+ Assert.Equal(100_000, Encoding.UTF8.GetByteCount(d.RequestSummary!));
+ }
+
+ [Fact]
+ public void ApiInbound_Failed_BodyAboveInboundMaxBytes_TruncatedToInboundMaxBytes()
+ {
+ var opts = new AuditLogOptions { InboundMaxBytes = 16_384 };
+ var evt = NewEvent(
+ channel: AuditChannel.ApiInbound, status: AuditStatus.Failed,
+ outcome: AuditOutcome.Failure, response: new string('z', 50_000));
+
+ var result = Redactor(opts).Apply(evt);
+
+ var d = Details(result);
+ Assert.True(d.PayloadTruncated);
+ Assert.True(Encoding.UTF8.GetByteCount(d.ResponseSummary!) <= 16_384);
+ }
+
+ [Fact]
+ public void Multibyte_UTF8_TruncatedAtCharacterBoundary_NotMidByte()
+ {
+ var sb = new StringBuilder();
+ for (int i = 0; i < 2100; i++) sb.Append("😀");
+ var input = sb.ToString();
+ var evt = NewEvent(request: input);
+
+ var result = Redactor().Apply(evt);
+
+ var d = Details(result);
+ var bytes = Encoding.UTF8.GetByteCount(d.RequestSummary!);
+ Assert.True(bytes <= 8192);
+ Assert.Equal(0, bytes % 4);
+ Assert.DoesNotContain('�', d.RequestSummary!);
+ Assert.True(d.PayloadTruncated);
+ }
+
+ [Fact]
+ public void ExistingDetailsPayloadTruncated_RemainsTrue()
+ {
+ var evt = NewEvent(request: "small", detailsPayloadTruncated: true);
+
+ var result = Redactor().Apply(evt);
+
+ var d = Details(result);
+ Assert.Equal("small", d.RequestSummary);
+ Assert.True(d.PayloadTruncated);
+ }
+
+ // ---- Target length cap -------------------------------------------------
+
+ [Fact]
+ public void Target_OverCap_Truncated_ToByteBoundary()
+ {
+ // Target is a canonical top-level field; the redactor caps it at the
+ // default cap so an absurdly long target can't blow the column.
+ var longTarget = new string('t', 10 * 1024);
+ var evt = NewEvent(status: AuditStatus.Delivered, outcome: AuditOutcome.Success, target: longTarget);
+
+ var result = Redactor().Apply(evt);
+
+ Assert.NotNull(result.Target);
+ Assert.True(Encoding.UTF8.GetByteCount(result.Target!) <= 8192);
+ }
+
+ // ---- Fast-path ---------------------------------------------------------
+
+ [Fact]
+ public void FastPath_NullDetailsAndShortTarget_ReturnsSameInstance()
+ {
+ var evt = new AuditEvent
+ {
+ EventId = Guid.NewGuid(),
+ OccurredAtUtc = DateTimeOffset.UtcNow,
+ Actor = "tester",
+ Action = "ApiOutbound.ApiCall",
+ Outcome = AuditOutcome.Success,
+ Target = "short",
+ DetailsJson = null,
+ };
+
+ var result = Redactor().Apply(evt);
+
+ Assert.Same(evt, result);
+ }
+
+ [Fact]
+ public void FastPath_EmptyDetailsAndShortTarget_ReturnsSameInstance()
+ {
+ var evt = new AuditEvent
+ {
+ EventId = Guid.NewGuid(),
+ OccurredAtUtc = DateTimeOffset.UtcNow,
+ Actor = "tester",
+ Action = "ApiOutbound.ApiCall",
+ Outcome = AuditOutcome.Success,
+ Target = null,
+ DetailsJson = "",
+ };
+
+ var result = Redactor().Apply(evt);
+
+ Assert.Same(evt, result);
+ }
+
+ // ---- Never-throws safety net -------------------------------------------
+
+ [Fact]
+ public void MalformedDetailsJson_NeverThrows_ReturnsSafeCopy()
+ {
+ // Deserialize never throws (returns empty details), but a malformed
+ // DetailsJson with a long Target still flows the slow path. Assert the
+ // redactor returns a result without throwing.
+ var evt = new AuditEvent
+ {
+ EventId = Guid.NewGuid(),
+ OccurredAtUtc = DateTimeOffset.UtcNow,
+ Actor = "tester",
+ Action = "ApiOutbound.ApiCall",
+ Outcome = AuditOutcome.Success,
+ Target = new string('x', 10 * 1024),
+ DetailsJson = "{not valid json at all]",
+ };
+
+ var ex = Record.Exception(() => Redactor().Apply(evt));
+
+ Assert.Null(ex);
+ }
+
+ /// Counts calls.
+ private sealed class CountingRedactionFailureCounter : IAuditRedactionFailureCounter
+ {
+ private int _count;
+ public int Count => _count;
+ public void Increment() => System.Threading.Interlocked.Increment(ref _count);
+ }
+
+ /// IOptionsMonitor test double — returns the same snapshot on every read.
+ private sealed class StaticMonitor : IOptionsMonitor
+ {
+ private readonly AuditLogOptions _value;
+ public StaticMonitor(AuditLogOptions value) => _value = value;
+ public AuditLogOptions CurrentValue => _value;
+ public AuditLogOptions Get(string? name) => _value;
+ public IDisposable? OnChange(Action listener) => null;
+ }
+}