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:
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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(':');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user