// ---------------------------------------------------------------------------- // Copyright (c) 2025 StellaOps contributors. All rights reserved. // SPDX-License-Identifier: AGPL-3.0-or-later // ---------------------------------------------------------------------------- using System.Text; using System.Text.Json; using System.Text.Json.Serialization; namespace StellaOps.Provcache.Oci; /// /// Builds OCI attestations for Provcache DecisionDigest objects. /// The attestation follows the in-toto Statement format with a custom predicate type. /// public sealed class ProvcacheOciAttestationBuilder : IProvcacheOciAttestationBuilder { private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, WriteIndented = false // Deterministic output }; private readonly TimeProvider _timeProvider; /// /// Initializes a new instance of the class. /// public ProvcacheOciAttestationBuilder(TimeProvider? timeProvider = null) { _timeProvider = timeProvider ?? TimeProvider.System; } /// public ProvcacheOciAttestationResult Build(ProvcacheOciAttestationRequest request) { ArgumentNullException.ThrowIfNull(request); ValidateRequest(request); // Build subject from artifact reference var subject = BuildSubject(request.ArtifactReference, request.ArtifactDigest); // Build predicate from DecisionDigest var predicate = BuildPredicate(request); // Build the in-toto statement var statement = new ProvcacheStatement { Subject = [subject], Predicate = predicate }; // Serialize to canonical JSON (deterministic) var statementJson = JsonSerializer.Serialize(statement, SerializerOptions); var statementBytes = Encoding.UTF8.GetBytes(statementJson); // Build OCI annotations var annotations = BuildAnnotations(request, predicate); return new ProvcacheOciAttestationResult( Statement: statement, StatementJson: statementJson, StatementBytes: statementBytes, MediaType: ProvcachePredicateTypes.MediaType, Annotations: annotations); } /// public ProvcacheOciAttachment CreateAttachment(ProvcacheOciAttestationRequest request) { var result = Build(request); return new ProvcacheOciAttachment( ArtifactReference: request.ArtifactReference, MediaType: result.MediaType, Payload: result.StatementJson, PayloadBytes: result.StatementBytes, Annotations: result.Annotations); } private static void ValidateRequest(ProvcacheOciAttestationRequest 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)); } if (string.IsNullOrWhiteSpace(request.DecisionDigest.VeriKey)) { throw new ArgumentException("DecisionDigest.VeriKey is required.", nameof(request)); } } private static ProvcacheSubject BuildSubject(string artifactReference, string artifactDigest) { // Parse digest format: "sha256:abc123..." or just the hash var (algorithm, hash) = ParseDigest(artifactDigest); // Extract name from reference (remove tag/digest suffix) var name = ExtractArtifactName(artifactReference); return new ProvcacheSubject { Name = name, Digest = new Dictionary(StringComparer.Ordinal) { [algorithm] = hash } }; } private static (string algorithm, string hash) ParseDigest(string digest) { if (string.IsNullOrWhiteSpace(digest)) { throw new ArgumentException("Digest cannot be empty.", nameof(digest)); } // Handle "sha256:abc123..." format var colonIndex = digest.IndexOf(':'); if (colonIndex > 0) { return (digest[..colonIndex], digest[(colonIndex + 1)..]); } // Assume SHA256 if no algorithm prefix return ("sha256", digest); } private static string ExtractArtifactName(string reference) { // Remove @sha256:... digest suffix var atIndex = reference.LastIndexOf('@'); if (atIndex > 0) { return reference[..atIndex]; } // Remove :tag suffix, but be careful with port numbers // e.g., ghcr.io:443/org/repo:tag -> ghcr.io:443/org/repo var colonIndex = reference.LastIndexOf(':'); if (colonIndex > 0) { // Check if this is a port number (no slash after colon) var slashIndex = reference.LastIndexOf('/'); if (slashIndex > colonIndex) { // The colon is before the last slash, so it's a port number return reference; } return reference[..colonIndex]; } return reference; } private ProvcachePredicate BuildPredicate(ProvcacheOciAttestationRequest request) { var digest = request.DecisionDigest; var manifest = request.InputManifest; return new ProvcachePredicate { VeriKey = digest.VeriKey, VerdictHash = digest.VerdictHash, ProofRoot = digest.ProofRoot, TrustScore = digest.TrustScore, TrustScoreBreakdown = BuildTrustBreakdown(digest.TrustScoreBreakdown), InputManifest = BuildInputSummary(manifest, digest), ReplaySeed = new ProvcacheReplaySeed { FeedIds = digest.ReplaySeed.FeedIds, RuleIds = digest.ReplaySeed.RuleIds, FrozenEpoch = digest.ReplaySeed.FrozenEpoch?.ToString("O") }, CreatedAt = digest.CreatedAt.ToString("O"), ExpiresAt = digest.ExpiresAt.ToString("O"), VerdictSummary = request.VerdictSummary }; } private static ProvcacheTrustBreakdown? BuildTrustBreakdown(TrustScoreBreakdown? breakdown) { if (breakdown is null) { return null; } return new ProvcacheTrustBreakdown { Reachability = new ProvcacheTrustComponent { Score = breakdown.Reachability.Score, Weight = breakdown.Reachability.Weight }, SbomCompleteness = new ProvcacheTrustComponent { Score = breakdown.SbomCompleteness.Score, Weight = breakdown.SbomCompleteness.Weight }, VexCoverage = new ProvcacheTrustComponent { Score = breakdown.VexCoverage.Score, Weight = breakdown.VexCoverage.Weight }, PolicyFreshness = new ProvcacheTrustComponent { Score = breakdown.PolicyFreshness.Score, Weight = breakdown.PolicyFreshness.Weight }, SignerTrust = new ProvcacheTrustComponent { Score = breakdown.SignerTrust.Score, Weight = breakdown.SignerTrust.Weight } }; } private static ProvcacheInputSummary BuildInputSummary(InputManifest? manifest, DecisionDigest digest) { if (manifest is null) { // Fallback: extract from VeriKey components if available return new ProvcacheInputSummary { SourceHash = ExtractComponentHash(digest.VeriKey, 0), SbomHash = null, VexSetHash = null, PolicyHash = null, SignerSetHash = null }; } return new ProvcacheInputSummary { SourceHash = manifest.SourceArtifact.Digest, SbomHash = manifest.Sbom.Hash, VexSetHash = manifest.Vex.SetHash, PolicyHash = manifest.Policy.Hash, SignerSetHash = manifest.Signers.SetHash }; } private static string ExtractComponentHash(string veriKey, int index) { // VeriKey format is typically sha256: // This is a fallback; prefer using InputManifest return veriKey; } private static IReadOnlyDictionary BuildAnnotations( ProvcacheOciAttestationRequest request, ProvcachePredicate predicate) { var annotations = new Dictionary(StringComparer.Ordinal) { // Standard OCI annotations ["org.opencontainers.image.title"] = "stellaops.provcache.decision", ["org.opencontainers.image.description"] = "Provcache decision attestation for provenance-cached vulnerability decisions", ["org.opencontainers.image.created"] = predicate.CreatedAt, // StellaOps-specific annotations ["stellaops.provcache.verikey"] = predicate.VeriKey, ["stellaops.provcache.verdict-hash"] = predicate.VerdictHash, ["stellaops.provcache.proof-root"] = predicate.ProofRoot, ["stellaops.provcache.trust-score"] = predicate.TrustScore.ToString(), ["stellaops.provcache.expires-at"] = predicate.ExpiresAt }; // Add optional tenant annotation if (!string.IsNullOrWhiteSpace(request.TenantId)) { annotations["stellaops.tenant"] = request.TenantId; } // Add optional scope annotation if (!string.IsNullOrWhiteSpace(request.Scope)) { annotations["stellaops.scope"] = request.Scope; } return annotations; } } /// /// Interface for building OCI attestations for Provcache decisions. /// public interface IProvcacheOciAttestationBuilder { /// /// Builds an OCI attestation from a DecisionDigest. /// ProvcacheOciAttestationResult Build(ProvcacheOciAttestationRequest request); /// /// Creates an OCI attachment ready for pushing to a registry. /// ProvcacheOciAttachment CreateAttachment(ProvcacheOciAttestationRequest request); } /// /// Request for building a Provcache OCI attestation. /// public sealed record ProvcacheOciAttestationRequest { /// /// OCI artifact reference (e.g., ghcr.io/org/repo:tag or ghcr.io/org/repo@sha256:...). /// public required string ArtifactReference { get; init; } /// /// Digest of the artifact (e.g., sha256:abc123...). /// public required string ArtifactDigest { get; init; } /// /// The DecisionDigest to create an attestation for. /// public required DecisionDigest DecisionDigest { get; init; } /// /// Optional: Full InputManifest for detailed provenance. /// public InputManifest? InputManifest { get; init; } /// /// Optional: Summary of verdicts. /// public ProvcacheVerdictSummary? VerdictSummary { get; init; } /// /// Optional: Tenant identifier for multi-tenant scenarios. /// public string? TenantId { get; init; } /// /// Optional: Scope identifier (e.g., environment, pipeline). /// public string? Scope { get; init; } } /// /// Result of building an OCI attestation. /// public sealed record ProvcacheOciAttestationResult( ProvcacheStatement Statement, string StatementJson, byte[] StatementBytes, string MediaType, IReadOnlyDictionary Annotations); /// /// OCI attachment ready for pushing to a registry. /// public sealed record ProvcacheOciAttachment( string ArtifactReference, string MediaType, string Payload, byte[] PayloadBytes, IReadOnlyDictionary Annotations);