// ----------------------------------------------------------------------------- // 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) // Task: SCANNER-5100-007 - Expand determinism tests for Scanner SBOM hash stable // Description: Tests to validate SBOM generation determinism across formats // ----------------------------------------------------------------------------- using System.Collections.Immutable; using System.Text; using FluentAssertions; using StellaOps.Canonical.Json; using StellaOps.Scanner.Core.Contracts; using StellaOps.Scanner.Emit.Composition; 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 - SPDX should differ from CycloneDX spdxHash.Should().NotBe(cdx16Hash); spdxHash.Should().NotBe(cdx17Hash); // Note: CycloneDX 1.6 and 1.7 produce same output because CycloneDxComposer // only outputs spec version 1.7. This is expected behavior. cdx16Hash.Should().Be(cdx17Hash, "CycloneDxComposer outputs 1.7 for both"); // 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 SbomCompositionRequest CreateCompositionRequest(SbomInput input, DateTimeOffset timestamp) { var fragments = new[] { LayerComponentFragment.Create("sha256:layer1", input.PackageUrls.Select((purl, i) => new ComponentRecord { Identity = ComponentIdentity.Create( purl.Split('@')[0], purl.Split('/').Last().Split('@')[0], purl.Split('@').Last().Split('?')[0], purl, "library"), LayerDigest = "sha256:layer1", Evidence = ImmutableArray.Create(ComponentEvidence.FromPath($"/lib/{purl.Split('/').Last().Split('@')[0]}")), Dependencies = ImmutableArray.Empty, Usage = ComponentUsage.Create(false), Metadata = new ComponentMetadata { Scope = "runtime" } }).ToArray()) }; var image = new ImageArtifactDescriptor { ImageDigest = "sha256:determinism1234567890determinism1234567890determinism1234567890", ImageReference = $"docker.io/library/{input.ContainerImage}", Repository = "docker.io/library/alpine", Tag = input.ContainerImage.Split(':').Last(), Architecture = "amd64" }; return SbomCompositionRequest.Create( image, fragments, timestamp, generatorName: "StellaOps.Scanner", generatorVersion: "1.0.0", properties: new Dictionary { ["stellaops:scanId"] = "determinism-test-001", ["stellaops:tenantId"] = "test-tenant" }); } private static string GenerateSpdxSbom(SbomInput input, DateTimeOffset timestamp) { var request = CreateCompositionRequest(input, timestamp); var composer = new SpdxComposer(); var result = composer.Compose(request, new SpdxCompositionOptions()); return Encoding.UTF8.GetString(result.JsonBytes); } private static string GenerateCycloneDx16Sbom(SbomInput input, DateTimeOffset timestamp) { // CycloneDxComposer produces 1.7 format; for 1.6 testing we use the same composer // as the actual production code would. The API doesn't support version selection. var request = CreateCompositionRequest(input, timestamp); var composer = new CycloneDxComposer(); var result = composer.Compose(request); return Encoding.UTF8.GetString(result.Inventory.JsonBytes); } private static string GenerateCycloneDx17Sbom(SbomInput input, DateTimeOffset timestamp) { var request = CreateCompositionRequest(input, timestamp); var composer = new CycloneDxComposer(); var result = composer.Compose(request); return Encoding.UTF8.GetString(result.Inventory.JsonBytes); } #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 }