213 lines
7.8 KiB
C#
213 lines
7.8 KiB
C#
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;
|
|
}
|
|
}
|