Implement VEX document verification system with issuer management and signature verification

- Added IIssuerDirectory interface for managing VEX document issuers, including methods for registration, revocation, and trust validation.
- Created InMemoryIssuerDirectory class as an in-memory implementation of IIssuerDirectory for testing and single-instance deployments.
- Introduced ISignatureVerifier interface for verifying signatures on VEX documents, with support for multiple signature formats.
- Developed SignatureVerifier class as the default implementation of ISignatureVerifier, allowing extensibility for different signature formats.
- Implemented handlers for DSSE and JWS signature formats, including methods for verification and signature extraction.
- Defined various records and enums for issuer and signature metadata, enhancing the structure and clarity of the verification process.
This commit is contained in:
StellaOps Bot
2025-12-06 13:41:22 +02:00
parent 2141196496
commit 5e514532df
112 changed files with 24861 additions and 211 deletions

View File

@@ -0,0 +1,178 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.Attestation;
/// <summary>
/// Status of an attestation report section.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<AttestationReportStatus>))]
public enum AttestationReportStatus
{
Pass,
Fail,
Warn,
Skipped,
Pending
}
/// <summary>
/// Aggregated attestation report for an artifact per CONTRACT-VERIFICATION-POLICY-006.
/// </summary>
public sealed record ArtifactAttestationReport(
[property: JsonPropertyName("artifact_digest")] string ArtifactDigest,
[property: JsonPropertyName("artifact_uri")] string? ArtifactUri,
[property: JsonPropertyName("overall_status")] AttestationReportStatus OverallStatus,
[property: JsonPropertyName("attestation_count")] int AttestationCount,
[property: JsonPropertyName("verification_results")] IReadOnlyList<AttestationVerificationSummary> VerificationResults,
[property: JsonPropertyName("policy_compliance")] PolicyComplianceSummary PolicyCompliance,
[property: JsonPropertyName("coverage")] AttestationCoverageSummary Coverage,
[property: JsonPropertyName("evaluated_at")] DateTimeOffset EvaluatedAt);
/// <summary>
/// Summary of a single attestation verification.
/// </summary>
public sealed record AttestationVerificationSummary(
[property: JsonPropertyName("attestation_id")] string AttestationId,
[property: JsonPropertyName("predicate_type")] string PredicateType,
[property: JsonPropertyName("status")] AttestationReportStatus Status,
[property: JsonPropertyName("policy_id")] string? PolicyId,
[property: JsonPropertyName("policy_version")] string? PolicyVersion,
[property: JsonPropertyName("signature_status")] SignatureVerificationStatus SignatureStatus,
[property: JsonPropertyName("freshness_status")] FreshnessVerificationStatus FreshnessStatus,
[property: JsonPropertyName("transparency_status")] TransparencyVerificationStatus TransparencyStatus,
[property: JsonPropertyName("issues")] IReadOnlyList<string> Issues,
[property: JsonPropertyName("created_at")] DateTimeOffset CreatedAt);
/// <summary>
/// Signature verification status.
/// </summary>
public sealed record SignatureVerificationStatus(
[property: JsonPropertyName("status")] AttestationReportStatus Status,
[property: JsonPropertyName("total_signatures")] int TotalSignatures,
[property: JsonPropertyName("verified_signatures")] int VerifiedSignatures,
[property: JsonPropertyName("required_signatures")] int RequiredSignatures,
[property: JsonPropertyName("signers")] IReadOnlyList<SignerVerificationInfo> Signers);
/// <summary>
/// Signer verification information.
/// </summary>
public sealed record SignerVerificationInfo(
[property: JsonPropertyName("key_fingerprint")] string KeyFingerprint,
[property: JsonPropertyName("issuer")] string? Issuer,
[property: JsonPropertyName("subject")] string? Subject,
[property: JsonPropertyName("algorithm")] string Algorithm,
[property: JsonPropertyName("verified")] bool Verified,
[property: JsonPropertyName("trusted")] bool Trusted);
/// <summary>
/// Freshness verification status.
/// </summary>
public sealed record FreshnessVerificationStatus(
[property: JsonPropertyName("status")] AttestationReportStatus Status,
[property: JsonPropertyName("created_at")] DateTimeOffset CreatedAt,
[property: JsonPropertyName("age_seconds")] int AgeSeconds,
[property: JsonPropertyName("max_age_seconds")] int? MaxAgeSeconds,
[property: JsonPropertyName("is_fresh")] bool IsFresh);
/// <summary>
/// Transparency log verification status.
/// </summary>
public sealed record TransparencyVerificationStatus(
[property: JsonPropertyName("status")] AttestationReportStatus Status,
[property: JsonPropertyName("rekor_entry")] RekorEntryInfo? RekorEntry,
[property: JsonPropertyName("inclusion_verified")] bool InclusionVerified);
/// <summary>
/// Rekor transparency log entry information.
/// </summary>
public sealed record RekorEntryInfo(
[property: JsonPropertyName("uuid")] string Uuid,
[property: JsonPropertyName("log_index")] long LogIndex,
[property: JsonPropertyName("log_url")] string? LogUrl,
[property: JsonPropertyName("integrated_time")] DateTimeOffset IntegratedTime);
/// <summary>
/// Summary of policy compliance for an artifact.
/// </summary>
public sealed record PolicyComplianceSummary(
[property: JsonPropertyName("status")] AttestationReportStatus Status,
[property: JsonPropertyName("policies_evaluated")] int PoliciesEvaluated,
[property: JsonPropertyName("policies_passed")] int PoliciesPassed,
[property: JsonPropertyName("policies_failed")] int PoliciesFailed,
[property: JsonPropertyName("policies_warned")] int PoliciesWarned,
[property: JsonPropertyName("policy_results")] IReadOnlyList<PolicyEvaluationSummary> PolicyResults);
/// <summary>
/// Summary of a policy evaluation.
/// </summary>
public sealed record PolicyEvaluationSummary(
[property: JsonPropertyName("policy_id")] string PolicyId,
[property: JsonPropertyName("policy_version")] string PolicyVersion,
[property: JsonPropertyName("status")] AttestationReportStatus Status,
[property: JsonPropertyName("verdict")] string Verdict,
[property: JsonPropertyName("issues")] IReadOnlyList<string> Issues);
/// <summary>
/// Summary of attestation coverage for an artifact.
/// </summary>
public sealed record AttestationCoverageSummary(
[property: JsonPropertyName("predicate_types_required")] IReadOnlyList<string> PredicateTypesRequired,
[property: JsonPropertyName("predicate_types_present")] IReadOnlyList<string> PredicateTypesPresent,
[property: JsonPropertyName("predicate_types_missing")] IReadOnlyList<string> PredicateTypesMissing,
[property: JsonPropertyName("coverage_percentage")] double CoveragePercentage,
[property: JsonPropertyName("is_complete")] bool IsComplete);
/// <summary>
/// Query options for attestation reports.
/// </summary>
public sealed record AttestationReportQuery(
[property: JsonPropertyName("artifact_digests")] IReadOnlyList<string>? ArtifactDigests,
[property: JsonPropertyName("artifact_uri_pattern")] string? ArtifactUriPattern,
[property: JsonPropertyName("policy_ids")] IReadOnlyList<string>? PolicyIds,
[property: JsonPropertyName("predicate_types")] IReadOnlyList<string>? PredicateTypes,
[property: JsonPropertyName("status_filter")] IReadOnlyList<AttestationReportStatus>? StatusFilter,
[property: JsonPropertyName("from_time")] DateTimeOffset? FromTime,
[property: JsonPropertyName("to_time")] DateTimeOffset? ToTime,
[property: JsonPropertyName("include_details")] bool IncludeDetails,
[property: JsonPropertyName("limit")] int Limit = 100,
[property: JsonPropertyName("offset")] int Offset = 0);
/// <summary>
/// Response containing attestation reports.
/// </summary>
public sealed record AttestationReportListResponse(
[property: JsonPropertyName("reports")] IReadOnlyList<ArtifactAttestationReport> Reports,
[property: JsonPropertyName("total")] int Total,
[property: JsonPropertyName("limit")] int Limit,
[property: JsonPropertyName("offset")] int Offset);
/// <summary>
/// Aggregated attestation statistics.
/// </summary>
public sealed record AttestationStatistics(
[property: JsonPropertyName("total_artifacts")] int TotalArtifacts,
[property: JsonPropertyName("total_attestations")] int TotalAttestations,
[property: JsonPropertyName("status_distribution")] IReadOnlyDictionary<AttestationReportStatus, int> StatusDistribution,
[property: JsonPropertyName("predicate_type_distribution")] IReadOnlyDictionary<string, int> PredicateTypeDistribution,
[property: JsonPropertyName("policy_distribution")] IReadOnlyDictionary<string, int> PolicyDistribution,
[property: JsonPropertyName("average_age_seconds")] double AverageAgeSeconds,
[property: JsonPropertyName("coverage_rate")] double CoverageRate,
[property: JsonPropertyName("evaluated_at")] DateTimeOffset EvaluatedAt);
/// <summary>
/// Request to verify attestations for an artifact.
/// </summary>
public sealed record VerifyArtifactRequest(
[property: JsonPropertyName("artifact_digest")] string ArtifactDigest,
[property: JsonPropertyName("artifact_uri")] string? ArtifactUri,
[property: JsonPropertyName("policy_ids")] IReadOnlyList<string>? PolicyIds,
[property: JsonPropertyName("include_transparency")] bool IncludeTransparency = true);
/// <summary>
/// Stored attestation report entry.
/// </summary>
public sealed record StoredAttestationReport(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("report")] ArtifactAttestationReport Report,
[property: JsonPropertyName("stored_at")] DateTimeOffset StoredAt,
[property: JsonPropertyName("expires_at")] DateTimeOffset? ExpiresAt);

