using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Globalization; using System.Linq; using System.Security.Cryptography; using System.Text; using System.Text.Json; using CycloneDX; using CycloneDX.Models; using CycloneDX.Models.Vulnerabilities; using JsonSerializer = CycloneDX.Json.Serializer; using ProtoSerializer = CycloneDX.Protobuf.Serializer; using StellaOps.Scanner.Core.Contracts; using StellaOps.Scanner.Core.Utility; namespace StellaOps.Scanner.Emit.Composition; public sealed class CycloneDxComposer { private static readonly Guid SerialNamespace = new("0d3a422b-6e1b-4d9b-9c35-654b706c97e8"); private const string InventoryMediaTypeJson = "application/vnd.cyclonedx+json; version=1.6"; private const string UsageMediaTypeJson = "application/vnd.cyclonedx+json; version=1.6; view=usage"; private const string InventoryMediaTypeProtobuf = "application/vnd.cyclonedx+protobuf; version=1.6"; private const string UsageMediaTypeProtobuf = "application/vnd.cyclonedx+protobuf; version=1.6; view=usage"; public SbomCompositionResult Compose(SbomCompositionRequest request) { ArgumentNullException.ThrowIfNull(request); if (request.LayerFragments.IsDefaultOrEmpty) { throw new ArgumentException("At least one layer fragment is required.", nameof(request)); } var graph = ComponentGraphBuilder.Build(request.LayerFragments); var generatedAt = ScannerTimestamps.Normalize(request.GeneratedAt); var inventoryArtifact = BuildArtifact( request, graph, SbomView.Inventory, graph.Components, generatedAt, InventoryMediaTypeJson, InventoryMediaTypeProtobuf); var usageComponents = graph.Components .Where(static component => component.Usage.UsedByEntrypoint) .ToImmutableArray(); CycloneDxArtifact? usageArtifact = null; if (!usageComponents.IsEmpty) { usageArtifact = BuildArtifact( request, graph, SbomView.Usage, usageComponents, generatedAt, UsageMediaTypeJson, UsageMediaTypeProtobuf); } var compositionRecipeJson = BuildCompositionRecipeJson(graph, generatedAt); var compositionRecipeSha = ComputeSha256(compositionRecipeJson); var compositionRecipeUri = $"cas://sbom/composition/{compositionRecipeSha}.json"; inventoryArtifact = inventoryArtifact with { MerkleRoot = compositionRecipeSha, CompositionRecipeUri = compositionRecipeUri, }; if (usageArtifact is not null) { usageArtifact = usageArtifact with { MerkleRoot = compositionRecipeSha, CompositionRecipeUri = compositionRecipeUri, }; } return new SbomCompositionResult { Inventory = inventoryArtifact, Usage = usageArtifact, Graph = graph, CompositionRecipeJson = compositionRecipeJson, CompositionRecipeSha256 = compositionRecipeSha, }; } private CycloneDxArtifact BuildArtifact( SbomCompositionRequest request, ComponentGraph graph, SbomView view, ImmutableArray components, DateTimeOffset generatedAt, string jsonMediaType, string protobufMediaType) { var bom = BuildBom(request, graph, view, components, generatedAt); var json = JsonSerializer.Serialize(bom); var jsonBytes = Encoding.UTF8.GetBytes(json); var protobufBytes = ProtoSerializer.Serialize(bom); var jsonHash = ComputeSha256(jsonBytes); var protobufHash = ComputeSha256(protobufBytes); var merkleRoot = request.AdditionalProperties is not null && request.AdditionalProperties.TryGetValue("stellaops:merkle.root", out var root) ? root : null; string? compositionUri = null; string? compositionRecipeUri = null; request.AdditionalProperties?.TryGetValue("stellaops:composition.manifest", out compositionUri); request.AdditionalProperties?.TryGetValue("stellaops:composition.recipe", out compositionRecipeUri); return new CycloneDxArtifact { View = view, SerialNumber = bom.SerialNumber ?? string.Empty, GeneratedAt = generatedAt, Components = components, JsonBytes = jsonBytes, JsonSha256 = jsonHash, ContentHash = jsonHash, MerkleRoot = merkleRoot, CompositionUri = compositionUri, CompositionRecipeUri = compositionRecipeUri, JsonMediaType = jsonMediaType, ProtobufBytes = protobufBytes, ProtobufSha256 = protobufHash, ProtobufMediaType = protobufMediaType, }; } private static byte[] BuildCompositionRecipeJson(ComponentGraph graph, DateTimeOffset generatedAt) { var recipe = new { schema = "stellaops.composition.recipe@1", generatedAt = ScannerTimestamps.ToIso8601(generatedAt), layers = graph.Layers.Select(layer => new { layer.LayerDigest, components = layer.Components .Select(component => component.Identity.Key) .OrderBy(key => key, StringComparer.Ordinal) .ToArray(), }).OrderBy(entry => entry.LayerDigest, StringComparer.Ordinal).ToArray(), }; var json = JsonSerializer.Serialize(recipe, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false, }); return Encoding.UTF8.GetBytes(json); } private Bom BuildBom( SbomCompositionRequest request, ComponentGraph graph, SbomView view, ImmutableArray components, DateTimeOffset generatedAt) { var bom = new Bom { SpecVersion = SpecificationVersion.v1_6, Version = 1, Metadata = BuildMetadata(request, view, generatedAt), Components = BuildComponents(components), Dependencies = BuildDependencies(components), }; var vulnerabilities = BuildVulnerabilities(request, graph, components); if (vulnerabilities is not null) { bom.Vulnerabilities = vulnerabilities; } var serialPayload = $"{request.Image.ImageDigest}|{view}|{ScannerTimestamps.ToIso8601(generatedAt)}"; bom.SerialNumber = $"urn:uuid:{ScannerIdentifiers.CreateDeterministicGuid(SerialNamespace, Encoding.UTF8.GetBytes(serialPayload)).ToString("d", CultureInfo.InvariantCulture)}"; return bom; } private static Metadata BuildMetadata(SbomCompositionRequest request, SbomView view, DateTimeOffset generatedAt) { var metadata = new Metadata { Timestamp = generatedAt.UtcDateTime, Component = BuildMetadataComponent(request.Image), }; if (request.AdditionalProperties is not null && request.AdditionalProperties.Count > 0) { metadata.Properties = request.AdditionalProperties .Where(static pair => !string.IsNullOrWhiteSpace(pair.Key) && pair.Value is not null) .OrderBy(static pair => pair.Key, StringComparer.Ordinal) .Select(pair => new Property { Name = pair.Key, Value = pair.Value, }) .ToList(); } if (metadata.Properties is null) { metadata.Properties = new List(); } if (!string.IsNullOrWhiteSpace(request.GeneratorName)) { metadata.Properties.Add(new Property { Name = "stellaops:generator.name", Value = request.GeneratorName, }); if (!string.IsNullOrWhiteSpace(request.GeneratorVersion)) { metadata.Properties.Add(new Property { Name = "stellaops:generator.version", Value = request.GeneratorVersion, }); } } metadata.Properties.Add(new Property { Name = "stellaops:sbom.view", Value = view.ToString().ToLowerInvariant(), }); return metadata; } private static Component BuildMetadataComponent(ImageArtifactDescriptor image) { var digest = image.ImageDigest; var digestValue = digest.Split(':', 2, StringSplitOptions.TrimEntries)[^1]; var bomRef = $"image:{digestValue}"; var name = image.ImageReference ?? image.Repository ?? digest; var component = new Component { BomRef = bomRef, Type = Component.Classification.Container, Name = name, Version = digestValue, Purl = BuildImagePurl(image), Properties = new List { new() { Name = "stellaops:image.digest", Value = image.ImageDigest }, }, }; if (!string.IsNullOrWhiteSpace(image.ImageReference)) { component.Properties.Add(new Property { Name = "stellaops:image.reference", Value = image.ImageReference }); } if (!string.IsNullOrWhiteSpace(image.Repository)) { component.Properties.Add(new Property { Name = "stellaops:image.repository", Value = image.Repository }); } if (!string.IsNullOrWhiteSpace(image.Tag)) { component.Properties.Add(new Property { Name = "stellaops:image.tag", Value = image.Tag }); } if (!string.IsNullOrWhiteSpace(image.Architecture)) { component.Properties.Add(new Property { Name = "stellaops:image.architecture", Value = image.Architecture }); } return component; } private static string? BuildImagePurl(ImageArtifactDescriptor image) { if (string.IsNullOrWhiteSpace(image.Repository)) { return null; } var repo = image.Repository.Trim(); var tag = string.IsNullOrWhiteSpace(image.Tag) ? null : image.Tag.Trim(); var digest = image.ImageDigest.Trim(); var purlBuilder = new StringBuilder("pkg:oci/"); purlBuilder.Append(repo.Replace("/", "%2F", StringComparison.Ordinal)); if (!string.IsNullOrWhiteSpace(tag)) { purlBuilder.Append('@').Append(tag); } purlBuilder.Append("?digest=").Append(Uri.EscapeDataString(digest)); if (!string.IsNullOrWhiteSpace(image.Architecture)) { purlBuilder.Append("&arch=").Append(Uri.EscapeDataString(image.Architecture.Trim())); } return purlBuilder.ToString(); } private static List BuildComponents(ImmutableArray components) { var result = new List(components.Length); foreach (var component in components) { var model = new Component { BomRef = component.Identity.Key, Name = component.Identity.Name, Version = component.Identity.Version, Purl = component.Identity.Purl, Group = component.Identity.Group, Type = MapClassification(component.Identity.ComponentType), Scope = MapScope(component.Metadata?.Scope), Properties = BuildProperties(component), }; result.Add(model); } return result; } private static List? BuildProperties(AggregatedComponent component) { var properties = new List(); if (component.Metadata?.Properties is not null) { foreach (var property in component.Metadata.Properties.OrderBy(static pair => pair.Key, StringComparer.Ordinal)) { properties.Add(new Property { Name = property.Key, Value = property.Value, }); } } if (!string.IsNullOrWhiteSpace(component.Metadata?.BuildId)) { properties.Add(new Property { Name = "stellaops:buildId", Value = component.Metadata!.BuildId, }); } properties.Add(new Property { Name = "stellaops:firstLayerDigest", Value = component.FirstLayerDigest }); if (component.LastLayerDigest is not null) { properties.Add(new Property { Name = "stellaops:lastLayerDigest", Value = component.LastLayerDigest }); } if (!component.LayerDigests.IsDefaultOrEmpty) { properties.Add(new Property { Name = "stellaops:layerDigests", Value = string.Join(",", component.LayerDigests), }); } if (component.Usage.UsedByEntrypoint) { properties.Add(new Property { Name = "stellaops:usage.usedByEntrypoint", Value = "true" }); } if (!component.Usage.Entrypoints.IsDefaultOrEmpty && component.Usage.Entrypoints.Length > 0) { for (var index = 0; index < component.Usage.Entrypoints.Length; index++) { properties.Add(new Property { Name = $"stellaops:usage.entrypoint[{index}]", Value = component.Usage.Entrypoints[index], }); } } for (var index = 0; index < component.Evidence.Length; index++) { var evidence = component.Evidence[index]; var builder = new StringBuilder(evidence.Kind); builder.Append(':').Append(evidence.Value); if (!string.IsNullOrWhiteSpace(evidence.Source)) { builder.Append('@').Append(evidence.Source); } properties.Add(new Property { Name = $"stellaops:evidence[{index}]", Value = builder.ToString(), }); } return properties; } private static List? BuildDependencies(ImmutableArray components) { var componentKeys = components.Select(static component => component.Identity.Key).ToImmutableHashSet(StringComparer.Ordinal); var dependencies = new List(); foreach (var component in components) { if (component.Dependencies.IsDefaultOrEmpty || component.Dependencies.Length == 0) { continue; } var filtered = component.Dependencies.Where(componentKeys.Contains).ToArray(); if (filtered.Length == 0) { continue; } dependencies.Add(new Dependency { Ref = component.Identity.Key, Dependencies = filtered .Select(dependencyKey => new Dependency { Ref = dependencyKey }) .ToList(), }); } return dependencies.Count == 0 ? null : dependencies; } private static List? BuildVulnerabilities( SbomCompositionRequest request, ComponentGraph graph, ImmutableArray viewComponents) { if (request.PolicyFindings.IsDefaultOrEmpty || request.PolicyFindings.Length == 0) { return null; } if (viewComponents.IsDefaultOrEmpty || viewComponents.Length == 0) { return null; } var componentKeys = viewComponents .Select(static component => component.Identity.Key) .ToImmutableHashSet(StringComparer.Ordinal); if (componentKeys.Count == 0) { return null; } var vulnerabilities = new List(request.PolicyFindings.Length); foreach (var finding in request.PolicyFindings) { if (!graph.ComponentMap.TryGetValue(finding.ComponentKey, out var component)) { continue; } if (!componentKeys.Contains(component.Identity.Key)) { continue; } var ratings = BuildRatings(finding.Score); var properties = BuildVulnerabilityProperties(finding); var vulnerability = new Vulnerability { BomRef = finding.FindingId, Id = finding.VulnerabilityId ?? finding.FindingId, Source = new Source { Name = "StellaOps.Policy" }, Affects = new List { new() { Ref = component.Identity.Key } }, Ratings = ratings, Properties = properties, }; vulnerabilities.Add(vulnerability); } return vulnerabilities.Count == 0 ? null : vulnerabilities; } private static List? BuildRatings(double score) { if (double.IsNaN(score) || double.IsInfinity(score)) { return null; } return new List { new() { Method = ScoreMethod.Other, Justification = "StellaOps Policy score", Score = score, Severity = Severity.Unknown, Source = new Source { Name = "StellaOps.Policy" }, } }; } private static List? BuildVulnerabilityProperties(SbomPolicyFinding finding) { var properties = new List(); AddStringProperty(properties, "stellaops:policy.status", finding.Status); AddStringProperty(properties, "stellaops:policy.configVersion", finding.ConfigVersion); AddBooleanProperty(properties, "stellaops:policy.quiet", finding.Quiet); AddStringProperty(properties, "stellaops:policy.quietedBy", finding.QuietedBy); AddStringProperty(properties, "stellaops:policy.confidenceBand", finding.ConfidenceBand); AddStringProperty(properties, "stellaops:policy.sourceTrust", finding.SourceTrust); AddStringProperty(properties, "stellaops:policy.reachability", finding.Reachability); AddDoubleProperty(properties, "stellaops:policy.score", finding.Score); AddNullableDoubleProperty(properties, "stellaops:policy.unknownConfidence", finding.UnknownConfidence); AddNullableDoubleProperty(properties, "stellaops:policy.unknownAgeDays", finding.UnknownAgeDays); if (!finding.Inputs.IsDefaultOrEmpty && finding.Inputs.Length > 0) { foreach (var (key, value) in finding.Inputs) { AddDoubleProperty(properties, $"stellaops:policy.input.{key}", value); } } if (properties.Count == 0) { return null; } properties.Sort(static (left, right) => StringComparer.Ordinal.Compare(left.Name, right.Name)); return properties; } private static void AddStringProperty(ICollection properties, string name, string? value) { if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(value)) { return; } properties.Add(new Property { Name = name, Value = value.Trim(), }); } private static void AddBooleanProperty(ICollection properties, string name, bool value) { if (string.IsNullOrWhiteSpace(name)) { return; } properties.Add(new Property { Name = name, Value = value ? "true" : "false", }); } private static void AddDoubleProperty(ICollection properties, string name, double value) { if (string.IsNullOrWhiteSpace(name) || double.IsNaN(value) || double.IsInfinity(value)) { return; } properties.Add(new Property { Name = name, Value = FormatDouble(value), }); } private static void AddNullableDoubleProperty(ICollection properties, string name, double? value) { if (!value.HasValue) { return; } AddDoubleProperty(properties, name, value.Value); } private static Component.Classification MapClassification(string? type) { if (string.IsNullOrWhiteSpace(type)) { return Component.Classification.Library; } return type.Trim().ToLowerInvariant() switch { "application" => Component.Classification.Application, "framework" => Component.Classification.Framework, "container" => Component.Classification.Container, "operating-system" or "os" => Component.Classification.Operating_System, "device" => Component.Classification.Device, "firmware" => Component.Classification.Firmware, "file" => Component.Classification.File, _ => Component.Classification.Library, }; } private static Component.ComponentScope? MapScope(string? scope) { if (string.IsNullOrWhiteSpace(scope)) { return null; } return scope.Trim().ToLowerInvariant() switch { "runtime" or "required" => Component.ComponentScope.Required, "development" or "optional" => Component.ComponentScope.Optional, "excluded" => Component.ComponentScope.Excluded, _ => null, }; } private static string FormatDouble(double value) => value.ToString("0.############################", CultureInfo.InvariantCulture); private static string ComputeSha256(byte[] bytes) { using var sha256 = SHA256.Create(); var hash = sha256.ComputeHash(bytes); return Convert.ToHexString(hash).ToLowerInvariant(); } }