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; + } +}