View File

@@ -0,0 +1,394 @@
using Microsoft.Extensions.Logging;
namespace StellaOps.Policy.Engine.Attestation;
/// <summary>
/// Service for managing attestation reports per CONTRACT-VERIFICATION-POLICY-006.
/// </summary>
internal sealed class AttestationReportService : IAttestationReportService
{
private readonly IAttestationReportStore _store;
private readonly IVerificationPolicyStore _policyStore;
private readonly TimeProvider _timeProvider;
private readonly ILogger<AttestationReportService> _logger;
private static readonly TimeSpan DefaultTtl = TimeSpan.FromDays(7);
public AttestationReportService(
IAttestationReportStore store,
IVerificationPolicyStore policyStore,
TimeProvider timeProvider,
ILogger<AttestationReportService> logger)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_policyStore = policyStore ?? throw new ArgumentNullException(nameof(policyStore));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<ArtifactAttestationReport?> GetReportAsync(string artifactDigest, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
var stored = await _store.GetAsync(artifactDigest, cancellationToken).ConfigureAwait(false);
if (stored == null)
{
return null;
}
// Check if expired
if (stored.ExpiresAt.HasValue && stored.ExpiresAt.Value <= _timeProvider.GetUtcNow())
{
_logger.LogDebug("Report for artifact {ArtifactDigest} has expired", artifactDigest);
return null;
}
return stored.Report;
}
public async Task<AttestationReportListResponse> ListReportsAsync(AttestationReportQuery query, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
var reports = await _store.ListAsync(query, cancellationToken).ConfigureAwait(false);
var total = await _store.CountAsync(query, cancellationToken).ConfigureAwait(false);
var artifactReports = reports
.Where(r => !r.ExpiresAt.HasValue || r.ExpiresAt.Value > _timeProvider.GetUtcNow())
.Select(r => r.Report)
.ToList();
return new AttestationReportListResponse(
Reports: artifactReports,
Total: total,
Limit: query.Limit,
Offset: query.Offset);
}
public async Task<ArtifactAttestationReport> GenerateReportAsync(VerifyArtifactRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(request.ArtifactDigest);
var now = _timeProvider.GetUtcNow();
// Get applicable policies
var policies = await GetApplicablePoliciesAsync(request.PolicyIds, cancellationToken).ConfigureAwait(false);
// Generate verification results (simulated - would connect to actual Attestor service)
var verificationResults = await GenerateVerificationResultsAsync(request, policies, now, cancellationToken).ConfigureAwait(false);
// Calculate policy compliance
var policyCompliance = CalculatePolicyCompliance(policies, verificationResults);
// Calculate coverage
var coverage = CalculateCoverage(policies, verificationResults);
// Determine overall status
var overallStatus = DetermineOverallStatus(verificationResults, policyCompliance);
var report = new ArtifactAttestationReport(
ArtifactDigest: request.ArtifactDigest,
ArtifactUri: request.ArtifactUri,
OverallStatus: overallStatus,
AttestationCount: verificationResults.Count,
VerificationResults: verificationResults,
PolicyCompliance: policyCompliance,
Coverage: coverage,
EvaluatedAt: now);
_logger.LogInformation(
"Generated attestation report for artifact {ArtifactDigest} with status {Status}",
request.ArtifactDigest,
overallStatus);
return report;
}
public async Task<StoredAttestationReport> StoreReportAsync(ArtifactAttestationReport report, TimeSpan? ttl = null, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(report);
var now = _timeProvider.GetUtcNow();
var expiresAt = now.Add(ttl ?? DefaultTtl);
var storedReport = new StoredAttestationReport(
Id: $"report-{report.ArtifactDigest}-{now.Ticks}",
Report: report,
StoredAt: now,
ExpiresAt: expiresAt);
await _store.CreateAsync(storedReport, cancellationToken).ConfigureAwait(false);
_logger.LogDebug(
"Stored attestation report for artifact {ArtifactDigest}, expires at {ExpiresAt}",
report.ArtifactDigest,
expiresAt);
return storedReport;
}
public async Task<AttestationStatistics> GetStatisticsAsync(AttestationReportQuery? filter = null, CancellationToken cancellationToken = default)
{
var query = filter ?? new AttestationReportQuery(
ArtifactDigests: null,
ArtifactUriPattern: null,
PolicyIds: null,
PredicateTypes: null,
StatusFilter: null,
FromTime: null,
ToTime: null,
IncludeDetails: false,
Limit: int.MaxValue,
Offset: 0);
var reports = await _store.ListAsync(query, cancellationToken).ConfigureAwait(false);
var now = _timeProvider.GetUtcNow();
// Filter expired
var validReports = reports
.Where(r => !r.ExpiresAt.HasValue || r.ExpiresAt.Value > now)
.ToList();
var statusDistribution = validReports
.GroupBy(r => r.Report.OverallStatus)
.ToDictionary(g => g.Key, g => g.Count());
var predicateTypeDistribution = validReports
.SelectMany(r => r.Report.VerificationResults)
.GroupBy(v => v.PredicateType)
.ToDictionary(g => g.Key, g => g.Count());
var policyDistribution = validReports
.SelectMany(r => r.Report.VerificationResults)
.Where(v => v.PolicyId != null)
.GroupBy(v => v.PolicyId!)
.ToDictionary(g => g.Key, g => g.Count());
var totalAttestations = validReports.Sum(r => r.Report.AttestationCount);
var averageAgeSeconds = validReports.Count > 0
? validReports.Average(r => (now - r.Report.EvaluatedAt).TotalSeconds)
: 0;
var coverageRate = validReports.Count > 0
? validReports.Average(r => r.Report.Coverage.CoveragePercentage)
: 0;
return new AttestationStatistics(
TotalArtifacts: validReports.Count,
TotalAttestations: totalAttestations,
StatusDistribution: statusDistribution,
PredicateTypeDistribution: predicateTypeDistribution,
PolicyDistribution: policyDistribution,
AverageAgeSeconds: averageAgeSeconds,
CoverageRate: coverageRate,
EvaluatedAt: now);
}
public async Task<int> PurgeExpiredReportsAsync(CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
var count = await _store.DeleteExpiredAsync(now, cancellationToken).ConfigureAwait(false);
if (count > 0)
{
_logger.LogInformation("Purged {Count} expired attestation reports", count);
}
return count;
}
private async Task<IReadOnlyList<VerificationPolicy>> GetApplicablePoliciesAsync(
IReadOnlyList<string>? policyIds,
CancellationToken cancellationToken)
{
if (policyIds is { Count: > 0 })
{
var policies = new List<VerificationPolicy>();
foreach (var policyId in policyIds)
{
var policy = await _policyStore.GetAsync(policyId, cancellationToken).ConfigureAwait(false);
if (policy != null)
{
policies.Add(policy);
}
}
return policies;
}
// Get all policies if none specified
return await _policyStore.ListAsync(null, cancellationToken).ConfigureAwait(false);
}
private Task<IReadOnlyList<AttestationVerificationSummary>> GenerateVerificationResultsAsync(
VerifyArtifactRequest request,
IReadOnlyList<VerificationPolicy> policies,
DateTimeOffset now,
CancellationToken cancellationToken)
{
// This would normally connect to the Attestor service to verify actual attestations
// For now, generate placeholder results based on policies
var results = new List<AttestationVerificationSummary>();
foreach (var policy in policies)
{
foreach (var predicateType in policy.PredicateTypes)
{
// Simulated verification result
results.Add(new AttestationVerificationSummary(
AttestationId: $"attest-{Guid.NewGuid():N}",
PredicateType: predicateType,
Status: AttestationReportStatus.Pending,
PolicyId: policy.PolicyId,
PolicyVersion: policy.Version,
SignatureStatus: new SignatureVerificationStatus(
Status: AttestationReportStatus.Pending,
TotalSignatures: 0,
VerifiedSignatures: 0,
RequiredSignatures: policy.SignerRequirements.MinimumSignatures,
Signers: []),
FreshnessStatus: new FreshnessVerificationStatus(
Status: AttestationReportStatus.Pending,
CreatedAt: now,
AgeSeconds: 0,
MaxAgeSeconds: policy.ValidityWindow?.MaxAttestationAge,
IsFresh: true),
TransparencyStatus: new TransparencyVerificationStatus(
Status: policy.SignerRequirements.RequireRekor
? AttestationReportStatus.Pending
: AttestationReportStatus.Skipped,
RekorEntry: null,
InclusionVerified: false),
Issues: [],
CreatedAt: now));
}
}
return Task.FromResult<IReadOnlyList<AttestationVerificationSummary>>(results);
}
private static PolicyComplianceSummary CalculatePolicyCompliance(
IReadOnlyList<VerificationPolicy> policies,
IReadOnlyList<AttestationVerificationSummary> results)
{
var policyResults = new List<PolicyEvaluationSummary>();
var passed = 0;
var failed = 0;
var warned = 0;
foreach (var policy in policies)
{
var policyVerifications = results.Where(r => r.PolicyId == policy.PolicyId).ToList();
var status = AttestationReportStatus.Pending;
var verdict = "pending";
var issues = new List<string>();
if (policyVerifications.All(v => v.Status == AttestationReportStatus.Pass))
{
status = AttestationReportStatus.Pass;
verdict = "compliant";
passed++;
}
else if (policyVerifications.Any(v => v.Status == AttestationReportStatus.Fail))
{
status = AttestationReportStatus.Fail;
verdict = "non-compliant";
failed++;
issues.AddRange(policyVerifications.SelectMany(v => v.Issues));
}
else if (policyVerifications.Any(v => v.Status == AttestationReportStatus.Warn))
{
status = AttestationReportStatus.Warn;
verdict = "warning";
warned++;
}
policyResults.Add(new PolicyEvaluationSummary(
PolicyId: policy.PolicyId,
PolicyVersion: policy.Version,
Status: status,
Verdict: verdict,
Issues: issues));
}
var overallStatus = failed > 0
? AttestationReportStatus.Fail
: warned > 0
? AttestationReportStatus.Warn
: passed > 0
? AttestationReportStatus.Pass
: AttestationReportStatus.Pending;
return new PolicyComplianceSummary(
Status: overallStatus,
PoliciesEvaluated: policies.Count,
PoliciesPassed: passed,
PoliciesFailed: failed,
PoliciesWarned: warned,
PolicyResults: policyResults);
}
private static AttestationCoverageSummary CalculateCoverage(
IReadOnlyList<VerificationPolicy> policies,
IReadOnlyList<AttestationVerificationSummary> results)
{
var requiredTypes = policies
.SelectMany(p => p.PredicateTypes)
.Distinct()
.ToList();
var presentTypes = results
.Select(r => r.PredicateType)
.Distinct()
.ToList();
var missingTypes = requiredTypes.Except(presentTypes).ToList();
var coveragePercentage = requiredTypes.Count > 0
? (double)(requiredTypes.Count - missingTypes.Count) / requiredTypes.Count * 100
: 100;
return new AttestationCoverageSummary(
PredicateTypesRequired: requiredTypes,
PredicateTypesPresent: presentTypes,
PredicateTypesMissing: missingTypes,
CoveragePercentage: Math.Round(coveragePercentage, 2),
IsComplete: missingTypes.Count == 0);
}
private static AttestationReportStatus DetermineOverallStatus(
IReadOnlyList<AttestationVerificationSummary> results,
PolicyComplianceSummary compliance)
{
if (compliance.Status == AttestationReportStatus.Fail)
{
return AttestationReportStatus.Fail;
}
if (results.Any(r => r.Status == AttestationReportStatus.Fail))
{
return AttestationReportStatus.Fail;
}
if (compliance.Status == AttestationReportStatus.Warn ||
results.Any(r => r.Status == AttestationReportStatus.Warn))
{
return AttestationReportStatus.Warn;
}
if (results.All(r => r.Status == AttestationReportStatus.Pass))
{
return AttestationReportStatus.Pass;
}
if (results.All(r => r.Status == AttestationReportStatus.Pending))
{
return AttestationReportStatus.Pending;
}
return AttestationReportStatus.Skipped;
}
}

