321 lines
9.9 KiB
C#
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);
|
|
}
|
|
}
|