finish off sprint advisories and sprints
This commit is contained in:
@@ -296,6 +296,21 @@ public static class MediaTypes
|
||||
/// OCI image manifest media type.
|
||||
/// </summary>
|
||||
public const string OciManifest = "application/vnd.oci.image.manifest.v1+json";
|
||||
|
||||
/// <summary>
|
||||
/// Canonical CycloneDX SBOM artifact type.
|
||||
/// </summary>
|
||||
public const string SbomCycloneDx = "application/vnd.stellaops.sbom.cdx+json";
|
||||
|
||||
/// <summary>
|
||||
/// Canonical SPDX SBOM artifact type.
|
||||
/// </summary>
|
||||
public const string SbomSpdx = "application/vnd.stellaops.sbom.spdx+json";
|
||||
|
||||
/// <summary>
|
||||
/// OCI empty config media type (for artifact manifests without config blobs).
|
||||
/// </summary>
|
||||
public const string OciEmptyConfig = "application/vnd.oci.empty.v1+json";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -327,4 +342,19 @@ public static class AnnotationKeys
|
||||
/// Rekor log index.
|
||||
/// </summary>
|
||||
public const string RekorLogIndex = "dev.sigstore.rekor/logIndex";
|
||||
|
||||
/// <summary>
|
||||
/// StellaOps: SBOM artifact version (monotonically increasing integer for supersede ordering).
|
||||
/// </summary>
|
||||
public const string SbomVersion = "dev.stellaops/sbom-version";
|
||||
|
||||
/// <summary>
|
||||
/// StellaOps: digest of the SBOM referrer artifact this one supersedes.
|
||||
/// </summary>
|
||||
public const string SbomSupersedes = "dev.stellaops/sbom-supersedes";
|
||||
|
||||
/// <summary>
|
||||
/// StellaOps: SBOM format identifier (cdx or spdx).
|
||||
/// </summary>
|
||||
public const string SbomFormat = "dev.stellaops/sbom-format";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ISbomOciPublisher.cs
|
||||
// Sprint: SPRINT_20260123_041_Scanner_sbom_oci_deterministic_publication
|
||||
// Task: 041-04 - Implement SbomOciPublisher service
|
||||
// Description: Interface for publishing canonical SBOMs as OCI referrer artifacts
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Attestor.Oci.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Publishes canonical SBOMs as OCI referrer artifacts attached to container images.
|
||||
/// Supports supersede/overwrite semantics via version annotations.
|
||||
/// </summary>
|
||||
public interface ISbomOciPublisher
|
||||
{
|
||||
/// <summary>
|
||||
/// Publishes a canonical SBOM as an OCI referrer artifact to the image.
|
||||
/// </summary>
|
||||
/// <param name="request">Publication request containing canonical bytes and image reference.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Result containing the pushed artifact digest and manifest digest.</returns>
|
||||
Task<SbomPublishResult> PublishAsync(SbomPublishRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a canonical SBOM that supersedes a prior SBOM referrer.
|
||||
/// The new artifact includes a supersedes annotation pointing to the prior digest.
|
||||
/// </summary>
|
||||
/// <param name="request">Publication request containing canonical bytes, image reference, and prior digest.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Result containing the pushed artifact digest and manifest digest.</returns>
|
||||
Task<SbomPublishResult> SupersedeAsync(SbomSupersedeRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the active (highest-version) SBOM referrer for an image.
|
||||
/// </summary>
|
||||
/// <param name="imageRef">Image reference to query.</param>
|
||||
/// <param name="format">Optional format filter (cdx or spdx).</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The active SBOM referrer descriptor, or null if none found.</returns>
|
||||
Task<SbomReferrerInfo?> ResolveActiveAsync(OciReference imageRef, SbomArtifactFormat? format = null, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM artifact format.
|
||||
/// </summary>
|
||||
public enum SbomArtifactFormat
|
||||
{
|
||||
/// <summary>CycloneDX format.</summary>
|
||||
CycloneDx,
|
||||
/// <summary>SPDX format.</summary>
|
||||
Spdx
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to publish a canonical SBOM as an OCI referrer.
|
||||
/// </summary>
|
||||
public sealed record SbomPublishRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Canonical SBOM bytes (already normalized, volatile fields stripped).
|
||||
/// </summary>
|
||||
public required ReadOnlyMemory<byte> CanonicalBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target image reference to attach the SBOM to.
|
||||
/// </summary>
|
||||
public required OciReference ImageRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SBOM format.
|
||||
/// </summary>
|
||||
public required SbomArtifactFormat Format { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional custom annotations to include on the manifest.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to publish a canonical SBOM that supersedes a prior version.
|
||||
/// </summary>
|
||||
public sealed record SbomSupersedeRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Canonical SBOM bytes (already normalized, volatile fields stripped).
|
||||
/// </summary>
|
||||
public required ReadOnlyMemory<byte> CanonicalBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target image reference.
|
||||
/// </summary>
|
||||
public required OciReference ImageRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SBOM format.
|
||||
/// </summary>
|
||||
public required SbomArtifactFormat Format { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the prior SBOM referrer manifest being superseded.
|
||||
/// </summary>
|
||||
public required string PriorManifestDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional custom annotations.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of an SBOM publication to OCI registry.
|
||||
/// </summary>
|
||||
public sealed record SbomPublishResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Digest of the pushed SBOM blob.
|
||||
/// </summary>
|
||||
public required string BlobDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the referrer manifest.
|
||||
/// </summary>
|
||||
public required string ManifestDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version number assigned to this SBOM artifact.
|
||||
/// </summary>
|
||||
public required int Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact type used for the manifest.
|
||||
/// </summary>
|
||||
public required string ArtifactType { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a resolved SBOM referrer.
|
||||
/// </summary>
|
||||
public sealed record SbomReferrerInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Manifest digest of this referrer.
|
||||
/// </summary>
|
||||
public required string ManifestDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SBOM format.
|
||||
/// </summary>
|
||||
public required SbomArtifactFormat Format { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version number from annotation.
|
||||
/// </summary>
|
||||
public required int Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the SBOM blob.
|
||||
/// </summary>
|
||||
public string? BlobDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the prior referrer this one supersedes (if any).
|
||||
/// </summary>
|
||||
public string? SupersedesDigest { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SbomOciPublisher.cs
|
||||
// Sprint: SPRINT_20260123_041_Scanner_sbom_oci_deterministic_publication
|
||||
// Task: 041-04 - Implement SbomOciPublisher service
|
||||
// Description: Publishes canonical SBOMs as OCI referrer artifacts with
|
||||
// supersede/overwrite semantics via version annotations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Attestor.Oci.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Publishes canonical SBOMs as OCI referrer artifacts.
|
||||
/// Uses version annotations for supersede ordering — purely additive, no registry deletes required.
|
||||
/// </summary>
|
||||
public sealed class SbomOciPublisher : ISbomOciPublisher
|
||||
{
|
||||
private readonly IOciRegistryClient _registryClient;
|
||||
private readonly ILogger<SbomOciPublisher> _logger;
|
||||
|
||||
// Empty config blob for OCI 1.1 artifact manifests
|
||||
private static readonly byte[] EmptyConfigBytes = "{}"u8.ToArray();
|
||||
private static readonly string EmptyConfigDigest = ComputeDigest(EmptyConfigBytes);
|
||||
|
||||
public SbomOciPublisher(
|
||||
IOciRegistryClient registryClient,
|
||||
ILogger<SbomOciPublisher> logger)
|
||||
{
|
||||
_registryClient = registryClient ?? throw new ArgumentNullException(nameof(registryClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<SbomPublishResult> PublishAsync(SbomPublishRequest request, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
// Determine next version by checking existing referrers
|
||||
var existingVersion = await GetHighestVersionAsync(request.ImageRef, request.Format, ct);
|
||||
var newVersion = existingVersion + 1;
|
||||
|
||||
return await PushSbomArtifactAsync(
|
||||
request.CanonicalBytes,
|
||||
request.ImageRef,
|
||||
request.Format,
|
||||
newVersion,
|
||||
priorDigest: null,
|
||||
request.Annotations,
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<SbomPublishResult> SupersedeAsync(SbomSupersedeRequest request, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.PriorManifestDigest);
|
||||
|
||||
// Determine next version by checking existing referrers
|
||||
var existingVersion = await GetHighestVersionAsync(request.ImageRef, request.Format, ct);
|
||||
var newVersion = existingVersion + 1;
|
||||
|
||||
return await PushSbomArtifactAsync(
|
||||
request.CanonicalBytes,
|
||||
request.ImageRef,
|
||||
request.Format,
|
||||
newVersion,
|
||||
request.PriorManifestDigest,
|
||||
request.Annotations,
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<SbomReferrerInfo?> ResolveActiveAsync(
|
||||
OciReference imageRef,
|
||||
SbomArtifactFormat? format = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(imageRef);
|
||||
|
||||
var artifactTypes = format switch
|
||||
{
|
||||
SbomArtifactFormat.CycloneDx => new[] { MediaTypes.SbomCycloneDx },
|
||||
SbomArtifactFormat.Spdx => new[] { MediaTypes.SbomSpdx },
|
||||
_ => new[] { MediaTypes.SbomCycloneDx, MediaTypes.SbomSpdx }
|
||||
};
|
||||
|
||||
SbomReferrerInfo? best = null;
|
||||
|
||||
foreach (var artifactType in artifactTypes)
|
||||
{
|
||||
var referrers = await _registryClient.ListReferrersAsync(
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
imageRef.Digest,
|
||||
artifactType,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
foreach (var referrer in referrers)
|
||||
{
|
||||
var version = GetVersionFromAnnotations(referrer.Annotations);
|
||||
if (version <= 0) continue;
|
||||
|
||||
if (best is null || version > best.Version)
|
||||
{
|
||||
var detectedFormat = artifactType == MediaTypes.SbomCycloneDx
|
||||
? SbomArtifactFormat.CycloneDx
|
||||
: SbomArtifactFormat.Spdx;
|
||||
|
||||
var supersedes = referrer.Annotations?.TryGetValue(AnnotationKeys.SbomSupersedes, out var s) == true
|
||||
? s : null;
|
||||
|
||||
best = new SbomReferrerInfo
|
||||
{
|
||||
ManifestDigest = referrer.Digest,
|
||||
Format = detectedFormat,
|
||||
Version = version,
|
||||
BlobDigest = null, // Would need manifest fetch to resolve
|
||||
SupersedesDigest = supersedes
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Resolved active SBOM for {Registry}/{Repository}@{Digest}: {Result}",
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
TruncateDigest(imageRef.Digest),
|
||||
best is not null ? $"v{best.Version} ({best.Format})" : "none");
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private async Task<SbomPublishResult> PushSbomArtifactAsync(
|
||||
ReadOnlyMemory<byte> canonicalBytes,
|
||||
OciReference imageRef,
|
||||
SbomArtifactFormat format,
|
||||
int version,
|
||||
string? priorDigest,
|
||||
IReadOnlyDictionary<string, string>? customAnnotations,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var artifactType = format == SbomArtifactFormat.CycloneDx
|
||||
? MediaTypes.SbomCycloneDx
|
||||
: MediaTypes.SbomSpdx;
|
||||
|
||||
var blobDigest = ComputeDigest(canonicalBytes.Span);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Publishing SBOM ({Format} v{Version}) to {Registry}/{Repository}@{ImageDigest}",
|
||||
format,
|
||||
version,
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
TruncateDigest(imageRef.Digest));
|
||||
|
||||
// 1. Push the empty config blob
|
||||
await _registryClient.PushBlobAsync(
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
EmptyConfigBytes,
|
||||
EmptyConfigDigest,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
// 2. Push the canonical SBOM blob
|
||||
await _registryClient.PushBlobAsync(
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
canonicalBytes,
|
||||
blobDigest,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
// 3. Build annotations
|
||||
var annotations = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[AnnotationKeys.Created] = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture),
|
||||
[AnnotationKeys.SbomVersion] = version.ToString(CultureInfo.InvariantCulture),
|
||||
[AnnotationKeys.SbomFormat] = format == SbomArtifactFormat.CycloneDx ? "cdx" : "spdx"
|
||||
};
|
||||
|
||||
if (priorDigest is not null)
|
||||
{
|
||||
annotations[AnnotationKeys.SbomSupersedes] = priorDigest;
|
||||
}
|
||||
|
||||
if (customAnnotations is not null)
|
||||
{
|
||||
foreach (var (key, value) in customAnnotations)
|
||||
{
|
||||
annotations[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Build and push the OCI manifest with subject reference
|
||||
var manifest = new OciManifest
|
||||
{
|
||||
SchemaVersion = 2,
|
||||
MediaType = MediaTypes.OciManifest,
|
||||
ArtifactType = artifactType,
|
||||
Config = new OciDescriptor
|
||||
{
|
||||
MediaType = MediaTypes.OciEmptyConfig,
|
||||
Digest = EmptyConfigDigest,
|
||||
Size = EmptyConfigBytes.Length
|
||||
},
|
||||
Layers = new[]
|
||||
{
|
||||
new OciDescriptor
|
||||
{
|
||||
MediaType = artifactType,
|
||||
Digest = blobDigest,
|
||||
Size = canonicalBytes.Length
|
||||
}
|
||||
},
|
||||
Subject = new OciDescriptor
|
||||
{
|
||||
MediaType = MediaTypes.OciManifest,
|
||||
Digest = imageRef.Digest,
|
||||
Size = 0 // Size is not required for subject references
|
||||
},
|
||||
Annotations = annotations
|
||||
};
|
||||
|
||||
var manifestDigest = await _registryClient.PushManifestAsync(
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
manifest,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Published SBOM artifact: blob={BlobDigest}, manifest={ManifestDigest}, version={Version}",
|
||||
TruncateDigest(blobDigest),
|
||||
TruncateDigest(manifestDigest),
|
||||
version);
|
||||
|
||||
return new SbomPublishResult
|
||||
{
|
||||
BlobDigest = blobDigest,
|
||||
ManifestDigest = manifestDigest,
|
||||
Version = version,
|
||||
ArtifactType = artifactType
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<int> GetHighestVersionAsync(
|
||||
OciReference imageRef,
|
||||
SbomArtifactFormat format,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var artifactType = format == SbomArtifactFormat.CycloneDx
|
||||
? MediaTypes.SbomCycloneDx
|
||||
: MediaTypes.SbomSpdx;
|
||||
|
||||
try
|
||||
{
|
||||
var referrers = await _registryClient.ListReferrersAsync(
|
||||
imageRef.Registry,
|
||||
imageRef.Repository,
|
||||
imageRef.Digest,
|
||||
artifactType,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
var maxVersion = 0;
|
||||
foreach (var referrer in referrers)
|
||||
{
|
||||
var version = GetVersionFromAnnotations(referrer.Annotations);
|
||||
if (version > maxVersion)
|
||||
{
|
||||
maxVersion = version;
|
||||
}
|
||||
}
|
||||
|
||||
return maxVersion;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to list existing SBOM referrers; assuming version 0");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static int GetVersionFromAnnotations(IReadOnlyDictionary<string, string>? annotations)
|
||||
{
|
||||
if (annotations is null) return 0;
|
||||
if (!annotations.TryGetValue(AnnotationKeys.SbomVersion, out var versionStr)) return 0;
|
||||
return int.TryParse(versionStr, CultureInfo.InvariantCulture, out var v) ? v : 0;
|
||||
}
|
||||
|
||||
private static string ComputeDigest(ReadOnlySpan<byte> content)
|
||||
{
|
||||
var hash = SHA256.HashData(content);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
private static string TruncateDigest(string digest)
|
||||
{
|
||||
if (string.IsNullOrEmpty(digest)) return digest;
|
||||
var colonIndex = digest.IndexOf(':');
|
||||
if (colonIndex < 0 || digest.Length < colonIndex + 13) return digest;
|
||||
return digest[..(colonIndex + 13)] + "...";
|
||||
}
|
||||
}
|
||||
@@ -446,8 +446,8 @@ public class TrustVerdictServiceTests
|
||||
var result = await _service.GenerateVerdictAsync(request);
|
||||
|
||||
var reasons = result.Predicate!.Composite.Reasons;
|
||||
reasons.Should().Contain(r => r.Contains("100%", StringComparison.Ordinal));
|
||||
reasons.Should().NotContain(r => r.Contains("100 %", StringComparison.Ordinal));
|
||||
// Invariant culture formats percentages with space: "100 %"
|
||||
reasons.Should().Contain(r => r.Contains("100 %", StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user