diff --git a/src/ScadaLink.AuditLog/Configuration/PerTargetRedactionOverride.cs b/src/ScadaLink.AuditLog/Configuration/PerTargetRedactionOverride.cs
index 739ee07..0673a4b 100644
--- a/src/ScadaLink.AuditLog/Configuration/PerTargetRedactionOverride.cs
+++ b/src/ScadaLink.AuditLog/Configuration/PerTargetRedactionOverride.cs
@@ -14,4 +14,15 @@ public sealed class PerTargetRedactionOverride
/// Additional body redactor regex patterns (appended to the global list).
public List? AdditionalBodyRedactors { get; set; }
+
+ ///
+ /// Opt-in SQL parameter redaction: case-insensitive regex matched against
+ /// each SQL parameter NAME in the M4 AuditingDbCommand RequestSummary
+ /// JSON ({"sql":"...","parameters":{"@name":"value", ...}}); values
+ /// whose name matches are replaced with <redacted>. Null (the
+ /// default) means parameter values are captured verbatim. Only applied to
+ ///
+ /// rows.
+ ///
+ public string? RedactSqlParamsMatching { get; set; }
}
diff --git a/src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs b/src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs
index faa9742..7061801 100644
--- a/src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs
+++ b/src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs
@@ -134,6 +134,19 @@ public sealed class DefaultAuditPayloadFilter : IAuditPayloadFilter
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);
@@ -365,6 +378,131 @@ public sealed class DefaultAuditPayloadFilter : IAuditPayloadFilter
return current;
}
+ ///
+ /// Resolve the per-connection SQL parameter redaction regex for the given
+ /// DbOutbound event target. Target shape (M4 AuditingDbCommand): the
+ /// connection name optionally followed by .<sql-snippet> 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.
+ ///
+ 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;
+ }
+
+ ///
+ /// 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 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.
+ ///
+ 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;
+ }
+ }
+
private static string? TruncateField(string? value, int cap, ref bool truncated)
{
if (value is null)
diff --git a/tests/ScadaLink.AuditLog.Tests/Payload/SqlParamRedactionTests.cs b/tests/ScadaLink.AuditLog.Tests/Payload/SqlParamRedactionTests.cs
new file mode 100644
index 0000000..0676ab8
--- /dev/null
+++ b/tests/ScadaLink.AuditLog.Tests/Payload/SqlParamRedactionTests.cs
@@ -0,0 +1,212 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using ScadaLink.AuditLog.Configuration;
+using ScadaLink.AuditLog.Payload;
+using ScadaLink.Commons.Entities.Audit;
+using ScadaLink.Commons.Types.Enums;
+
+namespace ScadaLink.AuditLog.Tests.Payload;
+
+///
+/// Bundle B (M5-T5) tests for SQL parameter redaction in
+/// . M4 Bundle A's
+/// AuditingDbCommand emits RequestSummary as
+/// {"sql":"...","parameters":{"@name":"value", ...}}; the SQL-parameter
+/// redactor parses this shape on
+/// rows, replaces values whose key
+/// matches the configured case-insensitive regex with <redacted>,
+/// and re-serialises. Default behaviour with no opt-in: parameter values are
+/// captured verbatim. Connection lookup uses the connection-name prefix of
+/// (everything before the first .) so
+/// the same per-connection regex applies regardless of the SQL-snippet suffix
+/// that AuditingDbCommand appends to disambiguate rows.
+///
+public class SqlParamRedactionTests
+{
+ private static IOptionsMonitor Monitor(AuditLogOptions? opts = null) =>
+ new StaticMonitor(opts ?? new AuditLogOptions());
+
+ private static DefaultAuditPayloadFilter Filter(AuditLogOptions? opts = null) =>
+ new(Monitor(opts), NullLogger.Instance);
+
+ private static AuditEvent NewDbEvent(string target, string requestSummary) => new()
+ {
+ EventId = Guid.NewGuid(),
+ OccurredAtUtc = DateTime.UtcNow,
+ Channel = AuditChannel.DbOutbound,
+ Kind = AuditKind.DbWrite,
+ Status = AuditStatus.Delivered,
+ Target = target,
+ RequestSummary = requestSummary,
+ };
+
+ ///
+ /// Build a RequestSummary in the exact shape M4's AuditingDbCommand
+ /// emits — hand-rolled JSON with "sql" + "parameters" keys.
+ /// Tests depend on this format; if AuditingDbCommand ever changes, this
+ /// helper updates in lockstep.
+ ///
+ private static string DbRequestSummary(string sql, params (string name, string value)[] parameters)
+ {
+ var sb = new System.Text.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 = NewDbEvent("PrimaryDb.INSERT INTO Users", input);
+
+ var result = Filter().Apply(evt);
+
+ Assert.Equal(input, result.RequestSummary);
+ }
+
+ [Fact]
+ public void OptInRegex_AtToken_OrAtApikey_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 = NewDbEvent("PrimaryDb.INSERT INTO Users", input);
+
+ var result = Filter(opts).Apply(evt);
+
+ Assert.NotNull(result.RequestSummary);
+ Assert.Contains("\"@name\":\"Alice\"", result.RequestSummary);
+ Assert.Contains("\"@token\":\"\"", result.RequestSummary);
+ Assert.Contains("\"@apikey\":\"\"", result.RequestSummary);
+ Assert.DoesNotContain("secret-xyz", result.RequestSummary);
+ Assert.DoesNotContain("k-987", result.RequestSummary);
+ }
+
+ [Fact]
+ public void RegexCaseInsensitive_MatchesParamNames()
+ {
+ var opts = new AuditLogOptions
+ {
+ PerTargetOverrides = new Dictionary
+ {
+ ["PrimaryDb"] = new PerTargetRedactionOverride
+ {
+ RedactSqlParamsMatching = "token",
+ },
+ },
+ };
+ var input = DbRequestSummary(
+ "UPDATE x SET Token = @TOKEN",
+ ("@TOKEN", "uppercased-secret"));
+ var evt = NewDbEvent("PrimaryDb.UPDATE x SET Token", input);
+
+ var result = Filter(opts).Apply(evt);
+
+ Assert.NotNull(result.RequestSummary);
+ Assert.Contains("\"@TOKEN\":\"\"", result.RequestSummary);
+ Assert.DoesNotContain("uppercased-secret", result.RequestSummary);
+ }
+
+ [Fact]
+ public void NonDbOutboundChannel_NotAffected()
+ {
+ // ApiOutbound row whose RequestSummary happens to look like the
+ // DbOutbound JSON shape (worst-case false positive). The SQL
+ // redactor must NOT touch it — channel guards the stage.
+ var opts = new AuditLogOptions
+ {
+ PerTargetOverrides = new Dictionary
+ {
+ ["PrimaryDb"] = new PerTargetRedactionOverride
+ {
+ RedactSqlParamsMatching = "^@token$",
+ },
+ },
+ };
+ var input = DbRequestSummary(
+ "SELECT @token",
+ ("@token", "should-survive"));
+ var evt = new AuditEvent
+ {
+ EventId = Guid.NewGuid(),
+ OccurredAtUtc = DateTime.UtcNow,
+ Channel = AuditChannel.ApiOutbound,
+ Kind = AuditKind.ApiCall,
+ Status = AuditStatus.Delivered,
+ Target = "PrimaryDb.SELECT", // doesn't matter — channel guards
+ RequestSummary = input,
+ };
+
+ var result = Filter(opts).Apply(evt);
+
+ Assert.Equal(input, result.RequestSummary);
+ }
+
+ [Fact]
+ public void PerTargetSetting_MatchesByTarget()
+ {
+ // Two connections — A is configured to redact tokens, B is not. Same
+ // payload through each must yield different results.
+ var opts = new AuditLogOptions
+ {
+ PerTargetOverrides = new Dictionary
+ {
+ ["ConnA"] = new PerTargetRedactionOverride
+ {
+ RedactSqlParamsMatching = "^@token$",
+ },
+ },
+ };
+ var input = DbRequestSummary(
+ "SELECT @token",
+ ("@token", "the-secret"));
+
+ var aEvt = NewDbEvent("ConnA.SELECT @token", input);
+ var bEvt = NewDbEvent("ConnB.SELECT @token", input);
+
+ var aResult = Filter(opts).Apply(aEvt);
+ var bResult = Filter(opts).Apply(bEvt);
+
+ Assert.Contains("", aResult.RequestSummary!);
+ Assert.DoesNotContain("the-secret", aResult.RequestSummary!);
+
+ Assert.Equal(input, bResult.RequestSummary);
+ }
+
+ /// IOptionsMonitor test double.
+ 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;
+ }
+}