save progress

This commit is contained in:
StellaOps Bot
2026-01-06 09:42:02 +02:00
parent 94d68bee8b
commit 37e11918e0
443 changed files with 85863 additions and 897 deletions

View File

@@ -0,0 +1,123 @@
// <copyright file="ITraceAnonymizer.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
// Sprint: SPRINT_20260105_002_002_TEST_trace_replay_evidence
// Task: TREP-001, TREP-002
using System.Collections.Immutable;
namespace StellaOps.Replay.Anonymization;
/// <summary>
/// Anonymizes production traces for safe use in testing.
/// </summary>
public interface ITraceAnonymizer
{
/// <summary>
/// Anonymize a production trace, removing PII and sensitive data.
/// </summary>
/// <param name="trace">The production trace to anonymize.</param>
/// <param name="options">Anonymization options.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The anonymized trace.</returns>
Task<AnonymizedTrace> AnonymizeAsync(
ProductionTrace trace,
AnonymizationOptions options,
CancellationToken ct = default);
/// <summary>
/// Validate that a trace is properly anonymized.
/// </summary>
/// <param name="trace">The anonymized trace to validate.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Validation result.</returns>
Task<AnonymizationValidationResult> ValidateAnonymizationAsync(
AnonymizedTrace trace,
CancellationToken ct = default);
}
/// <summary>
/// Options controlling trace anonymization behavior.
/// </summary>
/// <param name="RedactImageNames">Whether to redact container image names.</param>
/// <param name="RedactUserIds">Whether to redact user identifiers.</param>
/// <param name="RedactIpAddresses">Whether to redact IP addresses.</param>
/// <param name="RedactFilePaths">Whether to redact file paths.</param>
/// <param name="RedactEnvironmentVariables">Whether to redact environment variables.</param>
/// <param name="PreserveTimingPatterns">Whether to preserve relative timing patterns.</param>
/// <param name="AdditionalPiiPatterns">Additional regex patterns to treat as PII.</param>
/// <param name="AllowlistedValues">Values to preserve without redaction.</param>
public sealed record AnonymizationOptions(
bool RedactImageNames = true,
bool RedactUserIds = true,
bool RedactIpAddresses = true,
bool RedactFilePaths = true,
bool RedactEnvironmentVariables = true,
bool PreserveTimingPatterns = true,
ImmutableArray<string> AdditionalPiiPatterns = default,
ImmutableArray<string> AllowlistedValues = default)
{
/// <summary>
/// Default anonymization options with all redactions enabled.
/// </summary>
public static AnonymizationOptions Default => new();
/// <summary>
/// Minimal anonymization that only redacts obvious PII.
/// </summary>
public static AnonymizationOptions Minimal => new(
RedactFilePaths: false,
RedactEnvironmentVariables: false);
}
/// <summary>
/// Result of anonymization validation.
/// </summary>
/// <param name="IsValid">Whether the trace is properly anonymized.</param>
/// <param name="Violations">Any detected PII violations.</param>
/// <param name="Warnings">Non-critical warnings about the trace.</param>
public sealed record AnonymizationValidationResult(
bool IsValid,
ImmutableArray<PiiViolation> Violations,
ImmutableArray<string> Warnings)
{
/// <summary>
/// Creates a successful validation result.
/// </summary>
public static AnonymizationValidationResult Success() =>
new(true, ImmutableArray<PiiViolation>.Empty, ImmutableArray<string>.Empty);
/// <summary>
/// Creates a failed validation result with violations.
/// </summary>
public static AnonymizationValidationResult Failure(params PiiViolation[] violations) =>
new(false, [.. violations], ImmutableArray<string>.Empty);
}
/// <summary>
/// A detected PII violation in an anonymized trace.
/// </summary>
/// <param name="SpanId">The span containing the violation.</param>
/// <param name="FieldPath">Path to the field containing PII.</param>
/// <param name="ViolationType">Type of PII detected.</param>
/// <param name="SampleValue">Masked sample of the detected value.</param>
public sealed record PiiViolation(
string SpanId,
string FieldPath,
PiiType ViolationType,
string SampleValue);
/// <summary>
/// Types of PII that can be detected.
/// </summary>
public enum PiiType
{
IpAddress,
Email,
UserId,
FilePath,
ImageName,
EnvironmentVariable,
Uuid,
Custom
}

View File

