using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using System.Text.Json; using StellaOps.Scanner.Core.Contracts; using StellaOps.Scanner.Emit.Composition; using Xunit; namespace StellaOps.Scanner.Emit.Tests.Composition; public sealed class CycloneDxComposerTests { [Fact] public void Compose_ProducesInventoryAndUsageArtifacts() { var request = BuildRequest(); var composer = new CycloneDxComposer(); var result = composer.Compose(request); Assert.NotNull(result.Inventory); Assert.StartsWith("urn:uuid:", result.Inventory.SerialNumber, StringComparison.Ordinal); Assert.Equal("application/vnd.cyclonedx+json; version=1.6", result.Inventory.JsonMediaType); Assert.Equal("application/vnd.cyclonedx+protobuf; version=1.6", result.Inventory.ProtobufMediaType); Assert.Equal(2, result.Inventory.Components.Length); Assert.NotNull(result.Usage); Assert.Equal("application/vnd.cyclonedx+json; version=1.6; view=usage", result.Usage!.JsonMediaType); Assert.Single(result.Usage.Components); Assert.Equal("pkg:npm/a", result.Usage.Components[0].Identity.Key); ValidateJson(result.Inventory.JsonBytes, expectedComponentCount: 2, expectedView: "inventory"); ValidateJson(result.Usage.JsonBytes, expectedComponentCount: 1, expectedView: "usage"); var inventoryComponentA = result.Inventory.Components.Single(component => string.Equals(component.Identity.Key, "pkg:npm/a", StringComparison.Ordinal)); Assert.Equal("abcdef1234567890abcdef1234567890abcdef12", inventoryComponentA.Metadata?.BuildId); using var inventoryDoc = JsonDocument.Parse(result.Inventory.JsonBytes); var inventoryRoot = inventoryDoc.RootElement; Assert.True(inventoryRoot.TryGetProperty("vulnerabilities", out var inventoryVulnerabilities)); var inventoryVulns = inventoryVulnerabilities.EnumerateArray().ToArray(); Assert.Equal(2, inventoryVulns.Length); var primaryVuln = inventoryVulns.Single(v => string.Equals(v.GetProperty("bom-ref").GetString(), "finding-a", StringComparison.Ordinal)); var primaryProperties = primaryVuln.GetProperty("properties") .EnumerateArray() .ToDictionary( element => element.GetProperty("name").GetString()!, element => element.GetProperty("value").GetString()!, StringComparer.Ordinal); Assert.Equal("Blocked", primaryProperties["stellaops:policy.status"]); Assert.Equal("true", primaryProperties["stellaops:policy.quiet"]); Assert.Equal("40.5", primaryProperties["stellaops:policy.score"]); Assert.Equal("medium", primaryProperties["stellaops:policy.confidenceBand"]); Assert.Equal("runtime", primaryProperties["stellaops:policy.reachability"]); Assert.Equal("0.45", primaryProperties["stellaops:policy.input.reachabilityWeight"]); var ratingScore = primaryVuln.GetProperty("ratings").EnumerateArray().Single().GetProperty("score").GetDouble(); Assert.Equal(40.5, ratingScore); using var usageDoc = JsonDocument.Parse(result.Usage.JsonBytes); var usageRoot = usageDoc.RootElement; Assert.True(usageRoot.TryGetProperty("vulnerabilities", out var usageVulnerabilities)); var usageVulns = usageVulnerabilities.EnumerateArray().ToArray(); Assert.Single(usageVulns); Assert.Equal("finding-a", usageVulns[0].GetProperty("bom-ref").GetString()); } [Fact] public void Compose_IsDeterministic() { var request = BuildRequest(); var composer = new CycloneDxComposer(); var first = composer.Compose(request); var second = composer.Compose(request); Assert.Equal(first.Inventory.JsonSha256, second.Inventory.JsonSha256); Assert.Equal(first.Inventory.ProtobufSha256, second.Inventory.ProtobufSha256); Assert.Equal(first.Inventory.SerialNumber, second.Inventory.SerialNumber); Assert.NotNull(first.Usage); Assert.NotNull(second.Usage); Assert.Equal(first.Usage!.JsonSha256, second.Usage!.JsonSha256); Assert.Equal(first.Usage.ProtobufSha256, second.Usage.ProtobufSha256); Assert.Equal(first.Usage.SerialNumber, second.Usage.SerialNumber); } private static SbomCompositionRequest BuildRequest() { var fragments = new[] { LayerComponentFragment.Create("sha256:layer1", new[] { new ComponentRecord { Identity = ComponentIdentity.Create("pkg:npm/a", "component-a", "1.0.0", "pkg:npm/a@1.0.0", "library"), LayerDigest = "sha256:layer1", Evidence = ImmutableArray.Create(ComponentEvidence.FromPath("/app/node_modules/a/package.json")), Dependencies = ImmutableArray.Create("pkg:npm/b"), Usage = ComponentUsage.Create(true, new[] { "/app/start.sh" }), Metadata = new ComponentMetadata { Scope = "runtime", Licenses = new[] { "MIT" }, Properties = new Dictionary { ["stellaops:source"] = "package-lock.json", ["stellaops.os.analyzer"] = "apk", ["stellaops.os.architecture"] = "x86_64", }, BuildId = "ABCDEF1234567890ABCDEF1234567890ABCDEF12", }, } }), LayerComponentFragment.Create("sha256:layer2", new[] { new ComponentRecord { Identity = ComponentIdentity.Create("pkg:npm/b", "component-b", "2.0.0", "pkg:npm/b@2.0.0", "library"), LayerDigest = "sha256:layer2", Evidence = ImmutableArray.Create(ComponentEvidence.FromPath("/app/node_modules/b/package.json")), Usage = ComponentUsage.Create(false), Metadata = new ComponentMetadata { Scope = "development", Properties = new Dictionary { ["stellaops.os.analyzer"] = "language-node", }, }, } }) }; var image = new ImageArtifactDescriptor { ImageDigest = "sha256:1234567890abcdef", ImageReference = "registry.example.com/app/service:1.2.3", Repository = "registry.example.com/app/service", Tag = "1.2.3", Architecture = "amd64", }; return SbomCompositionRequest.Create( image, fragments, new DateTimeOffset(2025, 10, 19, 12, 0, 0, TimeSpan.Zero), generatorName: "StellaOps.Scanner", generatorVersion: "0.10.0", properties: new Dictionary { ["stellaops:scanId"] = "scan-1234", }, policyFindings: new[] { new SbomPolicyFinding { FindingId = "finding-a", ComponentKey = "pkg:npm/a", VulnerabilityId = "CVE-2025-0001", Status = "Blocked", Score = 40.5, ConfigVersion = "1.0", Quiet = true, QuietedBy = "policy/quiet-critical-runtime", UnknownConfidence = 0.42, ConfidenceBand = "medium", UnknownAgeDays = 5, SourceTrust = "NVD", Reachability = "runtime", Inputs = ImmutableArray.Create( new KeyValuePair("severityWeight", 90), new KeyValuePair("trustWeight", 1.0), new KeyValuePair("reachabilityWeight", 0.45)) }, new SbomPolicyFinding { FindingId = "finding-b", ComponentKey = "pkg:npm/b", VulnerabilityId = "CVE-2025-0002", Status = "Warned", Score = 12.5, ConfigVersion = "1.0", Quiet = false, SourceTrust = "StellaOps", Reachability = "indirect", Inputs = ImmutableArray.Create( new KeyValuePair("severityWeight", 55), new KeyValuePair("trustWeight", 0.85)) } }); } private static void ValidateJson(byte[] data, int expectedComponentCount, string expectedView) { using var document = JsonDocument.Parse(data); var root = document.RootElement; Assert.True(root.TryGetProperty("metadata", out var metadata), "metadata property missing"); var properties = metadata.GetProperty("properties"); var viewProperty = properties.EnumerateArray() .Single(prop => string.Equals(prop.GetProperty("name").GetString(), "stellaops:sbom.view", StringComparison.Ordinal)); Assert.Equal(expectedView, viewProperty.GetProperty("value").GetString()); var components = root.GetProperty("components").EnumerateArray().ToArray(); Assert.Equal(expectedComponentCount, components.Length); var names = components.Select(component => component.GetProperty("name").GetString()!).ToArray(); Assert.Equal(names, names.OrderBy(n => n, StringComparer.Ordinal).ToArray()); var firstComponentProperties = components[0].GetProperty("properties").EnumerateArray().ToDictionary( element => element.GetProperty("name").GetString()!, element => element.GetProperty("value").GetString()!, StringComparer.Ordinal); Assert.Equal("apk", firstComponentProperties["stellaops.os.analyzer"]); Assert.Equal("x86_64", firstComponentProperties["stellaops.os.architecture"]); Assert.Equal("abcdef1234567890abcdef1234567890abcdef12", firstComponentProperties["stellaops:buildId"]); } }