namespace StellaOps.Interop.Tests.CycloneDx; [Trait("Category", "Interop")] [Trait("Format", "CycloneDX")] public class CycloneDxRoundTripTests : IClassFixture { private readonly InteropTestHarness _harness; public CycloneDxRoundTripTests(InteropTestHarness harness) { _harness = harness; } [Theory] [MemberData(nameof(TestImages))] public async Task Syft_GeneratesCycloneDx_GrypeCanConsume(string imageRef) { // Generate SBOM with Syft var sbomResult = await _harness.GenerateSbomWithSyft( imageRef, SbomFormat.CycloneDx16); sbomResult.Success.Should().BeTrue("Syft should generate CycloneDX SBOM"); // Scan from SBOM with Grype var grypeResult = await _harness.ScanWithGrypeFromSbom(sbomResult.Path!); grypeResult.Success.Should().BeTrue("Grype should consume Syft-generated CycloneDX SBOM"); // Grype should be able to parse and find vulnerabilities grypeResult.Findings.Should().NotBeNull(); } [Theory] [MemberData(nameof(TestImages))] public async Task Stella_GeneratesCycloneDx_GrypeCanConsume(string imageRef) { // Generate SBOM with Stella var sbomResult = await _harness.GenerateSbomWithStella( imageRef, SbomFormat.CycloneDx16); sbomResult.Success.Should().BeTrue("Stella should generate CycloneDX SBOM"); // Scan from SBOM with Grype var grypeResult = await _harness.ScanWithGrypeFromSbom(sbomResult.Path!); grypeResult.Success.Should().BeTrue("Grype should consume Stella-generated CycloneDX SBOM"); } [Theory] [MemberData(nameof(TestImages))] [Trait("Category", "Parity")] public async Task Stella_And_Grype_FindingsParity_Above95Percent(string imageRef) { // Generate SBOM with Stella var stellaSbom = await _harness.GenerateSbomWithStella( imageRef, SbomFormat.CycloneDx16); stellaSbom.Success.Should().BeTrue(); // TODO: Get Stella findings from scan result var stellaFindings = new List(); // Scan SBOM with Grype var grypeResult = await _harness.ScanWithGrypeFromSbom(stellaSbom.Path!); grypeResult.Success.Should().BeTrue(); // Compare findings var comparison = _harness.CompareFindings( stellaFindings, grypeResult.Findings!, tolerancePercent: 5); comparison.ParityPercent.Should().BeGreaterOrEqualTo(95, $"Findings parity {comparison.ParityPercent:F2}% is below 95% threshold. " + $"Only in Stella: {comparison.OnlyInStella}, Only in Grype: {comparison.OnlyInGrype}"); } [Theory] [MemberData(nameof(TestImages))] [Trait("Category", "Attestation")] public async Task CycloneDx_Attestation_RoundTrip(string imageRef) { // Skip if not in CI - cosign requires credentials if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI"))) { // Skip in local dev return; } // Generate SBOM var sbomResult = await _harness.GenerateSbomWithStella( imageRef, SbomFormat.CycloneDx16); sbomResult.Success.Should().BeTrue(); // Attest with cosign var attestResult = await _harness.AttestWithCosign( sbomResult.Path!, imageRef); attestResult.Success.Should().BeTrue("Cosign should attest SBOM"); // TODO: Verify attestation // var verifyResult = await _harness.VerifyCosignAttestation(imageRef); // verifyResult.Success.Should().BeTrue(); // Digest should match // var attestedDigest = verifyResult.PredicateDigest; // attestedDigest.Should().Be(sbomResult.Digest); } [Fact] [Trait("Category", "Schema")] public async Task Stella_CycloneDx_ValidatesAgainstSchema() { var imageRef = "alpine:3.18"; // Generate SBOM var sbomResult = await _harness.GenerateSbomWithStella( imageRef, SbomFormat.CycloneDx16); sbomResult.Success.Should().BeTrue(); // TODO: Validate against CycloneDX 1.6 JSON schema sbomResult.Content.Should().NotBeNullOrEmpty(); sbomResult.Content.Should().Contain("\"bomFormat\": \"CycloneDX\""); sbomResult.Content.Should().Contain("\"specVersion\": \"1.6\""); } public static IEnumerable TestImages => [ ["alpine:3.18"], ["debian:12-slim"], ["node:20-alpine"], ["python:3.12-slim"], ["golang:1.22-alpine"] ]; }