Files
git.stella-ops.org/src/Scanner/__Libraries/StellaOps.Scanner.Emit/Composition/CompositionRecipeService.cs
2026-01-07 09:43:12 +02:00

321 lines
9.9 KiB
C#

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;
/// <summary>
/// Service for building and validating composition recipes.
/// </summary>
public interface ICompositionRecipeService
{
/// <summary>
/// Builds a composition recipe from a composition result.
/// </summary>
CompositionRecipeResponse BuildRecipe(
string scanId,
string imageDigest,
DateTimeOffset createdAt,
SbomCompositionResult compositionResult,
string? generatorName = null,
string? generatorVersion = null);
/// <summary>
/// Verifies a composition recipe against stored SBOMs.
/// </summary>
CompositionRecipeVerificationResult Verify(
CompositionRecipeResponse recipe,
ImmutableArray<LayerSbomRef> actualLayerSboms);
}
/// <summary>
/// API response for composition recipe endpoint.
/// </summary>
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; }
}
/// <summary>
/// The composition recipe itself.
/// </summary>
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<CompositionRecipeLayer> Layers { get; init; }
[JsonPropertyName("merkleRoot")]
public required string MerkleRoot { get; init; }
[JsonPropertyName("aggregatedSbomDigests")]
public required AggregatedSbomDigests AggregatedSbomDigests { get; init; }
}
/// <summary>
/// A single layer in the composition recipe.
/// </summary>
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; }
}
/// <summary>
/// Digests for a layer's SBOMs.
/// </summary>
public sealed record LayerSbomDigests
{
[JsonPropertyName("cyclonedx")]
public required string CycloneDx { get; init; }
[JsonPropertyName("spdx")]
public required string Spdx { get; init; }
}
/// <summary>
/// Digests for the aggregated (image-level) SBOMs.
/// </summary>
public sealed record AggregatedSbomDigests
{
[JsonPropertyName("cyclonedx")]
public required string CycloneDx { get; init; }
[JsonPropertyName("spdx")]
public string? Spdx { get; init; }
}
/// <summary>
/// Result of composition recipe verification.
/// </summary>
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<string> Errors { get; init; } = ImmutableArray<string>.Empty;
}
/// <summary>
/// Default implementation of <see cref="ICompositionRecipeService"/>.
/// </summary>
public sealed class CompositionRecipeService : ICompositionRecipeService
{
private const string RecipeVersion = "1.0.0";
/// <inheritdoc />
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,
};
}
/// <inheritdoc />
public CompositionRecipeVerificationResult Verify(
CompositionRecipeResponse recipe,
ImmutableArray<LayerSbomRef> actualLayerSboms)
{
ArgumentNullException.ThrowIfNull(recipe);
var errors = ImmutableArray.CreateBuilder<string>();
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<CompositionRecipeLayer> layers)
{
if (layers.IsDefaultOrEmpty)
{
return ComputeSha256(Array.Empty<byte>());
}
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<byte[]>();
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);
}
}