// ----------------------------------------------------------------------------- // E2EReproducibilityTestFixture.cs // Sprint: SPRINT_8200_0001_0004_e2e_reproducibility_test // Task: E2E-8200-002 - Create E2EReproducibilityTestFixture with full service composition // Description: Test fixture providing full pipeline composition for E2E reproducibility tests. // Supports: ingest → normalize → diff → decide → attest → bundle → reverify // ----------------------------------------------------------------------------- using System.Security.Cryptography; using System.Text.Json; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using StellaOps.Attestor.Dsse; using StellaOps.Canonical.Json; using StellaOps.Policy.Deltas; using Testcontainers.PostgreSql; namespace StellaOps.Integration.E2E; /// /// Test fixture for end-to-end reproducibility tests. /// Provides a fully configured test environment with: /// - PostgreSQL database via Testcontainers /// - Mock advisory feeds /// - Policy engine with test policies /// - Attestor for DSSE envelope creation /// - Full pipeline execution capability /// public sealed class E2EReproducibilityTestFixture : IAsyncLifetime { private PostgreSqlContainer? _postgresContainer; private WebApplicationFactory? _factory; private ECDsa? _signingKey; private bool _initialized; /// /// Gets the frozen timestamp used for deterministic tests. /// public DateTimeOffset FrozenTimestamp { get; } = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero); /// /// Initializes the test fixture, starting required services. /// public async Task InitializeAsync() { if (_initialized) return; // Generate deterministic signing key from fixed seed _signingKey = GenerateDeterministicKey(42); // Start PostgreSQL container _postgresContainer = new PostgreSqlBuilder() .WithImage("postgres:16-alpine") .WithDatabase("stellaops_e2e_test") .WithUsername("e2e_test_user") .WithPassword("e2e_test_password") .WithPortBinding(5432, true) .Build(); await _postgresContainer.StartAsync(); // Create the test web application factory _factory = new WebApplicationFactory() .WithWebHostBuilder(builder => { builder.ConfigureAppConfiguration((context, config) => { config.AddInMemoryCollection(new Dictionary { ["ConnectionStrings:ScannerDb"] = _postgresContainer.GetConnectionString(), ["Scanner:Authority:Enabled"] = "false", ["Scanner:AllowAnonymous"] = "true", ["Scanner:ProofChain:Enabled"] = "true", ["Scanner:ProofChain:SigningKeyId"] = "e2e-test-key", ["Scanner:ProofChain:AutoSign"] = "true", ["Scanner:Determinism:FrozenClock"] = "true", ["Scanner:Determinism:FrozenTimestamp"] = FrozenTimestamp.ToString("O"), ["Logging:LogLevel:Default"] = "Warning" }); }); builder.ConfigureServices(services => { services.AddLogging(logging => { logging.ClearProviders(); logging.AddConsole(); logging.SetMinimumLevel(LogLevel.Warning); }); }); }); _initialized = true; } /// /// Creates an HTTP client for the test application. /// public async Task CreateClientAsync() { if (!_initialized) { await InitializeAsync(); } return _factory!.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); } /// /// Creates a snapshot of all inputs with computed hashes for verification. /// public async Task SnapshotInputsAsync( string? sbomFixturePath = null, string? advisoryFeedPath = null, string? policyPackPath = null, string? vexDocumentPath = null) { // Load default fixtures if not specified var sbomContent = sbomFixturePath is not null ? await File.ReadAllBytesAsync(sbomFixturePath) : CreateMinimalSbom(); var advisoryFeed = advisoryFeedPath is not null ? await File.ReadAllBytesAsync(advisoryFeedPath) : CreateMockAdvisoryFeed(); var policyPack = policyPackPath is not null ? await File.ReadAllBytesAsync(policyPackPath) : CreateDefaultPolicyPack(); var vexDocument = vexDocumentPath is not null ? await File.ReadAllBytesAsync(vexDocumentPath) : null; return new InputSnapshot { Sbom = sbomContent, SbomHash = ComputeHash(sbomContent), AdvisoryFeed = advisoryFeed, AdvisoryFeedHash = ComputeHash(advisoryFeed), PolicyPack = policyPack, PolicyPackHash = ComputeHash(policyPack), VexDocument = vexDocument, VexDocumentHash = vexDocument is not null ? ComputeHash(vexDocument) : null, SnapshotTimestamp = FrozenTimestamp }; } /// /// Executes the full pipeline with the given inputs. /// public async Task RunFullPipelineAsync(InputSnapshot inputs) { // Stage 1: Ingest advisories var advisories = await IngestAdvisoriesAsync(inputs.AdvisoryFeed); // Stage 2: Normalize advisories var normalized = await NormalizeAdvisoriesAsync(advisories); // Stage 3: Diff SBOM against advisories var diff = await ComputeDiffAsync(inputs.Sbom, normalized); // Stage 4: Evaluate policy and compute verdict var verdict = await EvaluatePolicyAsync(diff, inputs.PolicyPack, inputs.VexDocument); // Stage 5: Create DSSE attestation var envelope = await CreateAttestationAsync(verdict); // Stage 6: Package into bundle var bundle = await CreateBundleAsync(envelope, inputs); return new PipelineResult { VerdictId = verdict.VerdictId, VerdictHash = ComputeHash(SerializeVerdict(verdict)), EnvelopeHash = ComputeHash(SerializeEnvelope(envelope)), BundleManifest = bundle.Manifest, BundleManifestHash = ComputeHash(bundle.Manifest), ExecutionTimestamp = FrozenTimestamp }; } #region Stage 1: Ingest /// /// Ingests advisory feed data. /// public Task> IngestAdvisoriesAsync(byte[] feedData) { // Parse advisory feed (mock implementation for E2E tests) var advisories = ParseAdvisoryFeed(feedData); return Task.FromResult(advisories); } private static IReadOnlyList ParseAdvisoryFeed(byte[] feedData) { // For E2E tests, parse the mock feed format var json = System.Text.Encoding.UTF8.GetString(feedData); using var doc = JsonDocument.Parse(json); var advisories = new List(); foreach (var element in doc.RootElement.GetProperty("advisories").EnumerateArray()) { advisories.Add(new AdvisoryRecord { Id = element.GetProperty("id").GetString()!, CveId = element.GetProperty("cveId").GetString(), Severity = element.GetProperty("severity").GetString()!, AffectedPackages = element.GetProperty("affected").EnumerateArray() .Select(a => a.GetString()!) .ToList(), FixedVersions = element.TryGetProperty("fixed", out var fixedProp) ? fixedProp.EnumerateArray().Select(f => f.GetString()!).ToList() : [] }); } // Sort for determinism return advisories.OrderBy(a => a.Id, StringComparer.Ordinal).ToList(); } #endregion #region Stage 2: Normalize /// /// Normalizes and deduplicates advisories. /// public Task NormalizeAdvisoriesAsync(IReadOnlyList advisories) { // Deduplicate by CVE ID var uniqueByCve = advisories .GroupBy(a => a.CveId ?? a.Id) .Select(g => g.OrderBy(a => a.Id, StringComparer.Ordinal).First()) .OrderBy(a => a.CveId ?? a.Id, StringComparer.Ordinal) .ToList(); var normalized = new NormalizedAdvisories { Advisories = uniqueByCve, NormalizationTimestamp = FrozenTimestamp, ContentHash = ComputeHash(SerializeAdvisories(uniqueByCve)) }; return Task.FromResult(normalized); } private static byte[] SerializeAdvisories(IReadOnlyList advisories) { var serializable = advisories.Select(a => new { id = a.Id, cveId = a.CveId, severity = a.Severity, affected = a.AffectedPackages, fixed_ = a.FixedVersions }).ToList(); return System.Text.Encoding.UTF8.GetBytes(CanonJson.Serialize(serializable)); } #endregion #region Stage 3: Diff /// /// Computes diff between SBOM and advisories. /// public Task ComputeDiffAsync(byte[] sbomData, NormalizedAdvisories advisories) { // Parse SBOM and find affected components var sbom = ParseSbom(sbomData); var findings = new List(); foreach (var component in sbom.Components) { foreach (var advisory in advisories.Advisories) { if (advisory.AffectedPackages.Any(pkg => pkg.Equals(component.Purl, StringComparison.OrdinalIgnoreCase) || pkg.Contains(component.Name, StringComparison.OrdinalIgnoreCase))) { findings.Add(new Finding { Id = $"finding:{advisory.CveId ?? advisory.Id}:{component.Purl}", CveId = advisory.CveId ?? advisory.Id, Severity = advisory.Severity, AffectedComponent = component.Purl, ComponentVersion = component.Version, FixedVersions = advisory.FixedVersions }); } } } // Sort findings for determinism var sortedFindings = findings .OrderBy(f => f.CveId, StringComparer.Ordinal) .ThenBy(f => f.AffectedComponent, StringComparer.Ordinal) .ToList(); var diff = new DiffResult { Findings = sortedFindings, SbomDigest = ComputeHash(sbomData), AdvisoryDigest = advisories.ContentHash, DiffTimestamp = FrozenTimestamp }; return Task.FromResult(diff); } private static SbomData ParseSbom(byte[] sbomData) { var json = System.Text.Encoding.UTF8.GetString(sbomData); using var doc = JsonDocument.Parse(json); var components = new List(); foreach (var element in doc.RootElement.GetProperty("components").EnumerateArray()) { components.Add(new SbomComponent { Name = element.GetProperty("name").GetString()!, Version = element.GetProperty("version").GetString()!, Purl = element.GetProperty("purl").GetString()! }); } return new SbomData { Components = components.OrderBy(c => c.Purl, StringComparer.Ordinal).ToList() }; } #endregion #region Stage 4: Decide /// /// Evaluates policy and computes verdict. /// public Task EvaluatePolicyAsync(DiffResult diff, byte[] policyPack, byte[]? vexDocument) { // Parse VEX document if provided for exception handling var exceptions = vexDocument is not null ? ParseVexExceptions(vexDocument) : []; // Evaluate findings against policy var blockingDrivers = new List(); var warningDrivers = new List(); var appliedExceptions = new List(); foreach (var finding in diff.Findings) { // Check if finding is excepted via VEX var exception = exceptions.FirstOrDefault(e => e.VulnerabilityId.Equals(finding.CveId, StringComparison.OrdinalIgnoreCase)); if (exception is not null) { appliedExceptions.Add(exception.Id); continue; } var driver = new DeltaDriver { Type = "new-finding", Severity = MapSeverity(finding.Severity), Description = $"Vulnerability {finding.CveId} found in {finding.AffectedComponent}", CveId = finding.CveId, Purl = finding.AffectedComponent }; if (IsBlockingSeverity(finding.Severity)) { blockingDrivers.Add(driver); } else { warningDrivers.Add(driver); } } // Sort drivers for determinism blockingDrivers = [.. blockingDrivers.OrderBy(d => d.CveId, StringComparer.Ordinal) .ThenBy(d => d.Purl, StringComparer.Ordinal)]; warningDrivers = [.. warningDrivers.OrderBy(d => d.CveId, StringComparer.Ordinal) .ThenBy(d => d.Purl, StringComparer.Ordinal)]; appliedExceptions = [.. appliedExceptions.Order(StringComparer.Ordinal)]; // Compute gate level var gateLevel = blockingDrivers.Count > 0 ? DeltaGateLevel.G4 : DeltaGateLevel.G1; // Build verdict with content-addressed ID var deltaId = $"delta:sha256:{ComputeHashString(System.Text.Encoding.UTF8.GetBytes( CanonJson.Serialize(new { diff.SbomDigest, diff.AdvisoryDigest })))}"; var builder = new DeltaVerdictBuilder() .WithGate(gateLevel); foreach (var driver in blockingDrivers) { builder.AddBlockingDriver(driver); } foreach (var driver in warningDrivers) { builder.AddWarningDriver(driver); } foreach (var exception in appliedExceptions) { builder.AddException(exception); } var verdict = builder.Build(deltaId); return Task.FromResult(verdict); } private static IReadOnlyList ParseVexExceptions(byte[] vexData) { var json = System.Text.Encoding.UTF8.GetString(vexData); using var doc = JsonDocument.Parse(json); var exceptions = new List(); if (doc.RootElement.TryGetProperty("statements", out var statements)) { foreach (var stmt in statements.EnumerateArray()) { if (stmt.GetProperty("status").GetString() == "not_affected") { exceptions.Add(new VexException { Id = stmt.GetProperty("id").GetString()!, VulnerabilityId = stmt.GetProperty("vulnerability").GetString()!, Status = "not_affected", Justification = stmt.TryGetProperty("justification", out var j) ? j.GetString() : null }); } } } return exceptions.OrderBy(e => e.VulnerabilityId, StringComparer.Ordinal).ToList(); } private static DeltaDriverSeverity MapSeverity(string severity) => severity.ToUpperInvariant() switch { "CRITICAL" => DeltaDriverSeverity.Critical, "HIGH" => DeltaDriverSeverity.High, "MEDIUM" => DeltaDriverSeverity.Medium, "LOW" => DeltaDriverSeverity.Low, _ => DeltaDriverSeverity.Unknown }; private static bool IsBlockingSeverity(string severity) => severity.Equals("CRITICAL", StringComparison.OrdinalIgnoreCase) || severity.Equals("HIGH", StringComparison.OrdinalIgnoreCase); #endregion #region Stage 5: Attest /// /// Creates DSSE attestation for the verdict. /// public Task CreateAttestationAsync(DeltaVerdict verdict) { // Serialize verdict to canonical JSON var payload = SerializeVerdict(verdict); // Sign using deterministic key var signature = SignPayload(payload); var envelope = new DsseEnvelopeData { PayloadType = "application/vnd.stellaops.verdict+json", Payload = payload, Signatures = [ new DsseSignatureData { KeyId = "e2e-test-key", Signature = signature } ] }; return Task.FromResult(envelope); } private byte[] SignPayload(byte[] payload) { // Create PAE (Pre-Authentication Encoding) as per DSSE spec var payloadType = "application/vnd.stellaops.verdict+json"u8.ToArray(); var pae = CreatePae(payloadType, payload); // Sign with ECDSA P-256 return _signingKey!.SignData(pae, HashAlgorithmName.SHA256); } private static byte[] CreatePae(byte[] payloadType, byte[] payload) { // PAE(type, payload) = "DSSEv1" || SP || LEN(type) || SP || type || SP || LEN(payload) || SP || payload var parts = new List(); parts.AddRange("DSSEv1 "u8.ToArray()); parts.AddRange(System.Text.Encoding.UTF8.GetBytes(payloadType.Length.ToString())); parts.Add((byte)' '); parts.AddRange(payloadType); parts.Add((byte)' '); parts.AddRange(System.Text.Encoding.UTF8.GetBytes(payload.Length.ToString())); parts.Add((byte)' '); parts.AddRange(payload); return [.. parts]; } #endregion #region Stage 6: Bundle /// /// Creates a bundle containing the attestation and all artifacts. /// public Task CreateBundleAsync(DsseEnvelopeData envelope, InputSnapshot inputs) { // Create manifest with all artifact hashes var manifest = new BundleManifest { Version = "1.0", CreatedAt = FrozenTimestamp, Artifacts = new Dictionary { ["sbom"] = inputs.SbomHash, ["advisory-feed"] = inputs.AdvisoryFeedHash, ["policy-pack"] = inputs.PolicyPackHash, ["envelope"] = ComputeHashString(SerializeEnvelope(envelope)) } }; if (inputs.VexDocumentHash is not null) { manifest.Artifacts["vex-document"] = inputs.VexDocumentHash; } // Serialize manifest deterministically var manifestBytes = System.Text.Encoding.UTF8.GetBytes(CanonJson.Serialize(manifest)); var bundle = new BundleResult { Manifest = manifestBytes, Envelope = envelope, ManifestHash = ComputeHash(manifestBytes) }; return Task.FromResult(bundle); } #endregion #region Serialization Helpers private static byte[] SerializeVerdict(DeltaVerdict verdict) { return System.Text.Encoding.UTF8.GetBytes(CanonJson.Serialize(verdict)); } private static byte[] SerializeEnvelope(DsseEnvelopeData envelope) { var obj = new { payloadType = envelope.PayloadType, payload = Convert.ToBase64String(envelope.Payload), signatures = envelope.Signatures.Select(s => new { keyid = s.KeyId, sig = Convert.ToBase64String(s.Signature) }).ToArray() }; return System.Text.Encoding.UTF8.GetBytes(CanonJson.Serialize(obj)); } #endregion #region Hashing Helpers /// /// Computes SHA-256 hash of data and returns as hex string. /// public static string ComputeHash(byte[] data) { var hash = SHA256.HashData(data); return $"sha256:{Convert.ToHexStringLower(hash)}"; } /// /// Computes SHA-256 hash of data and returns hex string without prefix. /// public static string ComputeHashString(byte[] data) { var hash = SHA256.HashData(data); return Convert.ToHexStringLower(hash); } #endregion #region Test Data Factories /// /// Creates a minimal SBOM for testing. /// public static byte[] CreateMinimalSbom() { var sbom = new { bomFormat = "CycloneDX", specVersion = "1.5", version = 1, components = new[] { new { name = "lodash", version = "4.17.20", purl = "pkg:npm/lodash@4.17.20" }, new { name = "axios", version = "0.21.0", purl = "pkg:npm/axios@0.21.0" }, new { name = "moment", version = "2.29.0", purl = "pkg:npm/moment@2.29.0" } } }; return System.Text.Encoding.UTF8.GetBytes(CanonJson.Serialize(sbom)); } /// /// Creates a mock advisory feed for testing. /// public static byte[] CreateMockAdvisoryFeed() { var feed = new { advisories = new[] { new { id = "GHSA-2024-0001", cveId = "CVE-2024-0001", severity = "CRITICAL", affected = new[] { "pkg:npm/lodash@4.17.20" }, @fixed = new[] { "pkg:npm/lodash@4.17.21" } }, new { id = "GHSA-2024-0002", cveId = "CVE-2024-0002", severity = "HIGH", affected = new[] { "pkg:npm/axios@0.21.0" }, @fixed = new[] { "pkg:npm/axios@0.21.1" } }, new { id = "GHSA-2024-0003", cveId = "CVE-2024-0003", severity = "LOW", affected = new[] { "pkg:npm/moment@2.29.0" }, @fixed = new[] { "pkg:npm/moment@2.29.4" } } } }; return System.Text.Encoding.UTF8.GetBytes(CanonJson.Serialize(feed)); } /// /// Creates a default policy pack for testing. /// public static byte[] CreateDefaultPolicyPack() { var policy = new { version = "1.0", rules = new[] { new { severity = "CRITICAL", action = "block" }, new { severity = "HIGH", action = "block" }, new { severity = "MEDIUM", action = "warn" }, new { severity = "LOW", action = "warn" } } }; return System.Text.Encoding.UTF8.GetBytes(CanonJson.Serialize(policy)); } /// /// Creates a VEX document with exceptions. /// public static byte[] CreateVexDocumentWithExceptions(params string[] exceptedCveIds) { var statements = exceptedCveIds.Select((cve, i) => new { id = $"vex-exception-{i + 1:D3}", vulnerability = cve, status = "not_affected", justification = "vulnerable_code_not_in_execute_path" }).ToArray(); var vex = new { @context = "https://openvex.dev/ns/v0.2.0", id = "https://stellaops.test/vex/test-001", statements }; return System.Text.Encoding.UTF8.GetBytes(CanonJson.Serialize(vex)); } #endregion #region Key Generation /// /// Generates a deterministic ECDSA key from a seed. /// private static ECDsa GenerateDeterministicKey(int seed) { // Use a deterministic RNG seeded from the input var rng = new DeterministicRng(seed); var keyBytes = new byte[32]; rng.GetBytes(keyBytes); // Create ECDSA key from the deterministic bytes var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); // Import deterministic private key var parameters = new ECParameters { Curve = ECCurve.NamedCurves.nistP256, D = keyBytes, Q = default // Will be computed from D }; // Compute public key from private key var tempKey = ECDsa.Create(ECCurve.NamedCurves.nistP256); tempKey.ImportParameters(new ECParameters { Curve = ECCurve.NamedCurves.nistP256, D = keyBytes }); var exported = tempKey.ExportParameters(true); parameters.Q = exported.Q; ecdsa.ImportParameters(parameters); return ecdsa; } /// /// Gets the public key for verification. /// public byte[] GetPublicKey() { return _signingKey!.ExportSubjectPublicKeyInfo(); } #endregion /// /// Disposes of the test fixture resources. /// public async Task DisposeAsync() { _signingKey?.Dispose(); _factory?.Dispose(); if (_postgresContainer is not null) { await _postgresContainer.DisposeAsync(); } } } /// /// Deterministic random number generator for key generation. /// internal sealed class DeterministicRng(int seed) { private readonly Random _random = new(seed); public void GetBytes(byte[] data) { _random.NextBytes(data); } } #region Data Transfer Objects /// /// Snapshot of all pipeline inputs with hashes. /// public sealed class InputSnapshot { public required byte[] Sbom { get; init; } public required string SbomHash { get; init; } public required byte[] AdvisoryFeed { get; init; } public required string AdvisoryFeedHash { get; init; } public required byte[] PolicyPack { get; init; } public required string PolicyPackHash { get; init; } public byte[]? VexDocument { get; init; } public string? VexDocumentHash { get; init; } public DateTimeOffset SnapshotTimestamp { get; init; } } /// /// Result of full pipeline execution. /// public sealed class PipelineResult { public required string VerdictId { get; init; } public required string VerdictHash { get; init; } public required string EnvelopeHash { get; init; } public required byte[] BundleManifest { get; init; } public required string BundleManifestHash { get; init; } public DateTimeOffset ExecutionTimestamp { get; init; } } /// /// Advisory record from ingestion. /// public sealed class AdvisoryRecord { public required string Id { get; init; } public string? CveId { get; init; } public required string Severity { get; init; } public required IReadOnlyList AffectedPackages { get; init; } public IReadOnlyList FixedVersions { get; init; } = []; } /// /// Normalized advisories after deduplication. /// public sealed class NormalizedAdvisories { public required IReadOnlyList Advisories { get; init; } public DateTimeOffset NormalizationTimestamp { get; init; } public required string ContentHash { get; init; } } /// /// SBOM component data. /// public sealed class SbomComponent { public required string Name { get; init; } public required string Version { get; init; } public required string Purl { get; init; } } /// /// Parsed SBOM data. /// public sealed class SbomData { public required IReadOnlyList Components { get; init; } } /// /// Security finding from diff. /// public sealed class Finding { public required string Id { get; init; } public required string CveId { get; init; } public required string Severity { get; init; } public required string AffectedComponent { get; init; } public required string ComponentVersion { get; init; } public IReadOnlyList FixedVersions { get; init; } = []; } /// /// Result of diffing SBOM against advisories. /// public sealed class DiffResult { public required IReadOnlyList Findings { get; init; } public required string SbomDigest { get; init; } public required string AdvisoryDigest { get; init; } public DateTimeOffset DiffTimestamp { get; init; } } /// /// VEX exception from VEX document. /// public sealed class VexException { public required string Id { get; init; } public required string VulnerabilityId { get; init; } public required string Status { get; init; } public string? Justification { get; init; } } /// /// DSSE envelope data. /// public sealed class DsseEnvelopeData { public required string PayloadType { get; init; } public required byte[] Payload { get; init; } public required IReadOnlyList Signatures { get; init; } } /// /// DSSE signature data. /// public sealed class DsseSignatureData { public required string KeyId { get; init; } public required byte[] Signature { get; init; } } /// /// Bundle manifest structure. /// public sealed class BundleManifest { public required string Version { get; init; } public DateTimeOffset CreatedAt { get; init; } public required Dictionary Artifacts { get; init; } } /// /// Result of bundle creation. /// public sealed class BundleResult { public required byte[] Manifest { get; init; } public required DsseEnvelopeData Envelope { get; init; } public required string ManifestHash { get; init; } } #endregion /// /// Placeholder for Program class detection. /// The actual Program class is from Scanner.WebService. /// #pragma warning disable CA1050 // Declare types in namespaces public partial class Program { } #pragma warning restore CA1050