save development progress
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
// ----------------------------------------------------------------------------
|
||||
// Copyright (c) 2025 StellaOps contributors. All rights reserved.
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Provcache;
|
||||
using StellaOps.Provcache.Oci;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.Provcache;
|
||||
|
||||
/// <summary>
|
||||
/// Integrates Provcache OCI attestations with the ExportCenter OCI push workflow.
|
||||
/// Enables automatic attachment of DecisionDigest attestations to container images.
|
||||
/// </summary>
|
||||
public sealed class ProvcacheOciExporter : IProvcacheOciExporter
|
||||
{
|
||||
private readonly IProvcacheOciAttestationBuilder _attestationBuilder;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ProvcacheOciExporter"/> class.
|
||||
/// </summary>
|
||||
public ProvcacheOciExporter(
|
||||
IProvcacheOciAttestationBuilder attestationBuilder,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_attestationBuilder = attestationBuilder ?? throw new ArgumentNullException(nameof(attestationBuilder));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ProvcacheExportLayerContent CreateAttestationLayer(ProvcacheExportRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ValidateRequest(request);
|
||||
|
||||
// Build the attestation
|
||||
var attestationRequest = new ProvcacheOciAttestationRequest
|
||||
{
|
||||
ArtifactReference = request.ArtifactReference,
|
||||
ArtifactDigest = request.ArtifactDigest,
|
||||
DecisionDigest = request.DecisionDigest,
|
||||
InputManifest = request.InputManifest,
|
||||
VerdictSummary = request.VerdictSummary,
|
||||
TenantId = request.TenantId,
|
||||
Scope = request.Scope
|
||||
};
|
||||
|
||||
var attachment = _attestationBuilder.CreateAttachment(attestationRequest);
|
||||
|
||||
return new ProvcacheExportLayerContent(
|
||||
MediaType: ProvcachePredicateTypes.MediaType,
|
||||
Content: attachment.PayloadBytes,
|
||||
ContentJson: attachment.Payload,
|
||||
Digest: ComputeDigest(attachment.PayloadBytes),
|
||||
Annotations: attachment.Annotations);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ProvcacheExportResult BuildExport(ProvcacheExportRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var layer = CreateAttestationLayer(request);
|
||||
var manifest = BuildManifest(request, layer);
|
||||
|
||||
return new ProvcacheExportResult(
|
||||
Layer: layer,
|
||||
Manifest: manifest,
|
||||
ArtifactReference: request.ArtifactReference,
|
||||
SubjectDigest: request.ArtifactDigest,
|
||||
CreatedAt: _timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool ShouldAttachAttestation(ProvcacheAttachmentPolicy policy, DecisionDigest digest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
ArgumentNullException.ThrowIfNull(digest);
|
||||
|
||||
// Check if attestations are enabled
|
||||
if (!policy.Enabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check trust score threshold
|
||||
if (digest.TrustScore < policy.MinimumTrustScore)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the digest is expired
|
||||
if (digest.ExpiresAt <= _timeProvider.GetUtcNow())
|
||||
{
|
||||
return policy.AttachExpired;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void ValidateRequest(ProvcacheExportRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.ArtifactReference))
|
||||
{
|
||||
throw new ArgumentException("Artifact reference is required.", nameof(request));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ArtifactDigest))
|
||||
{
|
||||
throw new ArgumentException("Artifact digest is required.", nameof(request));
|
||||
}
|
||||
|
||||
if (request.DecisionDigest is null)
|
||||
{
|
||||
throw new ArgumentException("DecisionDigest is required.", nameof(request));
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeDigest(byte[] content)
|
||||
{
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(content);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
private ProvcacheExportManifest BuildManifest(
|
||||
ProvcacheExportRequest request,
|
||||
ProvcacheExportLayerContent layer)
|
||||
{
|
||||
var annotations = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["org.opencontainers.image.created"] = _timeProvider.GetUtcNow().ToString("O"),
|
||||
["org.opencontainers.image.title"] = "stellaops.provcache.decision",
|
||||
["org.opencontainers.image.description"] = "Provcache decision attestation",
|
||||
["stellaops.provcache.verikey"] = request.DecisionDigest.VeriKey,
|
||||
["stellaops.provcache.trust-score"] = request.DecisionDigest.TrustScore.ToString()
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.TenantId))
|
||||
{
|
||||
annotations["stellaops.tenant"] = request.TenantId;
|
||||
}
|
||||
|
||||
return new ProvcacheExportManifest(
|
||||
SchemaVersion: 2,
|
||||
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||
ArtifactType: ProvcachePredicateTypes.MediaType,
|
||||
SubjectDigest: request.ArtifactDigest,
|
||||
LayerDigest: layer.Digest,
|
||||
LayerMediaType: layer.MediaType,
|
||||
LayerSize: layer.Content.Length,
|
||||
Annotations: annotations);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for exporting Provcache OCI attestations.
|
||||
/// </summary>
|
||||
public interface IProvcacheOciExporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an attestation layer content for pushing to OCI registry.
|
||||
/// </summary>
|
||||
ProvcacheExportLayerContent CreateAttestationLayer(ProvcacheExportRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Builds a complete export result with manifest and layer.
|
||||
/// </summary>
|
||||
ProvcacheExportResult BuildExport(ProvcacheExportRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Determines if an attestation should be attached based on policy.
|
||||
/// </summary>
|
||||
bool ShouldAttachAttestation(ProvcacheAttachmentPolicy policy, DecisionDigest digest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for exporting a Provcache attestation.
|
||||
/// </summary>
|
||||
public sealed record ProvcacheExportRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// OCI artifact reference to attach the attestation to.
|
||||
/// </summary>
|
||||
public required string ArtifactReference { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the artifact.
|
||||
/// </summary>
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The DecisionDigest to export.
|
||||
/// </summary>
|
||||
public required DecisionDigest DecisionDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional: Full InputManifest for detailed provenance.
|
||||
/// </summary>
|
||||
public InputManifest? InputManifest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional: Summary of verdicts.
|
||||
/// </summary>
|
||||
public ProvcacheVerdictSummary? VerdictSummary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional: Tenant identifier.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional: Scope identifier.
|
||||
/// </summary>
|
||||
public string? Scope { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Layer content for OCI push.
|
||||
/// </summary>
|
||||
public sealed record ProvcacheExportLayerContent(
|
||||
string MediaType,
|
||||
byte[] Content,
|
||||
string ContentJson,
|
||||
string Digest,
|
||||
IReadOnlyDictionary<string, string> Annotations);
|
||||
|
||||
/// <summary>
|
||||
/// Complete export result for OCI push.
|
||||
/// </summary>
|
||||
public sealed record ProvcacheExportResult(
|
||||
ProvcacheExportLayerContent Layer,
|
||||
ProvcacheExportManifest Manifest,
|
||||
string ArtifactReference,
|
||||
string SubjectDigest,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// OCI manifest for the attestation artifact.
|
||||
/// </summary>
|
||||
public sealed record ProvcacheExportManifest(
|
||||
int SchemaVersion,
|
||||
string MediaType,
|
||||
string ArtifactType,
|
||||
string SubjectDigest,
|
||||
string LayerDigest,
|
||||
string LayerMediaType,
|
||||
long LayerSize,
|
||||
IReadOnlyDictionary<string, string> Annotations);
|
||||
|
||||
/// <summary>
|
||||
/// Policy for automatic attestation attachment.
|
||||
/// </summary>
|
||||
public sealed record ProvcacheAttachmentPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether automatic attestation is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum trust score required for attestation attachment.
|
||||
/// </summary>
|
||||
public int MinimumTrustScore { get; init; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to attach attestations for expired decisions.
|
||||
/// </summary>
|
||||
public bool AttachExpired { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Registry patterns to include (glob patterns).
|
||||
/// Empty means all registries.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> IncludeRegistries { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Registry patterns to exclude (glob patterns).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ExcludeRegistries { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Creates a default policy with attestations enabled.
|
||||
/// </summary>
|
||||
public static ProvcacheAttachmentPolicy Default => new();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a disabled policy.
|
||||
/// </summary>
|
||||
public static ProvcacheAttachmentPolicy Disabled => new() { Enabled = false };
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// ----------------------------------------------------------------------------
|
||||
// Copyright (c) 2025 StellaOps contributors. All rights reserved.
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.Provcache;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for Provcache OCI attestation features.
|
||||
/// </summary>
|
||||
public sealed class ProvcacheOciOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Provcache:Oci";
|
||||
|
||||
/// <summary>
|
||||
/// Whether OCI attestation features are enabled.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to automatically attach attestations to pushed images.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
public bool AutoAttach { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum trust score required for automatic attestation attachment.
|
||||
/// Default: 0 (attach all decisions).
|
||||
/// </summary>
|
||||
public int MinimumTrustScore { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to attach attestations for expired decisions.
|
||||
/// Default: false.
|
||||
/// </summary>
|
||||
public bool AttachExpiredDecisions { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to sign attestations with DSSE.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
public bool SignAttestations { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Key identifier for signing attestations.
|
||||
/// If null, uses the default signing key.
|
||||
/// </summary>
|
||||
public string? SigningKeyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include the full InputManifest in attestations.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
public bool IncludeInputManifest { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include verdict summary in attestations.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
public bool IncludeVerdictSummary { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Registry patterns to include for auto-attach (glob patterns).
|
||||
/// Empty means all registries.
|
||||
/// Example: ["ghcr.io/*", "docker.io/stellaops/*"]
|
||||
/// </summary>
|
||||
public string[] IncludeRegistries { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Registry patterns to exclude from auto-attach (glob patterns).
|
||||
/// Example: ["registry.internal/*"]
|
||||
/// </summary>
|
||||
public string[] ExcludeRegistries { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for OCI push operations in seconds.
|
||||
/// Default: 60.
|
||||
/// </summary>
|
||||
public int PushTimeoutSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Number of retries for failed push operations.
|
||||
/// Default: 3.
|
||||
/// </summary>
|
||||
public int PushRetryCount { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Delay between retries in milliseconds.
|
||||
/// Default: 1000.
|
||||
/// </summary>
|
||||
public int PushRetryDelayMs { get; set; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to fail the overall operation if attestation push fails.
|
||||
/// Default: false (attestation push failures are logged but don't block).
|
||||
/// </summary>
|
||||
public bool FailOnAttestationError { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Additional annotations to add to all attestations.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> CustomAnnotations { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Converts this options instance to an attachment policy.
|
||||
/// </summary>
|
||||
public ProvcacheAttachmentPolicy ToAttachmentPolicy() => new()
|
||||
{
|
||||
Enabled = Enabled && AutoAttach,
|
||||
MinimumTrustScore = MinimumTrustScore,
|
||||
AttachExpired = AttachExpiredDecisions,
|
||||
IncludeRegistries = IncludeRegistries,
|
||||
ExcludeRegistries = ExcludeRegistries
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates <see cref="ProvcacheOciOptions"/>.
|
||||
/// </summary>
|
||||
public static class ProvcacheOciOptionsValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates the options and returns any validation errors.
|
||||
/// </summary>
|
||||
public static IEnumerable<string> Validate(ProvcacheOciOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
if (options.MinimumTrustScore is < 0 or > 100)
|
||||
{
|
||||
yield return "MinimumTrustScore must be between 0 and 100.";
|
||||
}
|
||||
|
||||
if (options.PushTimeoutSeconds <= 0)
|
||||
{
|
||||
yield return "PushTimeoutSeconds must be greater than 0.";
|
||||
}
|
||||
|
||||
if (options.PushRetryCount < 0)
|
||||
{
|
||||
yield return "PushRetryCount must be non-negative.";
|
||||
}
|
||||
|
||||
if (options.PushRetryDelayMs < 0)
|
||||
{
|
||||
yield return "PushRetryDelayMs must be non-negative.";
|
||||
}
|
||||
|
||||
// Validate registry patterns are valid globs
|
||||
foreach (var pattern in options.IncludeRegistries)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pattern))
|
||||
{
|
||||
yield return "IncludeRegistries contains an empty pattern.";
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var pattern in options.ExcludeRegistries)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pattern))
|
||||
{
|
||||
yield return "ExcludeRegistries contains an empty pattern.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Throws if the options are invalid.
|
||||
/// </summary>
|
||||
public static void ValidateAndThrow(ProvcacheOciOptions options)
|
||||
{
|
||||
var errors = Validate(options).ToList();
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Invalid ProvcacheOciOptions: {string.Join("; ", errors)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user