From 08d6622e43062297d015a7bdfb3e66ac6a79cd13 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 16 Apr 2026 13:28:37 -0300 Subject: [PATCH] feat(infra): JsonSanitizer + AuditOptions binding (UDT-010 B3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the metadata sanitization layer per #REQ-AUD-5: SIGCM2.Infrastructure/Audit/JsonSanitizer.cs (static class): - Sanitize(object?, IReadOnlyCollection) -> 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(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} --- .../SIGCM2.Application/Audit/AuditOptions.cs | 7 +- .../Audit/JsonSanitizer.cs | 63 +++++++ .../DependencyInjection.cs | 4 + .../Audit/AuditOptionsBindingTests.cs | 71 +++++++ .../Audit/JsonSanitizerTests.cs | 173 ++++++++++++++++++ 5 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 src/api/SIGCM2.Infrastructure/Audit/JsonSanitizer.cs create mode 100644 tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditOptionsBindingTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Infrastructure/Audit/JsonSanitizerTests.cs diff --git a/src/api/SIGCM2.Application/Audit/AuditOptions.cs b/src/api/SIGCM2.Application/Audit/AuditOptions.cs index de19e3d..6e63a68 100644 --- a/src/api/SIGCM2.Application/Audit/AuditOptions.cs +++ b/src/api/SIGCM2.Application/Audit/AuditOptions.cs @@ -1,6 +1,11 @@ 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 const string SectionName = "Audit"; diff --git a/src/api/SIGCM2.Infrastructure/Audit/JsonSanitizer.cs b/src/api/SIGCM2.Infrastructure/Audit/JsonSanitizer.cs new file mode 100644 index 0000000..c1b1f1b --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Audit/JsonSanitizer.cs @@ -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 to JSON and removes any property whose key matches + /// (case-insensitively) an entry in . Recursive into nested + /// objects and arrays. Returns null if the input is null. + public static string? Sanitize(object? obj, IReadOnlyCollection 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 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 + } + } +} diff --git a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs index baa417f..1efd8a9 100644 --- a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs +++ b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs @@ -8,6 +8,7 @@ using Microsoft.IdentityModel.Tokens; using SIGCM2.Application.Abstractions; using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Abstractions.Security; +using SIGCM2.Application.Audit; using SIGCM2.Application.Auth; using SIGCM2.Infrastructure.Http; using SIGCM2.Infrastructure.Messaging; @@ -68,6 +69,9 @@ public static class DependencyInjection services.AddHttpContextAccessor(); services.AddScoped(); + // UDT-010: Audit options (SanitizedKeys blacklist) — overridable via appsettings "Audit". + services.Configure(configuration.GetSection(AuditOptions.SectionName)); + // Dispatcher services.AddScoped(); diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditOptionsBindingTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditOptionsBindingTests.cs new file mode 100644 index 0000000..09e236a --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/AuditOptionsBindingTests.cs @@ -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>? overrides = null) + { + // Minimum required config for AddInfrastructure to succeed. + var inMemory = new Dictionary + { + ["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>().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("Audit:SanitizedKeys:11", "customSecret"), + new KeyValuePair("Audit:SanitizedKeys:12", "internalToken"), + }); + + var opts = sp.GetRequiredService>().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 + } +} diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/Audit/JsonSanitizerTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/JsonSanitizerTests.cs new file mode 100644 index 0000000..5382b32 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Infrastructure/Audit/JsonSanitizerTests.cs @@ -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 + { + ["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\""); + } +}