save progress
This commit is contained in:
@@ -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
|
||||
}
|
||||
132
src/Replay/__Libraries/StellaOps.Replay.Anonymization/Models.cs
Normal file
132
src/Replay/__Libraries/StellaOps.Replay.Anonymization/Models.cs
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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; } = [];
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user