save development progress
This commit is contained in:
@@ -375,3 +375,185 @@ public sealed class ChunkVerificationResult
|
||||
/// </summary>
|
||||
public string? ComputedHash { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response model for GET /v1/provcache/{veriKey}/manifest.
|
||||
/// </summary>
|
||||
public sealed class InputManifestResponse
|
||||
{
|
||||
/// <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 SbomInfoDto Sbom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Information about VEX statements contributing to the decision.
|
||||
/// </summary>
|
||||
public required VexInfoDto Vex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Information about the policy used in evaluation.
|
||||
/// </summary>
|
||||
public required PolicyInfoDto Policy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Information about signers/attestors.
|
||||
/// </summary>
|
||||
public required SignerInfoDto Signers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Time window information for cache validity.
|
||||
/// </summary>
|
||||
public required TimeWindowInfoDto TimeWindow { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the manifest was generated.
|
||||
/// </summary>
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM information in API response.
|
||||
/// </summary>
|
||||
public sealed class SbomInfoDto
|
||||
{
|
||||
/// <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>
|
||||
/// Completeness percentage (0-100).
|
||||
/// </summary>
|
||||
public int? CompletenessScore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX information in API response.
|
||||
/// </summary>
|
||||
public sealed class VexInfoDto
|
||||
{
|
||||
/// <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>
|
||||
/// Policy information in API response.
|
||||
/// </summary>
|
||||
public sealed class PolicyInfoDto
|
||||
{
|
||||
/// <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>
|
||||
/// Human-readable policy name.
|
||||
/// </summary>
|
||||
public string? Name { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signer information in API response.
|
||||
/// </summary>
|
||||
public sealed class SignerInfoDto
|
||||
{
|
||||
/// <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<SignerCertificateDto>? Certificates { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signer certificate information in API response.
|
||||
/// </summary>
|
||||
public sealed class SignerCertificateDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Subject of the certificate (e.g., CN=...).
|
||||
/// </summary>
|
||||
public string? Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Certificate issuer.
|
||||
/// </summary>
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the certificate expires.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Time window information in API response.
|
||||
/// </summary>
|
||||
public sealed class TimeWindowInfoDto
|
||||
{
|
||||
/// <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; }
|
||||
}
|
||||
|
||||
@@ -70,6 +70,15 @@ public static partial class ProvcacheEndpointExtensions
|
||||
.Produces<ProvcacheMetricsResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
|
||||
|
||||
// GET /v1/provcache/{veriKey}/manifest
|
||||
group.MapGet("/{veriKey}/manifest", GetInputManifest)
|
||||
.WithName("GetInputManifest")
|
||||
.WithSummary("Get input manifest for VeriKey components")
|
||||
.WithDescription("Returns detailed information about the inputs (SBOM, VEX, policy, signers) that formed a cached decision. Use for transparency and debugging.")
|
||||
.Produces<InputManifestResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
|
||||
|
||||
// Map evidence paging endpoints under /proofs
|
||||
var proofsGroup = endpoints.MapGroup($"{prefix}/proofs")
|
||||
.WithTags("Provcache Evidence")
|
||||
@@ -307,6 +316,119 @@ public static partial class ProvcacheEndpointExtensions
|
||||
title: "Metrics retrieval failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /v1/provcache/{veriKey}/manifest
|
||||
/// </summary>
|
||||
private static async Task<IResult> GetInputManifest(
|
||||
string veriKey,
|
||||
IProvcacheService provcacheService,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<ProvcacheApiEndpoints> logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
logger.LogDebug("GET /v1/provcache/{VeriKey}/manifest", veriKey);
|
||||
|
||||
try
|
||||
{
|
||||
// First get the entry to verify it exists
|
||||
var result = await provcacheService.GetAsync(veriKey, bypassCache: false, cancellationToken);
|
||||
|
||||
if (result.Status == ProvcacheResultStatus.CacheMiss)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Entry not found",
|
||||
Detail = $"No cache entry found for VeriKey: {veriKey}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
if (result.Status == ProvcacheResultStatus.Expired)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Entry expired",
|
||||
Detail = $"Cache entry for VeriKey '{veriKey}' has expired",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
var entry = result.Entry;
|
||||
if (entry is null)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Entry not found",
|
||||
Detail = $"No cache entry found for VeriKey: {veriKey}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
// Build the input manifest from the entry metadata
|
||||
// In a full implementation, we'd resolve these hashes to more detailed metadata
|
||||
// from their respective stores (SBOM store, VEX store, policy registry, etc.)
|
||||
var manifest = BuildInputManifest(entry, timeProvider);
|
||||
|
||||
return Results.Ok(manifest);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error getting input manifest for VeriKey {VeriKey}", veriKey);
|
||||
return Results.Problem(
|
||||
detail: ex.Message,
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
title: "Manifest retrieval failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an InputManifestResponse from a ProvcacheEntry.
|
||||
/// </summary>
|
||||
private static InputManifestResponse BuildInputManifest(ProvcacheEntry entry, TimeProvider timeProvider)
|
||||
{
|
||||
// Build input manifest from the entry and its embedded DecisionDigest
|
||||
// The DecisionDigest contains the VeriKey components as hashes
|
||||
var decision = entry.Decision;
|
||||
|
||||
return new InputManifestResponse
|
||||
{
|
||||
VeriKey = entry.VeriKey,
|
||||
SourceArtifact = new SourceArtifactInfo
|
||||
{
|
||||
// VeriKey includes source hash as first component
|
||||
Digest = entry.VeriKey,
|
||||
},
|
||||
Sbom = new SbomInfoDto
|
||||
{
|
||||
// SBOM hash is embedded in VeriKey computation
|
||||
// In a full implementation, we'd resolve this from the SBOM store
|
||||
Hash = $"sha256:{entry.VeriKey[7..39]}...", // Placeholder - actual hash would come from VeriKey decomposition
|
||||
},
|
||||
Vex = new VexInfoDto
|
||||
{
|
||||
// VEX hash set is embedded in VeriKey computation
|
||||
HashSetHash = $"sha256:{entry.VeriKey[7..39]}...", // Placeholder
|
||||
StatementCount = 0, // Would be resolved from VEX store
|
||||
},
|
||||
Policy = new PolicyInfoDto
|
||||
{
|
||||
Hash = entry.PolicyHash,
|
||||
},
|
||||
Signers = new SignerInfoDto
|
||||
{
|
||||
SetHash = entry.SignerSetHash,
|
||||
SignerCount = 0, // Would be resolved from signer registry
|
||||
},
|
||||
TimeWindow = new TimeWindowInfoDto
|
||||
{
|
||||
Bucket = entry.FeedEpoch,
|
||||
StartsAt = entry.CreatedAt,
|
||||
EndsAt = entry.ExpiresAt,
|
||||
},
|
||||
GeneratedAt = timeProvider.GetUtcNow(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (C) 2025 StellaOps Contributors
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Provcache.Api;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Provcache.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Contract tests verifying the structure and serialization of new API response fields.
|
||||
/// These tests ensure the API contracts remain stable across versions.
|
||||
/// </summary>
|
||||
public sealed class ApiContractTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false,
|
||||
};
|
||||
|
||||
#region CacheSource Contract Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("none")]
|
||||
[InlineData("inMemory")]
|
||||
[InlineData("redis")]
|
||||
public void CacheSource_AllValues_SerializeCorrectly(string expectedValue)
|
||||
{
|
||||
// CacheSource enum values should serialize to their lowercase/camelCase string equivalents
|
||||
// This ensures compatibility with the OpenAPI spec and frontend models
|
||||
var response = new
|
||||
{
|
||||
cacheSource = expectedValue,
|
||||
cacheHit = expectedValue != "none"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(response, JsonOptions);
|
||||
|
||||
json.Should().Contain($"\"cacheSource\":\"{expectedValue}\"");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TrustScoreBreakdown Contract Tests
|
||||
|
||||
[Fact]
|
||||
public void TrustScoreBreakdown_DefaultWeights_SumToOne()
|
||||
{
|
||||
// Verify the standard weights sum to 1.0 (100%)
|
||||
var breakdown = TrustScoreBreakdown.CreateDefault();
|
||||
|
||||
var totalWeight = breakdown.Reachability.Weight +
|
||||
breakdown.SbomCompleteness.Weight +
|
||||
breakdown.VexCoverage.Weight +
|
||||
breakdown.PolicyFreshness.Weight +
|
||||
breakdown.SignerTrust.Weight;
|
||||
|
||||
totalWeight.Should().Be(1.00m, "standard weights must sum to 100%");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrustScoreBreakdown_StandardWeights_MatchDocumentation()
|
||||
{
|
||||
// Verify weights match the documented percentages
|
||||
// Reachability: 25%, SBOM: 20%, VEX: 20%, Policy: 15%, Signer: 20%
|
||||
var breakdown = TrustScoreBreakdown.CreateDefault();
|
||||
|
||||
breakdown.Reachability.Weight.Should().Be(0.25m, "Reachability weight should be 25%");
|
||||
breakdown.SbomCompleteness.Weight.Should().Be(0.20m, "SBOM completeness weight should be 20%");
|
||||
breakdown.VexCoverage.Weight.Should().Be(0.20m, "VEX coverage weight should be 20%");
|
||||
breakdown.PolicyFreshness.Weight.Should().Be(0.15m, "Policy freshness weight should be 15%");
|
||||
breakdown.SignerTrust.Weight.Should().Be(0.20m, "Signer trust weight should be 20%");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrustScoreBreakdown_ComputeTotal_ReturnsCorrectWeightedSum()
|
||||
{
|
||||
// Given all scores at 100, total should be 100
|
||||
var breakdown = TrustScoreBreakdown.CreateDefault(
|
||||
reachabilityScore: 100,
|
||||
sbomScore: 100,
|
||||
vexScore: 100,
|
||||
policyScore: 100,
|
||||
signerScore: 100);
|
||||
|
||||
breakdown.ComputeTotal().Should().Be(100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrustScoreBreakdown_ComputeTotal_WithZeroScores_ReturnsZero()
|
||||
{
|
||||
var breakdown = TrustScoreBreakdown.CreateDefault();
|
||||
|
||||
breakdown.ComputeTotal().Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrustScoreBreakdown_ComputeTotal_WithMixedScores_ComputesCorrectly()
|
||||
{
|
||||
// Specific test case:
|
||||
// Reachability: 80 * 0.25 = 20
|
||||
// SBOM: 90 * 0.20 = 18
|
||||
// VEX: 70 * 0.20 = 14
|
||||
// Policy: 100 * 0.15 = 15
|
||||
// Signer: 60 * 0.20 = 12
|
||||
// Total: 79
|
||||
var breakdown = TrustScoreBreakdown.CreateDefault(
|
||||
reachabilityScore: 80,
|
||||
sbomScore: 90,
|
||||
vexScore: 70,
|
||||
policyScore: 100,
|
||||
signerScore: 60);
|
||||
|
||||
breakdown.ComputeTotal().Should().Be(79);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrustScoreBreakdown_Serialization_IncludesAllComponents()
|
||||
{
|
||||
var breakdown = TrustScoreBreakdown.CreateDefault(50, 60, 70, 80, 90);
|
||||
|
||||
var json = JsonSerializer.Serialize(breakdown, JsonOptions);
|
||||
|
||||
json.Should().Contain("\"reachability\":");
|
||||
json.Should().Contain("\"sbomCompleteness\":");
|
||||
json.Should().Contain("\"vexCoverage\":");
|
||||
json.Should().Contain("\"policyFreshness\":");
|
||||
json.Should().Contain("\"signerTrust\":");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrustScoreComponent_Contribution_CalculatesCorrectly()
|
||||
{
|
||||
var component = new TrustScoreComponent { Score = 80, Weight = 0.25m };
|
||||
|
||||
component.Contribution.Should().Be(20m, "80 * 0.25 = 20");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DecisionDigest Contract Tests
|
||||
|
||||
[Fact]
|
||||
public void DecisionDigest_TrustScoreBreakdown_IsOptional()
|
||||
{
|
||||
// DecisionDigest should serialize correctly without TrustScoreBreakdown
|
||||
// The field is nullable and can be omitted when not applicable
|
||||
var digest = new DecisionDigest
|
||||
{
|
||||
DigestVersion = "v1",
|
||||
VeriKey = "sha256:abc",
|
||||
VerdictHash = "sha256:def",
|
||||
ProofRoot = "sha256:ghi",
|
||||
ReplaySeed = new ReplaySeed { FeedIds = [], RuleIds = [] },
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1),
|
||||
TrustScore = 85,
|
||||
TrustScoreBreakdown = null
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(digest, JsonOptions);
|
||||
|
||||
// The JSON should serialize successfully, proving the field is optional
|
||||
json.Should().Contain("\"trustScore\":85");
|
||||
// trustScoreBreakdown being null is a valid state
|
||||
digest.TrustScoreBreakdown.Should().BeNull("TrustScoreBreakdown should be optional");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecisionDigest_WithBreakdown_SerializesCorrectly()
|
||||
{
|
||||
var digest = new DecisionDigest
|
||||
{
|
||||
DigestVersion = "v1",
|
||||
VeriKey = "sha256:abc",
|
||||
VerdictHash = "sha256:def",
|
||||
ProofRoot = "sha256:ghi",
|
||||
ReplaySeed = new ReplaySeed { FeedIds = [], RuleIds = [] },
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1),
|
||||
TrustScore = 79,
|
||||
TrustScoreBreakdown = TrustScoreBreakdown.CreateDefault(80, 90, 70, 100, 60)
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(digest, JsonOptions);
|
||||
|
||||
json.Should().Contain("\"trustScoreBreakdown\":");
|
||||
json.Should().Contain("\"reachability\":");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region InputManifest Contract Tests
|
||||
|
||||
[Fact]
|
||||
public void InputManifestResponse_RequiredFields_NotNull()
|
||||
{
|
||||
var manifest = new InputManifestResponse
|
||||
{
|
||||
VeriKey = "sha256:test",
|
||||
SourceArtifact = new SourceArtifactInfo { Digest = "sha256:image" },
|
||||
Sbom = new SbomInfoDto { Hash = "sha256:sbom" },
|
||||
Vex = new VexInfoDto { HashSetHash = "sha256:vex" },
|
||||
Policy = new PolicyInfoDto { Hash = "sha256:policy" },
|
||||
Signers = new SignerInfoDto { SetHash = "sha256:signers" },
|
||||
TimeWindow = new TimeWindowInfoDto { Bucket = "2024-12-24" },
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
manifest.VeriKey.Should().NotBeNull();
|
||||
manifest.SourceArtifact.Should().NotBeNull();
|
||||
manifest.Sbom.Should().NotBeNull();
|
||||
manifest.Vex.Should().NotBeNull();
|
||||
manifest.Policy.Should().NotBeNull();
|
||||
manifest.Signers.Should().NotBeNull();
|
||||
manifest.TimeWindow.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InputManifestResponse_Serialization_IncludesAllComponents()
|
||||
{
|
||||
var manifest = new InputManifestResponse
|
||||
{
|
||||
VeriKey = "sha256:test",
|
||||
SourceArtifact = new SourceArtifactInfo { Digest = "sha256:image" },
|
||||
Sbom = new SbomInfoDto { Hash = "sha256:sbom" },
|
||||
Vex = new VexInfoDto { HashSetHash = "sha256:vex" },
|
||||
Policy = new PolicyInfoDto { Hash = "sha256:policy" },
|
||||
Signers = new SignerInfoDto { SetHash = "sha256:signers" },
|
||||
TimeWindow = new TimeWindowInfoDto { Bucket = "2024-12-24" },
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(manifest, JsonOptions);
|
||||
|
||||
json.Should().Contain("\"veriKey\":");
|
||||
json.Should().Contain("\"sourceArtifact\":");
|
||||
json.Should().Contain("\"sbom\":");
|
||||
json.Should().Contain("\"vex\":");
|
||||
json.Should().Contain("\"policy\":");
|
||||
json.Should().Contain("\"signers\":");
|
||||
json.Should().Contain("\"timeWindow\":");
|
||||
json.Should().Contain("\"generatedAt\":");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SbomInfoDto_OptionalFields_CanBeNull()
|
||||
{
|
||||
var sbom = new SbomInfoDto
|
||||
{
|
||||
Hash = "sha256:required",
|
||||
Format = null,
|
||||
PackageCount = null,
|
||||
CompletenessScore = null
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(sbom, JsonOptions);
|
||||
|
||||
json.Should().Contain("\"hash\":");
|
||||
// Optional fields should not be serialized as null (default JsonSerializer behavior with ignore defaults)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexInfoDto_Sources_CanBeEmpty()
|
||||
{
|
||||
var vex = new VexInfoDto
|
||||
{
|
||||
HashSetHash = "sha256:test",
|
||||
StatementCount = 0,
|
||||
Sources = []
|
||||
};
|
||||
|
||||
vex.Sources.Should().BeEmpty();
|
||||
vex.StatementCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolicyInfoDto_OptionalFields_PreserveValues()
|
||||
{
|
||||
var policy = new PolicyInfoDto
|
||||
{
|
||||
Hash = "sha256:policy",
|
||||
PackId = "org-policy-v2",
|
||||
Version = 5,
|
||||
Name = "Organization Security Policy"
|
||||
};
|
||||
|
||||
policy.Hash.Should().Be("sha256:policy");
|
||||
policy.PackId.Should().Be("org-policy-v2");
|
||||
policy.Version.Should().Be(5);
|
||||
policy.Name.Should().Be("Organization Security Policy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignerInfoDto_Certificates_CanBeNull()
|
||||
{
|
||||
var signers = new SignerInfoDto
|
||||
{
|
||||
SetHash = "sha256:signers",
|
||||
SignerCount = 0,
|
||||
Certificates = null
|
||||
};
|
||||
|
||||
signers.Certificates.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignerCertificateDto_AllFields_AreOptional()
|
||||
{
|
||||
var cert = new SignerCertificateDto
|
||||
{
|
||||
Subject = null,
|
||||
Issuer = null,
|
||||
ExpiresAt = null
|
||||
};
|
||||
|
||||
// Should not throw - all fields are optional
|
||||
var json = JsonSerializer.Serialize(cert, JsonOptions);
|
||||
json.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TimeWindowInfoDto_Bucket_IsRequired()
|
||||
{
|
||||
var timeWindow = new TimeWindowInfoDto
|
||||
{
|
||||
Bucket = "2024-W52",
|
||||
StartsAt = DateTimeOffset.Parse("2024-12-23T00:00:00Z"),
|
||||
EndsAt = DateTimeOffset.Parse("2024-12-30T00:00:00Z")
|
||||
};
|
||||
|
||||
timeWindow.Bucket.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region API Response Backwards Compatibility
|
||||
|
||||
[Fact]
|
||||
public void ProvcacheGetResponse_Status_ValidValues()
|
||||
{
|
||||
// Verify status field uses expected values
|
||||
var statuses = new[] { "hit", "miss", "bypassed", "expired" };
|
||||
|
||||
foreach (var status in statuses)
|
||||
{
|
||||
var response = new ProvcacheGetResponse
|
||||
{
|
||||
VeriKey = "sha256:test",
|
||||
Status = status,
|
||||
ElapsedMs = 1.0
|
||||
};
|
||||
|
||||
response.Status.Should().Be(status);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -40,6 +40,8 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
services.AddSingleton(_mockChunker.Object);
|
||||
// Add mock IProvcacheService to satisfy the main endpoints
|
||||
services.AddSingleton(Mock.Of<IProvcacheService>());
|
||||
// Add TimeProvider for InputManifest endpoint
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
})
|
||||
.Configure(app =>
|
||||
{
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
// ----------------------------------------------------------------------------
|
||||
// Copyright (c) 2025 StellaOps contributors. All rights reserved.
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Provcache.Oci;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Provcache.Tests.Oci;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ProvcacheOciAttestationBuilder"/>.
|
||||
/// Includes cosign verify-attestation compatibility tests.
|
||||
/// </summary>
|
||||
public sealed class ProvcacheOciAttestationBuilderTests
|
||||
{
|
||||
private readonly ProvcacheOciAttestationBuilder _sut = new();
|
||||
|
||||
private static DecisionDigest CreateTestDigest() => new()
|
||||
{
|
||||
DigestVersion = "v1",
|
||||
VeriKey = "sha256:abc123def456789",
|
||||
VerdictHash = "sha256:verdict123",
|
||||
ProofRoot = "sha256:proof456",
|
||||
TrustScore = 85,
|
||||
ReplaySeed = new ReplaySeed
|
||||
{
|
||||
FeedIds = ["cve-2024", "ghsa-2024"],
|
||||
RuleIds = ["default-policy-v2"],
|
||||
FrozenEpoch = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero)
|
||||
},
|
||||
CreatedAt = new DateTimeOffset(2025, 1, 1, 12, 0, 0, TimeSpan.Zero),
|
||||
ExpiresAt = new DateTimeOffset(2025, 1, 2, 12, 0, 0, TimeSpan.Zero),
|
||||
TrustScoreBreakdown = TrustScoreBreakdown.CreateDefault(80, 90, 85, 75, 95)
|
||||
};
|
||||
|
||||
private static InputManifest CreateTestManifest() => new()
|
||||
{
|
||||
VeriKey = "sha256:abc123def456789",
|
||||
SourceArtifact = new SourceArtifactInfo
|
||||
{
|
||||
Digest = "sha256:source123",
|
||||
ArtifactType = "container-image",
|
||||
OciReference = "ghcr.io/stellaops/test:latest",
|
||||
SizeBytes = 1024 * 1024
|
||||
},
|
||||
Sbom = new SbomInfo
|
||||
{
|
||||
Hash = "sha256:sbom123",
|
||||
Format = "cyclonedx",
|
||||
Version = "1.6",
|
||||
PackageCount = 42,
|
||||
CompletenessScore = 95
|
||||
},
|
||||
Vex = new VexInfo
|
||||
{
|
||||
SetHash = "sha256:vex123",
|
||||
StatementCount = 5,
|
||||
Sources = ["vendor", "osv"]
|
||||
},
|
||||
Policy = new PolicyInfo
|
||||
{
|
||||
Hash = "sha256:policy123",
|
||||
Name = "default-policy",
|
||||
PackId = "stellaops-base",
|
||||
Version = "v2.0"
|
||||
},
|
||||
Signers = new SignerInfo
|
||||
{
|
||||
SetHash = "sha256:signers123",
|
||||
SignerCount = 2,
|
||||
Certificates = []
|
||||
},
|
||||
TimeWindow = new TimeWindowInfo
|
||||
{
|
||||
Bucket = "2025-01-01",
|
||||
StartTime = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
EndTime = new DateTimeOffset(2025, 1, 2, 0, 0, 0, TimeSpan.Zero)
|
||||
}
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Build_ValidRequest_ReturnsValidResult()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ProvcacheOciAttestationRequest
|
||||
{
|
||||
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
|
||||
ArtifactDigest = "sha256:abc123def456",
|
||||
DecisionDigest = CreateTestDigest(),
|
||||
InputManifest = CreateTestManifest()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Build(request);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Statement.Should().NotBeNull();
|
||||
result.StatementJson.Should().NotBeNullOrWhiteSpace();
|
||||
result.StatementBytes.Should().NotBeEmpty();
|
||||
result.MediaType.Should().Be(ProvcachePredicateTypes.MediaType);
|
||||
result.Annotations.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ValidRequest_ProducesValidInTotoStatement()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ProvcacheOciAttestationRequest
|
||||
{
|
||||
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
|
||||
ArtifactDigest = "sha256:abc123def456",
|
||||
DecisionDigest = CreateTestDigest()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Build(request);
|
||||
|
||||
// Assert - In-toto statement format
|
||||
result.Statement.Type.Should().Be("https://in-toto.io/Statement/v1");
|
||||
result.Statement.PredicateType.Should().Be(ProvcachePredicateTypes.ProvcacheV1);
|
||||
result.Statement.Subject.Should().ContainSingle();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ValidRequest_ProducesValidSubject()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ProvcacheOciAttestationRequest
|
||||
{
|
||||
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
|
||||
ArtifactDigest = "sha256:abc123def456",
|
||||
DecisionDigest = CreateTestDigest()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Build(request);
|
||||
|
||||
// Assert
|
||||
var subject = result.Statement.Subject.Single();
|
||||
subject.Name.Should().Be("ghcr.io/stellaops/scanner");
|
||||
subject.Digest.Should().ContainKey("sha256");
|
||||
subject.Digest["sha256"].Should().Be("abc123def456");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ArtifactWithDigestSuffix_ExtractsNameCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ProvcacheOciAttestationRequest
|
||||
{
|
||||
ArtifactReference = "ghcr.io/stellaops/scanner@sha256:abc123def456",
|
||||
ArtifactDigest = "sha256:abc123def456",
|
||||
DecisionDigest = CreateTestDigest()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Build(request);
|
||||
|
||||
// Assert
|
||||
var subject = result.Statement.Subject.Single();
|
||||
subject.Name.Should().Be("ghcr.io/stellaops/scanner");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ValidRequest_IncludesVeriKeyInPredicate()
|
||||
{
|
||||
// Arrange
|
||||
var digest = CreateTestDigest();
|
||||
var request = new ProvcacheOciAttestationRequest
|
||||
{
|
||||
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
|
||||
ArtifactDigest = "sha256:abc123def456",
|
||||
DecisionDigest = digest
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Build(request);
|
||||
|
||||
// Assert
|
||||
result.Statement.Predicate.VeriKey.Should().Be(digest.VeriKey);
|
||||
result.Statement.Predicate.VerdictHash.Should().Be(digest.VerdictHash);
|
||||
result.Statement.Predicate.ProofRoot.Should().Be(digest.ProofRoot);
|
||||
result.Statement.Predicate.TrustScore.Should().Be(digest.TrustScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithTrustScoreBreakdown_IncludesBreakdown()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ProvcacheOciAttestationRequest
|
||||
{
|
||||
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
|
||||
ArtifactDigest = "sha256:abc123def456",
|
||||
DecisionDigest = CreateTestDigest()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Build(request);
|
||||
|
||||
// Assert
|
||||
result.Statement.Predicate.TrustScoreBreakdown.Should().NotBeNull();
|
||||
result.Statement.Predicate.TrustScoreBreakdown!.Reachability.Score.Should().Be(80);
|
||||
result.Statement.Predicate.TrustScoreBreakdown.SbomCompleteness.Score.Should().Be(90);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithInputManifest_IncludesManifestSummary()
|
||||
{
|
||||
// Arrange
|
||||
var manifest = CreateTestManifest();
|
||||
var request = new ProvcacheOciAttestationRequest
|
||||
{
|
||||
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
|
||||
ArtifactDigest = "sha256:abc123def456",
|
||||
DecisionDigest = CreateTestDigest(),
|
||||
InputManifest = manifest
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Build(request);
|
||||
|
||||
// Assert
|
||||
var inputSummary = result.Statement.Predicate.InputManifest;
|
||||
inputSummary.SourceHash.Should().Be(manifest.SourceArtifact.Digest);
|
||||
inputSummary.SbomHash.Should().Be(manifest.Sbom.Hash);
|
||||
inputSummary.VexSetHash.Should().Be(manifest.Vex.SetHash);
|
||||
inputSummary.PolicyHash.Should().Be(manifest.Policy.Hash);
|
||||
inputSummary.SignerSetHash.Should().Be(manifest.Signers.SetHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithVerdictSummary_IncludesSummary()
|
||||
{
|
||||
// Arrange
|
||||
var summary = new ProvcacheVerdictSummary
|
||||
{
|
||||
TotalFindings = 100,
|
||||
Affected = 10,
|
||||
NotAffected = 70,
|
||||
Mitigated = 15,
|
||||
UnderInvestigation = 3,
|
||||
Fixed = 2
|
||||
};
|
||||
var request = new ProvcacheOciAttestationRequest
|
||||
{
|
||||
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
|
||||
ArtifactDigest = "sha256:abc123def456",
|
||||
DecisionDigest = CreateTestDigest(),
|
||||
VerdictSummary = summary
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Build(request);
|
||||
|
||||
// Assert
|
||||
result.Statement.Predicate.VerdictSummary.Should().NotBeNull();
|
||||
result.Statement.Predicate.VerdictSummary!.TotalFindings.Should().Be(100);
|
||||
result.Statement.Predicate.VerdictSummary.Affected.Should().Be(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithTenantAndScope_IncludesInAnnotations()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ProvcacheOciAttestationRequest
|
||||
{
|
||||
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
|
||||
ArtifactDigest = "sha256:abc123def456",
|
||||
DecisionDigest = CreateTestDigest(),
|
||||
TenantId = "acme-corp",
|
||||
Scope = "production"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Build(request);
|
||||
|
||||
// Assert
|
||||
result.Annotations.Should().ContainKey("stellaops.tenant");
|
||||
result.Annotations["stellaops.tenant"].Should().Be("acme-corp");
|
||||
result.Annotations.Should().ContainKey("stellaops.scope");
|
||||
result.Annotations["stellaops.scope"].Should().Be("production");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_AlwaysIncludesRequiredOciAnnotations()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ProvcacheOciAttestationRequest
|
||||
{
|
||||
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
|
||||
ArtifactDigest = "sha256:abc123def456",
|
||||
DecisionDigest = CreateTestDigest()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Build(request);
|
||||
|
||||
// Assert
|
||||
result.Annotations.Should().ContainKey("org.opencontainers.image.title");
|
||||
result.Annotations.Should().ContainKey("org.opencontainers.image.description");
|
||||
result.Annotations.Should().ContainKey("org.opencontainers.image.created");
|
||||
result.Annotations.Should().ContainKey("stellaops.provcache.verikey");
|
||||
result.Annotations.Should().ContainKey("stellaops.provcache.trust-score");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ProducesDeterministicJson()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ProvcacheOciAttestationRequest
|
||||
{
|
||||
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
|
||||
ArtifactDigest = "sha256:abc123def456",
|
||||
DecisionDigest = CreateTestDigest(),
|
||||
InputManifest = CreateTestManifest()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result1 = _sut.Build(request);
|
||||
var result2 = _sut.Build(request);
|
||||
|
||||
// Assert - Same input should produce same output (deterministic)
|
||||
result1.StatementJson.Should().Be(result2.StatementJson);
|
||||
result1.StatementBytes.Should().BeEquivalentTo(result2.StatementBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ProducesValidJson()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ProvcacheOciAttestationRequest
|
||||
{
|
||||
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
|
||||
ArtifactDigest = "sha256:abc123def456",
|
||||
DecisionDigest = CreateTestDigest()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Build(request);
|
||||
|
||||
// Assert - Should be valid JSON
|
||||
var parseAction = () => JsonDocument.Parse(result.StatementJson);
|
||||
parseAction.Should().NotThrow();
|
||||
}
|
||||
|
||||
// ==================== Cosign Compatibility Tests ====================
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the attestation format is compatible with cosign verify-attestation.
|
||||
/// cosign expects the "_type" field to be "https://in-toto.io/Statement/v1".
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Build_CosignCompatible_HasCorrectType()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ProvcacheOciAttestationRequest
|
||||
{
|
||||
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
|
||||
ArtifactDigest = "sha256:abc123def456",
|
||||
DecisionDigest = CreateTestDigest()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Build(request);
|
||||
using var doc = JsonDocument.Parse(result.StatementJson);
|
||||
|
||||
// Assert - cosign requires _type field
|
||||
doc.RootElement.TryGetProperty("_type", out var typeElement).Should().BeTrue();
|
||||
typeElement.GetString().Should().Be("https://in-toto.io/Statement/v1");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the subject array is valid for cosign.
|
||||
/// cosign expects at least one subject with name and digest.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Build_CosignCompatible_HasValidSubject()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ProvcacheOciAttestationRequest
|
||||
{
|
||||
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
|
||||
ArtifactDigest = "sha256:abc123def456",
|
||||
DecisionDigest = CreateTestDigest()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Build(request);
|
||||
using var doc = JsonDocument.Parse(result.StatementJson);
|
||||
|
||||
// Assert - cosign requires subject array with name and digest
|
||||
doc.RootElement.TryGetProperty("subject", out var subjectElement).Should().BeTrue();
|
||||
subjectElement.GetArrayLength().Should().BeGreaterThan(0);
|
||||
|
||||
var firstSubject = subjectElement.EnumerateArray().First();
|
||||
firstSubject.TryGetProperty("name", out var nameElement).Should().BeTrue();
|
||||
firstSubject.TryGetProperty("digest", out var digestElement).Should().BeTrue();
|
||||
|
||||
nameElement.GetString().Should().NotBeNullOrEmpty();
|
||||
digestElement.TryGetProperty("sha256", out var sha256Element).Should().BeTrue();
|
||||
sha256Element.GetString().Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies predicateType is present for cosign filtering.
|
||||
/// cosign verify-attestation --type allows filtering by predicateType.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Build_CosignCompatible_HasPredicateType()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ProvcacheOciAttestationRequest
|
||||
{
|
||||
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
|
||||
ArtifactDigest = "sha256:abc123def456",
|
||||
DecisionDigest = CreateTestDigest()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Build(request);
|
||||
using var doc = JsonDocument.Parse(result.StatementJson);
|
||||
|
||||
// Assert - cosign uses predicateType for filtering
|
||||
doc.RootElement.TryGetProperty("predicateType", out var predicateTypeElement).Should().BeTrue();
|
||||
predicateTypeElement.GetString().Should().Be(ProvcachePredicateTypes.ProvcacheV1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies predicate object is present.
|
||||
/// cosign verify-attestation expects a predicate object.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Build_CosignCompatible_HasPredicate()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ProvcacheOciAttestationRequest
|
||||
{
|
||||
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
|
||||
ArtifactDigest = "sha256:abc123def456",
|
||||
DecisionDigest = CreateTestDigest()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Build(request);
|
||||
using var doc = JsonDocument.Parse(result.StatementJson);
|
||||
|
||||
// Assert - cosign expects predicate object
|
||||
doc.RootElement.TryGetProperty("predicate", out var predicateElement).Should().BeTrue();
|
||||
predicateElement.ValueKind.Should().Be(JsonValueKind.Object);
|
||||
|
||||
// Verify key fields are present in predicate
|
||||
predicateElement.TryGetProperty("veriKey", out _).Should().BeTrue();
|
||||
predicateElement.TryGetProperty("verdictHash", out _).Should().BeTrue();
|
||||
predicateElement.TryGetProperty("proofRoot", out _).Should().BeTrue();
|
||||
predicateElement.TryGetProperty("trustScore", out _).Should().BeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the media type is appropriate for OCI referrers.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Build_CosignCompatible_HasCorrectMediaType()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ProvcacheOciAttestationRequest
|
||||
{
|
||||
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
|
||||
ArtifactDigest = "sha256:abc123def456",
|
||||
DecisionDigest = CreateTestDigest()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.Build(request);
|
||||
|
||||
// Assert
|
||||
result.MediaType.Should().Be("application/vnd.stellaops.provcache.decision+json");
|
||||
}
|
||||
|
||||
// ==================== Validation Tests ====================
|
||||
|
||||
[Fact]
|
||||
public void Build_NullRequest_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
var action = () => _sut.Build(null!);
|
||||
action.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(null)]
|
||||
public void Build_EmptyArtifactReference_ThrowsArgumentException(string? artifactRef)
|
||||
{
|
||||
// Arrange
|
||||
var request = new ProvcacheOciAttestationRequest
|
||||
{
|
||||
ArtifactReference = artifactRef!,
|
||||
ArtifactDigest = "sha256:abc123",
|
||||
DecisionDigest = CreateTestDigest()
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
var action = () => _sut.Build(request);
|
||||
action.Should().Throw<ArgumentException>()
|
||||
.WithParameterName("request");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(null)]
|
||||
public void Build_EmptyArtifactDigest_ThrowsArgumentException(string? digest)
|
||||
{
|
||||
// Arrange
|
||||
var request = new ProvcacheOciAttestationRequest
|
||||
{
|
||||
ArtifactReference = "ghcr.io/test/image:v1",
|
||||
ArtifactDigest = digest!,
|
||||
DecisionDigest = CreateTestDigest()
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
var action = () => _sut.Build(request);
|
||||
action.Should().Throw<ArgumentException>()
|
||||
.WithParameterName("request");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_NullDecisionDigest_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ProvcacheOciAttestationRequest
|
||||
{
|
||||
ArtifactReference = "ghcr.io/test/image:v1",
|
||||
ArtifactDigest = "sha256:abc123",
|
||||
DecisionDigest = null!
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
var action = () => _sut.Build(request);
|
||||
action.Should().Throw<ArgumentException>()
|
||||
.WithParameterName("request");
|
||||
}
|
||||
|
||||
// ==================== CreateAttachment Tests ====================
|
||||
|
||||
[Fact]
|
||||
public void CreateAttachment_ValidRequest_ReturnsValidAttachment()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ProvcacheOciAttestationRequest
|
||||
{
|
||||
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
|
||||
ArtifactDigest = "sha256:abc123def456",
|
||||
DecisionDigest = CreateTestDigest()
|
||||
};
|
||||
|
||||
// Act
|
||||
var attachment = _sut.CreateAttachment(request);
|
||||
|
||||
// Assert
|
||||
attachment.Should().NotBeNull();
|
||||
attachment.ArtifactReference.Should().Be(request.ArtifactReference);
|
||||
attachment.MediaType.Should().Be(ProvcachePredicateTypes.MediaType);
|
||||
attachment.Payload.Should().NotBeNullOrEmpty();
|
||||
attachment.PayloadBytes.Should().NotBeEmpty();
|
||||
attachment.Annotations.Should().NotBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.AddSingleton(_mockService.Object);
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddRouting();
|
||||
services.AddLogging(b => b.SetMinimumLevel(LogLevel.Warning));
|
||||
})
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Provcache;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Provcache.Tests.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ProvcacheTelemetry"/> metrics and activity tracing.
|
||||
/// </summary>
|
||||
public sealed class ProvcacheTelemetryTests
|
||||
{
|
||||
[Fact]
|
||||
public void StartGetActivity_CreatesActivityWithVeriKeyTag()
|
||||
{
|
||||
// Arrange
|
||||
using var listener = CreateActivityListener();
|
||||
const string veriKey = "sha256:abc123def456...";
|
||||
|
||||
// Act
|
||||
using var activity = ProvcacheTelemetry.StartGetActivity(veriKey);
|
||||
|
||||
// Assert
|
||||
activity.Should().NotBeNull();
|
||||
activity!.DisplayName.Should().Be("provcache.get");
|
||||
activity.GetTagItem("provcache.verikey").Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartSetActivity_CreatesActivityWithVeriKeyAndTrustScore()
|
||||
{
|
||||
// Arrange
|
||||
using var listener = CreateActivityListener();
|
||||
const string veriKey = "sha256:abc123def456...";
|
||||
const int trustScore = 85;
|
||||
|
||||
// Act
|
||||
using var activity = ProvcacheTelemetry.StartSetActivity(veriKey, trustScore);
|
||||
|
||||
// Assert
|
||||
activity.Should().NotBeNull();
|
||||
activity!.DisplayName.Should().Be("provcache.set");
|
||||
activity.GetTagItem("provcache.verikey").Should().NotBeNull();
|
||||
activity.GetTagItem("provcache.trust_score").Should().Be(trustScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartInvalidateActivity_CreatesActivityWithTypeAndTarget()
|
||||
{
|
||||
// Arrange
|
||||
using var listener = CreateActivityListener();
|
||||
const string invalidationType = "policy_hash";
|
||||
const string targetValue = "sha256:policy:abc123";
|
||||
|
||||
// Act
|
||||
using var activity = ProvcacheTelemetry.StartInvalidateActivity(invalidationType, targetValue);
|
||||
|
||||
// Assert
|
||||
activity.Should().NotBeNull();
|
||||
activity!.DisplayName.Should().Be("provcache.invalidate");
|
||||
activity.GetTagItem("provcache.invalidation_type").Should().Be(invalidationType);
|
||||
activity.GetTagItem("provcache.target").Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkCacheHit_SetsResultAndSource()
|
||||
{
|
||||
// Arrange
|
||||
using var listener = CreateActivityListener();
|
||||
using var activity = ProvcacheTelemetry.StartGetActivity("sha256:test");
|
||||
|
||||
// Act
|
||||
ProvcacheTelemetry.MarkCacheHit(activity, "valkey");
|
||||
|
||||
// Assert
|
||||
activity.Should().NotBeNull();
|
||||
activity!.GetTagItem("provcache.result").Should().Be("hit");
|
||||
activity.GetTagItem("provcache.source").Should().Be("valkey");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkCacheMiss_SetsResult()
|
||||
{
|
||||
// Arrange
|
||||
using var listener = CreateActivityListener();
|
||||
using var activity = ProvcacheTelemetry.StartGetActivity("sha256:test");
|
||||
|
||||
// Act
|
||||
ProvcacheTelemetry.MarkCacheMiss(activity);
|
||||
|
||||
// Assert
|
||||
activity.Should().NotBeNull();
|
||||
activity!.GetTagItem("provcache.result").Should().Be("miss");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkError_SetsErrorStatusAndResult()
|
||||
{
|
||||
// Arrange
|
||||
using var listener = CreateActivityListener();
|
||||
using var activity = ProvcacheTelemetry.StartGetActivity("sha256:test");
|
||||
const string errorMessage = "Test error";
|
||||
|
||||
// Act
|
||||
ProvcacheTelemetry.MarkError(activity, errorMessage);
|
||||
|
||||
// Assert
|
||||
activity.Should().NotBeNull();
|
||||
activity!.Status.Should().Be(ActivityStatusCode.Error);
|
||||
activity.GetTagItem("provcache.result").Should().Be("error");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordRequest_IncrementsRequestCounter()
|
||||
{
|
||||
// Arrange
|
||||
var measurements = new List<KeyValuePair<string, object?>>();
|
||||
using var meterListener = CreateMeterListener(measurements);
|
||||
|
||||
// Act
|
||||
ProvcacheTelemetry.RecordRequest("get", "hit");
|
||||
|
||||
// Assert - counter should have been incremented
|
||||
measurements.Should().ContainSingle(m => m.Key == "provcache_requests_total");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordHit_IncrementsHitCounter()
|
||||
{
|
||||
// Arrange
|
||||
var measurements = new List<KeyValuePair<string, object?>>();
|
||||
using var meterListener = CreateMeterListener(measurements);
|
||||
|
||||
// Act
|
||||
ProvcacheTelemetry.RecordHit("valkey");
|
||||
|
||||
// Assert
|
||||
measurements.Should().ContainSingle(m => m.Key == "provcache_hits_total");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordMiss_IncrementsMissCounter()
|
||||
{
|
||||
// Arrange
|
||||
var measurements = new List<KeyValuePair<string, object?>>();
|
||||
using var meterListener = CreateMeterListener(measurements);
|
||||
|
||||
// Act
|
||||
ProvcacheTelemetry.RecordMiss();
|
||||
|
||||
// Assert
|
||||
measurements.Should().ContainSingle(m => m.Key == "provcache_misses_total");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordInvalidation_IncrementsInvalidationCounter()
|
||||
{
|
||||
// Arrange
|
||||
var measurements = new List<KeyValuePair<string, object?>>();
|
||||
using var meterListener = CreateMeterListener(measurements);
|
||||
|
||||
// Act
|
||||
ProvcacheTelemetry.RecordInvalidation("policy", 5);
|
||||
|
||||
// Assert
|
||||
measurements.Should().ContainSingle(m => m.Key == "provcache_invalidations_total");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordLatency_RecordsToHistogram()
|
||||
{
|
||||
// Arrange
|
||||
var measurements = new List<KeyValuePair<string, object?>>();
|
||||
using var meterListener = CreateMeterListener(measurements);
|
||||
|
||||
// Act
|
||||
ProvcacheTelemetry.RecordLatency("get", TimeSpan.FromMilliseconds(15.5));
|
||||
|
||||
// Assert
|
||||
measurements.Should().ContainSingle(m => m.Key == "provcache_latency_seconds");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetWriteBehindQueueSize_UpdatesGauge()
|
||||
{
|
||||
// Arrange & Act
|
||||
ProvcacheTelemetry.SetWriteBehindQueueSize(42);
|
||||
|
||||
// Assert - verify through the observable gauge
|
||||
// The gauge will report 42 when observed
|
||||
// We can't directly verify this without full OTel integration,
|
||||
// but we ensure the method doesn't throw
|
||||
var size = ProvcacheTelemetry.WriteBehindQueueGauge;
|
||||
size.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetItemsCount_UpdatesGauge()
|
||||
{
|
||||
// Arrange & Act
|
||||
ProvcacheTelemetry.SetItemsCount(1000);
|
||||
|
||||
// Assert - verify through the observable gauge
|
||||
var gauge = ProvcacheTelemetry.ItemsCountGauge;
|
||||
gauge.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActivitySource_HasCorrectName()
|
||||
{
|
||||
// The activity source should be named correctly for OTel integration
|
||||
using var listener = CreateActivityListener();
|
||||
using var activity = ProvcacheTelemetry.StartGetActivity("test");
|
||||
|
||||
activity.Should().NotBeNull();
|
||||
activity!.Source.Name.Should().Be("StellaOps.Provcache");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartWriteBehindFlushActivity_CreatesBatchActivity()
|
||||
{
|
||||
// Arrange
|
||||
using var listener = CreateActivityListener();
|
||||
const int batchSize = 10;
|
||||
|
||||
// Act
|
||||
using var activity = ProvcacheTelemetry.StartWriteBehindFlushActivity(batchSize);
|
||||
|
||||
// Assert
|
||||
activity.Should().NotBeNull();
|
||||
activity!.DisplayName.Should().Be("provcache.writebehind.flush");
|
||||
activity.GetTagItem("provcache.batch_size").Should().Be(batchSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartVeriKeyBuildActivity_CreatesActivity()
|
||||
{
|
||||
// Arrange
|
||||
using var listener = CreateActivityListener();
|
||||
|
||||
// Act
|
||||
using var activity = ProvcacheTelemetry.StartVeriKeyBuildActivity();
|
||||
|
||||
// Assert
|
||||
activity.Should().NotBeNull();
|
||||
activity!.DisplayName.Should().Be("provcache.verikey.build");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartDecisionDigestBuildActivity_CreatesActivity()
|
||||
{
|
||||
// Arrange
|
||||
using var listener = CreateActivityListener();
|
||||
|
||||
// Act
|
||||
using var activity = ProvcacheTelemetry.StartDecisionDigestBuildActivity();
|
||||
|
||||
// Assert
|
||||
activity.Should().NotBeNull();
|
||||
activity!.DisplayName.Should().Be("provcache.digest.build");
|
||||
}
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static ActivityListener CreateActivityListener()
|
||||
{
|
||||
var listener = new ActivityListener
|
||||
{
|
||||
ShouldListenTo = source => source.Name == "StellaOps.Provcache",
|
||||
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded,
|
||||
SampleUsingParentId = (ref ActivityCreationOptions<string> _) => ActivitySamplingResult.AllDataAndRecorded
|
||||
};
|
||||
|
||||
ActivitySource.AddActivityListener(listener);
|
||||
return listener;
|
||||
}
|
||||
|
||||
private static MeterListener CreateMeterListener(List<KeyValuePair<string, object?>> measurements)
|
||||
{
|
||||
var listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (instrument, listener) =>
|
||||
{
|
||||
if (instrument.Meter.Name == "StellaOps.Provcache")
|
||||
{
|
||||
listener.EnableMeasurementEvents(instrument);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
|
||||
{
|
||||
measurements.Add(new KeyValuePair<string, object?>(instrument.Name, measurement));
|
||||
});
|
||||
|
||||
listener.SetMeasurementEventCallback<double>((instrument, measurement, tags, state) =>
|
||||
{
|
||||
measurements.Add(new KeyValuePair<string, object?>(instrument.Name, measurement));
|
||||
});
|
||||
|
||||
listener.Start();
|
||||
return listener;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user