// ----------------------------------------------------------------------------- // VexDeterminismTests.cs // Sprint: SPRINT_5100_0007_0003 - Epic B (Determinism Gate) // Task: T4 - VEX Export Determinism (OpenVEX, CSAF) // Description: Tests to validate VEX generation determinism across formats // ----------------------------------------------------------------------------- using System.Text; using FluentAssertions; using StellaOps.Canonical.Json; using StellaOps.Testing.Determinism; using Xunit; namespace StellaOps.Integration.Determinism; /// /// Determinism validation tests for VEX export generation. /// Ensures identical inputs produce identical VEX documents across: /// - OpenVEX format /// - CSAF 2.0 VEX format /// - Multiple runs with frozen time /// - Parallel execution /// public class VexDeterminismTests { #region OpenVEX Determinism Tests [Fact] public void OpenVex_WithIdenticalInput_ProducesDeterministicOutput() { // Arrange var input = CreateSampleVexInput(); var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z"); // Act - Generate VEX multiple times var vex1 = GenerateOpenVex(input, frozenTime); var vex2 = GenerateOpenVex(input, frozenTime); var vex3 = GenerateOpenVex(input, frozenTime); // Assert - All outputs should be identical vex1.Should().Be(vex2); vex2.Should().Be(vex3); } [Fact] public void OpenVex_CanonicalHash_IsStable() { // Arrange var input = CreateSampleVexInput(); var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z"); // Act - Generate VEX and compute canonical hash twice var vex1 = GenerateOpenVex(input, frozenTime); var hash1 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(vex1)); var vex2 = GenerateOpenVex(input, frozenTime); var hash2 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(vex2)); // Assert hash1.Should().Be(hash2, "Same input should produce same canonical hash"); hash1.Should().MatchRegex("^[0-9a-f]{64}$"); } [Fact] public void OpenVex_DeterminismManifest_CanBeCreated() { // Arrange var input = CreateSampleVexInput(); var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z"); var vex = GenerateOpenVex(input, frozenTime); var vexBytes = Encoding.UTF8.GetBytes(vex); var artifactInfo = new ArtifactInfo { Type = "vex", Name = "test-container-vex", Version = "1.0.0", Format = "OpenVEX" }; var toolchain = new ToolchainInfo { Platform = ".NET 10.0", Components = new[] { new ComponentInfo { Name = "StellaOps.Excititor", Version = "1.0.0" } } }; // Act - Create determinism manifest var manifest = DeterminismManifestWriter.CreateManifest( vexBytes, artifactInfo, toolchain); // Assert manifest.SchemaVersion.Should().Be("1.0"); manifest.Artifact.Format.Should().Be("OpenVEX"); manifest.CanonicalHash.Algorithm.Should().Be("SHA-256"); manifest.CanonicalHash.Value.Should().MatchRegex("^[0-9a-f]{64}$"); } [Fact] public async Task OpenVex_ParallelGeneration_ProducesDeterministicOutput() { // Arrange var input = CreateSampleVexInput(); var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z"); // Act - Generate in parallel 20 times var tasks = Enumerable.Range(0, 20) .Select(_ => Task.Run(() => GenerateOpenVex(input, frozenTime))) .ToArray(); var vexDocuments = await Task.WhenAll(tasks); // Assert - All outputs should be identical vexDocuments.Should().AllBe(vexDocuments[0]); } [Fact] public void OpenVex_StatementOrdering_IsDeterministic() { // Arrange - Multiple claims for different products in random order var input = CreateMultiStatementVexInput(); var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z"); // Act - Generate VEX multiple times var vex1 = GenerateOpenVex(input, frozenTime); var vex2 = GenerateOpenVex(input, frozenTime); // Assert - Statement order should be deterministic vex1.Should().Be(vex2); vex1.Should().Contain("\"product_ids\""); } [Fact] public void OpenVex_JustificationText_IsCanonicalized() { // Arrange - Claims with varying justification text formatting var input = CreateSampleVexInput(); var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z"); // Act var vex = GenerateOpenVex(input, frozenTime); // Assert - Justification should be present and normalized vex.Should().Contain("justification"); vex.Should().Contain("inline_mitigations_already_exist"); } #endregion #region CSAF 2.0 VEX Determinism Tests [Fact] public void CsafVex_WithIdenticalInput_ProducesDeterministicOutput() { // Arrange var input = CreateSampleVexInput(); var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z"); // Act - Generate VEX multiple times var vex1 = GenerateCsafVex(input, frozenTime); var vex2 = GenerateCsafVex(input, frozenTime); var vex3 = GenerateCsafVex(input, frozenTime); // Assert - All outputs should be identical vex1.Should().Be(vex2); vex2.Should().Be(vex3); } [Fact] public void CsafVex_CanonicalHash_IsStable() { // Arrange var input = CreateSampleVexInput(); var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z"); // Act - Generate VEX and compute canonical hash twice var vex1 = GenerateCsafVex(input, frozenTime); var hash1 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(vex1)); var vex2 = GenerateCsafVex(input, frozenTime); var hash2 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(vex2)); // Assert hash1.Should().Be(hash2, "Same input should produce same canonical hash"); hash1.Should().MatchRegex("^[0-9a-f]{64}$"); } [Fact] public void CsafVex_DeterminismManifest_CanBeCreated() { // Arrange var input = CreateSampleVexInput(); var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z"); var vex = GenerateCsafVex(input, frozenTime); var vexBytes = Encoding.UTF8.GetBytes(vex); var artifactInfo = new ArtifactInfo { Type = "vex", Name = "test-container-vex", Version = "1.0.0", Format = "CSAF 2.0" }; var toolchain = new ToolchainInfo { Platform = ".NET 10.0", Components = new[] { new ComponentInfo { Name = "StellaOps.Excititor", Version = "1.0.0" } } }; // Act - Create determinism manifest var manifest = DeterminismManifestWriter.CreateManifest( vexBytes, artifactInfo, toolchain); // Assert manifest.SchemaVersion.Should().Be("1.0"); manifest.Artifact.Format.Should().Be("CSAF 2.0"); manifest.CanonicalHash.Algorithm.Should().Be("SHA-256"); manifest.CanonicalHash.Value.Should().MatchRegex("^[0-9a-f]{64}$"); } [Fact] public async Task CsafVex_ParallelGeneration_ProducesDeterministicOutput() { // Arrange var input = CreateSampleVexInput(); var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z"); // Act - Generate in parallel 20 times var tasks = Enumerable.Range(0, 20) .Select(_ => Task.Run(() => GenerateCsafVex(input, frozenTime))) .ToArray(); var vexDocuments = await Task.WhenAll(tasks); // Assert - All outputs should be identical vexDocuments.Should().AllBe(vexDocuments[0]); } [Fact] public void CsafVex_VulnerabilityOrdering_IsDeterministic() { // Arrange - Multiple vulnerabilities var input = CreateMultiStatementVexInput(); var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z"); // Act - Generate VEX multiple times var vex1 = GenerateCsafVex(input, frozenTime); var vex2 = GenerateCsafVex(input, frozenTime); // Assert - Vulnerability order should be deterministic vex1.Should().Be(vex2); vex1.Should().Contain("\"vulnerabilities\""); } [Fact] public void CsafVex_ProductTree_IsDeterministic() { // Arrange - Multiple products var input = CreateMultiStatementVexInput(); var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z"); // Act var vex = GenerateCsafVex(input, frozenTime); // Assert - Product tree should be present and ordered vex.Should().Contain("\"product_tree\""); vex.Should().Contain("\"branches\""); } #endregion #region Cross-Format Consistency Tests [Fact] public void AllVexFormats_WithSameInput_ProduceDifferentButStableHashes() { // Arrange var input = CreateSampleVexInput(); var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z"); // Act - Generate all formats var openVex = GenerateOpenVex(input, frozenTime); var csafVex = GenerateCsafVex(input, frozenTime); var openVexHash = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(openVex)); var csafVexHash = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(csafVex)); // Assert - Each format should have different hash but be deterministic openVexHash.Should().NotBe(csafVexHash); // All hashes should be valid SHA-256 openVexHash.Should().MatchRegex("^[0-9a-f]{64}$"); csafVexHash.Should().MatchRegex("^[0-9a-f]{64}$"); } [Fact] public void AllVexFormats_CanProduceDeterminismManifests() { // Arrange var input = CreateSampleVexInput(); var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z"); var toolchain = new ToolchainInfo { Platform = ".NET 10.0", Components = new[] { new ComponentInfo { Name = "StellaOps.Excititor", Version = "1.0.0" } } }; // Act - Generate manifests for all formats var formats = new[] { "OpenVEX", "CSAF 2.0" }; var generators = new Func[] { GenerateOpenVex, GenerateCsafVex }; var manifests = formats.Zip(generators) .Select(pair => { var vex = pair.Second(input, frozenTime); var vexBytes = Encoding.UTF8.GetBytes(vex); var artifactInfo = new ArtifactInfo { Type = "vex", Name = "test-container-vex", Version = "1.0.0", Format = pair.First }; return DeterminismManifestWriter.CreateManifest(vexBytes, artifactInfo, toolchain); }) .ToArray(); // Assert manifests.Should().HaveCount(2); manifests.Should().AllSatisfy(m => { m.SchemaVersion.Should().Be("1.0"); m.CanonicalHash.Algorithm.Should().Be("SHA-256"); m.CanonicalHash.Value.Should().MatchRegex("^[0-9a-f]{64}$"); }); } #endregion #region Status Transition Determinism Tests [Fact] public void VexStatus_NotAffected_IsDeterministic() { // Arrange var input = CreateVexInputWithStatus(VexStatus.NotAffected, "vulnerable_code_not_present"); var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z"); // Act var vex1 = GenerateOpenVex(input, frozenTime); var vex2 = GenerateOpenVex(input, frozenTime); // Assert vex1.Should().Be(vex2); vex1.Should().Contain("not_affected"); vex1.Should().Contain("vulnerable_code_not_present"); } [Fact] public void VexStatus_Affected_IsDeterministic() { // Arrange var input = CreateVexInputWithStatus(VexStatus.Affected, null); var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z"); // Act var vex1 = GenerateOpenVex(input, frozenTime); var vex2 = GenerateOpenVex(input, frozenTime); // Assert vex1.Should().Be(vex2); vex1.Should().Contain("affected"); } [Fact] public void VexStatus_Fixed_IsDeterministic() { // Arrange var input = CreateVexInputWithStatus(VexStatus.Fixed, null); var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z"); // Act var vex1 = GenerateOpenVex(input, frozenTime); var vex2 = GenerateOpenVex(input, frozenTime); // Assert vex1.Should().Be(vex2); vex1.Should().Contain("fixed"); } [Fact] public void VexStatus_UnderInvestigation_IsDeterministic() { // Arrange var input = CreateVexInputWithStatus(VexStatus.UnderInvestigation, null); var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z"); // Act var vex1 = GenerateOpenVex(input, frozenTime); var vex2 = GenerateOpenVex(input, frozenTime); // Assert vex1.Should().Be(vex2); vex1.Should().Contain("under_investigation"); } #endregion #region Helper Methods private static VexInput CreateSampleVexInput() { return new VexInput { VulnerabilityId = "CVE-2024-1234", Product = "pkg:oci/myapp@sha256:abc123", Status = VexStatus.NotAffected, Justification = "inline_mitigations_already_exist", ImpactStatement = "The vulnerable code path is not reachable in this deployment.", Timestamp = DateTimeOffset.Parse("2025-12-23T18:00:00Z") }; } private static VexInput CreateMultiStatementVexInput() { return new VexInput { VulnerabilityId = "CVE-2024-1234", Product = "pkg:oci/myapp@sha256:abc123", Status = VexStatus.NotAffected, Justification = "vulnerable_code_not_present", ImpactStatement = null, AdditionalProducts = new[] { "pkg:oci/myapp@sha256:def456", "pkg:oci/myapp@sha256:ghi789" }, AdditionalVulnerabilities = new[] { "CVE-2024-5678", "CVE-2024-9012" }, Timestamp = DateTimeOffset.Parse("2025-12-23T18:00:00Z") }; } private static VexInput CreateVexInputWithStatus(VexStatus status, string? justification) { return new VexInput { VulnerabilityId = "CVE-2024-1234", Product = "pkg:oci/myapp@sha256:abc123", Status = status, Justification = justification, ImpactStatement = null, Timestamp = DateTimeOffset.Parse("2025-12-23T18:00:00Z") }; } private static string GenerateOpenVex(VexInput input, DateTimeOffset timestamp) { // TODO: Integrate with actual OpenVexExporter // For now, return deterministic stub following OpenVEX spec var deterministicId = GenerateDeterministicId(input, "openvex"); var productIds = new[] { input.Product } .Concat(input.AdditionalProducts ?? Array.Empty()) .OrderBy(p => p, StringComparer.Ordinal) .Select(p => $"\"{p}\""); var vulnerabilities = new[] { input.VulnerabilityId } .Concat(input.AdditionalVulnerabilities ?? Array.Empty()) .OrderBy(v => v, StringComparer.Ordinal); var statements = vulnerabilities.Select(vuln => $$""" { "vulnerability": {"@id": "{{vuln}}"}, "products": [{{string.Join(", ", productIds)}}], "status": "{{StatusToString(input.Status)}}", "justification": "{{input.Justification ?? ""}}", "impact_statement": "{{input.ImpactStatement ?? ""}}" } """); return $$""" { "@context": "https://openvex.dev/ns/v0.2.0", "@id": "{{deterministicId}}", "author": "StellaOps Excititor", "timestamp": "{{timestamp:O}}", "version": 1, "statements": [ {{string.Join(",\n ", statements)}} ] } """; } private static string GenerateCsafVex(VexInput input, DateTimeOffset timestamp) { // TODO: Integrate with actual CsafExporter // For now, return deterministic stub following CSAF 2.0 spec var deterministicId = GenerateDeterministicId(input, "csaf"); var productIds = new[] { input.Product } .Concat(input.AdditionalProducts ?? Array.Empty()) .OrderBy(p => p, StringComparer.Ordinal); var vulnerabilities = new[] { input.VulnerabilityId } .Concat(input.AdditionalVulnerabilities ?? Array.Empty()) .OrderBy(v => v, StringComparer.Ordinal) .Select(vuln => $$""" { "cve": "{{vuln}}", "product_status": { "{{CsafStatusCategory(input.Status)}}": [{{string.Join(", ", productIds.Select(p => $"\"{p}\""))}}] } } """); var branches = productIds.Select(p => $$""" { "category": "product_version", "name": "{{p}}" } """); return $$""" { "document": { "category": "vex", "csaf_version": "2.0", "title": "StellaOps VEX CSAF Export", "publisher": { "category": "tool", "name": "StellaOps Excititor" }, "tracking": { "id": "{{deterministicId}}", "status": "final", "version": "1", "initial_release_date": "{{timestamp:O}}", "current_release_date": "{{timestamp:O}}" } }, "product_tree": { "branches": [ {{string.Join(",\n ", branches)}} ] }, "vulnerabilities": [ {{string.Join(",\n ", vulnerabilities)}} ] } """; } private static string GenerateDeterministicId(VexInput input, string context) { var inputString = $"{context}:{input.VulnerabilityId}:{input.Product}:{input.Status}:{input.Timestamp:O}"; var hash = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(inputString)); return $"urn:uuid:{hash[..8]}-{hash[8..12]}-{hash[12..16]}-{hash[16..20]}-{hash[20..32]}"; } private static string StatusToString(VexStatus status) => status switch { VexStatus.NotAffected => "not_affected", VexStatus.Affected => "affected", VexStatus.Fixed => "fixed", VexStatus.UnderInvestigation => "under_investigation", _ => "unknown" }; private static string CsafStatusCategory(VexStatus status) => status switch { VexStatus.NotAffected => "known_not_affected", VexStatus.Affected => "known_affected", VexStatus.Fixed => "fixed", VexStatus.UnderInvestigation => "under_investigation", _ => "unknown" }; #endregion #region DTOs private sealed record VexInput { public required string VulnerabilityId { get; init; } public required string Product { get; init; } public required VexStatus Status { get; init; } public string? Justification { get; init; } public string? ImpactStatement { get; init; } public string[]? AdditionalProducts { get; init; } public string[]? AdditionalVulnerabilities { get; init; } public required DateTimeOffset Timestamp { get; init; } } private enum VexStatus { NotAffected, Affected, Fixed, UnderInvestigation } #endregion }