sprints and audit work

This commit is contained in:
StellaOps Bot
2026-01-07 09:36:16 +02:00
parent 05833e0af2
commit ab364c6032
377 changed files with 64534 additions and 1627 deletions

View File

@@ -0,0 +1,320 @@
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);
}
}

View File

@@ -0,0 +1,265 @@
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;
/// <summary>
/// Writes per-layer SBOMs in CycloneDX 1.7 format.
/// </summary>
public sealed class CycloneDxLayerWriter : ILayerSbomWriter
{
private static readonly Guid SerialNamespace = new("1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d");
/// <inheritdoc />
public string Format => "cyclonedx";
/// <inheritdoc />
public Task<LayerSbomOutput> 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<Property>
{
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<Property>
{
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<Component> BuildComponents(ImmutableArray<ComponentRecord> components)
{
var result = new List<Component>(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<Property>? BuildProperties(ComponentRecord component)
{
var properties = new List<Property>();
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<Dependency>? BuildDependencies(ImmutableArray<ComponentRecord> components)
{
var componentKeys = components.Select(static c => c.Identity.Key).ToImmutableHashSet(StringComparer.Ordinal);
var dependencies = new List<Dependency>();
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();
}
}

View File

@@ -0,0 +1,100 @@
using System.Collections.Immutable;
using StellaOps.Scanner.Core.Contracts;
namespace StellaOps.Scanner.Emit.Composition;
/// <summary>
/// Writes per-layer SBOMs in a specific format (CycloneDX or SPDX).
/// </summary>
public interface ILayerSbomWriter
{
/// <summary>
/// The SBOM format produced by this writer.
/// </summary>
string Format { get; }
/// <summary>
/// Generates an SBOM for a single layer's components.
/// </summary>
/// <param name="request">The layer SBOM request containing layer info and components.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The generated SBOM bytes and digest.</returns>
Task<LayerSbomOutput> WriteAsync(LayerSbomRequest request, CancellationToken cancellationToken = default);
}
/// <summary>
/// Request to generate a per-layer SBOM.
/// </summary>
public sealed record LayerSbomRequest
{
/// <summary>
/// The image this layer belongs to.
/// </summary>
public required ImageArtifactDescriptor Image { get; init; }
/// <summary>
/// The layer digest (e.g., "sha256:abc123...").
/// </summary>
public required string LayerDigest { get; init; }
/// <summary>
/// The order of this layer in the image (0-indexed).
/// </summary>
public required int LayerOrder { get; init; }
/// <summary>
/// Components in this layer.
/// </summary>
public required ImmutableArray<ComponentRecord> Components { get; init; }
/// <summary>
/// When the SBOM was generated.
/// </summary>
public required DateTimeOffset GeneratedAt { get; init; }
/// <summary>
/// Generator name (e.g., "StellaOps.Scanner").
/// </summary>
public string? GeneratorName { get; init; }
/// <summary>
/// Generator version.
/// </summary>
public string? GeneratorVersion { get; init; }
}
/// <summary>
/// Output from a layer SBOM writer.
/// </summary>
public sealed record LayerSbomOutput
{
/// <summary>
/// The layer digest this SBOM represents.
/// </summary>
public required string LayerDigest { get; init; }
/// <summary>
/// The SBOM format (e.g., "cyclonedx", "spdx").
/// </summary>
public required string Format { get; init; }
/// <summary>
/// SBOM JSON bytes.
/// </summary>
public required byte[] JsonBytes { get; init; }
/// <summary>
/// SHA256 digest of the JSON (lowercase hex).
/// </summary>
public required string JsonDigest { get; init; }
/// <summary>
/// Media type of the JSON content.
/// </summary>
public required string MediaType { get; init; }
/// <summary>
/// Number of components in this layer SBOM.
/// </summary>
public required int ComponentCount { get; init; }
}

View File

@@ -0,0 +1,197 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Core.Utility;
namespace StellaOps.Scanner.Emit.Composition;
/// <summary>
/// Composes per-layer SBOMs for all layers in an image.
/// </summary>
public interface ILayerSbomComposer
{
/// <summary>
/// Generates per-layer SBOMs for all layers in the composition request.
/// </summary>
/// <param name="request">The composition request containing layer fragments.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Layer SBOM artifacts and references.</returns>
Task<LayerSbomCompositionResult> ComposeAsync(
SbomCompositionRequest request,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of per-layer SBOM composition.
/// </summary>
public sealed record LayerSbomCompositionResult
{
/// <summary>
/// Per-layer SBOM artifacts (bytes and digests).
/// </summary>
public required ImmutableArray<LayerSbomArtifact> Artifacts { get; init; }
/// <summary>
/// Per-layer SBOM references for storage in CAS.
/// </summary>
public required ImmutableArray<LayerSbomRef> References { get; init; }
/// <summary>
/// Merkle root computed from all layer SBOM digests (CycloneDX).
/// </summary>
public required string MerkleRoot { get; init; }
}
/// <summary>
/// Default implementation of <see cref="ILayerSbomComposer"/>.
/// </summary>
public sealed class LayerSbomComposer : ILayerSbomComposer
{
private readonly CycloneDxLayerWriter _cdxWriter = new();
private readonly SpdxLayerWriter _spdxWriter;
public LayerSbomComposer(SpdxLayerWriter? spdxWriter = null)
{
_spdxWriter = spdxWriter ?? new SpdxLayerWriter();
}
/// <inheritdoc />
public async Task<LayerSbomCompositionResult> ComposeAsync(
SbomCompositionRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
if (request.LayerFragments.IsDefaultOrEmpty)
{
return new LayerSbomCompositionResult
{
Artifacts = ImmutableArray<LayerSbomArtifact>.Empty,
References = ImmutableArray<LayerSbomRef>.Empty,
MerkleRoot = ComputeSha256(Array.Empty<byte>()),
};
}
var generatedAt = ScannerTimestamps.Normalize(request.GeneratedAt);
var artifacts = ImmutableArray.CreateBuilder<LayerSbomArtifact>(request.LayerFragments.Length);
var references = ImmutableArray.CreateBuilder<LayerSbomRef>(request.LayerFragments.Length);
var merkleLeaves = new List<byte[]>();
for (var order = 0; order < request.LayerFragments.Length; order++)
{
var fragment = request.LayerFragments[order];
var layerRequest = new LayerSbomRequest
{
Image = request.Image,
LayerDigest = fragment.LayerDigest,
LayerOrder = order,
Components = fragment.Components,
GeneratedAt = generatedAt,
GeneratorName = request.GeneratorName,
GeneratorVersion = request.GeneratorVersion,
};
var cdxOutput = await _cdxWriter.WriteAsync(layerRequest, cancellationToken).ConfigureAwait(false);
var spdxOutput = await _spdxWriter.WriteAsync(layerRequest, cancellationToken).ConfigureAwait(false);
var fragmentDigest = ComputeFragmentDigest(fragment);
var artifact = new LayerSbomArtifact
{
LayerDigest = fragment.LayerDigest,
CycloneDxJsonBytes = cdxOutput.JsonBytes,
CycloneDxDigest = cdxOutput.JsonDigest,
SpdxJsonBytes = spdxOutput.JsonBytes,
SpdxDigest = spdxOutput.JsonDigest,
ComponentCount = fragment.Components.Length,
};
var reference = new LayerSbomRef
{
LayerDigest = fragment.LayerDigest,
Order = order,
FragmentDigest = fragmentDigest,
CycloneDxDigest = cdxOutput.JsonDigest,
CycloneDxCasUri = $"cas://sbom/layers/{request.Image.ImageDigest}/{fragment.LayerDigest}.cdx.json",
SpdxDigest = spdxOutput.JsonDigest,
SpdxCasUri = $"cas://sbom/layers/{request.Image.ImageDigest}/{fragment.LayerDigest}.spdx.json",
ComponentCount = fragment.Components.Length,
};
artifacts.Add(artifact);
references.Add(reference);
merkleLeaves.Add(HexToBytes(cdxOutput.JsonDigest));
}
var merkleRoot = ComputeMerkleRoot(merkleLeaves);
return new LayerSbomCompositionResult
{
Artifacts = artifacts.ToImmutable(),
References = references.ToImmutable(),
MerkleRoot = merkleRoot,
};
}
private static string ComputeFragmentDigest(LayerComponentFragment fragment)
{
var componentKeys = fragment.Components
.Select(c => c.Identity.Key)
.OrderBy(k => k, StringComparer.Ordinal)
.ToArray();
var payload = $"{fragment.LayerDigest}|{string.Join(",", componentKeys)}";
return ComputeSha256(Encoding.UTF8.GetBytes(payload));
}
private static string ComputeMerkleRoot(List<byte[]> leaves)
{
if (leaves.Count == 0)
{
return ComputeSha256(Array.Empty<byte>());
}
if (leaves.Count == 1)
{
return Convert.ToHexString(leaves[0]).ToLowerInvariant();
}
var nodes = leaves.ToList();
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);
}
}

View File

@@ -0,0 +1,112 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Emit.Composition;
/// <summary>
/// Reference to a per-layer SBOM stored in CAS.
/// </summary>
public sealed record LayerSbomRef
{
/// <summary>
/// The digest of the layer (e.g., "sha256:abc123...").
/// </summary>
[JsonPropertyName("layerDigest")]
public required string LayerDigest { get; init; }
/// <summary>
/// The order of the layer in the image (0-indexed).
/// </summary>
[JsonPropertyName("order")]
public required int Order { get; init; }
/// <summary>
/// SHA256 digest of the layer fragment (component list).
/// </summary>
[JsonPropertyName("fragmentDigest")]
public required string FragmentDigest { get; init; }
/// <summary>
/// SHA256 digest of the CycloneDX SBOM for this layer.
/// </summary>
[JsonPropertyName("cycloneDxDigest")]
public required string CycloneDxDigest { get; init; }
/// <summary>
/// CAS URI of the CycloneDX SBOM.
/// </summary>
[JsonPropertyName("cycloneDxCasUri")]
public required string CycloneDxCasUri { get; init; }
/// <summary>
/// SHA256 digest of the SPDX SBOM for this layer.
/// </summary>
[JsonPropertyName("spdxDigest")]
public required string SpdxDigest { get; init; }
/// <summary>
/// CAS URI of the SPDX SBOM.
/// </summary>
[JsonPropertyName("spdxCasUri")]
public required string SpdxCasUri { get; init; }
/// <summary>
/// Number of components in this layer.
/// </summary>
[JsonPropertyName("componentCount")]
public required int ComponentCount { get; init; }
}
/// <summary>
/// Result of generating per-layer SBOMs.
/// </summary>
public sealed record LayerSbomResult
{
/// <summary>
/// References to all per-layer SBOMs, ordered by layer order.
/// </summary>
[JsonPropertyName("layerSboms")]
public required ImmutableArray<LayerSbomRef> LayerSboms { get; init; }
/// <summary>
/// Merkle root computed from all layer SBOM digests.
/// </summary>
[JsonPropertyName("merkleRoot")]
public required string MerkleRoot { get; init; }
}
/// <summary>
/// Artifact bytes for a single layer's SBOM.
/// </summary>
public sealed record LayerSbomArtifact
{
/// <summary>
/// The layer digest this SBOM represents.
/// </summary>
public required string LayerDigest { get; init; }
/// <summary>
/// CycloneDX JSON bytes.
/// </summary>
public required byte[] CycloneDxJsonBytes { get; init; }
/// <summary>
/// SHA256 of CycloneDX JSON.
/// </summary>
public required string CycloneDxDigest { get; init; }
/// <summary>
/// SPDX JSON bytes.
/// </summary>
public required byte[] SpdxJsonBytes { get; init; }
/// <summary>
/// SHA256 of SPDX JSON.
/// </summary>
public required string SpdxDigest { get; init; }
/// <summary>
/// Number of components in this layer.
/// </summary>
public required int ComponentCount { get; init; }
}

View File

@@ -90,4 +90,19 @@ public sealed record SbomCompositionResult
/// SHA256 hex of the composition recipe JSON.
/// </summary>
public required string CompositionRecipeSha256 { get; init; }
/// <summary>
/// Per-layer SBOM references. Each layer has CycloneDX and SPDX SBOMs.
/// </summary>
public ImmutableArray<LayerSbomRef> LayerSboms { get; init; } = ImmutableArray<LayerSbomRef>.Empty;
/// <summary>
/// Per-layer SBOM artifacts (bytes). Only populated when layer SBOM generation is enabled.
/// </summary>
public ImmutableArray<LayerSbomArtifact> LayerSbomArtifacts { get; init; } = ImmutableArray<LayerSbomArtifact>.Empty;
/// <summary>
/// Merkle root computed from per-layer SBOM digests.
/// </summary>
public string? LayerSbomMerkleRoot { get; init; }
}

View File

@@ -0,0 +1,335 @@
using System.Collections.Immutable;
using System.Globalization;
using StellaOps.Canonical.Json;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Core.Utility;
using StellaOps.Scanner.Emit.Spdx;
using StellaOps.Scanner.Emit.Spdx.Models;
using StellaOps.Scanner.Emit.Spdx.Serialization;
namespace StellaOps.Scanner.Emit.Composition;
/// <summary>
/// Writes per-layer SBOMs in SPDX 3.0.1 format.
/// </summary>
public sealed class SpdxLayerWriter : ILayerSbomWriter
{
private const string JsonMediaType = "application/spdx+json; version=3.0.1";
private readonly SpdxLicenseList _licenseList;
private readonly string _namespaceBase;
private readonly string? _creatorOrganization;
public SpdxLayerWriter(
SpdxLicenseListVersion licenseListVersion = SpdxLicenseListVersion.V3_21,
string namespaceBase = "https://stellaops.io/spdx",
string? creatorOrganization = null)
{
_licenseList = SpdxLicenseListProvider.Get(licenseListVersion);
_namespaceBase = namespaceBase;
_creatorOrganization = creatorOrganization;
}
/// <inheritdoc />
public string Format => "spdx";
/// <inheritdoc />
public Task<LayerSbomOutput> WriteAsync(LayerSbomRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var generatedAt = ScannerTimestamps.Normalize(request.GeneratedAt);
var document = BuildLayerDocument(request, generatedAt);
var jsonBytes = SpdxJsonLdSerializer.Serialize(document);
var jsonDigest = CanonJson.Sha256Hex(jsonBytes);
var output = new LayerSbomOutput
{
LayerDigest = request.LayerDigest,
Format = Format,
JsonBytes = jsonBytes,
JsonDigest = jsonDigest,
MediaType = JsonMediaType,
ComponentCount = request.Components.Length,
};
return Task.FromResult(output);
}
private SpdxDocument BuildLayerDocument(LayerSbomRequest request, DateTimeOffset generatedAt)
{
var layerDigestShort = request.LayerDigest.Split(':', 2, StringSplitOptions.TrimEntries)[^1];
var idBuilder = new SpdxIdBuilder(_namespaceBase, $"layer:{request.LayerDigest}");
var creationInfo = BuildCreationInfo(request, generatedAt);
var packages = new List<SpdxPackage>();
var packageIdMap = new Dictionary<string, string>(StringComparer.Ordinal);
var layerPackage = BuildLayerPackage(request, idBuilder, layerDigestShort);
packages.Add(layerPackage);
foreach (var component in request.Components.OrderBy(static c => c.Identity.Key, StringComparer.Ordinal))
{
var package = BuildComponentPackage(component, idBuilder);
packages.Add(package);
packageIdMap[component.Identity.Key] = package.SpdxId;
}
var relationships = BuildRelationships(idBuilder, request.Components, layerPackage, packageIdMap);
var rootElementIds = packages
.Select(static pkg => pkg.SpdxId)
.OrderBy(id => id, StringComparer.Ordinal)
.ToImmutableArray();
var sbom = new SpdxSbom
{
SpdxId = idBuilder.SbomId,
Name = "layer-sbom",
RootElements = new[] { layerPackage.SpdxId }.ToImmutableArray(),
Elements = rootElementIds,
SbomTypes = new[] { "build" }.ToImmutableArray()
};
return new SpdxDocument
{
DocumentNamespace = idBuilder.DocumentNamespace,
Name = $"SBOM for layer {request.LayerOrder} ({layerDigestShort[..12]}...)",
CreationInfo = creationInfo,
Sbom = sbom,
Elements = packages.Cast<SpdxElement>().ToImmutableArray(),
Relationships = relationships,
ProfileConformance = ImmutableArray.Create("core", "software")
};
}
private SpdxCreationInfo BuildCreationInfo(LayerSbomRequest request, DateTimeOffset generatedAt)
{
var creators = ImmutableArray.CreateBuilder<string>();
var toolName = !string.IsNullOrWhiteSpace(request.GeneratorName)
? request.GeneratorName!.Trim()
: "StellaOps-Scanner";
if (!string.IsNullOrWhiteSpace(toolName))
{
var toolLabel = !string.IsNullOrWhiteSpace(request.GeneratorVersion)
? $"{toolName}-{request.GeneratorVersion!.Trim()}"
: toolName;
creators.Add($"Tool: {toolLabel}");
}
if (!string.IsNullOrWhiteSpace(_creatorOrganization))
{
creators.Add($"Organization: {_creatorOrganization!.Trim()}");
}
return new SpdxCreationInfo
{
Created = generatedAt,
Creators = creators.ToImmutable(),
SpecVersion = SpdxDefaults.SpecVersion
};
}
private static SpdxPackage BuildLayerPackage(LayerSbomRequest request, SpdxIdBuilder idBuilder, string layerDigestShort)
{
var digestParts = request.LayerDigest.Split(':', 2, StringSplitOptions.TrimEntries);
var algorithm = digestParts.Length == 2 ? digestParts[0].ToUpperInvariant() : "SHA256";
var digestValue = digestParts.Length == 2 ? digestParts[1] : request.LayerDigest;
var checksums = ImmutableArray.Create(new SpdxChecksum
{
Algorithm = algorithm,
Value = digestValue
});
return new SpdxPackage
{
SpdxId = idBuilder.CreatePackageId($"layer:{request.LayerDigest}"),
Name = $"layer-{request.LayerOrder}",
Version = layerDigestShort,
DownloadLocation = "NOASSERTION",
PrimaryPurpose = "container",
Checksums = checksums,
Comment = $"Container layer {request.LayerOrder} from image {request.Image.ImageDigest}"
};
}
private SpdxPackage BuildComponentPackage(ComponentRecord component, SpdxIdBuilder idBuilder)
{
var packageUrl = !string.IsNullOrWhiteSpace(component.Identity.Purl)
? component.Identity.Purl
: (component.Identity.Key.StartsWith("pkg:", StringComparison.Ordinal) ? component.Identity.Key : null);
var declared = BuildLicenseExpression(component.Metadata?.Licenses);
return new SpdxPackage
{
SpdxId = idBuilder.CreatePackageId(component.Identity.Key),
Name = component.Identity.Name,
Version = component.Identity.Version,
PackageUrl = packageUrl,
DownloadLocation = "NOASSERTION",
PrimaryPurpose = MapPrimaryPurpose(component.Identity.ComponentType),
DeclaredLicense = declared
};
}
private SpdxLicenseExpression? BuildLicenseExpression(IReadOnlyList<string>? licenses)
{
if (licenses is null || licenses.Count == 0)
{
return null;
}
var expressions = new List<SpdxLicenseExpression>();
foreach (var license in licenses)
{
if (string.IsNullOrWhiteSpace(license))
{
continue;
}
if (SpdxLicenseExpressionParser.TryParse(license, out var parsed, _licenseList))
{
expressions.Add(parsed!);
continue;
}
expressions.Add(new SpdxSimpleLicense(ToLicenseRef(license)));
}
if (expressions.Count == 0)
{
return null;
}
var current = expressions[0];
for (var i = 1; i < expressions.Count; i++)
{
current = new SpdxDisjunctiveLicense(current, expressions[i]);
}
return current;
}
private static string ToLicenseRef(string license)
{
var normalized = new string(license
.Trim()
.Select(ch => char.IsLetterOrDigit(ch) || ch == '.' || ch == '-' ? ch : '-')
.ToArray());
if (normalized.StartsWith("LicenseRef-", StringComparison.Ordinal))
{
return normalized;
}
return $"LicenseRef-{normalized}";
}
private static ImmutableArray<SpdxRelationship> BuildRelationships(
SpdxIdBuilder idBuilder,
ImmutableArray<ComponentRecord> components,
SpdxPackage layerPackage,
IReadOnlyDictionary<string, string> packageIdMap)
{
var relationships = new List<SpdxRelationship>();
var documentId = idBuilder.DocumentNamespace;
relationships.Add(new SpdxRelationship
{
SpdxId = idBuilder.CreateRelationshipId(documentId, "describes", layerPackage.SpdxId),
FromElement = documentId,
Type = SpdxRelationshipType.Describes,
ToElements = ImmutableArray.Create(layerPackage.SpdxId)
});
var dependencyTargets = new HashSet<string>(StringComparer.Ordinal);
foreach (var component in components)
{
foreach (var dependencyKey in component.Dependencies)
{
if (packageIdMap.ContainsKey(dependencyKey))
{
dependencyTargets.Add(dependencyKey);
}
}
}
var rootDependencies = components
.Where(component => !dependencyTargets.Contains(component.Identity.Key))
.OrderBy(component => component.Identity.Key, StringComparer.Ordinal)
.ToArray();
foreach (var component in rootDependencies)
{
if (!packageIdMap.TryGetValue(component.Identity.Key, out var targetId))
{
continue;
}
relationships.Add(new SpdxRelationship
{
SpdxId = idBuilder.CreateRelationshipId(layerPackage.SpdxId, "dependsOn", targetId),
FromElement = layerPackage.SpdxId,
Type = SpdxRelationshipType.DependsOn,
ToElements = ImmutableArray.Create(targetId)
});
}
foreach (var component in components.OrderBy(c => c.Identity.Key, StringComparer.Ordinal))
{
if (!packageIdMap.TryGetValue(component.Identity.Key, out var fromId))
{
continue;
}
var deps = component.Dependencies
.Where(packageIdMap.ContainsKey)
.OrderBy(key => key, StringComparer.Ordinal)
.ToArray();
foreach (var depKey in deps)
{
var toId = packageIdMap[depKey];
relationships.Add(new SpdxRelationship
{
SpdxId = idBuilder.CreateRelationshipId(fromId, "dependsOn", toId),
FromElement = fromId,
Type = SpdxRelationshipType.DependsOn,
ToElements = ImmutableArray.Create(toId)
});
}
}
return relationships
.OrderBy(rel => rel.FromElement, StringComparer.Ordinal)
.ThenBy(rel => rel.Type)
.ThenBy(rel => rel.ToElements.FirstOrDefault() ?? string.Empty, StringComparer.Ordinal)
.ToImmutableArray();
}
private static string? MapPrimaryPurpose(string? type)
{
if (string.IsNullOrWhiteSpace(type))
{
return "library";
}
return type.Trim().ToLowerInvariant() switch
{
"application" => "application",
"framework" => "framework",
"container" => "container",
"operating-system" or "os" => "operatingSystem",
"device" => "device",
"firmware" => "firmware",
"file" => "file",
_ => "library"
};
}
}