feat: add security sink detection patterns for JavaScript/TypeScript
- Introduced `sink-detect.js` with various security sink detection patterns categorized by type (e.g., command injection, SQL injection, file operations). - Implemented functions to build a lookup map for fast sink detection and to match sink calls against known patterns. - Added `package-lock.json` for dependency management.
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
namespace StellaOps.Policy.Engine.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Risk Verdict Attestation - the signed, replayable output of policy evaluation.
|
||||
/// This is the formal contract for communicating risk decisions.
|
||||
/// </summary>
|
||||
public sealed record RiskVerdictAttestation
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this attestation.
|
||||
/// Format: rva:{sha256-of-content}
|
||||
/// </summary>
|
||||
public required string AttestationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Schema version for forward compatibility.
|
||||
/// </summary>
|
||||
public string SchemaVersion { get; init; } = "1.0";
|
||||
|
||||
/// <summary>
|
||||
/// When this attestation was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The final verdict status.
|
||||
/// </summary>
|
||||
public required RiskVerdictStatus Verdict { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject artifact being evaluated.
|
||||
/// </summary>
|
||||
public required ArtifactSubject Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the policy that was evaluated.
|
||||
/// </summary>
|
||||
public required RvaPolicyRef Policy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the knowledge snapshot used.
|
||||
/// Enables replay with frozen inputs.
|
||||
/// </summary>
|
||||
public required string KnowledgeSnapshotId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence references supporting the verdict.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RvaEvidenceRef> Evidence { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Reason codes explaining the verdict.
|
||||
/// </summary>
|
||||
public IReadOnlyList<VerdictReasonCode> ReasonCodes { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Summary of unknowns encountered.
|
||||
/// </summary>
|
||||
public UnknownsSummary? Unknowns { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Exception IDs that were applied.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> AppliedExceptions { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable explanation of the verdict.
|
||||
/// </summary>
|
||||
public string? Explanation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expiration time for this verdict (optional).
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for extensibility.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; init; }
|
||||
= new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The four possible verdict outcomes.
|
||||
/// </summary>
|
||||
public enum RiskVerdictStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// No policy violations detected. Safe to proceed.
|
||||
/// </summary>
|
||||
Pass,
|
||||
|
||||
/// <summary>
|
||||
/// Policy violations detected. Block deployment.
|
||||
/// </summary>
|
||||
Fail,
|
||||
|
||||
/// <summary>
|
||||
/// Violations exist but are covered by approved exceptions.
|
||||
/// </summary>
|
||||
PassWithExceptions,
|
||||
|
||||
/// <summary>
|
||||
/// Cannot determine risk due to insufficient data.
|
||||
/// </summary>
|
||||
Indeterminate
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The artifact being evaluated.
|
||||
/// </summary>
|
||||
public sealed record ArtifactSubject
|
||||
{
|
||||
/// <summary>
|
||||
/// Artifact digest (sha256:...).
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact type: container-image, sbom, binary, etc.
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable name (e.g., image:tag).
|
||||
/// </summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Registry or repository URI.
|
||||
/// </summary>
|
||||
public string? Uri { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the evaluated policy.
|
||||
/// </summary>
|
||||
public sealed record RvaPolicyRef
|
||||
{
|
||||
public required string PolicyId { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
public string? Uri { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to evidence supporting the verdict.
|
||||
/// </summary>
|
||||
public sealed record RvaEvidenceRef
|
||||
{
|
||||
public required string Type { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
public string? Uri { get; init; }
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of unknowns encountered during evaluation.
|
||||
/// </summary>
|
||||
public sealed record UnknownsSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Total number of unknowns.
|
||||
/// </summary>
|
||||
public int Total { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of blocking unknowns.
|
||||
/// </summary>
|
||||
public int BlockingCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Breakdown by unknown type.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, int> ByType { get; init; }
|
||||
= new Dictionary<string, int>();
|
||||
}
|
||||
224
src/Policy/StellaOps.Policy.Engine/Attestation/RvaBuilder.cs
Normal file
224
src/Policy/StellaOps.Policy.Engine/Attestation/RvaBuilder.cs
Normal file
@@ -0,0 +1,224 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Fluent builder for constructing Risk Verdict Attestations.
|
||||
/// </summary>
|
||||
public sealed class RvaBuilder
|
||||
{
|
||||
private RiskVerdictStatus _verdict;
|
||||
private ArtifactSubject? _subject;
|
||||
private RvaPolicyRef? _policy;
|
||||
private string? _snapshotId;
|
||||
private readonly List<RvaEvidenceRef> _evidence = [];
|
||||
private readonly List<VerdictReasonCode> _reasonCodes = [];
|
||||
private readonly List<string> _exceptions = [];
|
||||
private UnknownsSummary? _unknowns;
|
||||
private string? _explanation;
|
||||
private DateTimeOffset? _expiresAt;
|
||||
private readonly Dictionary<string, string> _metadata = [];
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
|
||||
public RvaBuilder(ICryptoHash cryptoHash)
|
||||
{
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
}
|
||||
|
||||
public RvaBuilder WithVerdict(RiskVerdictStatus verdict)
|
||||
{
|
||||
_verdict = verdict;
|
||||
return this;
|
||||
}
|
||||
|
||||
public RvaBuilder WithSubject(string digest, string type, string? name = null, string? uri = null)
|
||||
{
|
||||
_subject = new ArtifactSubject
|
||||
{
|
||||
Digest = digest,
|
||||
Type = type,
|
||||
Name = name,
|
||||
Uri = uri
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
public RvaBuilder WithSubject(ArtifactSubject subject)
|
||||
{
|
||||
_subject = subject;
|
||||
return this;
|
||||
}
|
||||
|
||||
public RvaBuilder WithPolicy(string policyId, string version, string digest, string? uri = null)
|
||||
{
|
||||
_policy = new RvaPolicyRef
|
||||
{
|
||||
PolicyId = policyId,
|
||||
Version = version,
|
||||
Digest = digest,
|
||||
Uri = uri
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
public RvaBuilder WithPolicy(RvaPolicyRef policy)
|
||||
{
|
||||
_policy = policy;
|
||||
return this;
|
||||
}
|
||||
|
||||
public RvaBuilder WithKnowledgeSnapshot(string snapshotId)
|
||||
{
|
||||
_snapshotId = snapshotId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public RvaBuilder WithEvidence(string type, string digest, string? uri = null, string? description = null)
|
||||
{
|
||||
_evidence.Add(new RvaEvidenceRef
|
||||
{
|
||||
Type = type,
|
||||
Digest = digest,
|
||||
Uri = uri,
|
||||
Description = description
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public RvaBuilder WithEvidence(RvaEvidenceRef evidence)
|
||||
{
|
||||
_evidence.Add(evidence);
|
||||
return this;
|
||||
}
|
||||
|
||||
public RvaBuilder WithReasonCode(VerdictReasonCode code)
|
||||
{
|
||||
if (!_reasonCodes.Contains(code))
|
||||
_reasonCodes.Add(code);
|
||||
return this;
|
||||
}
|
||||
|
||||
public RvaBuilder WithReasonCodes(IEnumerable<VerdictReasonCode> codes)
|
||||
{
|
||||
foreach (var code in codes)
|
||||
WithReasonCode(code);
|
||||
return this;
|
||||
}
|
||||
|
||||
public RvaBuilder WithException(string exceptionId)
|
||||
{
|
||||
_exceptions.Add(exceptionId);
|
||||
return this;
|
||||
}
|
||||
|
||||
public RvaBuilder WithExceptions(IEnumerable<string> exceptionIds)
|
||||
{
|
||||
foreach (var id in exceptionIds)
|
||||
WithException(id);
|
||||
return this;
|
||||
}
|
||||
|
||||
public RvaBuilder WithUnknowns(UnknownsSummary unknowns)
|
||||
{
|
||||
_unknowns = unknowns;
|
||||
return this;
|
||||
}
|
||||
|
||||
public RvaBuilder WithUnknowns(int total, int blockingCount)
|
||||
{
|
||||
_unknowns = new UnknownsSummary
|
||||
{
|
||||
Total = total,
|
||||
BlockingCount = blockingCount
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
public RvaBuilder WithExplanation(string explanation)
|
||||
{
|
||||
_explanation = explanation;
|
||||
return this;
|
||||
}
|
||||
|
||||
public RvaBuilder WithExpiration(DateTimeOffset expiresAt)
|
||||
{
|
||||
_expiresAt = expiresAt;
|
||||
return this;
|
||||
}
|
||||
|
||||
public RvaBuilder WithMetadata(string key, string value)
|
||||
{
|
||||
_metadata[key] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
public RiskVerdictAttestation Build()
|
||||
{
|
||||
if (_subject is null)
|
||||
throw new InvalidOperationException("Subject is required");
|
||||
if (_policy is null)
|
||||
throw new InvalidOperationException("Policy is required");
|
||||
if (_snapshotId is null)
|
||||
throw new InvalidOperationException("Knowledge snapshot ID is required");
|
||||
|
||||
var createdAt = DateTimeOffset.UtcNow;
|
||||
|
||||
var attestation = new RiskVerdictAttestation
|
||||
{
|
||||
AttestationId = "", // Computed below
|
||||
CreatedAt = createdAt,
|
||||
Verdict = _verdict,
|
||||
Subject = _subject,
|
||||
Policy = _policy,
|
||||
KnowledgeSnapshotId = _snapshotId,
|
||||
Evidence = _evidence.ToList(),
|
||||
ReasonCodes = _reasonCodes.ToList(),
|
||||
AppliedExceptions = _exceptions.ToList(),
|
||||
Unknowns = _unknowns,
|
||||
Explanation = _explanation ?? GenerateExplanation(),
|
||||
ExpiresAt = _expiresAt,
|
||||
Metadata = _metadata.ToDictionary()
|
||||
};
|
||||
|
||||
// Compute content-addressed ID
|
||||
var attestationId = ComputeAttestationId(attestation);
|
||||
|
||||
return attestation with { AttestationId = attestationId };
|
||||
}
|
||||
|
||||
private string ComputeAttestationId(RiskVerdictAttestation attestation)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(attestation with { AttestationId = "" },
|
||||
RvaSerializerOptions.Canonical);
|
||||
|
||||
var hash = _cryptoHash.ComputeHashHex(System.Text.Encoding.UTF8.GetBytes(json), "SHA256");
|
||||
return $"rva:sha256:{hash}";
|
||||
}
|
||||
|
||||
private string GenerateExplanation()
|
||||
{
|
||||
if (_reasonCodes.Count == 0)
|
||||
return $"Verdict: {_verdict}";
|
||||
|
||||
var reasons = string.Join(", ", _reasonCodes.Take(3).Select(c => c.GetDescription()));
|
||||
return $"Verdict: {_verdict}. Reasons: {reasons}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Centralized JSON serializer options for RVA.
|
||||
/// </summary>
|
||||
internal static class RvaSerializerOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Canonical JSON options for deterministic serialization.
|
||||
/// </summary>
|
||||
public static JsonSerializerOptions Canonical { get; } = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
}
|
||||
187
src/Policy/StellaOps.Policy.Engine/Attestation/RvaPredicate.cs
Normal file
187
src/Policy/StellaOps.Policy.Engine/Attestation/RvaPredicate.cs
Normal file
@@ -0,0 +1,187 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// In-toto predicate wrapper for Risk Verdict Attestations.
|
||||
/// </summary>
|
||||
public static class RvaPredicate
|
||||
{
|
||||
/// <summary>
|
||||
/// Predicate type URI for RVA.
|
||||
/// </summary>
|
||||
public const string PredicateType = "https://stella.ops/predicates/risk-verdict@v1";
|
||||
|
||||
/// <summary>
|
||||
/// Creates an in-toto statement from an RVA.
|
||||
/// </summary>
|
||||
public static RvaInTotoStatement CreateStatement(RiskVerdictAttestation attestation)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attestation);
|
||||
|
||||
return new RvaInTotoStatement
|
||||
{
|
||||
Type = "https://in-toto.io/Statement/v1",
|
||||
Subject =
|
||||
[
|
||||
new RvaInTotoSubject
|
||||
{
|
||||
Name = attestation.Subject.Name ?? attestation.Subject.Digest,
|
||||
Digest = new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = attestation.Subject.Digest.Replace("sha256:", "", StringComparison.Ordinal)
|
||||
}
|
||||
}
|
||||
],
|
||||
PredicateType = PredicateType,
|
||||
Predicate = CreatePredicateContent(attestation)
|
||||
};
|
||||
}
|
||||
|
||||
private static RvaPredicateContent CreatePredicateContent(RiskVerdictAttestation attestation)
|
||||
{
|
||||
return new RvaPredicateContent
|
||||
{
|
||||
AttestationId = attestation.AttestationId,
|
||||
SchemaVersion = attestation.SchemaVersion,
|
||||
Verdict = attestation.Verdict.ToString(),
|
||||
Policy = new PolicyPredicateRef
|
||||
{
|
||||
Id = attestation.Policy.PolicyId,
|
||||
Version = attestation.Policy.Version,
|
||||
Digest = attestation.Policy.Digest
|
||||
},
|
||||
KnowledgeSnapshotId = attestation.KnowledgeSnapshotId,
|
||||
Evidence = attestation.Evidence.Select(e => new EvidencePredicateRef
|
||||
{
|
||||
Type = e.Type,
|
||||
Digest = e.Digest,
|
||||
Uri = e.Uri
|
||||
}).ToList(),
|
||||
ReasonCodes = attestation.ReasonCodes.Select(c => c.ToString()).ToList(),
|
||||
Unknowns = attestation.Unknowns is not null ? new UnknownsPredicateRef
|
||||
{
|
||||
Total = attestation.Unknowns.Total,
|
||||
BlockingCount = attestation.Unknowns.BlockingCount
|
||||
} : null,
|
||||
AppliedExceptions = attestation.AppliedExceptions.ToList(),
|
||||
Explanation = attestation.Explanation,
|
||||
CreatedAt = attestation.CreatedAt.ToString("o"),
|
||||
ExpiresAt = attestation.ExpiresAt?.ToString("o")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement structure for RVA.
|
||||
/// </summary>
|
||||
public sealed record RvaInTotoStatement
|
||||
{
|
||||
[JsonPropertyName("_type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public required RvaInTotoSubject[] Subject { get; init; }
|
||||
|
||||
[JsonPropertyName("predicateType")]
|
||||
public required string PredicateType { get; init; }
|
||||
|
||||
[JsonPropertyName("predicate")]
|
||||
public required object Predicate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-toto subject structure for RVA.
|
||||
/// </summary>
|
||||
public sealed record RvaInTotoSubject
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public required Dictionary<string, string> Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RVA predicate content.
|
||||
/// </summary>
|
||||
public sealed record RvaPredicateContent
|
||||
{
|
||||
[JsonPropertyName("attestationId")]
|
||||
public required string AttestationId { get; init; }
|
||||
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; init; } = "1.0";
|
||||
|
||||
[JsonPropertyName("verdict")]
|
||||
public required string Verdict { get; init; }
|
||||
|
||||
[JsonPropertyName("policy")]
|
||||
public required PolicyPredicateRef Policy { get; init; }
|
||||
|
||||
[JsonPropertyName("knowledgeSnapshotId")]
|
||||
public required string KnowledgeSnapshotId { get; init; }
|
||||
|
||||
[JsonPropertyName("evidence")]
|
||||
public IReadOnlyList<EvidencePredicateRef> Evidence { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("reasonCodes")]
|
||||
public required IReadOnlyList<string> ReasonCodes { get; init; }
|
||||
|
||||
[JsonPropertyName("unknowns")]
|
||||
public UnknownsPredicateRef? Unknowns { get; init; }
|
||||
|
||||
[JsonPropertyName("appliedExceptions")]
|
||||
public required IReadOnlyList<string> AppliedExceptions { get; init; }
|
||||
|
||||
[JsonPropertyName("explanation")]
|
||||
public string? Explanation { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required string CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("expiresAt")]
|
||||
public string? ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy reference in predicate.
|
||||
/// </summary>
|
||||
public sealed record PolicyPredicateRef
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public required string Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence reference in predicate.
|
||||
/// </summary>
|
||||
public sealed record EvidencePredicateRef
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public required string Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("uri")]
|
||||
public string? Uri { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unknowns reference in predicate.
|
||||
/// </summary>
|
||||
public sealed record UnknownsPredicateRef
|
||||
{
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
[JsonPropertyName("blockingCount")]
|
||||
public int BlockingCount { get; init; }
|
||||
}
|
||||
235
src/Policy/StellaOps.Policy.Engine/Attestation/RvaService.cs
Normal file
235
src/Policy/StellaOps.Policy.Engine/Attestation/RvaService.cs
Normal file
@@ -0,0 +1,235 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Policy.Snapshots;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating and managing Risk Verdict Attestations.
|
||||
/// </summary>
|
||||
public sealed class RvaService : IRvaService
|
||||
{
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
private readonly ISnapshotService _snapshotService;
|
||||
private readonly IRvaStore _store;
|
||||
private readonly ILogger<RvaService> _logger;
|
||||
|
||||
public RvaService(
|
||||
ICryptoHash cryptoHash,
|
||||
ISnapshotService snapshotService,
|
||||
IRvaStore store,
|
||||
ILogger<RvaService> logger)
|
||||
{
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
_snapshotService = snapshotService ?? throw new ArgumentNullException(nameof(snapshotService));
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an RVA from a builder, validating the snapshot reference.
|
||||
/// </summary>
|
||||
public async Task<RiskVerdictAttestation> CreateAttestationAsync(
|
||||
RvaBuilder builder,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var attestation = builder.Build();
|
||||
|
||||
// Validate snapshot exists
|
||||
var snapshot = await _snapshotService.GetSnapshotAsync(attestation.KnowledgeSnapshotId, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (snapshot is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Knowledge snapshot {attestation.KnowledgeSnapshotId} not found");
|
||||
}
|
||||
|
||||
// Verify snapshot integrity
|
||||
var verification = await _snapshotService.VerifySnapshotAsync(snapshot, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!verification.IsValid)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Knowledge snapshot verification failed: {verification.Error}");
|
||||
}
|
||||
|
||||
// Store the attestation
|
||||
await _store.SaveAsync(attestation, ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created RVA {AttestationId} with verdict {Verdict} for {Artifact} using snapshot {SnapshotId}",
|
||||
attestation.AttestationId,
|
||||
attestation.Verdict,
|
||||
attestation.Subject.Digest,
|
||||
attestation.KnowledgeSnapshotId);
|
||||
|
||||
return attestation;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that an RVA can be replayed with its referenced snapshot.
|
||||
/// </summary>
|
||||
public async Task<ReplayValidation> ValidateForReplayAsync(
|
||||
RiskVerdictAttestation attestation,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Check snapshot exists
|
||||
var snapshot = await _snapshotService.GetSnapshotAsync(attestation.KnowledgeSnapshotId, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (snapshot is null)
|
||||
{
|
||||
return ReplayValidation.Fail("Knowledge snapshot not found");
|
||||
}
|
||||
|
||||
// Check snapshot integrity
|
||||
var verification = await _snapshotService.VerifySnapshotAsync(snapshot, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!verification.IsValid)
|
||||
{
|
||||
return ReplayValidation.Fail($"Snapshot verification failed: {verification.Error}");
|
||||
}
|
||||
|
||||
return ReplayValidation.Success(snapshot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves an attestation by ID.
|
||||
/// </summary>
|
||||
public async Task<RiskVerdictAttestation?> GetAttestationAsync(
|
||||
string attestationId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await _store.GetAsync(attestationId, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists attestations for a subject.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<RiskVerdictAttestation>> GetAttestationsForSubjectAsync(
|
||||
string subjectDigest,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await _store.GetBySubjectAsync(subjectDigest, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of replay validation.
|
||||
/// </summary>
|
||||
public sealed record ReplayValidation(
|
||||
bool CanReplay,
|
||||
string? Error,
|
||||
KnowledgeSnapshotManifest? Snapshot)
|
||||
{
|
||||
public static ReplayValidation Success(KnowledgeSnapshotManifest snapshot) =>
|
||||
new(true, null, snapshot);
|
||||
|
||||
public static ReplayValidation Fail(string error) =>
|
||||
new(false, error, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for RVA service.
|
||||
/// </summary>
|
||||
public interface IRvaService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an RVA from a builder.
|
||||
/// </summary>
|
||||
Task<RiskVerdictAttestation> CreateAttestationAsync(RvaBuilder builder, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validates that an RVA can be replayed.
|
||||
/// </summary>
|
||||
Task<ReplayValidation> ValidateForReplayAsync(RiskVerdictAttestation attestation, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves an attestation by ID.
|
||||
/// </summary>
|
||||
Task<RiskVerdictAttestation?> GetAttestationAsync(string attestationId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists attestations for a subject.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<RiskVerdictAttestation>> GetAttestationsForSubjectAsync(string subjectDigest, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for RVA persistence.
|
||||
/// </summary>
|
||||
public interface IRvaStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Saves an attestation.
|
||||
/// </summary>
|
||||
Task SaveAsync(RiskVerdictAttestation attestation, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves an attestation by ID.
|
||||
/// </summary>
|
||||
Task<RiskVerdictAttestation?> GetAsync(string attestationId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets attestations for a subject digest.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<RiskVerdictAttestation>> GetBySubjectAsync(string subjectDigest, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an attestation.
|
||||
/// </summary>
|
||||
Task<bool> DeleteAsync(string attestationId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IRvaStore"/> for testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryRvaStore : IRvaStore
|
||||
{
|
||||
private readonly Dictionary<string, RiskVerdictAttestation> _attestations = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public Task SaveAsync(RiskVerdictAttestation attestation, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
lock (_lock)
|
||||
{
|
||||
_attestations[attestation.AttestationId] = attestation;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<RiskVerdictAttestation?> GetAsync(string attestationId, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
lock (_lock)
|
||||
{
|
||||
return Task.FromResult(_attestations.TryGetValue(attestationId, out var att) ? att : null);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<RiskVerdictAttestation>> GetBySubjectAsync(string subjectDigest, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
lock (_lock)
|
||||
{
|
||||
var result = _attestations.Values
|
||||
.Where(a => a.Subject.Digest == subjectDigest)
|
||||
.OrderByDescending(a => a.CreatedAt)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<RiskVerdictAttestation>>(result);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(string attestationId, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
lock (_lock)
|
||||
{
|
||||
return Task.FromResult(_attestations.Remove(attestationId));
|
||||
}
|
||||
}
|
||||
}
|
||||
348
src/Policy/StellaOps.Policy.Engine/Attestation/RvaVerifier.cs
Normal file
348
src/Policy/StellaOps.Policy.Engine/Attestation/RvaVerifier.cs
Normal file
@@ -0,0 +1,348 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Policy.Snapshots;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies Risk Verdict Attestation signatures and integrity.
|
||||
/// </summary>
|
||||
public sealed class RvaVerifier : IRvaVerifier
|
||||
{
|
||||
private readonly ICryptoSigner? _signer;
|
||||
private readonly ISnapshotService _snapshotService;
|
||||
private readonly ILogger<RvaVerifier> _logger;
|
||||
|
||||
public RvaVerifier(
|
||||
ISnapshotService snapshotService,
|
||||
ILogger<RvaVerifier> logger,
|
||||
ICryptoSigner? signer = null)
|
||||
{
|
||||
_snapshotService = snapshotService ?? throw new ArgumentNullException(nameof(snapshotService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_signer = signer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a DSSE-wrapped RVA.
|
||||
/// </summary>
|
||||
public async Task<RvaVerificationResult> VerifyAsync(
|
||||
DsseEnvelope envelope,
|
||||
RvaVerificationOptions options,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
|
||||
var issues = new List<string>();
|
||||
string? signerIdentity = null;
|
||||
|
||||
// Step 1: Verify DSSE signature
|
||||
if (_signer is not null)
|
||||
{
|
||||
var sigResult = await VerifySignatureAsync(envelope, options, ct).ConfigureAwait(false);
|
||||
signerIdentity = sigResult.SignerIdentity;
|
||||
|
||||
if (!sigResult.IsValid)
|
||||
{
|
||||
issues.Add($"Signature verification failed: {sigResult.Error}");
|
||||
if (!options.ContinueOnSignatureFailure)
|
||||
{
|
||||
return RvaVerificationResult.Fail(issues);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Parse payload
|
||||
var attestation = ParsePayload(envelope);
|
||||
if (attestation is null)
|
||||
{
|
||||
issues.Add("Failed to parse RVA payload");
|
||||
return RvaVerificationResult.Fail(issues);
|
||||
}
|
||||
|
||||
// Step 3: Verify content-addressed ID
|
||||
var idValid = VerifyAttestationId(attestation);
|
||||
if (!idValid)
|
||||
{
|
||||
issues.Add("Attestation ID does not match content");
|
||||
return RvaVerificationResult.Fail(issues);
|
||||
}
|
||||
|
||||
// Step 4: Verify expiration
|
||||
if (options.CheckExpiration && attestation.ExpiresAt.HasValue)
|
||||
{
|
||||
if (attestation.ExpiresAt.Value < DateTimeOffset.UtcNow)
|
||||
{
|
||||
issues.Add($"Attestation expired at {attestation.ExpiresAt.Value:o}");
|
||||
if (!options.AllowExpired)
|
||||
{
|
||||
return RvaVerificationResult.Fail(issues);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Verify knowledge snapshot exists (if requested)
|
||||
if (options.VerifySnapshotExists)
|
||||
{
|
||||
var snapshot = await _snapshotService.GetSnapshotAsync(attestation.KnowledgeSnapshotId, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (snapshot is null)
|
||||
{
|
||||
issues.Add($"Knowledge snapshot {attestation.KnowledgeSnapshotId} not found");
|
||||
}
|
||||
}
|
||||
|
||||
var isValid = issues.Count == 0 ||
|
||||
issues.All(i => i.Contains("expired", StringComparison.OrdinalIgnoreCase) && options.AllowExpired);
|
||||
|
||||
return new RvaVerificationResult
|
||||
{
|
||||
IsValid = isValid,
|
||||
Attestation = attestation,
|
||||
SignerIdentity = signerIdentity,
|
||||
Issues = issues,
|
||||
VerifiedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a raw RVA (unsigned) for integrity.
|
||||
/// </summary>
|
||||
public Task<RvaVerificationResult> VerifyRawAsync(
|
||||
RiskVerdictAttestation attestation,
|
||||
RvaVerificationOptions options,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attestation);
|
||||
|
||||
var issues = new List<string>();
|
||||
|
||||
// Verify content-addressed ID
|
||||
var idValid = VerifyAttestationId(attestation);
|
||||
if (!idValid)
|
||||
{
|
||||
issues.Add("Attestation ID does not match content");
|
||||
return Task.FromResult(RvaVerificationResult.Fail(issues));
|
||||
}
|
||||
|
||||
// Verify expiration
|
||||
if (options.CheckExpiration && attestation.ExpiresAt.HasValue)
|
||||
{
|
||||
if (attestation.ExpiresAt.Value < DateTimeOffset.UtcNow)
|
||||
{
|
||||
issues.Add($"Attestation expired at {attestation.ExpiresAt.Value:o}");
|
||||
if (!options.AllowExpired)
|
||||
{
|
||||
return Task.FromResult(RvaVerificationResult.Fail(issues));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var isValid = issues.Count == 0 ||
|
||||
issues.All(i => i.Contains("expired", StringComparison.OrdinalIgnoreCase) && options.AllowExpired);
|
||||
|
||||
return Task.FromResult(new RvaVerificationResult
|
||||
{
|
||||
IsValid = isValid,
|
||||
Attestation = attestation,
|
||||
SignerIdentity = null,
|
||||
Issues = issues,
|
||||
VerifiedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quick verification of just the signature.
|
||||
/// </summary>
|
||||
public async Task<SignatureVerificationResult> VerifySignatureAsync(
|
||||
DsseEnvelope envelope,
|
||||
RvaVerificationOptions options,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (_signer is null)
|
||||
{
|
||||
return new SignatureVerificationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Error = "No signer configured for verification"
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var payload = envelope.Payload;
|
||||
var signatureBase64 = envelope.Signatures[0].Signature;
|
||||
var signature = Convert.FromBase64String(signatureBase64);
|
||||
|
||||
var isValid = await _signer.VerifyAsync(payload, signature, ct).ConfigureAwait(false);
|
||||
|
||||
return new SignatureVerificationResult
|
||||
{
|
||||
IsValid = isValid,
|
||||
SignerIdentity = envelope.Signatures[0].KeyId
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Signature verification failed");
|
||||
return new SignatureVerificationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private RiskVerdictAttestation? ParsePayload(DsseEnvelope envelope)
|
||||
{
|
||||
try
|
||||
{
|
||||
var payloadBytes = envelope.Payload.ToArray();
|
||||
var statement = JsonSerializer.Deserialize<RvaInTotoStatement>(payloadBytes);
|
||||
|
||||
if (statement?.PredicateType != RvaPredicate.PredicateType)
|
||||
return null;
|
||||
|
||||
var predicateJson = JsonSerializer.Serialize(statement.Predicate);
|
||||
var predicate = JsonSerializer.Deserialize<RvaPredicateContent>(predicateJson);
|
||||
|
||||
if (predicate is null)
|
||||
return null;
|
||||
|
||||
return ConvertToRva(statement, predicate);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse RVA payload");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static RiskVerdictAttestation ConvertToRva(RvaInTotoStatement statement, RvaPredicateContent predicate)
|
||||
{
|
||||
var subject = statement.Subject[0];
|
||||
var digest = subject.Digest.TryGetValue("sha256", out var sha) ? $"sha256:{sha}" : subject.Name;
|
||||
|
||||
return new RiskVerdictAttestation
|
||||
{
|
||||
AttestationId = predicate.AttestationId,
|
||||
SchemaVersion = predicate.SchemaVersion,
|
||||
CreatedAt = DateTimeOffset.Parse(predicate.CreatedAt),
|
||||
Verdict = Enum.Parse<RiskVerdictStatus>(predicate.Verdict),
|
||||
Subject = new ArtifactSubject
|
||||
{
|
||||
Digest = digest,
|
||||
Type = "container-image",
|
||||
Name = subject.Name
|
||||
},
|
||||
Policy = new RvaPolicyRef
|
||||
{
|
||||
PolicyId = predicate.Policy.Id,
|
||||
Version = predicate.Policy.Version,
|
||||
Digest = predicate.Policy.Digest
|
||||
},
|
||||
KnowledgeSnapshotId = predicate.KnowledgeSnapshotId,
|
||||
Evidence = predicate.Evidence.Select(e => new RvaEvidenceRef
|
||||
{
|
||||
Type = e.Type,
|
||||
Digest = e.Digest,
|
||||
Uri = e.Uri
|
||||
}).ToList(),
|
||||
ReasonCodes = predicate.ReasonCodes
|
||||
.Select(c => Enum.TryParse<VerdictReasonCode>(c, out var code) ? code : VerdictReasonCode.PassNoCves)
|
||||
.ToList(),
|
||||
Unknowns = predicate.Unknowns is not null ? new UnknownsSummary
|
||||
{
|
||||
Total = predicate.Unknowns.Total,
|
||||
BlockingCount = predicate.Unknowns.BlockingCount
|
||||
} : null,
|
||||
AppliedExceptions = predicate.AppliedExceptions,
|
||||
Explanation = predicate.Explanation,
|
||||
ExpiresAt = predicate.ExpiresAt is not null ? DateTimeOffset.Parse(predicate.ExpiresAt) : null
|
||||
};
|
||||
}
|
||||
|
||||
private static bool VerifyAttestationId(RiskVerdictAttestation attestation)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(attestation with { AttestationId = "" },
|
||||
RvaSerializerOptions.Canonical);
|
||||
var expectedId = $"rva:sha256:{ComputeSha256(json)}";
|
||||
return attestation.AttestationId == expectedId;
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string input)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of RVA verification.
|
||||
/// </summary>
|
||||
public sealed record RvaVerificationResult
|
||||
{
|
||||
public required bool IsValid { get; init; }
|
||||
public RiskVerdictAttestation? Attestation { get; init; }
|
||||
public string? SignerIdentity { get; init; }
|
||||
public IReadOnlyList<string> Issues { get; init; } = [];
|
||||
public DateTimeOffset VerifiedAt { get; init; }
|
||||
|
||||
public static RvaVerificationResult Fail(IReadOnlyList<string> issues) =>
|
||||
new() { IsValid = false, Issues = issues, VerifiedAt = DateTimeOffset.UtcNow };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of signature verification.
|
||||
/// </summary>
|
||||
public sealed record SignatureVerificationResult
|
||||
{
|
||||
public required bool IsValid { get; init; }
|
||||
public string? SignerIdentity { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for RVA verification.
|
||||
/// </summary>
|
||||
public sealed record RvaVerificationOptions
|
||||
{
|
||||
public bool CheckExpiration { get; init; } = true;
|
||||
public bool AllowExpired { get; init; } = false;
|
||||
public bool VerifySnapshotExists { get; init; } = false;
|
||||
public bool VerifySignerIdentity { get; init; } = true;
|
||||
public bool ContinueOnSignatureFailure { get; init; } = false;
|
||||
|
||||
public static RvaVerificationOptions Default { get; } = new();
|
||||
public static RvaVerificationOptions Strict { get; } = new()
|
||||
{
|
||||
VerifySnapshotExists = true,
|
||||
AllowExpired = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for RVA verification.
|
||||
/// </summary>
|
||||
public interface IRvaVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies a DSSE-wrapped RVA.
|
||||
/// </summary>
|
||||
Task<RvaVerificationResult> VerifyAsync(DsseEnvelope envelope, RvaVerificationOptions options, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a raw RVA for integrity.
|
||||
/// </summary>
|
||||
Task<RvaVerificationResult> VerifyRawAsync(RiskVerdictAttestation attestation, RvaVerificationOptions options, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies just the signature.
|
||||
/// </summary>
|
||||
Task<SignatureVerificationResult> VerifySignatureAsync(DsseEnvelope envelope, RvaVerificationOptions options, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
namespace StellaOps.Policy.Engine.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Structured reason codes explaining verdict outcomes.
|
||||
/// Format: CATEGORY.SUBCATEGORY.DETAIL
|
||||
/// </summary>
|
||||
public enum VerdictReasonCode
|
||||
{
|
||||
// PASS reasons
|
||||
/// <summary>
|
||||
/// No CVEs found in artifact.
|
||||
/// </summary>
|
||||
PassNoCves,
|
||||
|
||||
/// <summary>
|
||||
/// All CVEs are not reachable.
|
||||
/// </summary>
|
||||
PassNotReachable,
|
||||
|
||||
/// <summary>
|
||||
/// All CVEs are covered by VEX not_affected statements.
|
||||
/// </summary>
|
||||
PassVexNotAffected,
|
||||
|
||||
/// <summary>
|
||||
/// All CVEs are below severity threshold.
|
||||
/// </summary>
|
||||
PassBelowThreshold,
|
||||
|
||||
// FAIL reasons - CVE
|
||||
/// <summary>
|
||||
/// Reachable CVE exceeds severity threshold.
|
||||
/// </summary>
|
||||
FailCveReachable,
|
||||
|
||||
/// <summary>
|
||||
/// CVE in CISA KEV (Known Exploited Vulnerabilities).
|
||||
/// </summary>
|
||||
FailCveKev,
|
||||
|
||||
/// <summary>
|
||||
/// CVE with high EPSS score.
|
||||
/// </summary>
|
||||
FailCveEpss,
|
||||
|
||||
/// <summary>
|
||||
/// CVE severity exceeds maximum allowed.
|
||||
/// </summary>
|
||||
FailCveSeverity,
|
||||
|
||||
// FAIL reasons - Policy
|
||||
/// <summary>
|
||||
/// License violation detected.
|
||||
/// </summary>
|
||||
FailPolicyLicense,
|
||||
|
||||
/// <summary>
|
||||
/// Blocked package detected.
|
||||
/// </summary>
|
||||
FailPolicyBlockedPackage,
|
||||
|
||||
/// <summary>
|
||||
/// Unknown budget exceeded.
|
||||
/// </summary>
|
||||
FailPolicyUnknownBudget,
|
||||
|
||||
/// <summary>
|
||||
/// SBOM completeness below threshold.
|
||||
/// </summary>
|
||||
FailPolicySbomCompleteness,
|
||||
|
||||
// FAIL reasons - Provenance
|
||||
/// <summary>
|
||||
/// Missing provenance attestation.
|
||||
/// </summary>
|
||||
FailProvenanceMissing,
|
||||
|
||||
/// <summary>
|
||||
/// Provenance signature invalid.
|
||||
/// </summary>
|
||||
FailProvenanceInvalid,
|
||||
|
||||
// EXCEPTION reasons
|
||||
/// <summary>
|
||||
/// CVE covered by approved exception.
|
||||
/// </summary>
|
||||
ExceptionCve,
|
||||
|
||||
/// <summary>
|
||||
/// License covered by approved exception.
|
||||
/// </summary>
|
||||
ExceptionLicense,
|
||||
|
||||
/// <summary>
|
||||
/// Unknowns covered by approved exception.
|
||||
/// </summary>
|
||||
ExceptionUnknown,
|
||||
|
||||
// INDETERMINATE reasons
|
||||
/// <summary>
|
||||
/// Insufficient data to evaluate.
|
||||
/// </summary>
|
||||
IndeterminateInsufficientData,
|
||||
|
||||
/// <summary>
|
||||
/// Analyzer does not support this artifact type.
|
||||
/// </summary>
|
||||
IndeterminateUnsupported,
|
||||
|
||||
/// <summary>
|
||||
/// Conflicting VEX statements.
|
||||
/// </summary>
|
||||
IndeterminateVexConflict,
|
||||
|
||||
/// <summary>
|
||||
/// Required knowledge source unavailable.
|
||||
/// </summary>
|
||||
IndeterminateFeedUnavailable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for reason code handling.
|
||||
/// </summary>
|
||||
public static class VerdictReasonCodeExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the category of a reason code (Pass, Fail, Exception, Indeterminate).
|
||||
/// </summary>
|
||||
public static string GetCategory(this VerdictReasonCode code)
|
||||
{
|
||||
return code.ToString() switch
|
||||
{
|
||||
var s when s.StartsWith("Pass", StringComparison.Ordinal) => "Pass",
|
||||
var s when s.StartsWith("Fail", StringComparison.Ordinal) => "Fail",
|
||||
var s when s.StartsWith("Exception", StringComparison.Ordinal) => "Exception",
|
||||
var s when s.StartsWith("Indeterminate", StringComparison.Ordinal) => "Indeterminate",
|
||||
_ => "Unknown"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a human-readable description of the reason code.
|
||||
/// </summary>
|
||||
public static string GetDescription(this VerdictReasonCode code)
|
||||
{
|
||||
return code switch
|
||||
{
|
||||
VerdictReasonCode.PassNoCves => "No CVEs found in artifact",
|
||||
VerdictReasonCode.PassNotReachable => "All CVEs are not reachable",
|
||||
VerdictReasonCode.PassVexNotAffected => "All CVEs covered by VEX not_affected statements",
|
||||
VerdictReasonCode.PassBelowThreshold => "All CVEs below severity threshold",
|
||||
VerdictReasonCode.FailCveReachable => "Reachable CVE exceeds severity threshold",
|
||||
VerdictReasonCode.FailCveKev => "CVE in CISA Known Exploited Vulnerabilities list",
|
||||
VerdictReasonCode.FailCveEpss => "CVE with high EPSS score",
|
||||
VerdictReasonCode.FailCveSeverity => "CVE severity exceeds maximum allowed",
|
||||
VerdictReasonCode.FailPolicyLicense => "License violation detected",
|
||||
VerdictReasonCode.FailPolicyBlockedPackage => "Blocked package detected",
|
||||
VerdictReasonCode.FailPolicyUnknownBudget => "Unknown budget exceeded",
|
||||
VerdictReasonCode.FailPolicySbomCompleteness => "SBOM completeness below threshold",
|
||||
VerdictReasonCode.FailProvenanceMissing => "Missing provenance attestation",
|
||||
VerdictReasonCode.FailProvenanceInvalid => "Provenance signature invalid",
|
||||
VerdictReasonCode.ExceptionCve => "CVE covered by approved exception",
|
||||
VerdictReasonCode.ExceptionLicense => "License covered by approved exception",
|
||||
VerdictReasonCode.ExceptionUnknown => "Unknowns covered by approved exception",
|
||||
VerdictReasonCode.IndeterminateInsufficientData => "Insufficient data to evaluate",
|
||||
VerdictReasonCode.IndeterminateUnsupported => "Analyzer does not support this artifact type",
|
||||
VerdictReasonCode.IndeterminateVexConflict => "Conflicting VEX statements",
|
||||
VerdictReasonCode.IndeterminateFeedUnavailable => "Required knowledge source unavailable",
|
||||
_ => code.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a reason code indicates a passing state.
|
||||
/// </summary>
|
||||
public static bool IsPass(this VerdictReasonCode code)
|
||||
{
|
||||
return code.GetCategory() == "Pass";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a reason code indicates a failing state.
|
||||
/// </summary>
|
||||
public static bool IsFail(this VerdictReasonCode code)
|
||||
{
|
||||
return code.GetCategory() == "Fail";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user