// ----------------------------------------------------------------------------- // SbomDeterminismTests.cs // Sprint: SPRINT_5100_0007_0003 - Epic B (Determinism Gate) // Task: T3 - SBOM Export Determinism (SPDX 3.0.1, CycloneDX 1.6, CycloneDX 1.7) // Description: Tests to validate SBOM 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 SBOM generation. /// Ensures identical inputs produce identical SBOMs across: /// - SPDX 3.0.1 /// - CycloneDX 1.6 /// - CycloneDX 1.7 /// - Multiple runs with frozen time /// - Parallel execution /// public class SbomDeterminismTests { #region SPDX 3.0.1 Determinism Tests [Fact] public void SpdxSbom_WithIdenticalInput_ProducesDeterministicOutput() { // Arrange var input = CreateSampleSbomInput(); var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z"); // Act - Generate SBOM multiple times var sbom1 = GenerateSpdxSbom(input, frozenTime); var sbom2 = GenerateSpdxSbom(input, frozenTime); var sbom3 = GenerateSpdxSbom(input, frozenTime); // Assert - All outputs should be identical sbom1.Should().Be(sbom2); sbom2.Should().Be(sbom3); } [Fact] public void SpdxSbom_CanonicalHash_IsStable() { // Arrange var input = CreateSampleSbomInput(); var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z"); // Act - Generate SBOM and compute canonical hash twice var sbom1 = GenerateSpdxSbom(input, frozenTime); var hash1 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(sbom1)); var sbom2 = GenerateSpdxSbom(input, frozenTime); var hash2 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(sbom2)); // Assert hash1.Should().Be(hash2, "Same input should produce same canonical hash"); hash1.Should().MatchRegex("^[0-9a-f]{64}$"); } [Fact] public void SpdxSbom_DeterminismManifest_CanBeCreated() { // Arrange var input = CreateSampleSbomInput(); var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z"); var sbom = GenerateSpdxSbom(input, frozenTime); var sbomBytes = Encoding.UTF8.GetBytes(sbom); var artifactInfo = new ArtifactInfo { Type = "sbom", Name = "test-container-sbom", Version = "1.0.0", Format = "SPDX 3.0.1" }; var toolchain = new ToolchainInfo { Platform = ".NET 10.0", Components = new[] { new ComponentInfo { Name = "StellaOps.Scanner", Version = "1.0.0" } } }; // Act - Create determinism manifest var manifest = DeterminismManifestWriter.CreateManifest( sbomBytes, artifactInfo, toolchain); // Assert manifest.SchemaVersion.Should().Be("1.0"); manifest.Artifact.Format.Should().Be("SPDX 3.0.1"); manifest.CanonicalHash.Algorithm.Should().Be("SHA-256"); manifest.CanonicalHash.Value.Should().MatchRegex("^[0-9a-f]{64}$"); } [Fact] public async Task SpdxSbom_ParallelGeneration_ProducesDeterministicOutput() { // Arrange var input = CreateSampleSbomInput(); var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z"); // Act - Generate in parallel 20 times var tasks = Enumerable.Range(0, 20) .Select(_ => Task.Run(() => GenerateSpdxSbom(input, frozenTime))) .ToArray(); var sboms = await Task.WhenAll(tasks); // Assert - All outputs should be identical sboms.Should().AllBe(sboms[0]); } #endregion #region CycloneDX 1.6 Determinism Tests [Fact] public void CycloneDx16Sbom_WithIdenticalInput_ProducesDeterministicOutput() { // Arrange var input = CreateSampleSbomInput(); var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z"); // Act - Generate SBOM multiple times var sbom1 = GenerateCycloneDx16Sbom(input, frozenTime); var sbom2 = GenerateCycloneDx16Sbom(input, frozenTime); var sbom3 = GenerateCycloneDx16Sbom(input, frozenTime); // Assert - All outputs should be identical sbom1.Should().Be(sbom2); sbom2.Should().Be(sbom3); } [Fact] public void CycloneDx16Sbom_CanonicalHash_IsStable() { // Arrange var input = CreateSampleSbomInput(); var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z"); // Act - Generate SBOM and compute canonical hash twice var sbom1 = GenerateCycloneDx16Sbom(input, frozenTime); var hash1 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(sbom1)); var sbom2 = GenerateCycloneDx16Sbom(input, frozenTime); var hash2 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(sbom2)); // Assert hash1.Should().Be(hash2, "Same input should produce same canonical hash"); hash1.Should().MatchRegex("^[0-9a-f]{64}$"); } [Fact] public void CycloneDx16Sbom_DeterminismManifest_CanBeCreated() { // Arrange var input = CreateSampleSbomInput(); var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z"); var sbom = GenerateCycloneDx16Sbom(input, frozenTime); var sbomBytes = Encoding.UTF8.GetBytes(sbom); var artifactInfo = new ArtifactInfo { Type = "sbom", Name = "test-container-sbom", Version = "1.0.0", Format = "CycloneDX 1.6" }; var toolchain = new ToolchainInfo { Platform = ".NET 10.0", Components = new[] { new ComponentInfo { Name = "StellaOps.Scanner", Version = "1.0.0" } } }; // Act - Create determinism manifest var manifest = DeterminismManifestWriter.CreateManifest( sbomBytes, artifactInfo, toolchain); // Assert manifest.SchemaVersion.Should().Be("1.0"); manifest.Artifact.Format.Should().Be("CycloneDX 1.6"); manifest.CanonicalHash.Algorithm.Should().Be("SHA-256"); manifest.CanonicalHash.Value.Should().MatchRegex("^[0-9a-f]{64}$"); } [Fact] public async Task CycloneDx16Sbom_ParallelGeneration_ProducesDeterministicOutput() { // Arrange var input = CreateSampleSbomInput(); var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z"); // Act - Generate in parallel 20 times var tasks = Enumerable.Range(0, 20) .Select(_ => Task.Run(() => GenerateCycloneDx16Sbom(input, frozenTime))) .ToArray(); var sboms = await Task.WhenAll(tasks); // Assert - All outputs should be identical sboms.Should().AllBe(sboms[0]); } #endregion #region CycloneDX 1.7 Determinism Tests [Fact] public void CycloneDx17Sbom_WithIdenticalInput_ProducesDeterministicOutput() { // Arrange var input = CreateSampleSbomInput(); var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z"); // Act - Generate SBOM multiple times var sbom1 = GenerateCycloneDx17Sbom(input, frozenTime); var sbom2 = GenerateCycloneDx17Sbom(input, frozenTime); var sbom3 = GenerateCycloneDx17Sbom(input, frozenTime); // Assert - All outputs should be identical sbom1.Should().Be(sbom2); sbom2.Should().Be(sbom3); } [Fact] public void CycloneDx17Sbom_CanonicalHash_IsStable() { // Arrange var input = CreateSampleSbomInput(); var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z"); // Act - Generate SBOM and compute canonical hash twice var sbom1 = GenerateCycloneDx17Sbom(input, frozenTime); var hash1 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(sbom1)); var sbom2 = GenerateCycloneDx17Sbom(input, frozenTime); var hash2 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(sbom2)); // Assert hash1.Should().Be(hash2, "Same input should produce same canonical hash"); hash1.Should().MatchRegex("^[0-9a-f]{64}$"); } [Fact] public void CycloneDx17Sbom_DeterminismManifest_CanBeCreated() { // Arrange var input = CreateSampleSbomInput(); var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z"); var sbom = GenerateCycloneDx17Sbom(input, frozenTime); var sbomBytes = Encoding.UTF8.GetBytes(sbom); var artifactInfo = new ArtifactInfo { Type = "sbom", Name = "test-container-sbom", Version = "1.0.0", Format = "CycloneDX 1.7" }; var toolchain = new ToolchainInfo { Platform = ".NET 10.0", Components = new[] { new ComponentInfo { Name = "StellaOps.Scanner", Version = "1.0.0" } } }; // Act - Create determinism manifest var manifest = DeterminismManifestWriter.CreateManifest( sbomBytes, artifactInfo, toolchain); // Assert manifest.SchemaVersion.Should().Be("1.0"); manifest.Artifact.Format.Should().Be("CycloneDX 1.7"); manifest.CanonicalHash.Algorithm.Should().Be("SHA-256"); manifest.CanonicalHash.Value.Should().MatchRegex("^[0-9a-f]{64}$"); } [Fact] public async Task CycloneDx17Sbom_ParallelGeneration_ProducesDeterministicOutput() { // Arrange var input = CreateSampleSbomInput(); var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z"); // Act - Generate in parallel 20 times var tasks = Enumerable.Range(0, 20) .Select(_ => Task.Run(() => GenerateCycloneDx17Sbom(input, frozenTime))) .ToArray(); var sboms = await Task.WhenAll(tasks); // Assert - All outputs should be identical sboms.Should().AllBe(sboms[0]); } #endregion #region Cross-Format Consistency Tests [Fact] public void AllFormats_WithSameInput_ProduceDifferentButStableHashes() { // Arrange var input = CreateSampleSbomInput(); var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z"); // Act - Generate all formats var spdx = GenerateSpdxSbom(input, frozenTime); var cdx16 = GenerateCycloneDx16Sbom(input, frozenTime); var cdx17 = GenerateCycloneDx17Sbom(input, frozenTime); var spdxHash = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(spdx)); var cdx16Hash = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(cdx16)); var cdx17Hash = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(cdx17)); // Assert - Each format should have different hash but be deterministic spdxHash.Should().NotBe(cdx16Hash); spdxHash.Should().NotBe(cdx17Hash); cdx16Hash.Should().NotBe(cdx17Hash); // All hashes should be valid SHA-256 spdxHash.Should().MatchRegex("^[0-9a-f]{64}$"); cdx16Hash.Should().MatchRegex("^[0-9a-f]{64}$"); cdx17Hash.Should().MatchRegex("^[0-9a-f]{64}$"); } [Fact] public void AllFormats_CanProduceDeterminismManifests() { // Arrange var input = CreateSampleSbomInput(); var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z"); var toolchain = new ToolchainInfo { Platform = ".NET 10.0", Components = new[] { new ComponentInfo { Name = "StellaOps.Scanner", Version = "1.0.0" } } }; // Act - Generate all formats and create manifests var spdxManifest = DeterminismManifestWriter.CreateManifest( Encoding.UTF8.GetBytes(GenerateSpdxSbom(input, frozenTime)), new ArtifactInfo { Type = "sbom", Name = "test-sbom", Version = "1.0.0", Format = "SPDX 3.0.1" }, toolchain); var cdx16Manifest = DeterminismManifestWriter.CreateManifest( Encoding.UTF8.GetBytes(GenerateCycloneDx16Sbom(input, frozenTime)), new ArtifactInfo { Type = "sbom", Name = "test-sbom", Version = "1.0.0", Format = "CycloneDX 1.6" }, toolchain); var cdx17Manifest = DeterminismManifestWriter.CreateManifest( Encoding.UTF8.GetBytes(GenerateCycloneDx17Sbom(input, frozenTime)), new ArtifactInfo { Type = "sbom", Name = "test-sbom", Version = "1.0.0", Format = "CycloneDX 1.7" }, toolchain); // Assert - All manifests should be valid spdxManifest.SchemaVersion.Should().Be("1.0"); cdx16Manifest.SchemaVersion.Should().Be("1.0"); cdx17Manifest.SchemaVersion.Should().Be("1.0"); spdxManifest.Artifact.Format.Should().Be("SPDX 3.0.1"); cdx16Manifest.Artifact.Format.Should().Be("CycloneDX 1.6"); cdx17Manifest.Artifact.Format.Should().Be("CycloneDX 1.7"); } #endregion #region Helper Methods private static SbomInput CreateSampleSbomInput() { return new SbomInput { ContainerImage = "alpine:3.18", PackageUrls = new[] { "pkg:apk/alpine/musl@1.2.4-r2?arch=x86_64", "pkg:apk/alpine/busybox@1.36.1-r2?arch=x86_64", "pkg:apk/alpine/alpine-baselayout@3.4.3-r1?arch=x86_64" }, Timestamp = DateTimeOffset.Parse("2025-12-23T18:00:00Z") }; } private static string GenerateSpdxSbom(SbomInput input, DateTimeOffset timestamp) { // TODO: Integrate with actual SpdxComposer // For now, return deterministic stub return $$""" { "spdxVersion": "SPDX-3.0.1", "dataLicense": "CC0-1.0", "SPDXID": "SPDXRef-DOCUMENT", "name": "{{input.ContainerImage}}", "creationInfo": { "created": "{{timestamp:O}}", "creators": ["Tool: StellaOps-Scanner-1.0.0"] }, "packages": [ {{string.Join(",", input.PackageUrls.Select(purl => $"{{\"SPDXID\":\"SPDXRef-{purl.GetHashCode():X8}\",\"name\":\"{purl}\"}}"))}} ] } """; } private static string GenerateCycloneDx16Sbom(SbomInput input, DateTimeOffset timestamp) { // TODO: Integrate with actual CycloneDxComposer (version 1.6) // For now, return deterministic stub var deterministicGuid = GenerateDeterministicGuid(input, "cdx-1.6"); return $$""" { "bomFormat": "CycloneDX", "specVersion": "1.6", "version": 1, "serialNumber": "urn:uuid:{{deterministicGuid}}", "metadata": { "timestamp": "{{timestamp:O}}", "component": { "type": "container", "name": "{{input.ContainerImage}}" } }, "components": [ {{string.Join(",", input.PackageUrls.Select(purl => $"{{\"type\":\"library\",\"name\":\"{purl}\"}}"))}} ] } """; } private static string GenerateCycloneDx17Sbom(SbomInput input, DateTimeOffset timestamp) { // TODO: Integrate with actual CycloneDxComposer (version 1.7) // For now, return deterministic stub with 1.7 features var deterministicGuid = GenerateDeterministicGuid(input, "cdx-1.7"); return $$""" { "bomFormat": "CycloneDX", "specVersion": "1.7", "version": 1, "serialNumber": "urn:uuid:{{deterministicGuid}}", "metadata": { "timestamp": "{{timestamp:O}}", "component": { "type": "container", "name": "{{input.ContainerImage}}" }, "properties": [ { "name": "cdx:bom:reproducible", "value": "true" } ] }, "components": [ {{string.Join(",", input.PackageUrls.Select(purl => $"{{\"type\":\"library\",\"name\":\"{purl}\"}}"))}} ] } """; } private static Guid GenerateDeterministicGuid(SbomInput input, string context) { // Generate deterministic GUID from input using SHA-256 var inputString = $"{context}:{input.ContainerImage}:{string.Join(",", input.PackageUrls)}:{input.Timestamp:O}"; var hash = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(inputString)); // Take first 32 characters (16 bytes) of hash to create GUID var guidBytes = Convert.FromHexString(hash[..32]); return new Guid(guidBytes); } #endregion #region DTOs private sealed record SbomInput { public required string ContainerImage { get; init; } public required string[] PackageUrls { get; init; } public required DateTimeOffset Timestamp { get; init; } } #endregion }