finish off sprint advisories and sprints

This commit is contained in:
master
2026-01-24 00:12:43 +02:00
parent 726d70dc7f
commit c70e83719e
266 changed files with 46699 additions and 1328 deletions

View File

@@ -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";
}

View File

@@ -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; }
}

View File

@@ -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)] + "...";
}
}

View File

@@ -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
{