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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user