View File

@@ -0,0 +1,97 @@
namespace StellaOps.Policy.Engine.Attestation;
/// <summary>
/// Service for managing and querying attestation reports per CONTRACT-VERIFICATION-POLICY-006.
/// </summary>
public interface IAttestationReportService
{
/// <summary>
/// Gets an attestation report for a specific artifact.
/// </summary>
Task<ArtifactAttestationReport?> GetReportAsync(
string artifactDigest,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists attestation reports matching the query.
/// </summary>
Task<AttestationReportListResponse> ListReportsAsync(
AttestationReportQuery query,
CancellationToken cancellationToken = default);
/// <summary>
/// Generates an attestation report for an artifact by verifying its attestations.
/// </summary>
Task<ArtifactAttestationReport> GenerateReportAsync(
VerifyArtifactRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Stores an attestation report.
/// </summary>
Task<StoredAttestationReport> StoreReportAsync(
ArtifactAttestationReport report,
TimeSpan? ttl = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets aggregated attestation statistics.
/// </summary>
Task<AttestationStatistics> GetStatisticsAsync(
AttestationReportQuery? filter = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes expired attestation reports.
/// </summary>
Task<int> PurgeExpiredReportsAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// Store for persisting attestation reports.
/// </summary>
public interface IAttestationReportStore
{
/// <summary>
/// Gets a stored report by artifact digest.
/// </summary>
Task<StoredAttestationReport?> GetAsync(
string artifactDigest,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists stored reports matching the query.
/// </summary>
Task<IReadOnlyList<StoredAttestationReport>> ListAsync(
AttestationReportQuery query,
CancellationToken cancellationToken = default);
/// <summary>
/// Counts stored reports matching the query.
/// </summary>
Task<int> CountAsync(
AttestationReportQuery? query = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Stores a report.
/// </summary>
Task<StoredAttestationReport> CreateAsync(
StoredAttestationReport report,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates a stored report.
/// </summary>
Task<StoredAttestationReport?> UpdateAsync(
string artifactDigest,
Func<StoredAttestationReport, StoredAttestationReport> update,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes expired reports.
/// </summary>
Task<int> DeleteExpiredAsync(
DateTimeOffset now,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,44 @@
namespace StellaOps.Policy.Engine.Attestation;
/// <summary>
/// Interface for persisting verification policies per CONTRACT-VERIFICATION-POLICY-006.
/// </summary>
public interface IVerificationPolicyStore
{
/// <summary>
/// Gets a policy by ID.
/// </summary>
Task<VerificationPolicy?> GetAsync(string policyId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all policies for a tenant scope.
/// </summary>
Task<IReadOnlyList<VerificationPolicy>> ListAsync(
string? tenantScope = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates a new policy.
/// </summary>
Task<VerificationPolicy> CreateAsync(
VerificationPolicy policy,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing policy.
/// </summary>
Task<VerificationPolicy?> UpdateAsync(
string policyId,
Func<VerificationPolicy, VerificationPolicy> update,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a policy.
/// </summary>
Task<bool> DeleteAsync(string policyId, CancellationToken cancellationToken = default);
/// <summary>
/// Checks if a policy exists.
/// </summary>
Task<bool> ExistsAsync(string policyId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,188 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
namespace StellaOps.Policy.Engine.Attestation;
/// <summary>
/// In-memory implementation of attestation report store per CONTRACT-VERIFICATION-POLICY-006.
/// </summary>
internal sealed class InMemoryAttestationReportStore : IAttestationReportStore
{
private readonly ConcurrentDictionary<string, StoredAttestationReport> _reports = new(StringComparer.OrdinalIgnoreCase);
public Task<StoredAttestationReport?> GetAsync(string artifactDigest, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
_reports.TryGetValue(artifactDigest, out var report);
return Task.FromResult(report);
}
public Task<IReadOnlyList<StoredAttestationReport>> ListAsync(AttestationReportQuery query, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
IEnumerable<StoredAttestationReport> reports = _reports.Values;
// Filter by artifact digests
if (query.ArtifactDigests is { Count: > 0 })
{
var digestSet = query.ArtifactDigests.ToHashSet(StringComparer.OrdinalIgnoreCase);
reports = reports.Where(r => digestSet.Contains(r.Report.ArtifactDigest));
}
// Filter by artifact URI pattern
if (!string.IsNullOrWhiteSpace(query.ArtifactUriPattern))
{
var pattern = new Regex(query.ArtifactUriPattern, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1));
reports = reports.Where(r => r.Report.ArtifactUri != null && pattern.IsMatch(r.Report.ArtifactUri));
}
// Filter by policy IDs
if (query.PolicyIds is { Count: > 0 })
{
var policySet = query.PolicyIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
reports = reports.Where(r =>
r.Report.VerificationResults.Any(v =>
v.PolicyId != null && policySet.Contains(v.PolicyId)));
}
// Filter by predicate types
if (query.PredicateTypes is { Count: > 0 })
{
var predicateSet = query.PredicateTypes.ToHashSet(StringComparer.Ordinal);
reports = reports.Where(r =>
r.Report.VerificationResults.Any(v => predicateSet.Contains(v.PredicateType)));
}
// Filter by status
if (query.StatusFilter is { Count: > 0 })
{
var statusSet = query.StatusFilter.ToHashSet();
reports = reports.Where(r => statusSet.Contains(r.Report.OverallStatus));
}
// Filter by time range
if (query.FromTime.HasValue)
{
reports = reports.Where(r => r.Report.EvaluatedAt >= query.FromTime.Value);
}
if (query.ToTime.HasValue)
{
reports = reports.Where(r => r.Report.EvaluatedAt <= query.ToTime.Value);
}
// Order by evaluated time descending
var result = reports
.OrderByDescending(r => r.Report.EvaluatedAt)
.Skip(query.Offset)
.Take(query.Limit)
.ToList() as IReadOnlyList<StoredAttestationReport>;
return Task.FromResult(result);
}
public Task<int> CountAsync(AttestationReportQuery? query = null, CancellationToken cancellationToken = default)
{
if (query == null)
{
return Task.FromResult(_reports.Count);
}
IEnumerable<StoredAttestationReport> reports = _reports.Values;
// Apply same filters as ListAsync but only count
if (query.ArtifactDigests is { Count: > 0 })
{
var digestSet = query.ArtifactDigests.ToHashSet(StringComparer.OrdinalIgnoreCase);
reports = reports.Where(r => digestSet.Contains(r.Report.ArtifactDigest));
}
if (!string.IsNullOrWhiteSpace(query.ArtifactUriPattern))
{
var pattern = new Regex(query.ArtifactUriPattern, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1));
reports = reports.Where(r => r.Report.ArtifactUri != null && pattern.IsMatch(r.Report.ArtifactUri));
}
if (query.PolicyIds is { Count: > 0 })
{
var policySet = query.PolicyIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
reports = reports.Where(r =>
r.Report.VerificationResults.Any(v =>
v.PolicyId != null && policySet.Contains(v.PolicyId)));
}
if (query.PredicateTypes is { Count: > 0 })
{
var predicateSet = query.PredicateTypes.ToHashSet(StringComparer.Ordinal);
reports = reports.Where(r =>
r.Report.VerificationResults.Any(v => predicateSet.Contains(v.PredicateType)));
}
if (query.StatusFilter is { Count: > 0 })
{
var statusSet = query.StatusFilter.ToHashSet();
reports = reports.Where(r => statusSet.Contains(r.Report.OverallStatus));
}
if (query.FromTime.HasValue)
{
reports = reports.Where(r => r.Report.EvaluatedAt >= query.FromTime.Value);
}
if (query.ToTime.HasValue)
{
reports = reports.Where(r => r.Report.EvaluatedAt <= query.ToTime.Value);
}
return Task.FromResult(reports.Count());
}
public Task<StoredAttestationReport> CreateAsync(StoredAttestationReport report, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(report);
// Upsert behavior - replace if exists
_reports[report.Report.ArtifactDigest] = report;
return Task.FromResult(report);
}
public Task<StoredAttestationReport?> UpdateAsync(
string artifactDigest,
Func<StoredAttestationReport, StoredAttestationReport> update,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
ArgumentNullException.ThrowIfNull(update);
if (!_reports.TryGetValue(artifactDigest, out var existing))
{
return Task.FromResult<StoredAttestationReport?>(null);
}
var updated = update(existing);
_reports[artifactDigest] = updated;
return Task.FromResult<StoredAttestationReport?>(updated);
}
public Task<int> DeleteExpiredAsync(DateTimeOffset now, CancellationToken cancellationToken = default)
{
var expired = _reports.Values
.Where(r => r.ExpiresAt.HasValue && r.ExpiresAt.Value <= now)
.Select(r => r.Report.ArtifactDigest)
.ToList();
var count = 0;
foreach (var digest in expired)
{
if (_reports.TryRemove(digest, out _))
{
count++;
}
}
return Task.FromResult(count);
}
}

View File

@@ -0,0 +1,86 @@
using System.Collections.Concurrent;
namespace StellaOps.Policy.Engine.Attestation;
/// <summary>
/// In-memory implementation of verification policy store per CONTRACT-VERIFICATION-POLICY-006.
/// </summary>
internal sealed class InMemoryVerificationPolicyStore : IVerificationPolicyStore
{
private readonly ConcurrentDictionary<string, VerificationPolicy> _policies = new(StringComparer.OrdinalIgnoreCase);
public Task<VerificationPolicy?> GetAsync(string policyId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(policyId);
_policies.TryGetValue(policyId, out var policy);
return Task.FromResult(policy);
}
public Task<IReadOnlyList<VerificationPolicy>> ListAsync(
string? tenantScope = null,
CancellationToken cancellationToken = default)
{
IEnumerable<VerificationPolicy> policies = _policies.Values;
if (!string.IsNullOrWhiteSpace(tenantScope))
{
policies = policies.Where(p =>
p.TenantScope == "*" ||
p.TenantScope.Equals(tenantScope, StringComparison.OrdinalIgnoreCase));
}
var result = policies
.OrderBy(p => p.PolicyId)
.ToList() as IReadOnlyList<VerificationPolicy>;
return Task.FromResult(result);
}
public Task<VerificationPolicy> CreateAsync(
VerificationPolicy policy,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(policy);
if (!_policies.TryAdd(policy.PolicyId, policy))
{
throw new InvalidOperationException($"Policy '{policy.PolicyId}' already exists.");
}
return Task.FromResult(policy);
}
public Task<VerificationPolicy?> UpdateAsync(
string policyId,
Func<VerificationPolicy, VerificationPolicy> update,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(policyId);
ArgumentNullException.ThrowIfNull(update);
if (!_policies.TryGetValue(policyId, out var existing))
{
return Task.FromResult<VerificationPolicy?>(null);
}
var updated = update(existing);
_policies[policyId] = updated;
return Task.FromResult<VerificationPolicy?>(updated);
}
public Task<bool> DeleteAsync(string policyId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(policyId);
return Task.FromResult(_policies.TryRemove(policyId, out _));
}
public Task<bool> ExistsAsync(string policyId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(policyId);
return Task.FromResult(_policies.ContainsKey(policyId));
}
}

View File

@@ -0,0 +1,264 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.Attestation;
/// <summary>
/// Editor metadata for verification policy forms per CONTRACT-VERIFICATION-POLICY-006.
/// </summary>
public sealed record VerificationPolicyEditorMetadata(
[property: JsonPropertyName("available_predicate_types")] IReadOnlyList<PredicateTypeInfo> AvailablePredicateTypes,
[property: JsonPropertyName("available_algorithms")] IReadOnlyList<AlgorithmInfo> AvailableAlgorithms,
[property: JsonPropertyName("default_signer_requirements")] SignerRequirements DefaultSignerRequirements,
[property: JsonPropertyName("validation_constraints")] ValidationConstraintsInfo ValidationConstraints);
/// <summary>
/// Information about a predicate type for editor dropdowns.
/// </summary>
public sealed record PredicateTypeInfo(
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("description")] string Description,
[property: JsonPropertyName("category")] PredicateCategory Category,
[property: JsonPropertyName("is_default")] bool IsDefault);
/// <summary>
/// Category of predicate type.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<PredicateCategory>))]
public enum PredicateCategory
{
StellaOps,
Slsa,
Sbom,
Vex
}
/// <summary>
/// Information about a signing algorithm for editor dropdowns.
/// </summary>
public sealed record AlgorithmInfo(
[property: JsonPropertyName("algorithm")] string Algorithm,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("description")] string Description,
[property: JsonPropertyName("key_type")] string KeyType,
[property: JsonPropertyName("is_recommended")] bool IsRecommended);
/// <summary>
/// Validation constraints exposed to the editor.
/// </summary>
public sealed record ValidationConstraintsInfo(
[property: JsonPropertyName("max_policy_id_length")] int MaxPolicyIdLength,
[property: JsonPropertyName("max_version_length")] int MaxVersionLength,
[property: JsonPropertyName("max_description_length")] int MaxDescriptionLength,
[property: JsonPropertyName("max_predicate_types")] int MaxPredicateTypes,
[property: JsonPropertyName("max_trusted_key_fingerprints")] int MaxTrustedKeyFingerprints,
[property: JsonPropertyName("max_trusted_issuers")] int MaxTrustedIssuers,
[property: JsonPropertyName("max_algorithms")] int MaxAlgorithms,
[property: JsonPropertyName("max_metadata_entries")] int MaxMetadataEntries,
[property: JsonPropertyName("max_attestation_age_seconds")] int MaxAttestationAgeSeconds);
/// <summary>
/// Editor view of a verification policy with validation state.
/// </summary>
public sealed record VerificationPolicyEditorView(
[property: JsonPropertyName("policy")] VerificationPolicy Policy,
[property: JsonPropertyName("validation")] VerificationPolicyValidationResult Validation,
[property: JsonPropertyName("suggestions")] IReadOnlyList<PolicySuggestion>? Suggestions,
[property: JsonPropertyName("can_delete")] bool CanDelete,
[property: JsonPropertyName("is_referenced")] bool IsReferenced);
/// <summary>
/// Suggestion for policy improvement.
/// </summary>
public sealed record PolicySuggestion(
[property: JsonPropertyName("code")] string Code,
[property: JsonPropertyName("field")] string Field,
[property: JsonPropertyName("message")] string Message,
[property: JsonPropertyName("suggested_value")] object? SuggestedValue);
/// <summary>
/// Request to validate a verification policy without persisting.
/// </summary>
public sealed record ValidatePolicyRequest(
[property: JsonPropertyName("policy_id")] string? PolicyId,
[property: JsonPropertyName("version")] string? Version,
[property: JsonPropertyName("description")] string? Description,
[property: JsonPropertyName("tenant_scope")] string? TenantScope,
[property: JsonPropertyName("predicate_types")] IReadOnlyList<string>? PredicateTypes,
[property: JsonPropertyName("signer_requirements")] SignerRequirements? SignerRequirements,
[property: JsonPropertyName("validity_window")] ValidityWindow? ValidityWindow,
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, object?>? Metadata);
/// <summary>
/// Response from policy validation.
/// </summary>
public sealed record ValidatePolicyResponse(
[property: JsonPropertyName("valid")] bool Valid,
[property: JsonPropertyName("errors")] IReadOnlyList<VerificationPolicyValidationError> Errors,
[property: JsonPropertyName("warnings")] IReadOnlyList<VerificationPolicyValidationError> Warnings,
[property: JsonPropertyName("suggestions")] IReadOnlyList<PolicySuggestion> Suggestions);
/// <summary>
/// Request to clone a verification policy.
/// </summary>
public sealed record ClonePolicyRequest(
[property: JsonPropertyName("source_policy_id")] string SourcePolicyId,
[property: JsonPropertyName("new_policy_id")] string NewPolicyId,
[property: JsonPropertyName("new_version")] string? NewVersion);
/// <summary>
/// Request to compare two verification policies.
/// </summary>
public sealed record ComparePoliciesRequest(
[property: JsonPropertyName("policy_id_a")] string PolicyIdA,
[property: JsonPropertyName("policy_id_b")] string PolicyIdB);
/// <summary>
/// Result of comparing two verification policies.
/// </summary>
public sealed record ComparePoliciesResponse(
[property: JsonPropertyName("policy_a")] VerificationPolicy PolicyA,
[property: JsonPropertyName("policy_b")] VerificationPolicy PolicyB,
[property: JsonPropertyName("differences")] IReadOnlyList<PolicyDifference> Differences);
/// <summary>
/// A difference between two policies.
/// </summary>
public sealed record PolicyDifference(
[property: JsonPropertyName("field")] string Field,
[property: JsonPropertyName("value_a")] object? ValueA,
[property: JsonPropertyName("value_b")] object? ValueB,
[property: JsonPropertyName("change_type")] DifferenceType ChangeType);
/// <summary>
/// Type of difference between policies.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<DifferenceType>))]
public enum DifferenceType
{
Added,
Removed,
Modified
}
/// <summary>
/// Provider of editor metadata for verification policies.
/// </summary>
public static class VerificationPolicyEditorMetadataProvider
{
private static readonly IReadOnlyList<PredicateTypeInfo> AvailablePredicateTypes =
[
// StellaOps types
new(PredicateTypes.SbomV1, "StellaOps SBOM", "Software Bill of Materials attestation", PredicateCategory.StellaOps, true),
new(PredicateTypes.VexV1, "StellaOps VEX", "Vulnerability Exploitability Exchange attestation", PredicateCategory.StellaOps, true),
new(PredicateTypes.VexDecisionV1, "StellaOps VEX Decision", "VEX decision record attestation", PredicateCategory.StellaOps, false),
new(PredicateTypes.PolicyV1, "StellaOps Policy", "Policy decision attestation", PredicateCategory.StellaOps, false),
new(PredicateTypes.PromotionV1, "StellaOps Promotion", "Artifact promotion attestation", PredicateCategory.StellaOps, false),
new(PredicateTypes.EvidenceV1, "StellaOps Evidence", "Evidence collection attestation", PredicateCategory.StellaOps, false),
new(PredicateTypes.GraphV1, "StellaOps Graph", "Dependency graph attestation", PredicateCategory.StellaOps, false),
new(PredicateTypes.ReplayV1, "StellaOps Replay", "Replay verification attestation", PredicateCategory.StellaOps, false),
// SLSA types
new(PredicateTypes.SlsaProvenanceV1, "SLSA Provenance v1", "SLSA v1.0 provenance attestation", PredicateCategory.Slsa, true),
new(PredicateTypes.SlsaProvenanceV02, "SLSA Provenance v0.2", "SLSA v0.2 provenance attestation (legacy)", PredicateCategory.Slsa, false),
// SBOM types
new(PredicateTypes.CycloneDxBom, "CycloneDX BOM", "CycloneDX Bill of Materials", PredicateCategory.Sbom, true),
new(PredicateTypes.SpdxDocument, "SPDX Document", "SPDX SBOM document", PredicateCategory.Sbom, true),
// VEX types
new(PredicateTypes.OpenVex, "OpenVEX", "OpenVEX vulnerability exchange", PredicateCategory.Vex, true)
];
private static readonly IReadOnlyList<AlgorithmInfo> AvailableAlgorithms =
[
new("ES256", "ECDSA P-256", "ECDSA with SHA-256 and P-256 curve", "EC", true),
new("ES384", "ECDSA P-384", "ECDSA with SHA-384 and P-384 curve", "EC", false),
new("ES512", "ECDSA P-521", "ECDSA with SHA-512 and P-521 curve", "EC", false),
new("RS256", "RSA-SHA256", "RSA with SHA-256", "RSA", true),
new("RS384", "RSA-SHA384", "RSA with SHA-384", "RSA", false),
new("RS512", "RSA-SHA512", "RSA with SHA-512", "RSA", false),
new("PS256", "RSA-PSS-SHA256", "RSA-PSS with SHA-256", "RSA", false),
new("PS384", "RSA-PSS-SHA384", "RSA-PSS with SHA-384", "RSA", false),
new("PS512", "RSA-PSS-SHA512", "RSA-PSS with SHA-512", "RSA", false),
new("EdDSA", "EdDSA", "Edwards-curve Digital Signature Algorithm (Ed25519)", "OKP", true)
];
/// <summary>
/// Gets the editor metadata for verification policy forms.
/// </summary>
public static VerificationPolicyEditorMetadata GetMetadata(
VerificationPolicyValidationConstraints? constraints = null)
{
var c = constraints ?? VerificationPolicyValidationConstraints.Default;
return new VerificationPolicyEditorMetadata(
AvailablePredicateTypes: AvailablePredicateTypes,
AvailableAlgorithms: AvailableAlgorithms,
DefaultSignerRequirements: SignerRequirements.Default,
ValidationConstraints: new ValidationConstraintsInfo(
MaxPolicyIdLength: c.MaxPolicyIdLength,
MaxVersionLength: c.MaxVersionLength,
MaxDescriptionLength: c.MaxDescriptionLength,
MaxPredicateTypes: c.MaxPredicateTypes,
MaxTrustedKeyFingerprints: c.MaxTrustedKeyFingerprints,
MaxTrustedIssuers: c.MaxTrustedIssuers,
MaxAlgorithms: c.MaxAlgorithms,
MaxMetadataEntries: c.MaxMetadataEntries,
MaxAttestationAgeSeconds: c.MaxAttestationAgeSeconds));
}
/// <summary>
/// Generates suggestions for a policy based on validation results.
/// </summary>
public static IReadOnlyList<PolicySuggestion> GenerateSuggestions(
CreateVerificationPolicyRequest request,
VerificationPolicyValidationResult validation)
{
var suggestions = new List<PolicySuggestion>();
// Suggest adding Rekor if not enabled
if (request.SignerRequirements is { RequireRekor: false })
{
suggestions.Add(new PolicySuggestion(
"SUG_VP_001",
"signer_requirements.require_rekor",
"Consider enabling Rekor for transparency log verification.",
true));
}
// Suggest adding trusted key fingerprints if empty
if (request.SignerRequirements is { TrustedKeyFingerprints.Count: 0 })
{
suggestions.Add(new PolicySuggestion(
"SUG_VP_002",
"signer_requirements.trusted_key_fingerprints",
"Consider adding trusted key fingerprints to restrict accepted signers.",
null));
}
// Suggest adding validity window if not set
if (request.ValidityWindow == null)
{
suggestions.Add(new PolicySuggestion(
"SUG_VP_003",
"validity_window",
"Consider setting a validity window to limit attestation age.",
new ValidityWindow(null, null, 2592000))); // 30 days default
}
// Suggest EdDSA if only RSA algorithms are selected
if (request.SignerRequirements?.Algorithms != null &&
request.SignerRequirements.Algorithms.All(a => a.StartsWith("RS", StringComparison.OrdinalIgnoreCase) ||
a.StartsWith("PS", StringComparison.OrdinalIgnoreCase)))
{
suggestions.Add(new PolicySuggestion(
"SUG_VP_004",
"signer_requirements.algorithms",
"Consider adding ES256 or EdDSA for better performance and smaller signatures.",
null));
}
return suggestions;
}
}

View File

@@ -0,0 +1,136 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.Attestation;
/// <summary>
/// Verification policy for attestation validation per CONTRACT-VERIFICATION-POLICY-006.
/// </summary>
public sealed record VerificationPolicy(
[property: JsonPropertyName("policy_id")] string PolicyId,
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("description")] string? Description,
[property: JsonPropertyName("tenant_scope")] string TenantScope,
[property: JsonPropertyName("predicate_types")] IReadOnlyList<string> PredicateTypes,
[property: JsonPropertyName("signer_requirements")] SignerRequirements SignerRequirements,
[property: JsonPropertyName("validity_window")] ValidityWindow? ValidityWindow,
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, object?>? Metadata,
[property: JsonPropertyName("created_at")] DateTimeOffset CreatedAt,
[property: JsonPropertyName("updated_at")] DateTimeOffset UpdatedAt);
/// <summary>
/// Signer requirements for attestation verification.
/// </summary>
public sealed record SignerRequirements(
[property: JsonPropertyName("minimum_signatures")] int MinimumSignatures,
[property: JsonPropertyName("trusted_key_fingerprints")] IReadOnlyList<string> TrustedKeyFingerprints,
[property: JsonPropertyName("trusted_issuers")] IReadOnlyList<string>? TrustedIssuers,
[property: JsonPropertyName("require_rekor")] bool RequireRekor,
[property: JsonPropertyName("algorithms")] IReadOnlyList<string>? Algorithms)
{
public static SignerRequirements Default => new(
MinimumSignatures: 1,
TrustedKeyFingerprints: [],
TrustedIssuers: null,
RequireRekor: false,
Algorithms: ["ES256", "RS256", "EdDSA"]);
}
/// <summary>
/// Validity window for attestations.
/// </summary>
public sealed record ValidityWindow(
[property: JsonPropertyName("not_before")] DateTimeOffset? NotBefore,
[property: JsonPropertyName("not_after")] DateTimeOffset? NotAfter,
[property: JsonPropertyName("max_attestation_age")] int? MaxAttestationAge);
/// <summary>
/// Request to create a verification policy.
/// </summary>
public sealed record CreateVerificationPolicyRequest(
[property: JsonPropertyName("policy_id")] string PolicyId,
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("description")] string? Description,
[property: JsonPropertyName("tenant_scope")] string? TenantScope,
[property: JsonPropertyName("predicate_types")] IReadOnlyList<string> PredicateTypes,
[property: JsonPropertyName("signer_requirements")] SignerRequirements? SignerRequirements,
[property: JsonPropertyName("validity_window")] ValidityWindow? ValidityWindow,
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, object?>? Metadata);
/// <summary>
/// Request to update a verification policy.
/// </summary>
public sealed record UpdateVerificationPolicyRequest(
[property: JsonPropertyName("version")] string? Version,
[property: JsonPropertyName("description")] string? Description,
[property: JsonPropertyName("predicate_types")] IReadOnlyList<string>? PredicateTypes,
[property: JsonPropertyName("signer_requirements")] SignerRequirements? SignerRequirements,
[property: JsonPropertyName("validity_window")] ValidityWindow? ValidityWindow,
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, object?>? Metadata);
/// <summary>
/// Result of verifying an attestation.
/// </summary>
public sealed record VerificationResult(
[property: JsonPropertyName("valid")] bool Valid,
[property: JsonPropertyName("predicate_type")] string? PredicateType,
[property: JsonPropertyName("signature_count")] int SignatureCount,
[property: JsonPropertyName("signers")] IReadOnlyList<SignerInfo> Signers,
[property: JsonPropertyName("rekor_entry")] RekorEntry? RekorEntry,
[property: JsonPropertyName("attestation_timestamp")] DateTimeOffset? AttestationTimestamp,
[property: JsonPropertyName("policy_id")] string PolicyId,
[property: JsonPropertyName("policy_version")] string PolicyVersion,
[property: JsonPropertyName("errors")] IReadOnlyList<string>? Errors);
/// <summary>
/// Information about a signer.
/// </summary>
public sealed record SignerInfo(
[property: JsonPropertyName("key_fingerprint")] string KeyFingerprint,
[property: JsonPropertyName("issuer")] string? Issuer,
[property: JsonPropertyName("algorithm")] string Algorithm,
[property: JsonPropertyName("verified")] bool Verified);
/// <summary>
/// Rekor transparency log entry.
/// </summary>
public sealed record RekorEntry(
[property: JsonPropertyName("uuid")] string Uuid,
[property: JsonPropertyName("log_index")] long LogIndex,
[property: JsonPropertyName("integrated_time")] DateTimeOffset IntegratedTime);
/// <summary>
/// Request to verify an attestation.
/// </summary>
public sealed record VerifyAttestationRequest(
[property: JsonPropertyName("envelope")] string Envelope,
[property: JsonPropertyName("policy_id")] string PolicyId);
/// <summary>
/// Standard predicate types supported by StellaOps.
/// </summary>
public static class PredicateTypes
{
// StellaOps types
public const string SbomV1 = "stella.ops/sbom@v1";
public const string VexV1 = "stella.ops/vex@v1";
public const string VexDecisionV1 = "stella.ops/vexDecision@v1";
public const string PolicyV1 = "stella.ops/policy@v1";
public const string PromotionV1 = "stella.ops/promotion@v1";
public const string EvidenceV1 = "stella.ops/evidence@v1";
public const string GraphV1 = "stella.ops/graph@v1";
public const string ReplayV1 = "stella.ops/replay@v1";
// Third-party types
public const string SlsaProvenanceV02 = "https://slsa.dev/provenance/v0.2";
public const string SlsaProvenanceV1 = "https://slsa.dev/provenance/v1";
public const string CycloneDxBom = "https://cyclonedx.org/bom";
public const string SpdxDocument = "https://spdx.dev/Document";
public const string OpenVex = "https://openvex.dev/ns";
public static readonly IReadOnlyList<string> DefaultAllowed = new[]
{
SbomV1, VexV1, VexDecisionV1, PolicyV1, PromotionV1,
EvidenceV1, GraphV1, ReplayV1,
SlsaProvenanceV1, CycloneDxBom, SpdxDocument, OpenVex
};
}

View File

@@ -0,0 +1,516 @@
using System.Text.RegularExpressions;
namespace StellaOps.Policy.Engine.Attestation;
/// <summary>
/// Validation result for verification policy per CONTRACT-VERIFICATION-POLICY-006.
/// </summary>
public sealed record VerificationPolicyValidationResult(
bool IsValid,
IReadOnlyList<VerificationPolicyValidationError> Errors)
{
public static VerificationPolicyValidationResult Success() =>
new(IsValid: true, Errors: Array.Empty<VerificationPolicyValidationError>());
public static VerificationPolicyValidationResult Failure(params VerificationPolicyValidationError[] errors) =>
new(IsValid: false, Errors: errors);
public static VerificationPolicyValidationResult Failure(IEnumerable<VerificationPolicyValidationError> errors) =>
new(IsValid: false, Errors: errors.ToList());
}
/// <summary>
/// Validation error for verification policy.
/// </summary>
public sealed record VerificationPolicyValidationError(
string Code,
string Field,
string Message,
ValidationSeverity Severity = ValidationSeverity.Error);
/// <summary>
/// Severity of validation error.
/// </summary>
public enum ValidationSeverity
{
Warning,
Error
}
/// <summary>
/// Constraints for verification policy validation.
/// </summary>
public sealed record VerificationPolicyValidationConstraints
{
public static VerificationPolicyValidationConstraints Default { get; } = new();
public int MaxPolicyIdLength { get; init; } = 256;
public int MaxVersionLength { get; init; } = 64;
public int MaxDescriptionLength { get; init; } = 2048;
public int MaxPredicateTypes { get; init; } = 50;
public int MaxTrustedKeyFingerprints { get; init; } = 100;
public int MaxTrustedIssuers { get; init; } = 50;
public int MaxAlgorithms { get; init; } = 20;
public int MaxMetadataEntries { get; init; } = 50;
public int MaxAttestationAgeSeconds { get; init; } = 31536000; // 1 year
}
/// <summary>
/// Validator for verification policies per CONTRACT-VERIFICATION-POLICY-006.
/// </summary>
public sealed class VerificationPolicyValidator
{
private static readonly Regex PolicyIdPattern = new(
@"^[a-zA-Z0-9][a-zA-Z0-9\-_.]*$",
RegexOptions.Compiled,
TimeSpan.FromSeconds(1));
private static readonly Regex VersionPattern = new(
@"^\d+\.\d+\.\d+(-[a-zA-Z0-9\-.]+)?(\+[a-zA-Z0-9\-.]+)?$",
RegexOptions.Compiled,
TimeSpan.FromSeconds(1));
private static readonly Regex FingerprintPattern = new(
@"^[0-9a-fA-F]{40,128}$",
RegexOptions.Compiled,
TimeSpan.FromSeconds(1));
private static readonly Regex TenantScopePattern = new(
@"^(\*|[a-zA-Z0-9][a-zA-Z0-9\-_.]*(\*[a-zA-Z0-9\-_.]*)?|[a-zA-Z0-9\-_.]*\*)$",
RegexOptions.Compiled,
TimeSpan.FromSeconds(1));
private static readonly HashSet<string> AllowedAlgorithms = new(StringComparer.OrdinalIgnoreCase)
{
"ES256", "ES384", "ES512",
"RS256", "RS384", "RS512",
"PS256", "PS384", "PS512",
"EdDSA"
};
private readonly VerificationPolicyValidationConstraints _constraints;
public VerificationPolicyValidator(VerificationPolicyValidationConstraints? constraints = null)
{
_constraints = constraints ?? VerificationPolicyValidationConstraints.Default;
}
/// <summary>
/// Validates a create request for verification policy.
/// </summary>
public VerificationPolicyValidationResult ValidateCreate(CreateVerificationPolicyRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var errors = new List<VerificationPolicyValidationError>();
// Validate PolicyId
ValidatePolicyId(request.PolicyId, errors);
// Validate Version
ValidateVersion(request.Version, errors);
// Validate Description
ValidateDescription(request.Description, errors);
// Validate TenantScope
ValidateTenantScope(request.TenantScope, errors);
// Validate PredicateTypes
ValidatePredicateTypes(request.PredicateTypes, errors);
// Validate SignerRequirements
ValidateSignerRequirements(request.SignerRequirements, errors);
// Validate ValidityWindow
ValidateValidityWindow(request.ValidityWindow, errors);
// Validate Metadata
ValidateMetadata(request.Metadata, errors);
return errors.Count == 0
? VerificationPolicyValidationResult.Success()
: VerificationPolicyValidationResult.Failure(errors);
}
/// <summary>
/// Validates an update request for verification policy.
/// </summary>
public VerificationPolicyValidationResult ValidateUpdate(UpdateVerificationPolicyRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var errors = new List<VerificationPolicyValidationError>();
// Version is optional in updates but must be valid if provided
if (request.Version != null)
{
ValidateVersion(request.Version, errors);
}
// Description is optional in updates
if (request.Description != null)
{
ValidateDescription(request.Description, errors);
}
// PredicateTypes is optional in updates
if (request.PredicateTypes != null)
{
ValidatePredicateTypes(request.PredicateTypes, errors);
}
// SignerRequirements is optional in updates
if (request.SignerRequirements != null)
{
ValidateSignerRequirements(request.SignerRequirements, errors);
}
// ValidityWindow is optional in updates
if (request.ValidityWindow != null)
{
ValidateValidityWindow(request.ValidityWindow, errors);
}
// Metadata is optional in updates
if (request.Metadata != null)
{
ValidateMetadata(request.Metadata, errors);
}
return errors.Count == 0
? VerificationPolicyValidationResult.Success()
: VerificationPolicyValidationResult.Failure(errors);
}
private void ValidatePolicyId(string? policyId, List<VerificationPolicyValidationError> errors)
{
if (string.IsNullOrWhiteSpace(policyId))
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_001",
"policy_id",
"Policy ID is required."));
return;
}
if (policyId.Length > _constraints.MaxPolicyIdLength)
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_002",
"policy_id",
$"Policy ID exceeds maximum length of {_constraints.MaxPolicyIdLength} characters."));
return;
}
if (!PolicyIdPattern.IsMatch(policyId))
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_003",
"policy_id",
"Policy ID must start with alphanumeric and contain only alphanumeric, hyphens, underscores, or dots."));
}
}
private void ValidateVersion(string? version, List<VerificationPolicyValidationError> errors)
{
if (string.IsNullOrWhiteSpace(version))
{
// Version defaults to "1.0.0" if not provided, so this is a warning
errors.Add(new VerificationPolicyValidationError(
"WARN_VP_001",
"version",
"Version not provided; defaulting to 1.0.0.",
ValidationSeverity.Warning));
return;
}
if (version.Length > _constraints.MaxVersionLength)
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_004",
"version",
$"Version exceeds maximum length of {_constraints.MaxVersionLength} characters."));
return;
}
if (!VersionPattern.IsMatch(version))
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_005",
"version",
"Version must follow semver format (e.g., 1.0.0, 2.1.0-alpha.1)."));
}
}
private void ValidateDescription(string? description, List<VerificationPolicyValidationError> errors)
{
if (description != null && description.Length > _constraints.MaxDescriptionLength)
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_006",
"description",
$"Description exceeds maximum length of {_constraints.MaxDescriptionLength} characters."));
}
}
private void ValidateTenantScope(string? tenantScope, List<VerificationPolicyValidationError> errors)
{
if (string.IsNullOrWhiteSpace(tenantScope))
{
// Defaults to "*" if not provided
return;
}
if (!TenantScopePattern.IsMatch(tenantScope))
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_007",
"tenant_scope",
"Tenant scope must be '*' or a valid identifier with optional wildcard suffix."));
}
}
private void ValidatePredicateTypes(IReadOnlyList<string>? predicateTypes, List<VerificationPolicyValidationError> errors)
{
if (predicateTypes == null || predicateTypes.Count == 0)
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_008",
"predicate_types",
"At least one predicate type is required."));
return;
}
if (predicateTypes.Count > _constraints.MaxPredicateTypes)
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_009",
"predicate_types",
$"Predicate types exceeds maximum count of {_constraints.MaxPredicateTypes}."));
return;
}
var seen = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < predicateTypes.Count; i++)
{
var predicateType = predicateTypes[i];
if (string.IsNullOrWhiteSpace(predicateType))
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_010",
$"predicate_types[{i}]",
"Predicate type cannot be empty."));
continue;
}
if (!seen.Add(predicateType))
{
errors.Add(new VerificationPolicyValidationError(
"WARN_VP_002",
$"predicate_types[{i}]",
$"Duplicate predicate type '{predicateType}'.",
ValidationSeverity.Warning));
}
// Check if it's a known predicate type or valid URI format
if (!IsKnownPredicateType(predicateType) && !IsValidPredicateTypeUri(predicateType))
{
errors.Add(new VerificationPolicyValidationError(
"WARN_VP_003",
$"predicate_types[{i}]",
$"Predicate type '{predicateType}' is not a known StellaOps or standard type.",
ValidationSeverity.Warning));
}
}
}
private void ValidateSignerRequirements(SignerRequirements? requirements, List<VerificationPolicyValidationError> errors)
{
if (requirements == null)
{
// Defaults to SignerRequirements.Default if not provided
return;
}
if (requirements.MinimumSignatures < 1)
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_011",
"signer_requirements.minimum_signatures",
"Minimum signatures must be at least 1."));
}
if (requirements.TrustedKeyFingerprints.Count > _constraints.MaxTrustedKeyFingerprints)
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_012",
"signer_requirements.trusted_key_fingerprints",
$"Trusted key fingerprints exceeds maximum count of {_constraints.MaxTrustedKeyFingerprints}."));
}
var seenFingerprints = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < requirements.TrustedKeyFingerprints.Count; i++)
{
var fingerprint = requirements.TrustedKeyFingerprints[i];
if (string.IsNullOrWhiteSpace(fingerprint))
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_013",
$"signer_requirements.trusted_key_fingerprints[{i}]",
"Key fingerprint cannot be empty."));
continue;
}
if (!FingerprintPattern.IsMatch(fingerprint))
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_014",
$"signer_requirements.trusted_key_fingerprints[{i}]",
"Key fingerprint must be a 40-128 character hex string."));
}
if (!seenFingerprints.Add(fingerprint))
{
errors.Add(new VerificationPolicyValidationError(
"WARN_VP_004",
$"signer_requirements.trusted_key_fingerprints[{i}]",
$"Duplicate key fingerprint.",
ValidationSeverity.Warning));
}
}
if (requirements.TrustedIssuers != null)
{
if (requirements.TrustedIssuers.Count > _constraints.MaxTrustedIssuers)
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_015",
"signer_requirements.trusted_issuers",
$"Trusted issuers exceeds maximum count of {_constraints.MaxTrustedIssuers}."));
}
for (var i = 0; i < requirements.TrustedIssuers.Count; i++)
{
var issuer = requirements.TrustedIssuers[i];
if (string.IsNullOrWhiteSpace(issuer))
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_016",
$"signer_requirements.trusted_issuers[{i}]",
"Issuer cannot be empty."));
}
}
}
if (requirements.Algorithms != null)
{
if (requirements.Algorithms.Count > _constraints.MaxAlgorithms)
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_017",
"signer_requirements.algorithms",
$"Algorithms exceeds maximum count of {_constraints.MaxAlgorithms}."));
}
for (var i = 0; i < requirements.Algorithms.Count; i++)
{
var algorithm = requirements.Algorithms[i];
if (string.IsNullOrWhiteSpace(algorithm))
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_018",
$"signer_requirements.algorithms[{i}]",
"Algorithm cannot be empty."));
continue;
}
if (!AllowedAlgorithms.Contains(algorithm))
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_019",
$"signer_requirements.algorithms[{i}]",
$"Algorithm '{algorithm}' is not supported. Allowed: {string.Join(", ", AllowedAlgorithms)}."));
}
}
}
}
private void ValidateValidityWindow(ValidityWindow? window, List<VerificationPolicyValidationError> errors)
{
if (window == null)
{
return;
}
if (window.NotBefore.HasValue && window.NotAfter.HasValue)
{
if (window.NotBefore.Value >= window.NotAfter.Value)
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_020",
"validity_window",
"not_before must be earlier than not_after."));
}
}
if (window.MaxAttestationAge.HasValue)
{
if (window.MaxAttestationAge.Value <= 0)
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_021",
"validity_window.max_attestation_age",
"Maximum attestation age must be a positive integer (seconds)."));
}
else if (window.MaxAttestationAge.Value > _constraints.MaxAttestationAgeSeconds)
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_022",
"validity_window.max_attestation_age",
$"Maximum attestation age exceeds limit of {_constraints.MaxAttestationAgeSeconds} seconds."));
}
}
}
private void ValidateMetadata(IReadOnlyDictionary<string, object?>? metadata, List<VerificationPolicyValidationError> errors)
{
if (metadata == null)
{
return;
}
if (metadata.Count > _constraints.MaxMetadataEntries)
{
errors.Add(new VerificationPolicyValidationError(
"ERR_VP_023",
"metadata",
$"Metadata exceeds maximum of {_constraints.MaxMetadataEntries} entries."));
}
}
private static bool IsKnownPredicateType(string predicateType)
{
return predicateType == PredicateTypes.SbomV1
|| predicateType == PredicateTypes.VexV1
|| predicateType == PredicateTypes.VexDecisionV1
|| predicateType == PredicateTypes.PolicyV1
|| predicateType == PredicateTypes.PromotionV1
|| predicateType == PredicateTypes.EvidenceV1
|| predicateType == PredicateTypes.GraphV1
|| predicateType == PredicateTypes.ReplayV1
|| predicateType == PredicateTypes.SlsaProvenanceV02
|| predicateType == PredicateTypes.SlsaProvenanceV1
|| predicateType == PredicateTypes.CycloneDxBom
|| predicateType == PredicateTypes.SpdxDocument
|| predicateType == PredicateTypes.OpenVex;
}
private static bool IsValidPredicateTypeUri(string predicateType)
{
// Predicate types are typically URIs or namespaced identifiers
return predicateType.Contains('/') || predicateType.Contains(':');
}
}