save development progress
This commit is contained in:
@@ -17,6 +17,7 @@ public sealed class DecisionDigestBuilder
|
||||
private DateTimeOffset? _createdAt;
|
||||
private DateTimeOffset? _expiresAt;
|
||||
private int? _trustScore;
|
||||
private TrustScoreBreakdown? _trustScoreBreakdown;
|
||||
private readonly ProvcacheOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
@@ -217,7 +218,20 @@ public sealed class DecisionDigestBuilder
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes trust score from component scores using weighted formula.
|
||||
/// Sets the trust score from a breakdown, computing the total automatically.
|
||||
/// </summary>
|
||||
/// <param name="breakdown">The trust score breakdown with component scores.</param>
|
||||
public DecisionDigestBuilder WithTrustScoreBreakdown(TrustScoreBreakdown breakdown)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(breakdown);
|
||||
_trustScoreBreakdown = breakdown;
|
||||
_trustScore = breakdown.ComputeTotal();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes trust score from component scores using weighted formula,
|
||||
/// and stores the breakdown for API responses.
|
||||
/// </summary>
|
||||
/// <param name="reachabilityScore">Reachability analysis coverage (0-100).</param>
|
||||
/// <param name="sbomCompletenessScore">SBOM completeness (0-100).</param>
|
||||
@@ -231,14 +245,16 @@ public sealed class DecisionDigestBuilder
|
||||
int policyFreshnessScore,
|
||||
int signerTrustScore)
|
||||
{
|
||||
// Weights from documentation:
|
||||
// Reachability: 25%, SBOM: 20%, VEX: 20%, Policy: 15%, Signer: 20%
|
||||
_trustScore = (int)Math.Round(
|
||||
reachabilityScore * 0.25 +
|
||||
sbomCompletenessScore * 0.20 +
|
||||
vexCoverageScore * 0.20 +
|
||||
policyFreshnessScore * 0.15 +
|
||||
signerTrustScore * 0.20);
|
||||
// Create breakdown with standard weights
|
||||
_trustScoreBreakdown = TrustScoreBreakdown.CreateDefault(
|
||||
reachabilityScore,
|
||||
sbomCompletenessScore,
|
||||
vexCoverageScore,
|
||||
policyFreshnessScore,
|
||||
signerTrustScore);
|
||||
|
||||
// Compute total from breakdown
|
||||
_trustScore = _trustScoreBreakdown.ComputeTotal();
|
||||
|
||||
// Clamp to valid range
|
||||
_trustScore = Math.Clamp(_trustScore.Value, 0, 100);
|
||||
@@ -263,7 +279,8 @@ public sealed class DecisionDigestBuilder
|
||||
ReplaySeed = _replaySeed!,
|
||||
CreatedAt = _createdAt!.Value,
|
||||
ExpiresAt = _expiresAt!.Value,
|
||||
TrustScore = _trustScore!.Value
|
||||
TrustScore = _trustScore!.Value,
|
||||
TrustScoreBreakdown = _trustScoreBreakdown
|
||||
};
|
||||
}
|
||||
|
||||
@@ -279,6 +296,7 @@ public sealed class DecisionDigestBuilder
|
||||
_createdAt = null;
|
||||
_expiresAt = null;
|
||||
_trustScore = null;
|
||||
_trustScoreBreakdown = null;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,99 @@ public sealed record DecisionDigest
|
||||
/// Based on reachability, SBOM completeness, VEX coverage, policy freshness, and signer trust.
|
||||
/// </summary>
|
||||
public required int TrustScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Breakdown of trust score by component.
|
||||
/// Each component has its own score (0-100) and weight that contributed to <see cref="TrustScore"/>.
|
||||
/// </summary>
|
||||
public TrustScoreBreakdown? TrustScoreBreakdown { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Breakdown of trust score by component, showing contribution from each evidence type.
|
||||
/// </summary>
|
||||
public sealed record TrustScoreBreakdown
|
||||
{
|
||||
/// <summary>
|
||||
/// Reachability evidence contribution (weight: 25%).
|
||||
/// Based on call graph / static analysis evidence.
|
||||
/// </summary>
|
||||
public required TrustScoreComponent Reachability { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SBOM completeness contribution (weight: 20%).
|
||||
/// Based on package coverage and license data.
|
||||
/// </summary>
|
||||
public required TrustScoreComponent SbomCompleteness { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX statement coverage contribution (weight: 20%).
|
||||
/// Based on vendor statements and OpenVEX coverage.
|
||||
/// </summary>
|
||||
public required TrustScoreComponent VexCoverage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy freshness contribution (weight: 15%).
|
||||
/// Based on last policy update timestamp.
|
||||
/// </summary>
|
||||
public required TrustScoreComponent PolicyFreshness { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signer trust contribution (weight: 20%).
|
||||
/// Based on signer reputation and key age.
|
||||
/// </summary>
|
||||
public required TrustScoreComponent SignerTrust { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Computes weighted total score from all components.
|
||||
/// </summary>
|
||||
public int ComputeTotal()
|
||||
{
|
||||
return (int)Math.Round(
|
||||
Reachability.Score * Reachability.Weight +
|
||||
SbomCompleteness.Score * SbomCompleteness.Weight +
|
||||
VexCoverage.Score * VexCoverage.Weight +
|
||||
PolicyFreshness.Score * PolicyFreshness.Weight +
|
||||
SignerTrust.Score * SignerTrust.Weight);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a default breakdown with standard weights.
|
||||
/// </summary>
|
||||
public static TrustScoreBreakdown CreateDefault(
|
||||
int reachabilityScore = 0,
|
||||
int sbomScore = 0,
|
||||
int vexScore = 0,
|
||||
int policyScore = 0,
|
||||
int signerScore = 0) => new()
|
||||
{
|
||||
Reachability = new TrustScoreComponent { Score = reachabilityScore, Weight = 0.25m },
|
||||
SbomCompleteness = new TrustScoreComponent { Score = sbomScore, Weight = 0.20m },
|
||||
VexCoverage = new TrustScoreComponent { Score = vexScore, Weight = 0.20m },
|
||||
PolicyFreshness = new TrustScoreComponent { Score = policyScore, Weight = 0.15m },
|
||||
SignerTrust = new TrustScoreComponent { Score = signerScore, Weight = 0.20m }
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual component of trust score with its score and weight.
|
||||
/// </summary>
|
||||
public sealed record TrustScoreComponent
|
||||
{
|
||||
/// <summary>
|
||||
/// Component score (0-100).
|
||||
/// </summary>
|
||||
public required int Score { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Weight of this component in the total score (0.0-1.0).
|
||||
/// </summary>
|
||||
public required decimal Weight { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Weighted contribution to total score.
|
||||
/// </summary>
|
||||
public decimal Contribution => Score * Weight;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
245
src/__Libraries/StellaOps.Provcache/Models/InputManifest.cs
Normal file
245
src/__Libraries/StellaOps.Provcache/Models/InputManifest.cs
Normal file
@@ -0,0 +1,245 @@
|
||||
namespace StellaOps.Provcache;
|
||||
|
||||
/// <summary>
|
||||
/// Manifest showing the exact inputs that form a VeriKey and cached decision.
|
||||
/// Used for transparency and debugging to show what evidence contributed to a decision.
|
||||
/// </summary>
|
||||
public sealed record InputManifest
|
||||
{
|
||||
/// <summary>
|
||||
/// The VeriKey this manifest describes.
|
||||
/// </summary>
|
||||
public required string VeriKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Information about the source artifact (container image, binary, etc.).
|
||||
/// </summary>
|
||||
public required SourceArtifactInfo SourceArtifact { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Information about the SBOM used in the decision.
|
||||
/// </summary>
|
||||
public required SbomInfo Sbom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Information about VEX statements contributing to the decision.
|
||||
/// </summary>
|
||||
public required VexInfo Vex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Information about the policy used in evaluation.
|
||||
/// </summary>
|
||||
public required PolicyInfo Policy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Information about signers/attestors.
|
||||
/// </summary>
|
||||
public required SignerInfo Signers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Time window information for cache validity.
|
||||
/// </summary>
|
||||
public required TimeWindowInfo TimeWindow { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about the source artifact.
|
||||
/// </summary>
|
||||
public sealed record SourceArtifactInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Content-addressed hash of the artifact (e.g., sha256:abc123...).
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of artifact (container-image, binary, archive, etc.).
|
||||
/// </summary>
|
||||
public string? ArtifactType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// OCI reference if applicable (e.g., ghcr.io/org/repo:tag).
|
||||
/// </summary>
|
||||
public string? OciReference { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size of the artifact in bytes.
|
||||
/// </summary>
|
||||
public long? SizeBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about the SBOM.
|
||||
/// </summary>
|
||||
public sealed record SbomInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Canonical hash of the SBOM content.
|
||||
/// </summary>
|
||||
public required string Hash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SBOM format (spdx-2.3, cyclonedx-1.6, etc.).
|
||||
/// </summary>
|
||||
public string? Format { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of packages in the SBOM.
|
||||
/// </summary>
|
||||
public int? PackageCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of packages with license information.
|
||||
/// </summary>
|
||||
public int? PackagesWithLicense { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Completeness percentage (0-100).
|
||||
/// </summary>
|
||||
public int? CompletenessScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the SBOM was created or last updated.
|
||||
/// </summary>
|
||||
public DateTimeOffset? CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about VEX statements.
|
||||
/// </summary>
|
||||
public sealed record VexInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Hash of the sorted VEX statement set.
|
||||
/// </summary>
|
||||
public required string HashSetHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of VEX statements contributing to this decision.
|
||||
/// </summary>
|
||||
public int StatementCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sources of VEX statements (vendor names, OpenVEX IDs, etc.).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Sources { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Most recent VEX statement timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset? LatestStatementAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Individual statement hashes (for verification).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> StatementHashes { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about the policy.
|
||||
/// </summary>
|
||||
public sealed record PolicyInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Canonical hash of the policy bundle.
|
||||
/// </summary>
|
||||
public required string Hash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy pack identifier.
|
||||
/// </summary>
|
||||
public string? PackId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy version number.
|
||||
/// </summary>
|
||||
public int? Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the policy was last updated.
|
||||
/// </summary>
|
||||
public DateTimeOffset? LastUpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable policy name.
|
||||
/// </summary>
|
||||
public string? Name { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about signers and attestors.
|
||||
/// </summary>
|
||||
public sealed record SignerInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Hash of the sorted signer set.
|
||||
/// </summary>
|
||||
public required string SetHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of signers in the set.
|
||||
/// </summary>
|
||||
public int SignerCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signer certificate information.
|
||||
/// </summary>
|
||||
public IReadOnlyList<SignerCertificate> Certificates { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a signer certificate.
|
||||
/// </summary>
|
||||
public sealed record SignerCertificate
|
||||
{
|
||||
/// <summary>
|
||||
/// Subject of the certificate (e.g., CN=...).
|
||||
/// </summary>
|
||||
public string? Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Certificate issuer.
|
||||
/// </summary>
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Certificate serial number or fingerprint.
|
||||
/// </summary>
|
||||
public string? Fingerprint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the certificate expires.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trust level (fulcio, self-signed, enterprise-ca, etc.).
|
||||
/// </summary>
|
||||
public string? TrustLevel { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about the time window used in VeriKey.
|
||||
/// </summary>
|
||||
public sealed record TimeWindowInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// The time window bucket identifier.
|
||||
/// </summary>
|
||||
public required string Bucket { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Start of the time window (UTC).
|
||||
/// </summary>
|
||||
public DateTimeOffset? StartsAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// End of the time window (UTC).
|
||||
/// </summary>
|
||||
public DateTimeOffset? EndsAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Duration of the time window.
|
||||
/// </summary>
|
||||
public TimeSpan? Duration { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
// ----------------------------------------------------------------------------
|
||||
// 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);
|
||||
@@ -0,0 +1,312 @@
|
||||
// ----------------------------------------------------------------------------
|
||||
// Copyright (c) 2025 StellaOps contributors. All rights reserved.
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Provcache.Oci;
|
||||
|
||||
/// <summary>
|
||||
/// Constants for the Provcache OCI attestation predicate type.
|
||||
/// </summary>
|
||||
public static class ProvcachePredicateTypes
|
||||
{
|
||||
/// <summary>
|
||||
/// The predicate type URI for Provcache decision attestations.
|
||||
/// Format: stella.ops/provcache@v1
|
||||
/// </summary>
|
||||
public const string ProvcacheV1 = "stella.ops/provcache@v1";
|
||||
|
||||
/// <summary>
|
||||
/// OCI media type for the attestation payload.
|
||||
/// </summary>
|
||||
public const string MediaType = "application/vnd.stellaops.provcache.decision+json";
|
||||
|
||||
/// <summary>
|
||||
/// Statement type for in-toto attestations.
|
||||
/// </summary>
|
||||
public const string InTotoStatementType = "https://in-toto.io/Statement/v1";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement wrapper for Provcache attestations following SLSA v1.0 format.
|
||||
/// </summary>
|
||||
public sealed record ProvcacheStatement
|
||||
{
|
||||
/// <summary>
|
||||
/// Statement type, always "https://in-toto.io/Statement/v1".
|
||||
/// </summary>
|
||||
[JsonPropertyName("_type")]
|
||||
public string Type { get; init; } = ProvcachePredicateTypes.InTotoStatementType;
|
||||
|
||||
/// <summary>
|
||||
/// List of subjects (artifacts) this attestation applies to.
|
||||
/// </summary>
|
||||
[JsonPropertyName("subject")]
|
||||
public required IReadOnlyList<ProvcacheSubject> Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Predicate type URI: stella.ops/provcache@v1.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicateType")]
|
||||
public string PredicateType { get; init; } = ProvcachePredicateTypes.ProvcacheV1;
|
||||
|
||||
/// <summary>
|
||||
/// The predicate payload containing the decision digest details.
|
||||
/// </summary>
|
||||
[JsonPropertyName("predicate")]
|
||||
public required ProvcachePredicate Predicate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject representing the artifact (container image) this attestation applies to.
|
||||
/// </summary>
|
||||
public sealed record ProvcacheSubject
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the artifact (e.g., container image reference without tag).
|
||||
/// Example: ghcr.io/stellaops/scanner
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Cryptographic digests of the artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public required IReadOnlyDictionary<string, string> Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Provcache predicate containing decision digest and provenance metadata.
|
||||
/// </summary>
|
||||
public sealed record ProvcachePredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// Schema version of this predicate format.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = "v1";
|
||||
|
||||
/// <summary>
|
||||
/// Composite cache key (VeriKey) that uniquely identifies the decision context.
|
||||
/// Format: sha256:<hex>
|
||||
/// </summary>
|
||||
[JsonPropertyName("veriKey")]
|
||||
public required string VeriKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash of sorted dispositions from the evaluation result.
|
||||
/// Format: sha256:<hex>
|
||||
/// </summary>
|
||||
[JsonPropertyName("verdictHash")]
|
||||
public required string VerdictHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Merkle root of all evidence chunks used in this decision.
|
||||
/// Format: sha256:<hex>
|
||||
/// </summary>
|
||||
[JsonPropertyName("proofRoot")]
|
||||
public required string ProofRoot { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Composite trust score (0-100) indicating decision confidence.
|
||||
/// </summary>
|
||||
[JsonPropertyName("trustScore")]
|
||||
public required int TrustScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Breakdown of trust score by component.
|
||||
/// </summary>
|
||||
[JsonPropertyName("trustScoreBreakdown")]
|
||||
public ProvcacheTrustBreakdown? TrustScoreBreakdown { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Input manifest describing components used for the decision.
|
||||
/// </summary>
|
||||
[JsonPropertyName("inputManifest")]
|
||||
public required ProvcacheInputSummary InputManifest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Replay identifiers for deterministic re-evaluation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("replaySeed")]
|
||||
public required ProvcacheReplaySeed ReplaySeed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when this decision was made.
|
||||
/// ISO 8601 format.
|
||||
/// </summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required string CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when this decision expires.
|
||||
/// ISO 8601 format.
|
||||
/// </summary>
|
||||
[JsonPropertyName("expiresAt")]
|
||||
public required string ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Summary of verdicts included in this decision.
|
||||
/// </summary>
|
||||
[JsonPropertyName("verdictSummary")]
|
||||
public ProvcacheVerdictSummary? VerdictSummary { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trust score breakdown by evidence component.
|
||||
/// </summary>
|
||||
public sealed record ProvcacheTrustBreakdown
|
||||
{
|
||||
/// <summary>
|
||||
/// Reachability evidence contribution (weight: 25%).
|
||||
/// </summary>
|
||||
[JsonPropertyName("reachability")]
|
||||
public required ProvcacheTrustComponent Reachability { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SBOM completeness contribution (weight: 20%).
|
||||
/// </summary>
|
||||
[JsonPropertyName("sbomCompleteness")]
|
||||
public required ProvcacheTrustComponent SbomCompleteness { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX coverage contribution (weight: 20%).
|
||||
/// </summary>
|
||||
[JsonPropertyName("vexCoverage")]
|
||||
public required ProvcacheTrustComponent VexCoverage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy freshness contribution (weight: 15%).
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyFreshness")]
|
||||
public required ProvcacheTrustComponent PolicyFreshness { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signer trust contribution (weight: 20%).
|
||||
/// </summary>
|
||||
[JsonPropertyName("signerTrust")]
|
||||
public required ProvcacheTrustComponent SignerTrust { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual trust score component.
|
||||
/// </summary>
|
||||
public sealed record ProvcacheTrustComponent
|
||||
{
|
||||
/// <summary>
|
||||
/// Component score (0-100).
|
||||
/// </summary>
|
||||
[JsonPropertyName("score")]
|
||||
public required int Score { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Weight of this component (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("weight")]
|
||||
public required decimal Weight { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of input components used for the decision.
|
||||
/// </summary>
|
||||
public sealed record ProvcacheInputSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Hash of the source artifact.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sourceHash")]
|
||||
public required string SourceHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash of the SBOM used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sbomHash")]
|
||||
public string? SbomHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash of the VEX set used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vexSetHash")]
|
||||
public string? VexSetHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash of the policy used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyHash")]
|
||||
public string? PolicyHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash of the signer set.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signerSetHash")]
|
||||
public string? SignerSetHash { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replay seed for deterministic re-evaluation.
|
||||
/// </summary>
|
||||
public sealed record ProvcacheReplaySeed
|
||||
{
|
||||
/// <summary>
|
||||
/// Advisory feed identifiers used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("feedIds")]
|
||||
public required IReadOnlyList<string> FeedIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy rule identifiers used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ruleIds")]
|
||||
public required IReadOnlyList<string> RuleIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional frozen epoch timestamp.
|
||||
/// </summary>
|
||||
[JsonPropertyName("frozenEpoch")]
|
||||
public string? FrozenEpoch { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of verdicts in the decision.
|
||||
/// </summary>
|
||||
public sealed record ProvcacheVerdictSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Total number of findings evaluated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("totalFindings")]
|
||||
public required int TotalFindings { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of findings marked as affected.
|
||||
/// </summary>
|
||||
[JsonPropertyName("affected")]
|
||||
public required int Affected { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of findings marked as not_affected.
|
||||
/// </summary>
|
||||
[JsonPropertyName("notAffected")]
|
||||
public required int NotAffected { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of findings with mitigations applied.
|
||||
/// </summary>
|
||||
[JsonPropertyName("mitigated")]
|
||||
public required int Mitigated { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of findings under investigation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("underInvestigation")]
|
||||
public required int UnderInvestigation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of findings with known fixes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fixed")]
|
||||
public required int Fixed { get; init; }
|
||||
}
|
||||
@@ -331,6 +331,9 @@ public sealed class ProvcacheService : IProvcacheService
|
||||
{
|
||||
var stats = await _repository.GetStatisticsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Update the items count gauge for Prometheus
|
||||
ProvcacheTelemetry.SetItemsCount(stats.TotalEntries);
|
||||
|
||||
double avgLatency, p99Latency;
|
||||
lock (_metricsLock)
|
||||
{
|
||||
|
||||
@@ -40,6 +40,7 @@ public static class ProvcacheTelemetry
|
||||
private const string WriteBehindQueueSizeMetric = "provcache_writebehind_queue_size";
|
||||
private const string LatencySecondsMetric = "provcache_latency_seconds";
|
||||
private const string EntriesSizeMetric = "provcache_entry_bytes";
|
||||
private const string ItemsCountMetric = "provcache_items_count";
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -92,6 +93,7 @@ public static class ProvcacheTelemetry
|
||||
#region Gauges
|
||||
|
||||
private static int _writeBehindQueueSize;
|
||||
private static long _itemsCount;
|
||||
|
||||
/// <summary>
|
||||
/// Observable gauge for write-behind queue size.
|
||||
@@ -102,6 +104,15 @@ public static class ProvcacheTelemetry
|
||||
unit: "items",
|
||||
description: "Current write-behind queue size.");
|
||||
|
||||
/// <summary>
|
||||
/// Observable gauge for total cache items count.
|
||||
/// </summary>
|
||||
public static readonly ObservableGauge<long> ItemsCountGauge = Meter.CreateObservableGauge(
|
||||
ItemsCountMetric,
|
||||
() => _itemsCount,
|
||||
unit: "items",
|
||||
description: "Current number of cached entries.");
|
||||
|
||||
/// <summary>
|
||||
/// Update the write-behind queue size gauge.
|
||||
/// </summary>
|
||||
@@ -111,6 +122,15 @@ public static class ProvcacheTelemetry
|
||||
_writeBehindQueueSize = size;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update the items count gauge.
|
||||
/// </summary>
|
||||
/// <param name="count">Current count of cache entries.</param>
|
||||
public static void SetItemsCount(long count)
|
||||
{
|
||||
_itemsCount = count;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Activity Tracing
|
||||
|
||||
Reference in New Issue
Block a user