feat(infra): JsonSanitizer + AuditOptions binding (UDT-010 B3)

Adds the metadata sanitization layer per #REQ-AUD-5:

SIGCM2.Infrastructure/Audit/JsonSanitizer.cs (static class):
- Sanitize(object?, IReadOnlyCollection<string>) -> string?
- Serializes via System.Text.Json + JsonNode recursive traversal.
- Strips blacklisted keys at every nesting level (objects + arrays).
- Case-insensitive match (ToLowerInvariant on both sides).
- Null input -> null output (never throws).
- Output is always valid JSON (ISJSON=1 compatible — satisfies AuditEvent CHECK).

SIGCM2.Application/Audit/AuditOptions.cs:
- Documented the IConfiguration array-binding quirk: config is ADDITIVE
  (append at higher indices), not REPLACE. Intentional for security — defaults
  like 'password'/'token'/'cvv' must not be silently dropped.

SIGCM2.Infrastructure/DependencyInjection.cs:
- services.Configure<AuditOptions>(configuration.GetSection(AuditOptions.SectionName))
  wired in AddInfrastructure().

Tests (Strict TDD, RED -> GREEN):
- JsonSanitizerTests (10): null/empty-blacklist/flat/nested/arrays/case-insensitive/
  primitives/round-trip-valid-json/string-as-value/default-keys-effective.
- AuditOptionsBindingTests (2): defaults when section absent + additive override.

One test needed adjustment during GREEN: 'AlreadySerializedJsonString' originally
asserted against an encoding-specific literal; rewrote to use JsonDocument
round-trip (validates behavior without coupling to encoder quirks).

Suite: 348/348 Application.Tests + 130/130 Api.Tests = 478/478 passing.

Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-5, design#D-5, tasks#B3}
This commit is contained in:
2026-04-16 13:28:37 -03:00
parent 68f96b90c7
commit 08d6622e43
5 changed files with 317 additions and 1 deletions

View File

