using System.Collections.Immutable; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using StellaOps.Scanner.Core.Contracts; using StellaOps.Scanner.Core.Utility; namespace StellaOps.Scanner.Emit.Composition; /// /// Service for building and validating composition recipes. /// public interface ICompositionRecipeService { /// /// Builds a composition recipe from a composition result. /// CompositionRecipeResponse BuildRecipe( string scanId, string imageDigest, DateTimeOffset createdAt, SbomCompositionResult compositionResult, string? generatorName = null, string? generatorVersion = null); /// /// Verifies a composition recipe against stored SBOMs. /// CompositionRecipeVerificationResult Verify( CompositionRecipeResponse recipe, ImmutableArray actualLayerSboms); } /// /// API response for composition recipe endpoint. /// public sealed record CompositionRecipeResponse { [JsonPropertyName("scanId")] public required string ScanId { get; init; } [JsonPropertyName("imageDigest")] public required string ImageDigest { get; init; } [JsonPropertyName("createdAt")] public required string CreatedAt { get; init; } [JsonPropertyName("recipe")] public required CompositionRecipe Recipe { get; init; } } /// /// The composition recipe itself. /// public sealed record CompositionRecipe { [JsonPropertyName("version")] public required string Version { get; init; } [JsonPropertyName("generatorName")] public required string GeneratorName { get; init; } [JsonPropertyName("generatorVersion")] public required string GeneratorVersion { get; init; } [JsonPropertyName("layers")] public required ImmutableArray Layers { get; init; } [JsonPropertyName("merkleRoot")] public required string MerkleRoot { get; init; } [JsonPropertyName("aggregatedSbomDigests")] public required AggregatedSbomDigests AggregatedSbomDigests { get; init; } } /// /// A single layer in the composition recipe. /// public sealed record CompositionRecipeLayer { [JsonPropertyName("digest")] public required string Digest { get; init; } [JsonPropertyName("order")] public required int Order { get; init; } [JsonPropertyName("fragmentDigest")] public required string FragmentDigest { get; init; } [JsonPropertyName("sbomDigests")] public required LayerSbomDigests SbomDigests { get; init; } [JsonPropertyName("componentCount")] public required int ComponentCount { get; init; } } /// /// Digests for a layer's SBOMs. /// public sealed record LayerSbomDigests { [JsonPropertyName("cyclonedx")] public required string CycloneDx { get; init; } [JsonPropertyName("spdx")] public required string Spdx { get; init; } } /// /// Digests for the aggregated (image-level) SBOMs. /// public sealed record AggregatedSbomDigests { [JsonPropertyName("cyclonedx")] public required string CycloneDx { get; init; } [JsonPropertyName("spdx")] public string? Spdx { get; init; } } /// /// Result of composition recipe verification. /// public sealed record CompositionRecipeVerificationResult { [JsonPropertyName("valid")] public required bool Valid { get; init; } [JsonPropertyName("merkleRootMatch")] public required bool MerkleRootMatch { get; init; } [JsonPropertyName("layerDigestsMatch")] public required bool LayerDigestsMatch { get; init; } [JsonPropertyName("errors")] public ImmutableArray Errors { get; init; } = ImmutableArray.Empty; } /// /// Default implementation of . /// public sealed class CompositionRecipeService : ICompositionRecipeService { private const string RecipeVersion = "1.0.0"; /// public CompositionRecipeResponse BuildRecipe( string scanId, string imageDigest, DateTimeOffset createdAt, SbomCompositionResult compositionResult, string? generatorName = null, string? generatorVersion = null) { ArgumentException.ThrowIfNullOrWhiteSpace(scanId); ArgumentException.ThrowIfNullOrWhiteSpace(imageDigest); ArgumentNullException.ThrowIfNull(compositionResult); var layers = compositionResult.LayerSboms .Select(layer => new CompositionRecipeLayer { Digest = layer.LayerDigest, Order = layer.Order, FragmentDigest = layer.FragmentDigest, SbomDigests = new LayerSbomDigests { CycloneDx = layer.CycloneDxDigest, Spdx = layer.SpdxDigest, }, ComponentCount = layer.ComponentCount, }) .OrderBy(l => l.Order) .ToImmutableArray(); var merkleRoot = compositionResult.LayerSbomMerkleRoot ?? ComputeMerkleRoot(layers); var recipe = new CompositionRecipe { Version = RecipeVersion, GeneratorName = generatorName ?? "StellaOps.Scanner", GeneratorVersion = generatorVersion ?? "2026.04", Layers = layers, MerkleRoot = merkleRoot, AggregatedSbomDigests = new AggregatedSbomDigests { CycloneDx = compositionResult.Inventory.JsonSha256, Spdx = compositionResult.SpdxInventory?.JsonSha256, }, }; return new CompositionRecipeResponse { ScanId = scanId, ImageDigest = imageDigest, CreatedAt = ScannerTimestamps.ToIso8601(createdAt), Recipe = recipe, }; } /// public CompositionRecipeVerificationResult Verify( CompositionRecipeResponse recipe, ImmutableArray actualLayerSboms) { ArgumentNullException.ThrowIfNull(recipe); var errors = ImmutableArray.CreateBuilder(); var layerDigestsMatch = true; if (recipe.Recipe.Layers.Length != actualLayerSboms.Length) { errors.Add($"Layer count mismatch: expected {recipe.Recipe.Layers.Length}, got {actualLayerSboms.Length}"); layerDigestsMatch = false; } else { for (var i = 0; i < recipe.Recipe.Layers.Length; i++) { var expected = recipe.Recipe.Layers[i]; var actual = actualLayerSboms.FirstOrDefault(l => l.Order == expected.Order); if (actual is null) { errors.Add($"Missing layer at order {expected.Order}"); layerDigestsMatch = false; continue; } if (expected.Digest != actual.LayerDigest) { errors.Add($"Layer {i} digest mismatch: expected {expected.Digest}, got {actual.LayerDigest}"); layerDigestsMatch = false; } if (expected.SbomDigests.CycloneDx != actual.CycloneDxDigest) { errors.Add($"Layer {i} CycloneDX digest mismatch: expected {expected.SbomDigests.CycloneDx}, got {actual.CycloneDxDigest}"); layerDigestsMatch = false; } if (expected.SbomDigests.Spdx != actual.SpdxDigest) { errors.Add($"Layer {i} SPDX digest mismatch: expected {expected.SbomDigests.Spdx}, got {actual.SpdxDigest}"); layerDigestsMatch = false; } } } var computedMerkleRoot = ComputeMerkleRoot(recipe.Recipe.Layers); var merkleRootMatch = recipe.Recipe.MerkleRoot == computedMerkleRoot; if (!merkleRootMatch) { errors.Add($"Merkle root mismatch: expected {recipe.Recipe.MerkleRoot}, computed {computedMerkleRoot}"); } return new CompositionRecipeVerificationResult { Valid = layerDigestsMatch && merkleRootMatch && errors.Count == 0, MerkleRootMatch = merkleRootMatch, LayerDigestsMatch = layerDigestsMatch, Errors = errors.ToImmutable(), }; } private static string ComputeMerkleRoot(ImmutableArray layers) { if (layers.IsDefaultOrEmpty) { return ComputeSha256(Array.Empty()); } var leaves = layers .OrderBy(l => l.Order) .Select(l => HexToBytes(l.SbomDigests.CycloneDx)) .ToList(); if (leaves.Count == 1) { return Convert.ToHexString(leaves[0]).ToLowerInvariant(); } var nodes = leaves; while (nodes.Count > 1) { var nextLevel = new List(); for (var i = 0; i < nodes.Count; i += 2) { if (i + 1 < nodes.Count) { var combined = new byte[nodes[i].Length + nodes[i + 1].Length]; Buffer.BlockCopy(nodes[i], 0, combined, 0, nodes[i].Length); Buffer.BlockCopy(nodes[i + 1], 0, combined, nodes[i].Length, nodes[i + 1].Length); nextLevel.Add(SHA256.HashData(combined)); } else { nextLevel.Add(nodes[i]); } } nodes = nextLevel; } return Convert.ToHexString(nodes[0]).ToLowerInvariant(); } private static string ComputeSha256(byte[] bytes) { return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); } private static byte[] HexToBytes(string hex) { return Convert.FromHexString(hex); } }