feat(auditlog): per-connection SQL parameter redaction opt-in (#23 M5)
This commit is contained in:
@@ -14,4 +14,15 @@ public sealed class PerTargetRedactionOverride
|
||||
|
||||
/// <summary>Additional body redactor regex patterns (appended to the global list).</summary>
|
||||
public List<string>? AdditionalBodyRedactors { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Opt-in SQL parameter redaction: case-insensitive regex matched against
|
||||
/// each SQL parameter NAME in the M4 <c>AuditingDbCommand</c> RequestSummary
|
||||
/// JSON (<c>{"sql":"...","parameters":{"@name":"value", ...}}</c>); values
|
||||
/// whose name matches are replaced with <c><redacted></c>. Null (the
|
||||
/// default) means parameter values are captured verbatim. Only applied to
|
||||
/// <see cref="ScadaLink.Commons.Types.Enums.AuditChannel.DbOutbound"/>
|
||||
/// rows.
|
||||
/// </summary>
|
||||
public string? RedactSqlParamsMatching { get; set; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the per-connection SQL parameter redaction regex for the given
|
||||
/// DbOutbound event target. Target shape (M4 AuditingDbCommand): the
|
||||
/// connection name optionally followed by <c>.<sql-snippet></c> for
|
||||
/// disambiguation; the per-target dictionary is keyed by the connection
|
||||
/// name alone, so we strip the snippet suffix before lookup. Patterns are
|
||||
/// compiled with case-insensitive matching to match the documented
|
||||
/// behaviour.
|
||||
/// </summary>
|
||||
private bool TryGetSqlParamRedactor(AuditLogOptions opts, string? target, out Regex? regex)
|
||||
{
|
||||
regex = null;
|
||||
if (string.IsNullOrEmpty(target))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var dot = target.IndexOf('.');
|
||||
var connectionKey = dot < 0 ? target : target[..dot];
|
||||
|
||||
if (!opts.PerTargetOverrides.TryGetValue(connectionKey, out var over)
|
||||
|| string.IsNullOrEmpty(over.RedactSqlParamsMatching))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Force case-insensitivity per the spec — even if the operator wrote
|
||||
// the pattern without an IgnoreCase flag. The compile cache key folds
|
||||
// the option to keep the entries unambiguous.
|
||||
var cacheKey = "(?i)" + over.RedactSqlParamsMatching;
|
||||
if (!TryGetCompiledRegex(cacheKey, out regex))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walk the M4 <c>{"sql":"...","parameters":{...}}</c> RequestSummary
|
||||
/// shape; for each parameter whose NAME matches
|
||||
/// <paramref name="paramNameRegex"/>, replace its value with
|
||||
/// <see cref="RedactedMarker"/>. Re-serialise.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// No-op pass-through when the input isn't parseable JSON, isn't a JSON
|
||||
/// object, or doesn't carry a top-level <c>"parameters"</c> object. On
|
||||
/// any unexpected fault the field is over-redacted with
|
||||
/// <see cref="RedactorErrorMarker"/> and the failure counter is bumped.
|
||||
/// </remarks>
|
||||
private string? RedactSqlParameters(string? json, Regex paramNameRegex)
|
||||
{
|
||||
if (json is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = json.AsSpan().TrimStart();
|
||||
if (trimmed.Length == 0 || trimmed[0] != '{')
|
||||
{
|
||||
return json;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
JsonNode? root;
|
||||
try
|
||||
{
|
||||
root = JsonNode.Parse(json);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return json;
|
||||
}
|
||||
|
||||
if (root is not JsonObject obj || obj["parameters"] is not JsonObject parameters)
|
||||
{
|
||||
return json;
|
||||
}
|
||||
|
||||
// Snapshot the names — mutating during enumeration is unsupported.
|
||||
var names = new List<string>(parameters.Count);
|
||||
foreach (var kvp in parameters)
|
||||
{
|
||||
names.Add(kvp.Key);
|
||||
}
|
||||
var anyChanged = false;
|
||||
foreach (var name in names)
|
||||
{
|
||||
bool matched;
|
||||
try
|
||||
{
|
||||
matched = paramNameRegex.IsMatch(name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"SQL parameter redactor faulted; over-redacting field with '{Marker}'",
|
||||
RedactorErrorMarker);
|
||||
try { _failureCounter.Increment(); } catch { /* swallow per §7 */ }
|
||||
return RedactorErrorMarker;
|
||||
}
|
||||
if (matched)
|
||||
{
|
||||
parameters[name] = JsonValue.Create(RedactedMarker);
|
||||
anyChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Avoid re-serialising (which would normalise whitespace / order)
|
||||
// when no parameter matched — keeps the on-disk row byte-identical
|
||||
// to the emitter's output on the no-match path.
|
||||
return anyChanged ? obj.ToJsonString(RedactedSummaryJsonOptions) : json;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"SQL parameter redactor faulted; over-redacting field with '{Marker}'",
|
||||
RedactorErrorMarker);
|
||||
try { _failureCounter.Increment(); } catch { /* swallow per §7 */ }
|
||||
return RedactorErrorMarker;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? TruncateField(string? value, int cap, ref bool truncated)
|
||||
{
|
||||
if (value is null)
|
||||
|
||||
212
tests/ScadaLink.AuditLog.Tests/Payload/SqlParamRedactionTests.cs
Normal file
212
tests/ScadaLink.AuditLog.Tests/Payload/SqlParamRedactionTests.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle B (M5-T5) tests for SQL parameter redaction in
|
||||
/// <see cref="DefaultAuditPayloadFilter"/>. M4 Bundle A's
|
||||
/// <c>AuditingDbCommand</c> emits <c>RequestSummary</c> as
|
||||
/// <c>{"sql":"...","parameters":{"@name":"value", ...}}</c>; the SQL-parameter
|
||||
/// redactor parses this shape on
|
||||
/// <see cref="AuditChannel.DbOutbound"/> rows, replaces values whose key
|
||||
/// matches the configured case-insensitive regex with <c><redacted></c>,
|
||||
/// and re-serialises. Default behaviour with no opt-in: parameter values are
|
||||
/// captured verbatim. Connection lookup uses the connection-name prefix of
|
||||
/// <see cref="AuditEvent.Target"/> (everything before the first <c>.</c>) so
|
||||
/// the same per-connection regex applies regardless of the SQL-snippet suffix
|
||||
/// that <c>AuditingDbCommand</c> appends to disambiguate rows.
|
||||
/// </summary>
|
||||
public class SqlParamRedactionTests
|
||||
{
|
||||
private static IOptionsMonitor<AuditLogOptions> Monitor(AuditLogOptions? opts = null) =>
|
||||
new StaticMonitor(opts ?? new AuditLogOptions());
|
||||
|
||||
private static DefaultAuditPayloadFilter Filter(AuditLogOptions? opts = null) =>
|
||||
new(Monitor(opts), NullLogger<DefaultAuditPayloadFilter>.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,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Build a RequestSummary in the exact shape M4's <c>AuditingDbCommand</c>
|
||||
/// emits — hand-rolled JSON with <c>"sql"</c> + <c>"parameters"</c> keys.
|
||||
/// Tests depend on this format; if AuditingDbCommand ever changes, this
|
||||
/// helper updates in lockstep.
|
||||
/// </summary>
|
||||
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<string, PerTargetRedactionOverride>
|
||||
{
|
||||
["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\":\"<redacted>\"", result.RequestSummary);
|
||||
Assert.Contains("\"@apikey\":\"<redacted>\"", 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<string, PerTargetRedactionOverride>
|
||||
{
|
||||
["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\":\"<redacted>\"", 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<string, PerTargetRedactionOverride>
|
||||
{
|
||||
["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<string, PerTargetRedactionOverride>
|
||||
{
|
||||
["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("<redacted>", aResult.RequestSummary!);
|
||||
Assert.DoesNotContain("the-secret", aResult.RequestSummary!);
|
||||
|
||||
Assert.Equal(input, bResult.RequestSummary);
|
||||
}
|
||||
|
||||
/// <summary>IOptionsMonitor test double.</summary>
|
||||
private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions>
|
||||
{
|
||||
private readonly AuditLogOptions _value;
|
||||
public StaticMonitor(AuditLogOptions value) => _value = value;
|
||||
public AuditLogOptions CurrentValue => _value;
|
||||
public AuditLogOptions Get(string? name) => _value;
|
||||
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user