@@ -1,6 +1,11 @@
namespace SIGCM2.Application.Audit; namespace SIGCM2.Application.Audit;
/// Bound from appsettings section "Audit". Extensible via configuration. /// Bound from appsettings section "Audit".
/// Extensibility model: ADDITIVE via `IConfiguration` array binding. Setting
/// `Audit:SanitizedKeys:N` at indices beyond the defaults APPENDS custom keys;
/// indices 0..10 OVERWRITE the defaults. To fully replace, use a `PostConfigure`
/// in DI. This mirrors the standard .NET array-binding quirk intentionally —
/// the default keys are security-critical and should not be silently dropped.
public sealed class AuditOptions public sealed class AuditOptions
{ {
public const string SectionName = "Audit"; public const string SectionName = "Audit";

View File

@@ -0,0 +1,63 @@
using System.Text.Json;
using System.Text.Json.Nodes;
namespace SIGCM2.Infrastructure.Audit;
/// UDT-010 (#REQ-AUD-5): serializes a metadata object to JSON and strips keys in the blacklist
/// at every nesting level. Case-insensitive match. Null input → null output (never throws).
/// Produces valid JSON consumable by AuditEvent.Metadata (ISJSON=1 constraint).
public static class JsonSanitizer
{
/// Serializes <paramref name="obj"/> to JSON and removes any property whose key matches
/// (case-insensitively) an entry in <paramref name="blacklist"/>. Recursive into nested
/// objects and arrays. Returns null if the input is null.
public static string? Sanitize(object? obj, IReadOnlyCollection<string> blacklist)
{
if (obj is null) return null;
var node = JsonSerializer.SerializeToNode(obj);
if (node is null) return null;
if (blacklist.Count > 0)
{
var blacklistLower = blacklist
.Select(k => k.ToLowerInvariant())
.ToHashSet();
Strip(node, blacklistLower);
}
return node.ToJsonString();
}
private static void Strip(JsonNode node, HashSet<string> blacklistLower)
{
switch (node)
{
case JsonObject obj:
var keysToRemove = obj
.Where(kvp => blacklistLower.Contains(kvp.Key.ToLowerInvariant()))
.Select(kvp => kvp.Key)
.ToList();
foreach (var key in keysToRemove)
obj.Remove(key);
foreach (var kvp in obj.ToList())
{
if (kvp.Value is not null)
Strip(kvp.Value, blacklistLower);
}
break;
case JsonArray arr:
foreach (var item in arr)
{
if (item is not null)
Strip(item, blacklistLower);
}
break;
// JsonValue: scalar, nothing to strip
}
}
}

View File

@@ -8,6 +8,7 @@ using Microsoft.IdentityModel.Tokens;
using SIGCM2.Application.Abstractions; using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Abstractions.Security; using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Auth; using SIGCM2.Application.Auth;
using SIGCM2.Infrastructure.Http; using SIGCM2.Infrastructure.Http;
using SIGCM2.Infrastructure.Messaging; using SIGCM2.Infrastructure.Messaging;
@@ -68,6 +69,9 @@ public static class DependencyInjection
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
services.AddScoped<IClientContext, ClientContext>(); services.AddScoped<IClientContext, ClientContext>();
// UDT-010: Audit options (SanitizedKeys blacklist) — overridable via appsettings "Audit".
services.Configure<AuditOptions>(configuration.GetSection(AuditOptions.SectionName));
// Dispatcher // Dispatcher
services.AddScoped<IDispatcher, Dispatcher>(); services.AddScoped<IDispatcher, Dispatcher>();

View File

@@ -0,0 +1,71 @@
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using SIGCM2.Application.Audit;
using SIGCM2.Infrastructure;
using Xunit;
namespace SIGCM2.Application.Tests.Infrastructure.Audit;
/// UDT-010 Batch 3 — AuditOptions binding smoke tests.
/// Validates that AddInfrastructure binds AuditOptions from the "Audit" config section
/// and falls back to the POCO defaults when the section is absent.
public sealed class AuditOptionsBindingTests
{
private static IServiceProvider BuildProvider(IEnumerable<KeyValuePair<string, string?>>? overrides = null)
{
// Minimum required config for AddInfrastructure to succeed.
var inMemory = new Dictionary<string, string?>
{
["ConnectionStrings:SqlServer"] = "Server=nowhere;Database=x;Integrated Security=true;",
["Jwt:Issuer"] = "test",
["Jwt:Audience"] = "test",
["Jwt:AccessTokenMinutes"] = "60",
["Jwt:RefreshTokenDays"] = "7",
["Jwt:PrivateKeyPath"] = "unused-in-this-test.pem",
["Jwt:PublicKeyPath"] = "unused-in-this-test.pem",
};
if (overrides is not null)
foreach (var kv in overrides)
inMemory[kv.Key] = kv.Value;
var config = new ConfigurationBuilder().AddInMemoryCollection(inMemory).Build();
var services = new ServiceCollection();
services.AddInfrastructure(config);
return services.BuildServiceProvider();
}
[Fact]
public void AuditOptions_WithoutConfigSection_UsesPocoDefaults()
{
using var sp = (ServiceProvider)BuildProvider();
var opts = sp.GetRequiredService<IOptions<AuditOptions>>().Value;
opts.SanitizedKeys.Should().Contain("password");
opts.SanitizedKeys.Should().Contain("refreshToken");
opts.SanitizedKeys.Should().Contain("apiKey");
}
[Fact]
public void AuditOptions_WithConfigSection_AddsToDefaults_PerIConfigurationArrayBinding()
{
// IConfiguration array binding is ADDITIVE, not REPLACE: config values at indices 0..N
// overwrite those indices but defaults beyond N are preserved. This is intended for
// AuditOptions — extensibility is additive (append, not replace).
using var sp = (ServiceProvider)BuildProvider(new[]
{
new KeyValuePair<string, string?>("Audit:SanitizedKeys:11", "customSecret"),
new KeyValuePair<string, string?>("Audit:SanitizedKeys:12", "internalToken"),
});
var opts = sp.GetRequiredService<IOptions<AuditOptions>>().Value;
// The 11 defaults remain + the 2 extras appear at the configured indices.
opts.SanitizedKeys.Should().Contain("password"); // default survived
opts.SanitizedKeys.Should().Contain("customSecret"); // appended via config
opts.SanitizedKeys.Should().Contain("internalToken"); // appended via config
}
}

View File

@@ -0,0 +1,173 @@
using FluentAssertions;
using SIGCM2.Infrastructure.Audit;
using Xunit;
namespace SIGCM2.Application.Tests.Infrastructure.Audit;
/// UDT-010 Batch 3 — JsonSanitizer unit tests (Strict TDD).
/// Validates #REQ-AUD-5: metadata MUST be stripped of blacklisted keys before persisting.
public sealed class JsonSanitizerTests
{
private static readonly string[] DefaultBlacklist =
[
"password", "passwordHash", "token", "refreshToken",
"accessToken", "cvv", "card", "cardNumber",
"secret", "apiKey", "privateKey",
];
[Fact]
public void Sanitize_NullInput_ReturnsNull()
{
var result = JsonSanitizer.Sanitize(null, DefaultBlacklist);
result.Should().BeNull();
}
[Fact]
public void Sanitize_EmptyBlacklist_PreservesAllKeys()
{
var result = JsonSanitizer.Sanitize(new { password = "x", username = "y" }, []);
result.Should().NotBeNull();
result.Should().Contain("\"password\"").And.Contain("\"username\"");
}
[Fact]
public void Sanitize_FlatObject_RemovesBlacklistedKeysAndKeepsOthers()
{
var result = JsonSanitizer.Sanitize(
new { password = "secret1", username = "juan", email = "j@x.com" },
DefaultBlacklist);
result.Should().NotBeNull();
result.Should().NotContain("\"password\"");
result.Should().NotContain("secret1");
result.Should().Contain("\"username\":\"juan\"");
result.Should().Contain("\"email\":\"j@x.com\"");
}
[Fact]
public void Sanitize_NestedObject_RemovesBlacklistedKeysAtEveryLevel()
{
var input = new
{
user = new { password = "x", email = "y@z.com" },
payload = new { nested = new { token = "abc", safe = "keep" } },
};
var result = JsonSanitizer.Sanitize(input, DefaultBlacklist);
result.Should().NotBeNull();
result.Should().NotContain("\"password\"").And.NotContain("\"token\"")
.And.NotContain("\"x\"").And.NotContain("abc");
result.Should().Contain("\"email\":\"y@z.com\"");
result.Should().Contain("\"safe\":\"keep\"");
}
[Fact]
public void Sanitize_ArrayOfObjects_StripsBlacklistedFromEachElement()
{
var input = new
{
items = new object[]
{
new { password = "a", name = "first" },
new { token = "b", name = "second" },
new { name = "third" },
},
};
var result = JsonSanitizer.Sanitize(input, DefaultBlacklist);
result.Should().NotBeNull();
result.Should().NotContain("\"password\"").And.NotContain("\"token\"");
result.Should().Contain("\"name\":\"first\"");
result.Should().Contain("\"name\":\"second\"");
result.Should().Contain("\"name\":\"third\"");
}
[Fact]
public void Sanitize_CaseInsensitiveMatching_StripsAllVariants()
{
var input = new Dictionary<string, object?>
{
["Password"] = "a",
["PASSWORD"] = "b",
["pAsSwOrD"] = "c",
["username"] = "keep",
};
var result = JsonSanitizer.Sanitize(input, new[] { "password" });
result.Should().NotBeNull();
result.Should().NotContain("\"a\"").And.NotContain("\"b\"").And.NotContain("\"c\"");
result.Should().Contain("\"username\":\"keep\"");
}
[Fact]
public void Sanitize_PreservesPrimitives_Numbers_Bools_Null()
{
var input = new { count = 42, flag = true, missing = (string?)null, password = "secret" };
var result = JsonSanitizer.Sanitize(input, DefaultBlacklist);
result.Should().NotBeNull();
result.Should().Contain("\"count\":42");
result.Should().Contain("\"flag\":true");
result.Should().Contain("\"missing\":null");
result.Should().NotContain("\"password\"");
}
[Fact]
public void Sanitize_ProducesValidJson()
{
var input = new { password = "x", user = new { token = "y", name = "z" } };
var result = JsonSanitizer.Sanitize(input, DefaultBlacklist);
result.Should().NotBeNull();
// Round-trip parse validates shape
var parsed = System.Text.Json.JsonDocument.Parse(result!);
parsed.RootElement.TryGetProperty("password", out _).Should().BeFalse();
parsed.RootElement.GetProperty("user").TryGetProperty("token", out _).Should().BeFalse();
parsed.RootElement.GetProperty("user").GetProperty("name").GetString().Should().Be("z");
}
[Fact]
public void Sanitize_AlreadySerializedJsonString_IsTreatedAsStringNotObject()
{
// Callers that want to sanitize an already-serialized JSON string must parse+re-sanitize
// themselves — the sanitizer treats a string argument as a JSON string primitive.
var result = JsonSanitizer.Sanitize("""{"password":"x"}""", DefaultBlacklist);
result.Should().NotBeNull();
// Round-trip parse proves the result is a JSON string primitive (not an object).
var parsed = System.Text.Json.JsonDocument.Parse(result!);
parsed.RootElement.ValueKind.Should().Be(System.Text.Json.JsonValueKind.String);
parsed.RootElement.GetString().Should().Be("""{"password":"x"}""");
}
[Fact]
public void Sanitize_AuditOptionsDefaultKeys_AreAllEffective()
{
// Defensive: AuditOptions.SanitizedKeys must match what tests expect.
var opts = new SIGCM2.Application.Audit.AuditOptions();
var input = new
{
password = "1", passwordHash = "2", token = "3", refreshToken = "4",
accessToken = "5", cvv = "6", card = "7", cardNumber = "8",
secret = "9", apiKey = "10", privateKey = "11",
keep = "yes",
};
var result = JsonSanitizer.Sanitize(input, opts.SanitizedKeys);
result.Should().NotBeNull();
foreach (var bad in new[] { "password", "passwordHash", "token", "refreshToken",
"accessToken", "cvv", "card", "cardNumber", "secret", "apiKey", "privateKey" })
{
result.Should().NotContain($"\"{bad}\"", because: $"'{bad}' is in default blacklist");
}
result.Should().Contain("\"keep\":\"yes\"");
}
}