373 lines
12 KiB
C#
373 lines
12 KiB
C#
// ----------------------------------------------------------------------------
|
|
// 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;
|
|
|
|
/// <summary>
|
|
/// Builds OCI attestations for Provcache DecisionDigest objects.
|
|
/// The attestation follows the in-toto Statement format with a custom predicate type.
|
|
/// </summary>
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="ProvcacheOciAttestationBuilder"/> class.
|
|
/// </summary>
|
|
public ProvcacheOciAttestationBuilder(TimeProvider? timeProvider = null)
|
|
{
|
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
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);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
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<string, string>(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:<hash>
|
|
// This is a fallback; prefer using InputManifest
|
|
return veriKey;
|
|
}
|
|
|
|
private static IReadOnlyDictionary<string, string> BuildAnnotations(
|
|
ProvcacheOciAttestationRequest request,
|
|
ProvcachePredicate predicate)
|
|
{
|
|
var annotations = new Dictionary<string, string>(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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Interface for building OCI attestations for Provcache decisions.
|
|
/// </summary>
|
|
public interface IProvcacheOciAttestationBuilder
|
|
{
|
|
/// <summary>
|
|
/// Builds an OCI attestation from a DecisionDigest.
|
|
/// </summary>
|
|
ProvcacheOciAttestationResult Build(ProvcacheOciAttestationRequest request);
|
|
|
|
/// <summary>
|
|
/// Creates an OCI attachment ready for pushing to a registry.
|
|
/// </summary>
|
|
ProvcacheOciAttachment CreateAttachment(ProvcacheOciAttestationRequest request);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Request for building a Provcache OCI attestation.
|
|
/// </summary>
|
|
public sealed record ProvcacheOciAttestationRequest
|
|
{
|
|
/// <summary>
|
|
/// OCI artifact reference (e.g., ghcr.io/org/repo:tag or ghcr.io/org/repo@sha256:...).
|
|
/// </summary>
|
|
public required string ArtifactReference { get; init; }
|
|
|
|
/// <summary>
|
|
/// Digest of the artifact (e.g., sha256:abc123...).
|
|
/// </summary>
|
|
public required string ArtifactDigest { get; init; }
|
|
|
|
/// <summary>
|
|
/// The DecisionDigest to create an attestation for.
|
|
/// </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 for multi-tenant scenarios.
|
|
/// </summary>
|
|
public string? TenantId { get; init; }
|
|
|
|
/// <summary>
|
|
/// Optional: Scope identifier (e.g., environment, pipeline).
|
|
/// </summary>
|
|
public string? Scope { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of building an OCI attestation.
|
|
/// </summary>
|
|
public sealed record ProvcacheOciAttestationResult(
|
|
ProvcacheStatement Statement,
|
|
string StatementJson,
|
|
byte[] StatementBytes,
|
|
string MediaType,
|
|
IReadOnlyDictionary<string, string> Annotations);
|
|
|
|
/// <summary>
|
|
/// OCI attachment ready for pushing to a registry.
|
|
/// </summary>
|
|
public sealed record ProvcacheOciAttachment(
|
|
string ArtifactReference,
|
|
string MediaType,
|
|
string Payload,
|
|
byte[] PayloadBytes,
|
|
IReadOnlyDictionary<string, string> Annotations);
|