using System.Collections.Immutable; using System.Globalization; using System.Security.Cryptography; using System.Text; using CycloneDX; using CycloneDX.Models; using StellaOps.Scanner.Core.Contracts; using StellaOps.Scanner.Core.Utility; using JsonSerializer = CycloneDX.Json.Serializer; namespace StellaOps.Scanner.Emit.Composition; /// /// Writes per-layer SBOMs in CycloneDX 1.7 format. /// public sealed class CycloneDxLayerWriter : ILayerSbomWriter { private static readonly Guid SerialNamespace = new("1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d"); /// public string Format => "cyclonedx"; /// public Task WriteAsync(LayerSbomRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); var generatedAt = ScannerTimestamps.Normalize(request.GeneratedAt); var bom = BuildLayerBom(request, generatedAt); var json16 = JsonSerializer.Serialize(bom); var json = CycloneDx17Extensions.UpgradeJsonTo17(json16); var jsonBytes = Encoding.UTF8.GetBytes(json); var jsonDigest = ComputeSha256(jsonBytes); var output = new LayerSbomOutput { LayerDigest = request.LayerDigest, Format = Format, JsonBytes = jsonBytes, JsonDigest = jsonDigest, MediaType = CycloneDx17Extensions.MediaTypes.InventoryJson, ComponentCount = request.Components.Length, }; return Task.FromResult(output); } private static Bom BuildLayerBom(LayerSbomRequest request, DateTimeOffset generatedAt) { // Note: CycloneDX.Core 10.x does not yet have v1_7 enum; serialize as v1_6 then upgrade via UpgradeJsonTo17() var bom = new Bom { SpecVersion = SpecificationVersion.v1_6, Version = 1, Metadata = BuildMetadata(request, generatedAt), Components = BuildComponents(request.Components), Dependencies = BuildDependencies(request.Components), }; var serialPayload = $"{request.Image.ImageDigest}|layer:{request.LayerDigest}|{ScannerTimestamps.ToIso8601(generatedAt)}"; bom.SerialNumber = $"urn:uuid:{ScannerIdentifiers.CreateDeterministicGuid(SerialNamespace, Encoding.UTF8.GetBytes(serialPayload)).ToString("d", CultureInfo.InvariantCulture)}"; return bom; } private static Metadata BuildMetadata(LayerSbomRequest request, DateTimeOffset generatedAt) { var layerDigestShort = request.LayerDigest.Split(':', 2, StringSplitOptions.TrimEntries)[^1]; var bomRef = $"layer:{layerDigestShort}"; var metadata = new Metadata { Timestamp = generatedAt.UtcDateTime, Component = new Component { BomRef = bomRef, Type = Component.Classification.Container, Name = $"layer-{request.LayerOrder}", Version = layerDigestShort, Properties = new List { new() { Name = "stellaops:layer.digest", Value = request.LayerDigest }, new() { Name = "stellaops:layer.order", Value = request.LayerOrder.ToString(CultureInfo.InvariantCulture) }, new() { Name = "stellaops:image.digest", Value = request.Image.ImageDigest }, }, }, Properties = new List { new() { Name = "stellaops:sbom.type", Value = "layer" }, new() { Name = "stellaops:sbom.view", Value = "inventory" }, }, }; if (!string.IsNullOrWhiteSpace(request.Image.ImageReference)) { metadata.Component.Properties.Add(new Property { Name = "stellaops:image.reference", Value = request.Image.ImageReference, }); } 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, }); } } return metadata; } private static List BuildComponents(ImmutableArray components) { var result = new List(components.Length); foreach (var component in components.OrderBy(static c => c.Identity.Key, StringComparer.Ordinal)) { 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(ComponentRecord 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:layerDigest", Value = component.LayerDigest }); 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.Count == 0 ? null : properties; } private static List? BuildDependencies(ImmutableArray components) { var componentKeys = components.Select(static c => c.Identity.Key).ToImmutableHashSet(StringComparer.Ordinal); var dependencies = new List(); foreach (var component in components.OrderBy(static c => c.Identity.Key, StringComparer.Ordinal)) { if (component.Dependencies.IsDefaultOrEmpty || component.Dependencies.Length == 0) { continue; } var filtered = component.Dependencies.Where(componentKeys.Contains).OrderBy(k => k, StringComparer.Ordinal).ToArray(); if (filtered.Length == 0) { continue; } dependencies.Add(new Dependency { Ref = component.Identity.Key, Dependencies = filtered.Select(key => new Dependency { Ref = key }).ToList(), }); } return dependencies.Count == 0 ? null : dependencies; } 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 ComputeSha256(byte[] bytes) { var hash = SHA256.HashData(bytes); return Convert.ToHexString(hash).ToLowerInvariant(); } }