sprints and audit work
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user