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,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));
}
}