feat(auditlog): per-connection SQL parameter redaction opt-in (#23 M5)

This commit is contained in:
Joseph Doherty
2026-05-20 17:11:53 -04:00
parent 37f17dc4a8
commit 5a7f3e8bf6
3 changed files with 361 additions and 0 deletions

View File

@@ -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>&lt;redacted&gt;</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; }
}

View File

@@ -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>.&lt;sql-snippet&gt;</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)