@@ -0,0 +1,132 @@
// <copyright file="Models.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
namespace StellaOps.Replay.Anonymization;
/// <summary>
/// A production trace captured for replay.
/// </summary>
/// <param name="TraceId">Unique identifier for the trace.</param>
/// <param name="CapturedAt">When the trace was captured.</param>
/// <param name="Type">Type of trace (scan, attestation, etc.).</param>
/// <param name="Spans">The spans that make up the trace.</param>
/// <param name="TotalDuration">Total duration of the trace.</param>
public sealed record ProductionTrace(
string TraceId,
DateTimeOffset CapturedAt,
TraceType Type,
ImmutableArray<TraceSpan> Spans,
TimeSpan TotalDuration);
/// <summary>
/// An anonymized trace safe for testing use.
/// </summary>
/// <param name="TraceId">Anonymized trace identifier.</param>
/// <param name="OriginalTraceIdHash">SHA-256 hash of original for correlation.</param>
/// <param name="CapturedAt">When the trace was captured.</param>
/// <param name="AnonymizedAt">When anonymization was performed.</param>
/// <param name="Type">Type of trace.</param>
/// <param name="Spans">Anonymized spans.</param>
/// <param name="Manifest">Anonymization manifest.</param>
/// <param name="TotalDuration">Total duration of the trace.</param>
public sealed record AnonymizedTrace(
string TraceId,
string OriginalTraceIdHash,
DateTimeOffset CapturedAt,
DateTimeOffset AnonymizedAt,
TraceType Type,
ImmutableArray<AnonymizedSpan> Spans,
AnonymizationManifest Manifest,
TimeSpan TotalDuration);
/// <summary>
/// A span within a trace.
/// </summary>
/// <param name="SpanId">Unique span identifier.</param>
/// <param name="ParentSpanId">Parent span identifier, if any.</param>
/// <param name="OperationName">Name of the operation.</param>
/// <param name="StartTime">When the span started.</param>
/// <param name="Duration">Duration of the span.</param>
/// <param name="Attributes">Key-value attributes on the span.</param>
/// <param name="Events">Events within the span.</param>
public sealed record TraceSpan(
string SpanId,
string? ParentSpanId,
string OperationName,
DateTimeOffset StartTime,
TimeSpan Duration,
ImmutableDictionary<string, string> Attributes,
ImmutableArray<SpanEvent> Events);
/// <summary>
/// An anonymized span.
/// </summary>
/// <param name="SpanId">Anonymized span identifier.</param>
/// <param name="ParentSpanId">Anonymized parent span identifier.</param>
/// <param name="OperationName">Operation name (may be anonymized).</param>
/// <param name="StartTime">Relative start time.</param>
/// <param name="Duration">Duration (preserved).</param>
/// <param name="Attributes">Anonymized attributes.</param>
/// <param name="Events">Anonymized events.</param>
public sealed record AnonymizedSpan(
string SpanId,
string? ParentSpanId,
string OperationName,
DateTimeOffset StartTime,
TimeSpan Duration,
ImmutableDictionary<string, string> Attributes,
ImmutableArray<AnonymizedSpanEvent> Events);
/// <summary>
/// An event within a span.
/// </summary>
/// <param name="Name">Event name.</param>
/// <param name="Timestamp">When the event occurred.</param>
/// <param name="Attributes">Event attributes.</param>
public sealed record SpanEvent(
string Name,
DateTimeOffset Timestamp,
ImmutableDictionary<string, string> Attributes);
/// <summary>
/// An anonymized event within a span.
/// </summary>
/// <param name="Name">Event name.</param>
/// <param name="Timestamp">Relative timestamp.</param>
/// <param name="Attributes">Anonymized attributes.</param>
public sealed record AnonymizedSpanEvent(
string Name,
DateTimeOffset Timestamp,
ImmutableDictionary<string, string> Attributes);
/// <summary>
/// Manifest describing anonymization that was performed.
/// </summary>
/// <param name="TotalFieldsProcessed">Total fields processed.</param>
/// <param name="FieldsRedacted">Number of fields redacted.</param>
/// <param name="FieldsPreserved">Number of fields preserved.</param>
/// <param name="RedactionCategories">Categories of redaction applied.</param>
/// <param name="AnonymizationVersion">Version of anonymization logic.</param>
public sealed record AnonymizationManifest(
int TotalFieldsProcessed,
int FieldsRedacted,
int FieldsPreserved,
ImmutableArray<string> RedactionCategories,
string AnonymizationVersion);
/// <summary>
/// Type of trace.
/// </summary>
public enum TraceType
{
Scan,
Attestation,
VexConsensus,
Advisory,
Evidence,
Auth,
MultiModule
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>true</IsPackable>
<Description>Trace anonymization for safe production trace replay in testing</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,401 @@
// <copyright file="TraceAnonymizer.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
namespace StellaOps.Replay.Anonymization;
/// <summary>
/// Default implementation of trace anonymization.
/// </summary>
public sealed partial class TraceAnonymizer : ITraceAnonymizer
{
private static readonly Regex IpAddressRegex = GenerateIpAddressRegex();
private static readonly Regex EmailRegex = GenerateEmailRegex();
private static readonly Regex UuidRegex = GenerateUuidRegex();
private static readonly Regex FilePathRegex = GenerateFilePathRegex();
private const string AnonymizationVersion = "1.0.0";
private readonly ILogger<TraceAnonymizer> _logger;
private readonly TimeProvider _timeProvider;
public TraceAnonymizer(ILogger<TraceAnonymizer> logger, TimeProvider timeProvider)
{
_logger = logger;
_timeProvider = timeProvider;
}
/// <inheritdoc/>
public Task<AnonymizedTrace> AnonymizeAsync(
ProductionTrace trace,
AnonymizationOptions options,
CancellationToken ct = default)
{
var anonymizedSpans = new List<AnonymizedSpan>();
var redactionCount = 0;
var totalFields = 0;
var categories = new HashSet<string>();
foreach (var span in trace.Spans)
{
ct.ThrowIfCancellationRequested();
var (anonymizedSpan, stats) = AnonymizeSpan(span, options);
anonymizedSpans.Add(anonymizedSpan);
totalFields += stats.TotalFields;
redactionCount += stats.RedactedFields;
foreach (var category in stats.Categories)
{
categories.Add(category);
}
}
var manifest = new AnonymizationManifest(
TotalFieldsProcessed: totalFields,
FieldsRedacted: redactionCount,
FieldsPreserved: totalFields - redactionCount,
RedactionCategories: [.. categories.Order()],
AnonymizationVersion: AnonymizationVersion);
var result = new AnonymizedTrace(
TraceId: GenerateDeterministicId(trace.TraceId),
OriginalTraceIdHash: ComputeSha256(trace.TraceId),
CapturedAt: trace.CapturedAt,
AnonymizedAt: _timeProvider.GetUtcNow(),
Type: trace.Type,
Spans: [.. anonymizedSpans],
Manifest: manifest,
TotalDuration: trace.TotalDuration);
_logger.LogDebug(
"Anonymized trace {TraceId}: {RedactedFields}/{TotalFields} fields redacted",
result.TraceId, redactionCount, totalFields);
return Task.FromResult(result);
}
/// <inheritdoc/>
public Task<AnonymizationValidationResult> ValidateAnonymizationAsync(
AnonymizedTrace trace,
CancellationToken ct = default)
{
var violations = new List<PiiViolation>();
foreach (var span in trace.Spans)
{
ct.ThrowIfCancellationRequested();
foreach (var (key, value) in span.Attributes)
{
var piiType = DetectPii(value);
if (piiType is not null)
{
violations.Add(new PiiViolation(
SpanId: span.SpanId,
FieldPath: $"attributes.{key}",
ViolationType: piiType.Value,
SampleValue: MaskValue(value)));
}
}
foreach (var evt in span.Events)
{
foreach (var (key, value) in evt.Attributes)
{
var piiType = DetectPii(value);
if (piiType is not null)
{
violations.Add(new PiiViolation(
SpanId: span.SpanId,
FieldPath: $"events.{evt.Name}.attributes.{key}",
ViolationType: piiType.Value,
SampleValue: MaskValue(value)));
}
}
}
}
if (violations.Count > 0)
{
_logger.LogWarning(
"Validation found {ViolationCount} PII violations in trace {TraceId}",
violations.Count, trace.TraceId);
return Task.FromResult(new AnonymizationValidationResult(
false, [.. violations], ImmutableArray<string>.Empty));
}
return Task.FromResult(AnonymizationValidationResult.Success());
}
private (AnonymizedSpan Span, AnonymizationStats Stats) AnonymizeSpan(
TraceSpan span,
AnonymizationOptions options)
{
var stats = new AnonymizationStats();
var anonymizedAttributes = new Dictionary<string, string>();
foreach (var (key, value) in span.Attributes)
{
stats.TotalFields++;
var (anonymized, wasRedacted, category) = AnonymizeValue(key, value, options);
if (wasRedacted)
{
stats.RedactedFields++;
if (category is not null)
{
stats.Categories.Add(category);
}
}
anonymizedAttributes[AnonymizeKey(key, options)] = anonymized;
}
var anonymizedEvents = span.Events.Select(evt =>
{
var eventAttributes = new Dictionary<string, string>();
foreach (var (key, value) in evt.Attributes)
{
stats.TotalFields++;
var (anonymized, wasRedacted, category) = AnonymizeValue(key, value, options);
if (wasRedacted)
{
stats.RedactedFields++;
if (category is not null)
{
stats.Categories.Add(category);
}
}
eventAttributes[key] = anonymized;
}
return new AnonymizedSpanEvent(
Name: evt.Name,
Timestamp: evt.Timestamp,
Attributes: eventAttributes.ToImmutableDictionary());
}).ToImmutableArray();
var anonymizedSpan = new AnonymizedSpan(
SpanId: HashIdentifier(span.SpanId),
ParentSpanId: span.ParentSpanId is not null ? HashIdentifier(span.ParentSpanId) : null,
OperationName: span.OperationName,
StartTime: span.StartTime,
Duration: span.Duration,
Attributes: anonymizedAttributes.ToImmutableDictionary(),
Events: anonymizedEvents);
return (anonymizedSpan, stats);
}
private (string Value, bool WasRedacted, string? Category) AnonymizeValue(
string key,
string value,
AnonymizationOptions options)
{
// Check allowlist first
if (!options.AllowlistedValues.IsDefaultOrEmpty &&
options.AllowlistedValues.Contains(value))
{
return (value, false, null);
}
var result = value;
var wasRedacted = false;
string? category = null;
// Apply redactions based on options
if (options.RedactIpAddresses && IpAddressRegex.IsMatch(result))
{
result = IpAddressRegex.Replace(result, "[REDACTED_IP]");
wasRedacted = true;
category = "ip_address";
}
if (options.RedactUserIds && IsUserIdField(key))
{
result = "[REDACTED_USER_ID]";
wasRedacted = true;
category = "user_id";
}
if (options.RedactFilePaths && FilePathRegex.IsMatch(result))
{
result = AnonymizeFilePath(result);
wasRedacted = true;
category = "file_path";
}
if (options.RedactImageNames && IsImageReference(key))
{
result = AnonymizeImageName(result);
wasRedacted = true;
category = "image_name";
}
if (options.RedactEnvironmentVariables && IsEnvVarField(key))
{
result = "[REDACTED_ENV]";
wasRedacted = true;
category = "env_var";
}
if (EmailRegex.IsMatch(result))
{
result = EmailRegex.Replace(result, "[REDACTED_EMAIL]");
wasRedacted = true;
category = "email";
}
// Apply custom patterns
if (!options.AdditionalPiiPatterns.IsDefaultOrEmpty)
{
foreach (var pattern in options.AdditionalPiiPatterns)
{
var regex = new Regex(pattern, RegexOptions.IgnoreCase);
if (regex.IsMatch(result))
{
result = regex.Replace(result, "[REDACTED]");
wasRedacted = true;
category = "custom";
}
}
}
return (result, wasRedacted, category);
}
private static string AnonymizeKey(string key, AnonymizationOptions options)
{
// Keys are generally preserved unless they contain PII patterns
if (options.RedactUserIds && key.Contains("user", StringComparison.OrdinalIgnoreCase))
{
return key; // Keep key but value was redacted
}
return key;
}
private static string AnonymizeFilePath(string path)
{
// Preserve structure but anonymize specific directories
// /home/user/project/file.txt -> /[HOME]/[USER]/[PROJECT]/file.txt
var parts = path.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries);
if (parts.Length <= 1)
{
return path;
}
var anonymizedParts = new List<string>();
for (int i = 0; i < parts.Length; i++)
{
// Preserve last component (filename) and common directories
if (i == parts.Length - 1 ||
IsCommonDirectory(parts[i]))
{
anonymizedParts.Add(parts[i]);
}
else
{
anonymizedParts.Add("[DIR]");
}
}
var separator = path.Contains('\\') ? "\\" : "/";
return string.Join(separator, anonymizedParts);
}
private static string AnonymizeImageName(string imageName)
{
// Preserve structure but anonymize registry/repo
// registry.example.com/team/app:v1.2.3 -> [REGISTRY]/[REPO]:v1.2.3
var tagIndex = imageName.LastIndexOf(':');
var tag = tagIndex > 0 ? imageName[tagIndex..] : ":latest";
return $"[REGISTRY]/[REPO]{tag}";
}
private static bool IsUserIdField(string key) =>
key.Contains("user", StringComparison.OrdinalIgnoreCase) ||
key.Contains("owner", StringComparison.OrdinalIgnoreCase) ||
key.Contains("author", StringComparison.OrdinalIgnoreCase) ||
key.Contains("creator", StringComparison.OrdinalIgnoreCase);
private static bool IsImageReference(string key) =>
key.Contains("image", StringComparison.OrdinalIgnoreCase) ||
key.Contains("container", StringComparison.OrdinalIgnoreCase) ||
key.Contains("registry", StringComparison.OrdinalIgnoreCase);
private static bool IsEnvVarField(string key) =>
key.Contains("env", StringComparison.OrdinalIgnoreCase) ||
key.Equals("PATH", StringComparison.OrdinalIgnoreCase) ||
key.Equals("HOME", StringComparison.OrdinalIgnoreCase);
private static bool IsCommonDirectory(string dir) =>
dir is "usr" or "var" or "etc" or "opt" or "tmp" or
"bin" or "lib" or "src" or "app" or "home";
private static PiiType? DetectPii(string value)
{
if (IpAddressRegex.IsMatch(value))
return PiiType.IpAddress;
if (EmailRegex.IsMatch(value))
return PiiType.Email;
if (value.Contains("@") && value.Contains("."))
return PiiType.Email;
return null;
}
private static string MaskValue(string value)
{
if (value.Length <= 4)
return "****";
return string.Concat(value.AsSpan(0, 2), "****", value.AsSpan(value.Length - 2));
}
private static string GenerateDeterministicId(string originalId)
{
var hash = ComputeSha256(originalId);
return $"anon-{hash[..16]}";
}
private static string HashIdentifier(string id)
{
var hash = ComputeSha256(id);
return hash[..16];
}
private static string ComputeSha256(string input)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
[GeneratedRegex(@"\b(?:\d{1,3}\.){3}\d{1,3}\b", RegexOptions.Compiled)]
private static partial Regex GenerateIpAddressRegex();
[GeneratedRegex(@"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", RegexOptions.Compiled)]
private static partial Regex GenerateEmailRegex();
[GeneratedRegex(@"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b", RegexOptions.Compiled | RegexOptions.IgnoreCase)]
private static partial Regex GenerateUuidRegex();
[GeneratedRegex(@"^[/\\]|[A-Za-z]:[/\\]", RegexOptions.Compiled)]
private static partial Regex GenerateFilePathRegex();
private sealed class AnonymizationStats
{
public int TotalFields { get; set; }
public int RedactedFields { get; set; }
public HashSet<string> Categories { get; } = [];
}
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Replay.Anonymization\StellaOps.Replay.Anonymization.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,477 @@
// <copyright file="TraceAnonymizerTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
// Sprint: SPRINT_20260105_002_002_TEST_trace_replay_evidence
// Task: TREP-001, TREP-002
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using Xunit;
namespace StellaOps.Replay.Anonymization.Tests;
[Trait("Category", "Unit")]
public sealed class TraceAnonymizerTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly TraceAnonymizer _anonymizer;
public TraceAnonymizerTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 5, 12, 0, 0, TimeSpan.Zero));
_anonymizer = new TraceAnonymizer(
NullLogger<TraceAnonymizer>.Instance,
_timeProvider);
}
[Fact]
public async Task AnonymizeAsync_RedactsIpAddresses_WhenEnabled()
{
// Arrange
var trace = CreateTraceWithAttributes(new Dictionary<string, string>
{
["client_ip"] = "192.168.1.100",
["server_ip"] = "10.0.0.1",
["message"] = "Connected from 172.16.0.1 to server"
});
var options = AnonymizationOptions.Default with { RedactIpAddresses = true };
// Act
var result = await _anonymizer.AnonymizeAsync(trace, options, TestContext.Current.CancellationToken);
// Assert
var span = result.Spans.Single();
span.Attributes["client_ip"].Should().Be("[REDACTED_IP]");
span.Attributes["server_ip"].Should().Be("[REDACTED_IP]");
span.Attributes["message"].Should().Contain("[REDACTED_IP]");
result.Manifest.RedactionCategories.Should().Contain("ip_address");
}
[Fact]
public async Task AnonymizeAsync_RedactsEmails_Automatically()
{
// Arrange
var trace = CreateTraceWithAttributes(new Dictionary<string, string>
{
["contact"] = "admin@example.com",
["message"] = "Sent notification to user@domain.org"
});
var options = AnonymizationOptions.Default;
// Act
var result = await _anonymizer.AnonymizeAsync(trace, options, TestContext.Current.CancellationToken);
// Assert
var span = result.Spans.Single();
span.Attributes["contact"].Should().Be("[REDACTED_EMAIL]");
span.Attributes["message"].Should().Contain("[REDACTED_EMAIL]");
result.Manifest.RedactionCategories.Should().Contain("email");
}
[Fact]
public async Task AnonymizeAsync_RedactsUserIds_WhenEnabled()
{
// Arrange
var trace = CreateTraceWithAttributes(new Dictionary<string, string>
{
["user_id"] = "user-12345",
["owner"] = "jsmith",
["author_name"] = "John Doe",
["regular_field"] = "not a user id"
});
var options = AnonymizationOptions.Default with { RedactUserIds = true };
// Act
var result = await _anonymizer.AnonymizeAsync(trace, options, TestContext.Current.CancellationToken);
// Assert
var span = result.Spans.Single();
span.Attributes["user_id"].Should().Be("[REDACTED_USER_ID]");
span.Attributes["owner"].Should().Be("[REDACTED_USER_ID]");
span.Attributes["author_name"].Should().Be("[REDACTED_USER_ID]");
span.Attributes["regular_field"].Should().Be("not a user id");
}
[Fact]
public async Task AnonymizeAsync_AnonymizesFilePaths_WhenEnabled()
{
// Arrange
var trace = CreateTraceWithAttributes(new Dictionary<string, string>
{
["file_path"] = "/home/jsmith/projects/secret/config.yaml",
["windows_path"] = "C:\\Users\\admin\\Documents\\report.pdf"
});
var options = AnonymizationOptions.Default with { RedactFilePaths = true };
// Act
var result = await _anonymizer.AnonymizeAsync(trace, options, TestContext.Current.CancellationToken);
// Assert
var span = result.Spans.Single();
span.Attributes["file_path"].Should().Contain("[DIR]").And.EndWith("config.yaml");
span.Attributes["windows_path"].Should().Contain("[DIR]").And.EndWith("report.pdf");
result.Manifest.RedactionCategories.Should().Contain("file_path");
}
[Fact]
public async Task AnonymizeAsync_AnonymizesImageNames_WhenEnabled()
{
// Arrange
var trace = CreateTraceWithAttributes(new Dictionary<string, string>
{
["image_ref"] = "registry.example.com/team/myapp:v1.2.3",
["container_image"] = "ghcr.io/org/service:latest"
});
var options = AnonymizationOptions.Default with { RedactImageNames = true };
// Act
var result = await _anonymizer.AnonymizeAsync(trace, options, TestContext.Current.CancellationToken);
// Assert
var span = result.Spans.Single();
span.Attributes["image_ref"].Should().Be("[REGISTRY]/[REPO]:v1.2.3");
span.Attributes["container_image"].Should().Be("[REGISTRY]/[REPO]:latest");
result.Manifest.RedactionCategories.Should().Contain("image_name");
}
[Fact]
public async Task AnonymizeAsync_RedactsEnvironmentVariables_WhenEnabled()
{
// Arrange
var trace = CreateTraceWithAttributes(new Dictionary<string, string>
{
["env_var"] = "DATABASE_URL=postgres://secret@host/db",
["PATH"] = "/usr/local/bin:/home/user/bin"
});
var options = AnonymizationOptions.Default with { RedactEnvironmentVariables = true };
// Act
var result = await _anonymizer.AnonymizeAsync(trace, options, TestContext.Current.CancellationToken);
// Assert
var span = result.Spans.Single();
span.Attributes["env_var"].Should().Be("[REDACTED_ENV]");
span.Attributes["PATH"].Should().Be("[REDACTED_ENV]");
result.Manifest.RedactionCategories.Should().Contain("env_var");
}
[Fact]
public async Task AnonymizeAsync_PreservesAllowlistedValues()
{
// Arrange
var trace = CreateTraceWithAttributes(new Dictionary<string, string>
{
["ip"] = "127.0.0.1",
["other_ip"] = "192.168.1.1"
});
var options = AnonymizationOptions.Default with
{
RedactIpAddresses = true,
AllowlistedValues = ["127.0.0.1"]
};
// Act
var result = await _anonymizer.AnonymizeAsync(trace, options, TestContext.Current.CancellationToken);
// Assert
var span = result.Spans.Single();
span.Attributes["ip"].Should().Be("127.0.0.1");
span.Attributes["other_ip"].Should().Be("[REDACTED_IP]");
}
[Fact]
public async Task AnonymizeAsync_AppliesCustomPatterns()
{
// Arrange
var trace = CreateTraceWithAttributes(new Dictionary<string, string>
{
["secret_key"] = "sk_live_abc123xyz789",
["api_key"] = "api-key-secret-12345"
});
var options = AnonymizationOptions.Default with
{
AdditionalPiiPatterns = ["sk_live_\\w+", "api-key-\\w+-\\d+"]
};
// Act
var result = await _anonymizer.AnonymizeAsync(trace, options, TestContext.Current.CancellationToken);
// Assert
var span = result.Spans.Single();
span.Attributes["secret_key"].Should().Be("[REDACTED]");
span.Attributes["api_key"].Should().Be("[REDACTED]");
result.Manifest.RedactionCategories.Should().Contain("custom");
}
[Fact]
public async Task AnonymizeAsync_GeneratesDeterministicTraceId()
{
// Arrange
var trace = CreateSimpleTrace("original-trace-id-123");
var options = AnonymizationOptions.Default;
// Act
var result1 = await _anonymizer.AnonymizeAsync(trace, options, TestContext.Current.CancellationToken);
var result2 = await _anonymizer.AnonymizeAsync(trace, options, TestContext.Current.CancellationToken);
// Assert
result1.TraceId.Should().Be(result2.TraceId);
result1.TraceId.Should().StartWith("anon-");
result1.TraceId.Should().NotBe(trace.TraceId);
}
[Fact]
public async Task AnonymizeAsync_HashesSpanIds()
{
// Arrange
var trace = CreateSimpleTrace("test-trace");
var options = AnonymizationOptions.Default;
// Act
var result = await _anonymizer.AnonymizeAsync(trace, options, TestContext.Current.CancellationToken);
// Assert
var span = result.Spans.Single();
span.SpanId.Should().HaveLength(16);
span.SpanId.Should().NotBe(trace.Spans[0].SpanId);
}
[Fact]
public async Task AnonymizeAsync_PreservesStructuralIntegrity()
{
// Arrange
var originalSpan = new TraceSpan(
SpanId: "span-1",
ParentSpanId: "parent-span",
OperationName: "ProcessRequest",
StartTime: _timeProvider.GetUtcNow(),
Duration: TimeSpan.FromMilliseconds(150),
Attributes: new Dictionary<string, string>
{
["status"] = "ok",
["count"] = "42"
}.ToImmutableDictionary(),
Events: [
new SpanEvent(
Name: "checkpoint",
Timestamp: _timeProvider.GetUtcNow().AddMilliseconds(50),
Attributes: new Dictionary<string, string>
{
["event_data"] = "data"
}.ToImmutableDictionary())
]);
var trace = new ProductionTrace(
TraceId: "trace-123",
CapturedAt: _timeProvider.GetUtcNow().AddDays(-1),
Type: TraceType.Scan,
Spans: [originalSpan],
TotalDuration: TimeSpan.FromMilliseconds(150));
var options = AnonymizationOptions.Default;
// Act
var result = await _anonymizer.AnonymizeAsync(trace, options, TestContext.Current.CancellationToken);
// Assert
result.Spans.Should().HaveCount(1);
var span = result.Spans[0];
span.OperationName.Should().Be("ProcessRequest");
span.Duration.Should().Be(TimeSpan.FromMilliseconds(150));
span.Events.Should().HaveCount(1);
span.Events[0].Name.Should().Be("checkpoint");
}
[Fact]
public async Task AnonymizeAsync_RecordsAnonymizationManifest()
{
// Arrange
var trace = CreateTraceWithAttributes(new Dictionary<string, string>
{
["ip"] = "192.168.1.1",
["email"] = "test@example.com",
["normal"] = "value"
});
var options = AnonymizationOptions.Default with { RedactIpAddresses = true };
// Act
var result = await _anonymizer.AnonymizeAsync(trace, options, TestContext.Current.CancellationToken);
// Assert
result.Manifest.TotalFieldsProcessed.Should().Be(3);
result.Manifest.FieldsRedacted.Should().Be(2);
result.Manifest.FieldsPreserved.Should().Be(1);
result.Manifest.AnonymizationVersion.Should().Be("1.0.0");
}
[Fact]
public async Task ValidateAnonymizationAsync_DetectsPiiViolations()
{
// Arrange
var leakyTrace = new AnonymizedTrace(
TraceId: "anon-test",
OriginalTraceIdHash: "hash",
CapturedAt: _timeProvider.GetUtcNow(),
AnonymizedAt: _timeProvider.GetUtcNow(),
Type: TraceType.Scan,
Spans: [
new AnonymizedSpan(
SpanId: "span1",
ParentSpanId: null,
OperationName: "test",
StartTime: _timeProvider.GetUtcNow(),
Duration: TimeSpan.FromSeconds(1),
Attributes: new Dictionary<string, string>
{
["leaked_ip"] = "192.168.1.100",
["leaked_email"] = "user@example.com"
}.ToImmutableDictionary(),
Events: [])
],
Manifest: new AnonymizationManifest(0, 0, 0, [], "1.0.0"),
TotalDuration: TimeSpan.FromSeconds(1));
// Act
var result = await _anonymizer.ValidateAnonymizationAsync(leakyTrace, TestContext.Current.CancellationToken);
// Assert
result.IsValid.Should().BeFalse();
result.Violations.Should().HaveCount(2);
result.Violations.Should().Contain(v => v.ViolationType == PiiType.IpAddress);
result.Violations.Should().Contain(v => v.ViolationType == PiiType.Email);
}
[Fact]
public async Task ValidateAnonymizationAsync_PassesForCleanTrace()
{
// Arrange
var cleanTrace = new AnonymizedTrace(
TraceId: "anon-test",
OriginalTraceIdHash: "hash",
CapturedAt: _timeProvider.GetUtcNow(),
AnonymizedAt: _timeProvider.GetUtcNow(),
Type: TraceType.Scan,
Spans: [
new AnonymizedSpan(
SpanId: "span1",
ParentSpanId: null,
OperationName: "test",
StartTime: _timeProvider.GetUtcNow(),
Duration: TimeSpan.FromSeconds(1),
Attributes: new Dictionary<string, string>
{
["status"] = "ok",
["count"] = "42"
}.ToImmutableDictionary(),
Events: [])
],
Manifest: new AnonymizationManifest(2, 0, 2, [], "1.0.0"),
TotalDuration: TimeSpan.FromSeconds(1));
// Act
var result = await _anonymizer.ValidateAnonymizationAsync(cleanTrace, TestContext.Current.CancellationToken);
// Assert
result.IsValid.Should().BeTrue();
result.Violations.Should().BeEmpty();
}
[Fact]
public async Task AnonymizeAsync_RespectsCancellation()
{
// Arrange
var trace = CreateTraceWithAttributes(new Dictionary<string, string>
{
["field"] = "value"
});
var options = AnonymizationOptions.Default;
using var cts = new CancellationTokenSource();
cts.Cancel();
// Act & Assert
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
await _anonymizer.AnonymizeAsync(trace, options, cts.Token));
}
[Fact]
public async Task AnonymizeAsync_PreservesTraceType()
{
// Arrange
var trace = CreateSimpleTrace("test", TraceType.VexConsensus);
var options = AnonymizationOptions.Default;
// Act
var result = await _anonymizer.AnonymizeAsync(trace, options, TestContext.Current.CancellationToken);
// Assert
result.Type.Should().Be(TraceType.VexConsensus);
}
[Fact]
public async Task AnonymizeAsync_PreservesDurations()
{
// Arrange
var originalDuration = TimeSpan.FromMinutes(5);
var trace = new ProductionTrace(
TraceId: "test",
CapturedAt: _timeProvider.GetUtcNow(),
Type: TraceType.Scan,
Spans: [
new TraceSpan(
SpanId: "span1",
ParentSpanId: null,
OperationName: "op",
StartTime: _timeProvider.GetUtcNow(),
Duration: TimeSpan.FromMinutes(2),
Attributes: ImmutableDictionary<string, string>.Empty,
Events: [])
],
TotalDuration: originalDuration);
var options = AnonymizationOptions.Default;
// Act
var result = await _anonymizer.AnonymizeAsync(trace, options, TestContext.Current.CancellationToken);
// Assert
result.TotalDuration.Should().Be(originalDuration);
result.Spans[0].Duration.Should().Be(TimeSpan.FromMinutes(2));
}
private ProductionTrace CreateSimpleTrace(string traceId, TraceType type = TraceType.Scan)
{
return new ProductionTrace(
TraceId: traceId,
CapturedAt: _timeProvider.GetUtcNow(),
Type: type,
Spans: [
new TraceSpan(
SpanId: "span-1",
ParentSpanId: null,
OperationName: "TestOperation",
StartTime: _timeProvider.GetUtcNow(),
Duration: TimeSpan.FromMilliseconds(100),
Attributes: ImmutableDictionary<string, string>.Empty,
Events: [])
],
TotalDuration: TimeSpan.FromMilliseconds(100));
}
private ProductionTrace CreateTraceWithAttributes(Dictionary<string, string> attributes)
{
return new ProductionTrace(
TraceId: "test-trace",
CapturedAt: _timeProvider.GetUtcNow(),
Type: TraceType.Scan,
Spans: [
new TraceSpan(
SpanId: "span-1",
ParentSpanId: null,
OperationName: "TestOperation",
StartTime: _timeProvider.GetUtcNow(),
Duration: TimeSpan.FromMilliseconds(100),
Attributes: attributes.ToImmutableDictionary(),
Events: [])
],
TotalDuration: TimeSpan.FromMilliseconds(100));
}
}