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\"");
+ }
+}