Implement nested log redaction (Telemetry-002)

RedactionEnricher now projects each property into a mutable view the ILogRedactor
can edit: scalars stay as their CLR value, while StructureValue/SequenceValue/
DictionaryValue become nested IDictionary<string,object?>/IList<object?> the
redactor descends into recursively. A field nested inside a destructured {@Object}
can now be masked or removed — closing the gap documented as a limitation.

- Project/Rebuild round-trip preserves StructureValue.TypeTag and original
  dictionary keys; redactor-synthesised plain dicts/lists are rebuilt too.
- Untouched properties are not reallocated: structural ValueEquals skips write-back
  unless a property actually changed. Scalar fast path and no-redactor/no-property
  short-circuits retained.
- +5 nested-reach tests (mask/remove a field, sequence element, dictionary value,
  two-levels-deep); the old 'cannot reach' limitation test replaced. Serilog 34, 0 warnings.
- ILogRedactor XML doc + library README updated to document the recursive reach.
This commit is contained in:
Joseph Doherty
2026-06-01 12:12:26 -04:00
parent ae0ccc9a3a
commit 05cc62aab3
4 changed files with 464 additions and 71 deletions
@@ -34,26 +34,115 @@ public sealed class RedactionTests
public void Redact(IDictionary<string, object?> properties) => properties.Remove(_key);
}
private sealed class StructuredFieldRedactor : ILogRedactor
/// <summary>Masks a field nested inside a destructured <c>{@command}</c> object.</summary>
private sealed class NestedFieldRedactor : ILogRedactor
{
// Attempts to mask a nested field of a destructured ({@Object}) property by mutating the
// value the seam exposes. Documents that the seam reaches scalar top-level properties only.
public void Redact(IDictionary<string, object?> properties)
{
if (properties.TryGetValue("command", out var value) && value is StructureValue)
if (properties.TryGetValue("command", out var value)
&& value is IDictionary<string, object?> command
&& command.ContainsKey("ApiKey"))
{
// The seam exposes the raw StructureValue wrapper, not a mutable dictionary of the
// object's fields, so a project redactor cannot reach inside to mask a nested field.
properties["command"] = Masked;
command["ApiKey"] = Masked;
}
}
}
/// <summary>Removes a field nested inside a destructured <c>{@command}</c> object.</summary>
private sealed class NestedRemovingRedactor : ILogRedactor
{
public void Redact(IDictionary<string, object?> properties)
{
if (properties.TryGetValue("command", out var value)
&& value is IDictionary<string, object?> command)
{
command.Remove("ApiKey");
}
}
}
/// <summary>Masks an element inside a destructured <c>{@items}</c> sequence.</summary>
private sealed class SequenceElementRedactor : ILogRedactor
{
public void Redact(IDictionary<string, object?> properties)
{
if (properties.TryGetValue("items", out var value) && value is IList<object?> items)
{
for (var i = 0; i < items.Count; i++)
{
if (items[i] is string s && s.StartsWith("secret", StringComparison.Ordinal))
{
items[i] = Masked;
}
}
}
}
}
/// <summary>Masks a value inside a destructured <c>{@map}</c> dictionary.</summary>
private sealed class DictionaryValueRedactor : ILogRedactor
{
public void Redact(IDictionary<string, object?> properties)
{
if (properties.TryGetValue("map", out var value)
&& value is IDictionary<string, object?> map
&& map.ContainsKey("token"))
{
map["token"] = Masked;
}
}
}
/// <summary>Masks a field two levels deep inside a destructured object graph.</summary>
private sealed class DeepFieldRedactor : ILogRedactor
{
public void Redact(IDictionary<string, object?> properties)
{
if (properties.TryGetValue("payload", out var value)
&& value is IDictionary<string, object?> payload
&& payload.TryGetValue("Inner", out var innerValue)
&& innerValue is IDictionary<string, object?> inner
&& inner.ContainsKey("ApiKey"))
{
inner["ApiKey"] = Masked;
}
}
}
// Named types so a destructured StructureValue's TypeTag is deterministic in assertions.
private sealed record Command(string Name, string ApiKey);
private sealed record Envelope(Inner Inner);
private sealed record Inner(string ApiKey, string Note);
private static string? ScalarOrNull(LogEvent logEvent, string propertyName) =>
logEvent.Properties.TryGetValue(propertyName, out var value) && value is ScalarValue scalar
? scalar.Value?.ToString()
: null;
/// <summary>
/// Builds a logger wired with <paramref name="redactor"/>, logs one event, and returns it —
/// the shared harness for the redaction tests.
/// </summary>
private static LogEvent LogWith(ILogRedactor redactor, string template, params object?[] args)
{
var serviceProvider = new ServiceCollection()
.AddSingleton<ILogRedactor>(redactor)
.BuildServiceProvider();
var sink = new InMemorySink();
var options = new ZbTelemetryOptions { ServiceName = "mxgateway" };
var loggerConfig = new LoggerConfiguration();
ZbSerilogConfig.Apply(loggerConfig, options, serviceProvider);
using Logger logger = loggerConfig.WriteTo.Sink(sink).CreateLogger();
logger.Information(template, args);
return Assert.Single(sink.LogEvents);
}
[Fact]
public void Registered_redactor_masks_sensitive_property()
{
@@ -122,36 +211,108 @@ public sealed class RedactionTests
}
/// <summary>
/// Telemetry-002/003: the redaction seam reaches scalar top-level properties only. A
/// destructured ({@Object}) property is exposed to the redactor as the raw Serilog
/// <see cref="StructureValue"/> wrapper, so a project redactor cannot mask a field nested
/// inside the object — it can only replace/remove the whole top-level property. This test
/// pins that documented limitation (see ILogRedactor XML doc and the shared contract).
/// Telemetry-002: a redactor can reach a field nested inside a destructured ({@Object})
/// property and mask it, leaving sibling fields and the structure's type tag intact.
/// </summary>
[Fact]
public void Redactor_cannot_reach_a_field_inside_a_destructured_object()
public void Redactor_masks_a_field_inside_a_destructured_object()
{
var serviceProvider = new ServiceCollection()
.AddSingleton<ILogRedactor>(new StructuredFieldRedactor())
.BuildServiceProvider();
var logEvent = LogWith(
new NestedFieldRedactor(),
"dispatching {@command}",
new Command("Write", "mxgw_secret"));
var sink = new InMemorySink();
var options = new ZbTelemetryOptions { ServiceName = "mxgateway" };
var structure = Assert.IsType<StructureValue>(logEvent.Properties["command"]);
var fields = structure.Properties.ToDictionary(p => p.Name, p => p.Value);
Assert.Equal(Masked, (fields["ApiKey"] as ScalarValue)?.Value?.ToString());
Assert.Equal("Write", (fields["Name"] as ScalarValue)?.Value?.ToString());
Assert.Equal(nameof(Command), structure.TypeTag);
}
var loggerConfig = new LoggerConfiguration();
ZbSerilogConfig.Apply(loggerConfig, options, serviceProvider);
using Logger logger = loggerConfig.WriteTo.Sink(sink).CreateLogger();
/// <summary>
/// Telemetry-002: a redactor can remove a field nested inside a destructured object; the
/// field is dropped from the rebuilt structure while siblings survive.
/// </summary>
[Fact]
public void Redactor_removes_a_field_inside_a_destructured_object()
{
var logEvent = LogWith(
new NestedRemovingRedactor(),
"dispatching {@command}",
new Command("Write", "mxgw_secret"));
var command = new { Name = "Write", ApiKey = "mxgw_secret" };
logger.Information("dispatching {@command}", command);
var structure = Assert.IsType<StructureValue>(logEvent.Properties["command"]);
var names = structure.Properties.Select(p => p.Name).ToList();
Assert.DoesNotContain("ApiKey", names);
Assert.Contains("Name", names);
}
var logEvent = Assert.Single(sink.LogEvents);
Assert.True(logEvent.Properties.TryGetValue("command", out var value));
/// <summary>Telemetry-002: a redactor can mask an element nested inside a destructured sequence.</summary>
[Fact]
public void Redactor_masks_an_element_inside_a_destructured_sequence()
{
var logEvent = LogWith(
new SequenceElementRedactor(),
"items {@items}",
(object)new[] { "keep", "secret-token" });
// The property was destructured into a StructureValue and exposed to the redactor as that
// wrapper. The redactor recognized it and replaced the whole top-level property with the
// mask — confirming the seam can only act at top-level granularity for structured values.
Assert.Equal(Masked, (value as ScalarValue)?.Value?.ToString());
var sequence = Assert.IsType<SequenceValue>(logEvent.Properties["items"]);
var elements = sequence.Elements.Select(e => (e as ScalarValue)?.Value?.ToString()).ToList();
Assert.Equal(new[] { "keep", Masked }, elements);
}
/// <summary>Telemetry-002: a redactor can mask a value nested inside a destructured dictionary.</summary>
[Fact]
public void Redactor_masks_a_value_inside_a_destructured_dictionary()
{
var map = new Dictionary<string, string> { ["user"] = "alice", ["token"] = "secret" };
var logEvent = LogWith(new DictionaryValueRedactor(), "map {@map}", map);
var dictionary = Assert.IsType<DictionaryValue>(logEvent.Properties["map"]);
var pairs = dictionary.Elements.ToDictionary(
e => (string)e.Key.Value!,
e => (e.Value as ScalarValue)?.Value?.ToString());
Assert.Equal("alice", pairs["user"]);
Assert.Equal(Masked, pairs["token"]);
}
/// <summary>Telemetry-002: nested reach is recursive — a field two levels deep is reachable.</summary>
[Fact]
public void Redactor_masks_a_field_two_levels_deep()
{
var logEvent = LogWith(
new DeepFieldRedactor(),
"payload {@payload}",
new Envelope(new Inner("secret", "keep")));
var outer = Assert.IsType<StructureValue>(logEvent.Properties["payload"]);
var innerValue = outer.Properties.Single(p => p.Name == "Inner").Value;
var inner = Assert.IsType<StructureValue>(innerValue);
var fields = inner.Properties.ToDictionary(p => p.Name, p => p.Value);
Assert.Equal(Masked, (fields["ApiKey"] as ScalarValue)?.Value?.ToString());
Assert.Equal("keep", (fields["Note"] as ScalarValue)?.Value?.ToString());
}
/// <summary>
/// A structured property the redactor does not touch must survive redaction unchanged
/// (siblings, values, and type tag intact) even while a scalar property is masked.
/// </summary>
[Fact]
public void Untouched_structured_property_survives_redaction_intact()
{
var logEvent = LogWith(
new FakeRedactor(),
"dispatching {apiKey} {@command}",
"mxgw_secret",
new Command("Write", "open"));
Assert.Equal(Masked, ScalarOrNull(logEvent, "apiKey"));
var structure = Assert.IsType<StructureValue>(logEvent.Properties["command"]);
var fields = structure.Properties.ToDictionary(p => p.Name, p => p.Value);
Assert.Equal("Write", (fields["Name"] as ScalarValue)?.Value?.ToString());
Assert.Equal("open", (fields["ApiKey"] as ScalarValue)?.Value?.ToString());
Assert.Equal(nameof(Command), structure.TypeTag);
}
[Fact]