340 lines
12 KiB
C#
340 lines
12 KiB
C#
using System.Text.Json;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using StellaOps.Replay.Core;
|
|
using StellaOps.Scanner.Evidence.Models;
|
|
|
|
namespace StellaOps.Scanner.Storage.Oci;
|
|
|
|
/// <summary>
|
|
/// Service for publishing FuncProof documents to OCI registries as referrer artifacts.
|
|
/// Follows the OCI referrer pattern to link FuncProof evidence to the original image.
|
|
/// </summary>
|
|
public interface IFuncProofOciPublisher
|
|
{
|
|
/// <summary>
|
|
/// Publishes a FuncProof document to an OCI registry as a referrer artifact.
|
|
/// </summary>
|
|
/// <param name="request">The publish request containing FuncProof and target details.</param>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
/// <returns>Result containing the pushed manifest digest and reference.</returns>
|
|
Task<FuncProofOciPublishResult> PublishAsync(FuncProofOciPublishRequest request, CancellationToken ct = default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Request to publish a FuncProof document to OCI registry.
|
|
/// </summary>
|
|
public sealed record FuncProofOciPublishRequest
|
|
{
|
|
/// <summary>
|
|
/// The FuncProof document to publish.
|
|
/// </summary>
|
|
public required FuncProof FuncProof { get; init; }
|
|
|
|
/// <summary>
|
|
/// Optional DSSE envelope containing the signed FuncProof.
|
|
/// If provided, this is published instead of the raw FuncProof.
|
|
/// </summary>
|
|
public DsseEnvelope? DsseEnvelope { get; init; }
|
|
|
|
/// <summary>
|
|
/// Target OCI registry reference (e.g., "registry.example.com/repo:tag").
|
|
/// </summary>
|
|
public required string RegistryReference { get; init; }
|
|
|
|
/// <summary>
|
|
/// Digest of the subject image this FuncProof refers to.
|
|
/// Used to create a referrer relationship (OCI referrer pattern).
|
|
/// </summary>
|
|
public required string SubjectDigest { get; init; }
|
|
|
|
/// <summary>
|
|
/// Optional tag for the FuncProof artifact. If null, uses the proof ID.
|
|
/// </summary>
|
|
public string? Tag { get; init; }
|
|
|
|
/// <summary>
|
|
/// Additional annotations to include in the OCI manifest.
|
|
/// </summary>
|
|
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of publishing a FuncProof document to OCI registry.
|
|
/// </summary>
|
|
public sealed record FuncProofOciPublishResult
|
|
{
|
|
public required bool Success { get; init; }
|
|
public string? ManifestDigest { get; init; }
|
|
public string? ManifestReference { get; init; }
|
|
public string? ProofLayerDigest { get; init; }
|
|
public string? Error { get; init; }
|
|
|
|
public static FuncProofOciPublishResult Failed(string error) => new()
|
|
{
|
|
Success = false,
|
|
Error = error
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Configuration options for FuncProof OCI publishing.
|
|
/// </summary>
|
|
public sealed class FuncProofOciOptions
|
|
{
|
|
public const string SectionName = "Scanner:FuncProof:Oci";
|
|
|
|
/// <summary>
|
|
/// Whether to publish FuncProof as a referrer artifact.
|
|
/// </summary>
|
|
public bool EnableReferrerPublish { get; set; } = true;
|
|
|
|
/// <summary>
|
|
/// Whether to include the DSSE envelope as a separate layer.
|
|
/// </summary>
|
|
public bool IncludeDsseLayer { get; set; } = true;
|
|
|
|
/// <summary>
|
|
/// Whether to compress the FuncProof content before publishing.
|
|
/// </summary>
|
|
public bool CompressContent { get; set; } = false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Default implementation of FuncProof OCI publisher.
|
|
/// </summary>
|
|
public sealed class FuncProofOciPublisher : IFuncProofOciPublisher
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
|
|
WriteIndented = false
|
|
};
|
|
|
|
private readonly IOciPushService _ociPushService;
|
|
private readonly IOptions<FuncProofOciOptions> _options;
|
|
private readonly ILogger<FuncProofOciPublisher> _logger;
|
|
|
|
public FuncProofOciPublisher(
|
|
IOciPushService ociPushService,
|
|
IOptions<FuncProofOciOptions> options,
|
|
ILogger<FuncProofOciPublisher> logger)
|
|
{
|
|
_ociPushService = ociPushService ?? throw new ArgumentNullException(nameof(ociPushService));
|
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
}
|
|
|
|
public async Task<FuncProofOciPublishResult> PublishAsync(
|
|
FuncProofOciPublishRequest request,
|
|
CancellationToken ct = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
ArgumentNullException.ThrowIfNull(request.FuncProof);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(request.RegistryReference);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(request.SubjectDigest);
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
if (string.IsNullOrEmpty(request.FuncProof.ProofId))
|
|
{
|
|
return FuncProofOciPublishResult.Failed("FuncProof must have a valid ProofId before publishing.");
|
|
}
|
|
|
|
_logger.LogDebug(
|
|
"Publishing FuncProof {ProofId} to OCI registry {Reference}",
|
|
request.FuncProof.ProofId,
|
|
request.RegistryReference);
|
|
|
|
try
|
|
{
|
|
var layers = BuildLayers(request);
|
|
var annotations = BuildAnnotations(request);
|
|
|
|
var pushRequest = new OciArtifactPushRequest
|
|
{
|
|
Reference = request.RegistryReference,
|
|
ArtifactType = FuncProofOciMediaTypes.ArtifactType,
|
|
Layers = layers,
|
|
SubjectDigest = request.SubjectDigest,
|
|
Annotations = annotations
|
|
};
|
|
|
|
var result = await _ociPushService.PushAsync(pushRequest, ct).ConfigureAwait(false);
|
|
|
|
if (!result.Success)
|
|
{
|
|
_logger.LogWarning(
|
|
"Failed to publish FuncProof {ProofId}: {Error}",
|
|
request.FuncProof.ProofId,
|
|
result.Error);
|
|
return FuncProofOciPublishResult.Failed(result.Error ?? "Unknown OCI push failure");
|
|
}
|
|
|
|
_logger.LogInformation(
|
|
"Published FuncProof {ProofId} to {Reference} with digest {Digest}",
|
|
request.FuncProof.ProofId,
|
|
result.ManifestReference,
|
|
result.ManifestDigest);
|
|
|
|
return new FuncProofOciPublishResult
|
|
{
|
|
Success = true,
|
|
ManifestDigest = result.ManifestDigest,
|
|
ManifestReference = result.ManifestReference,
|
|
ProofLayerDigest = result.LayerDigests?.FirstOrDefault()
|
|
};
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
_logger.LogError(ex, "Error publishing FuncProof {ProofId}", request.FuncProof.ProofId);
|
|
return FuncProofOciPublishResult.Failed($"Publish error: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private List<OciLayerContent> BuildLayers(FuncProofOciPublishRequest request)
|
|
{
|
|
var layers = new List<OciLayerContent>();
|
|
var opts = _options.Value;
|
|
|
|
// Primary FuncProof layer
|
|
byte[] proofContent;
|
|
string proofMediaType;
|
|
|
|
if (request.DsseEnvelope is not null && opts.IncludeDsseLayer)
|
|
{
|
|
// Use DSSE envelope as primary layer
|
|
proofContent = JsonSerializer.SerializeToUtf8Bytes(request.DsseEnvelope, JsonOptions);
|
|
proofMediaType = FuncProofOciMediaTypes.DsseLayer;
|
|
}
|
|
else
|
|
{
|
|
// Use raw FuncProof
|
|
proofContent = JsonSerializer.SerializeToUtf8Bytes(request.FuncProof, JsonOptions);
|
|
proofMediaType = FuncProofOciMediaTypes.ProofLayer;
|
|
}
|
|
|
|
if (opts.CompressContent)
|
|
{
|
|
proofContent = CompressGzip(proofContent);
|
|
proofMediaType += "+gzip";
|
|
}
|
|
|
|
layers.Add(new OciLayerContent
|
|
{
|
|
Content = proofContent,
|
|
MediaType = proofMediaType,
|
|
Annotations = new SortedDictionary<string, string>(StringComparer.Ordinal)
|
|
{
|
|
[OciAnnotations.Title] = $"funcproof-{request.FuncProof.ProofId}",
|
|
[FuncProofOciAnnotations.ProofId] = request.FuncProof.ProofId,
|
|
[FuncProofOciAnnotations.BuildId] = request.FuncProof.BuildId ?? string.Empty,
|
|
[FuncProofOciAnnotations.FunctionCount] = request.FuncProof.Functions.Length.ToString()
|
|
}
|
|
});
|
|
|
|
// Add raw FuncProof as secondary layer if DSSE was primary
|
|
if (request.DsseEnvelope is not null && opts.IncludeDsseLayer)
|
|
{
|
|
var rawContent = JsonSerializer.SerializeToUtf8Bytes(request.FuncProof, JsonOptions);
|
|
if (opts.CompressContent)
|
|
{
|
|
rawContent = CompressGzip(rawContent);
|
|
}
|
|
|
|
layers.Add(new OciLayerContent
|
|
{
|
|
Content = rawContent,
|
|
MediaType = opts.CompressContent
|
|
? FuncProofOciMediaTypes.ProofLayer + "+gzip"
|
|
: FuncProofOciMediaTypes.ProofLayer,
|
|
Annotations = new SortedDictionary<string, string>(StringComparer.Ordinal)
|
|
{
|
|
[OciAnnotations.Title] = $"funcproof-raw-{request.FuncProof.ProofId}"
|
|
}
|
|
});
|
|
}
|
|
|
|
return layers;
|
|
}
|
|
|
|
private SortedDictionary<string, string> BuildAnnotations(FuncProofOciPublishRequest request)
|
|
{
|
|
var annotations = new SortedDictionary<string, string>(StringComparer.Ordinal)
|
|
{
|
|
[OciAnnotations.Title] = $"FuncProof for {request.FuncProof.BuildId ?? request.FuncProof.ProofId}",
|
|
[FuncProofOciAnnotations.ProofId] = request.FuncProof.ProofId,
|
|
[FuncProofOciAnnotations.SchemaVersion] = FuncProofConstants.SchemaVersion
|
|
};
|
|
|
|
if (!string.IsNullOrEmpty(request.FuncProof.BuildId))
|
|
{
|
|
annotations[FuncProofOciAnnotations.BuildId] = request.FuncProof.BuildId;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(request.FuncProof.FileSha256))
|
|
{
|
|
annotations[FuncProofOciAnnotations.FileSha256] = request.FuncProof.FileSha256;
|
|
}
|
|
|
|
if (request.FuncProof.Meta?.BuildTime is not null)
|
|
{
|
|
annotations[OciAnnotations.Created] = request.FuncProof.Meta.BuildTime.Value.ToString("o");
|
|
}
|
|
|
|
// Merge user-provided annotations
|
|
if (request.Annotations is not null)
|
|
{
|
|
foreach (var (key, value) in request.Annotations)
|
|
{
|
|
annotations[key] = value;
|
|
}
|
|
}
|
|
|
|
return annotations;
|
|
}
|
|
|
|
private static byte[] CompressGzip(byte[] data)
|
|
{
|
|
using var output = new System.IO.MemoryStream();
|
|
using (var gzip = new System.IO.Compression.GZipStream(output, System.IO.Compression.CompressionLevel.Optimal))
|
|
{
|
|
gzip.Write(data, 0, data.Length);
|
|
}
|
|
return output.ToArray();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// OCI media types for FuncProof artifacts.
|
|
/// </summary>
|
|
public static class FuncProofOciMediaTypes
|
|
{
|
|
/// <summary>
|
|
/// Artifact type for FuncProof OCI artifacts.
|
|
/// </summary>
|
|
public const string ArtifactType = "application/vnd.stellaops.funcproof";
|
|
|
|
/// <summary>
|
|
/// Media type for the FuncProof JSON layer.
|
|
/// </summary>
|
|
public const string ProofLayer = "application/vnd.stellaops.funcproof+json";
|
|
|
|
/// <summary>
|
|
/// Media type for the DSSE envelope layer containing signed FuncProof.
|
|
/// </summary>
|
|
public const string DsseLayer = "application/vnd.stellaops.funcproof.dsse+json";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Custom OCI annotations for FuncProof artifacts.
|
|
/// </summary>
|
|
public static class FuncProofOciAnnotations
|
|
{
|
|
public const string ProofId = "io.stellaops.funcproof.id";
|
|
public const string BuildId = "io.stellaops.funcproof.build-id";
|
|
public const string FileSha256 = "io.stellaops.funcproof.file-sha256";
|
|
public const string FunctionCount = "io.stellaops.funcproof.function-count";
|
|
public const string SchemaVersion = "io.stellaops.funcproof.schema-version";
|
|
}
|