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";
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ using StellaOps.Policy.Engine.Attestation;
|
||||
using StellaOps.Policy.Engine.BuildGate;
|
||||
using StellaOps.Policy.Engine.Caching;
|
||||
using StellaOps.Policy.Engine.EffectiveDecisionMap;
|
||||
using StellaOps.Policy.Engine.Evaluation;
|
||||
using StellaOps.Policy.Engine.Events;
|
||||
using StellaOps.Policy.Engine.ExceptionCache;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
|
||||
253
src/Policy/StellaOps.Policy.Engine/Endpoints/BudgetEndpoints.cs
Normal file
253
src/Policy/StellaOps.Policy.Engine/Endpoints/BudgetEndpoints.cs
Normal file
@@ -0,0 +1,253 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BudgetEndpoints.cs
|
||||
// Sprint: SPRINT_4300_0002_0001 (Unknowns Budget Policy Integration)
|
||||
// Task: BUDGET-014 - Create budget management API endpoints
|
||||
// Description: API endpoints for managing unknown budget configurations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Unknowns.Configuration;
|
||||
using StellaOps.Policy.Unknowns.Models;
|
||||
using StellaOps.Policy.Unknowns.Services;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for managing unknown budget configurations.
|
||||
/// </summary>
|
||||
internal static class BudgetEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapBudgets(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/v1/policy/budgets")
|
||||
.RequireAuthorization()
|
||||
.WithTags("Unknown Budgets");
|
||||
|
||||
group.MapGet(string.Empty, ListBudgets)
|
||||
.WithName("ListBudgets")
|
||||
.WithSummary("List all configured unknown budgets.")
|
||||
.Produces<BudgetsListResponse>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapGet("/{environment}", GetBudget)
|
||||
.WithName("GetBudget")
|
||||
.WithSummary("Get budget for a specific environment.")
|
||||
.Produces<BudgetResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/{environment}/status", GetBudgetStatus)
|
||||
.WithName("GetBudgetStatus")
|
||||
.WithSummary("Get current budget status for an environment.")
|
||||
.Produces<BudgetStatusResponse>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapPost("/{environment}/check", CheckBudget)
|
||||
.WithName("CheckBudget")
|
||||
.WithSummary("Check unknowns against a budget.")
|
||||
.Produces<BudgetCheckResponse>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapGet("/defaults", GetDefaultBudgets)
|
||||
.WithName("GetDefaultBudgets")
|
||||
.WithSummary("Get the default budget configurations.")
|
||||
.Produces<DefaultBudgetsResponse>(StatusCodes.Status200OK);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static Ok<BudgetsListResponse> ListBudgets(
|
||||
IOptions<UnknownBudgetOptions> options)
|
||||
{
|
||||
var budgets = options.Value.Budgets
|
||||
.Select(kvp => ToBudgetDto(kvp.Key, kvp.Value))
|
||||
.OrderBy(b => b.Environment)
|
||||
.ToList();
|
||||
|
||||
return TypedResults.Ok(new BudgetsListResponse(
|
||||
budgets,
|
||||
budgets.Count,
|
||||
options.Value.EnforceBudgets));
|
||||
}
|
||||
|
||||
private static Results<Ok<BudgetResponse>, NotFound<ProblemDetails>> GetBudget(
|
||||
string environment,
|
||||
IUnknownBudgetService budgetService)
|
||||
{
|
||||
var budget = budgetService.GetBudgetForEnvironment(environment);
|
||||
|
||||
if (budget is null)
|
||||
{
|
||||
return TypedResults.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Budget not found",
|
||||
Detail = $"No budget configured for environment '{environment}'."
|
||||
});
|
||||
}
|
||||
|
||||
return TypedResults.Ok(new BudgetResponse(ToBudgetDto(environment, budget)));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<BudgetStatusResponse>, ProblemHttpResult>> GetBudgetStatus(
|
||||
HttpContext httpContext,
|
||||
string environment,
|
||||
IUnknownBudgetService budgetService,
|
||||
Unknowns.Repositories.IUnknownsRepository repository,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
if (tenantId == Guid.Empty)
|
||||
{
|
||||
return TypedResults.Problem("Tenant ID is required.", statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
// Get all unknowns for the tenant
|
||||
var unknowns = await repository.GetAllAsync(tenantId, limit: 10000, ct: ct);
|
||||
|
||||
var status = budgetService.GetBudgetStatus(environment, unknowns);
|
||||
|
||||
return TypedResults.Ok(new BudgetStatusResponse(
|
||||
status.Environment,
|
||||
status.TotalUnknowns,
|
||||
status.TotalLimit,
|
||||
status.PercentageUsed,
|
||||
status.IsExceeded,
|
||||
status.ViolationCount,
|
||||
status.ByReasonCode.ToDictionary(
|
||||
kvp => kvp.Key.ToString(),
|
||||
kvp => kvp.Value)));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<BudgetCheckResponse>, ProblemHttpResult>> CheckBudget(
|
||||
HttpContext httpContext,
|
||||
string environment,
|
||||
[FromBody] BudgetCheckRequest request,
|
||||
IUnknownBudgetService budgetService,
|
||||
Unknowns.Repositories.IUnknownsRepository repository,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
if (tenantId == Guid.Empty)
|
||||
{
|
||||
return TypedResults.Problem("Tenant ID is required.", statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
// Get unknowns (either from request or repository)
|
||||
IReadOnlyList<Unknown> unknowns;
|
||||
if (request.UnknownIds is { Count: > 0 })
|
||||
{
|
||||
var allUnknowns = await repository.GetAllAsync(tenantId, limit: 10000, ct: ct);
|
||||
unknowns = allUnknowns.Where(u => request.UnknownIds.Contains(u.Id)).ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
unknowns = await repository.GetAllAsync(tenantId, limit: 10000, ct: ct);
|
||||
}
|
||||
|
||||
var result = budgetService.CheckBudget(environment, unknowns);
|
||||
|
||||
return TypedResults.Ok(new BudgetCheckResponse(
|
||||
result.IsWithinBudget,
|
||||
result.RecommendedAction.ToString().ToLowerInvariant(),
|
||||
result.TotalUnknowns,
|
||||
result.TotalLimit,
|
||||
result.Message,
|
||||
result.Violations.Select(kvp => new BudgetViolationDto(
|
||||
kvp.Key.ToString(),
|
||||
kvp.Value.Count,
|
||||
kvp.Value.Limit)).ToList()));
|
||||
}
|
||||
|
||||
private static Ok<DefaultBudgetsResponse> GetDefaultBudgets()
|
||||
{
|
||||
return TypedResults.Ok(new DefaultBudgetsResponse(
|
||||
ToBudgetDto("production", DefaultBudgets.Production),
|
||||
ToBudgetDto("staging", DefaultBudgets.Staging),
|
||||
ToBudgetDto("development", DefaultBudgets.Development),
|
||||
ToBudgetDto("default", DefaultBudgets.Default)));
|
||||
}
|
||||
|
||||
private static Guid ResolveTenantId(HttpContext context)
|
||||
{
|
||||
if (context.Request.Headers.TryGetValue("X-Tenant-Id", out var tenantHeader) &&
|
||||
!string.IsNullOrWhiteSpace(tenantHeader) &&
|
||||
Guid.TryParse(tenantHeader.ToString(), out var headerTenantId))
|
||||
{
|
||||
return headerTenantId;
|
||||
}
|
||||
|
||||
var tenantClaim = context.User?.FindFirst("tenant_id")?.Value;
|
||||
if (!string.IsNullOrEmpty(tenantClaim) && Guid.TryParse(tenantClaim, out var claimTenantId))
|
||||
{
|
||||
return claimTenantId;
|
||||
}
|
||||
|
||||
return Guid.Empty;
|
||||
}
|
||||
|
||||
private static BudgetDto ToBudgetDto(string environment, UnknownBudget budget)
|
||||
{
|
||||
return new BudgetDto(
|
||||
environment,
|
||||
budget.TotalLimit,
|
||||
budget.ReasonLimits.ToDictionary(
|
||||
kvp => kvp.Key.ToString(),
|
||||
kvp => kvp.Value),
|
||||
budget.Action.ToString().ToLowerInvariant(),
|
||||
budget.ExceededMessage);
|
||||
}
|
||||
}
|
||||
|
||||
#region DTOs
|
||||
|
||||
/// <summary>Budget data transfer object.</summary>
|
||||
public sealed record BudgetDto(
|
||||
string Environment,
|
||||
int? TotalLimit,
|
||||
IReadOnlyDictionary<string, int> ReasonLimits,
|
||||
string Action,
|
||||
string? ExceededMessage);
|
||||
|
||||
/// <summary>Response containing a list of budgets.</summary>
|
||||
public sealed record BudgetsListResponse(
|
||||
IReadOnlyList<BudgetDto> Budgets,
|
||||
int TotalCount,
|
||||
bool EnforcementEnabled);
|
||||
|
||||
/// <summary>Response containing a single budget.</summary>
|
||||
public sealed record BudgetResponse(BudgetDto Budget);
|
||||
|
||||
/// <summary>Response containing budget status.</summary>
|
||||
public sealed record BudgetStatusResponse(
|
||||
string Environment,
|
||||
int TotalUnknowns,
|
||||
int? TotalLimit,
|
||||
decimal PercentageUsed,
|
||||
bool IsExceeded,
|
||||
int ViolationCount,
|
||||
IReadOnlyDictionary<string, int> ByReasonCode);
|
||||
|
||||
/// <summary>Request to check unknowns against a budget.</summary>
|
||||
public sealed record BudgetCheckRequest(IReadOnlyList<Guid>? UnknownIds = null);
|
||||
|
||||
/// <summary>Response from budget check.</summary>
|
||||
public sealed record BudgetCheckResponse(
|
||||
bool IsWithinBudget,
|
||||
string RecommendedAction,
|
||||
int TotalUnknowns,
|
||||
int? TotalLimit,
|
||||
string? Message,
|
||||
IReadOnlyList<BudgetViolationDto> Violations);
|
||||
|
||||
/// <summary>Budget violation details.</summary>
|
||||
public sealed record BudgetViolationDto(
|
||||
string ReasonCode,
|
||||
int Count,
|
||||
int Limit);
|
||||
|
||||
/// <summary>Response containing default budgets.</summary>
|
||||
public sealed record DefaultBudgetsResponse(
|
||||
BudgetDto Production,
|
||||
BudgetDto Staging,
|
||||
BudgetDto Development,
|
||||
BudgetDto Default);
|
||||
|
||||
#endregion
|
||||
244
src/Policy/StellaOps.Policy.Engine/Gates/DriftGateContext.cs
Normal file
244
src/Policy/StellaOps.Policy.Engine/Gates/DriftGateContext.cs
Normal file
@@ -0,0 +1,244 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DriftGateContext.cs
|
||||
// Sprint: SPRINT_3600_0005_0001_policy_ci_gate_integration
|
||||
// Description: Context for drift gate evaluation containing delta metrics.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Context for evaluating drift gates in policy evaluation.
|
||||
/// Contains delta metrics from reachability drift analysis.
|
||||
/// </summary>
|
||||
public sealed record DriftGateContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of newly reachable paths (positive delta).
|
||||
/// </summary>
|
||||
public required int DeltaReachable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of newly unreachable paths (negative delta, mitigation).
|
||||
/// </summary>
|
||||
public required int DeltaUnreachable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether any KEV (Known Exploited Vulnerability) is now reachable.
|
||||
/// </summary>
|
||||
public required bool HasKevReachable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX statuses of newly reachable vulnerabilities.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> NewlyReachableVexStatuses { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Maximum CVSS score among newly reachable vulnerabilities.
|
||||
/// </summary>
|
||||
public double? MaxCvss { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum EPSS score among newly reachable vulnerabilities.
|
||||
/// </summary>
|
||||
public double? MaxEpss { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Scan ID of the base (before) snapshot.
|
||||
/// </summary>
|
||||
public string? BaseScanId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Scan ID of the head (after) snapshot.
|
||||
/// </summary>
|
||||
public string? HeadScanId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Newly reachable sink IDs (for VEX candidate emission).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> NewlyReachableSinkIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Newly unreachable sink IDs (for VEX auto-mitigation).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> NewlyUnreachableSinkIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if there is any material drift.
|
||||
/// </summary>
|
||||
public bool HasMaterialDrift => DeltaReachable > 0 || DeltaUnreachable > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if drift represents a security regression.
|
||||
/// </summary>
|
||||
public bool IsRegression => DeltaReachable > 0 &&
|
||||
(HasKevReachable || NewlyReachableVexStatuses.Any(s =>
|
||||
s.Equals("affected", StringComparison.OrdinalIgnoreCase) ||
|
||||
s.Equals("under_investigation", StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if drift represents hardening (mitigation).
|
||||
/// </summary>
|
||||
public bool IsHardening => DeltaUnreachable > 0 && DeltaReachable == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for drift gate evaluation.
|
||||
/// </summary>
|
||||
public sealed record DriftGateRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The drift context containing delta metrics.
|
||||
/// </summary>
|
||||
public required DriftGateContext Context { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy configuration ID to use for evaluation.
|
||||
/// </summary>
|
||||
public string? PolicyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to allow override of blocking gates.
|
||||
/// </summary>
|
||||
public bool AllowOverride { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Justification for override (if AllowOverride is true).
|
||||
/// </summary>
|
||||
public string? OverrideJustification { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of drift gate evaluation.
|
||||
/// </summary>
|
||||
public sealed record DriftGateDecision
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique decision ID.
|
||||
/// </summary>
|
||||
public required string DecisionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overall decision.
|
||||
/// </summary>
|
||||
public required DriftGateDecisionType Decision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of gate results.
|
||||
/// </summary>
|
||||
public ImmutableArray<DriftGateResult> Gates { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Advisory message.
|
||||
/// </summary>
|
||||
public string? Advisory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gate that blocked (if blocked).
|
||||
/// </summary>
|
||||
public string? BlockedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for blocking (if blocked).
|
||||
/// </summary>
|
||||
public string? BlockReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Suggestion for resolving the block.
|
||||
/// </summary>
|
||||
public string? Suggestion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the decision was made.
|
||||
/// </summary>
|
||||
public required DateTimeOffset DecidedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Context that was evaluated.
|
||||
/// </summary>
|
||||
public required DriftGateContext Context { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a single drift gate.
|
||||
/// </summary>
|
||||
public sealed record DriftGateResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gate name/ID.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gate result type.
|
||||
/// </summary>
|
||||
public required DriftGateResultType Result { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for the result.
|
||||
/// </summary>
|
||||
public required string Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional note (for warnings/passes with notes).
|
||||
/// </summary>
|
||||
public string? Note { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Condition expression that was evaluated.
|
||||
/// </summary>
|
||||
public string? Condition { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of drift gate results.
|
||||
/// </summary>
|
||||
public enum DriftGateResultType
|
||||
{
|
||||
/// <summary>
|
||||
/// Gate passed.
|
||||
/// </summary>
|
||||
Pass,
|
||||
|
||||
/// <summary>
|
||||
/// Gate passed with a note.
|
||||
/// </summary>
|
||||
PassWithNote,
|
||||
|
||||
/// <summary>
|
||||
/// Gate produced a warning.
|
||||
/// </summary>
|
||||
Warn,
|
||||
|
||||
/// <summary>
|
||||
/// Gate blocked the drift.
|
||||
/// </summary>
|
||||
Block,
|
||||
|
||||
/// <summary>
|
||||
/// Gate was skipped.
|
||||
/// </summary>
|
||||
Skip
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of drift gate decisions.
|
||||
/// </summary>
|
||||
public enum DriftGateDecisionType
|
||||
{
|
||||
/// <summary>
|
||||
/// Drift is allowed to proceed.
|
||||
/// </summary>
|
||||
Allow,
|
||||
|
||||
/// <summary>
|
||||
/// Drift is allowed with warnings.
|
||||
/// </summary>
|
||||
Warn,
|
||||
|
||||
/// <summary>
|
||||
/// Drift is blocked by policy.
|
||||
/// </summary>
|
||||
Block
|
||||
}
|
||||
463
src/Policy/StellaOps.Policy.Engine/Gates/DriftGateEvaluator.cs
Normal file
463
src/Policy/StellaOps.Policy.Engine/Gates/DriftGateEvaluator.cs
Normal file
@@ -0,0 +1,463 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DriftGateEvaluator.cs
|
||||
// Sprint: SPRINT_3600_0005_0001_policy_ci_gate_integration
|
||||
// Description: Evaluates drift gates for CI/CD pipeline gating.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates drift gates for reachability drift analysis.
|
||||
/// </summary>
|
||||
public interface IDriftGateEvaluator
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates all drift gates for a drift analysis result.
|
||||
/// </summary>
|
||||
/// <param name="request">The drift gate evaluation request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The drift gate decision.</returns>
|
||||
Task<DriftGateDecision> EvaluateAsync(DriftGateRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IDriftGateEvaluator"/>.
|
||||
/// </summary>
|
||||
public sealed class DriftGateEvaluator : IDriftGateEvaluator
|
||||
{
|
||||
private readonly IOptionsMonitor<DriftGateOptions> _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<DriftGateEvaluator> _logger;
|
||||
|
||||
public DriftGateEvaluator(
|
||||
IOptionsMonitor<DriftGateOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<DriftGateEvaluator> logger)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<DriftGateDecision> EvaluateAsync(DriftGateRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var options = _options.CurrentValue;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var context = request.Context;
|
||||
|
||||
var decisionId = $"drift-gate:{now:yyyyMMddHHmmss}:{Guid.NewGuid():N}";
|
||||
var gateResults = new List<DriftGateResult>();
|
||||
|
||||
string? blockedBy = null;
|
||||
string? blockReason = null;
|
||||
string? suggestion = null;
|
||||
var warnings = new List<string>();
|
||||
|
||||
// If gates are disabled, allow everything
|
||||
if (!options.Enabled)
|
||||
{
|
||||
return Task.FromResult(CreateAllowDecision(decisionId, context, now, "Drift gates disabled"));
|
||||
}
|
||||
|
||||
// If no material drift, allow
|
||||
if (!context.HasMaterialDrift)
|
||||
{
|
||||
return Task.FromResult(CreateAllowDecision(decisionId, context, now, "No material drift detected"));
|
||||
}
|
||||
|
||||
// 1. Evaluate built-in KEV gate
|
||||
if (options.BlockOnKev)
|
||||
{
|
||||
var kevResult = EvaluateKevGate(context);
|
||||
gateResults.Add(kevResult);
|
||||
if (kevResult.Result == DriftGateResultType.Block)
|
||||
{
|
||||
blockedBy = kevResult.Name;
|
||||
blockReason = kevResult.Reason;
|
||||
suggestion = "Review KEV exposure and mitigate before proceeding";
|
||||
}
|
||||
else if (kevResult.Result == DriftGateResultType.Warn)
|
||||
{
|
||||
warnings.Add(kevResult.Reason);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Evaluate built-in affected reachable gate
|
||||
if (blockedBy is null && options.BlockOnAffectedReachable)
|
||||
{
|
||||
var affectedResult = EvaluateAffectedReachableGate(context);
|
||||
gateResults.Add(affectedResult);
|
||||
if (affectedResult.Result == DriftGateResultType.Block)
|
||||
{
|
||||
blockedBy = affectedResult.Name;
|
||||
blockReason = affectedResult.Reason;
|
||||
suggestion = "Triage new reachable affected vulnerabilities";
|
||||
}
|
||||
else if (affectedResult.Result == DriftGateResultType.Warn)
|
||||
{
|
||||
warnings.Add(affectedResult.Reason);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Evaluate CVSS threshold gate
|
||||
if (blockedBy is null && options.CvssBlockThreshold.HasValue)
|
||||
{
|
||||
var cvssResult = EvaluateCvssGate(context, options.CvssBlockThreshold.Value);
|
||||
gateResults.Add(cvssResult);
|
||||
if (cvssResult.Result == DriftGateResultType.Block)
|
||||
{
|
||||
blockedBy = cvssResult.Name;
|
||||
blockReason = cvssResult.Reason;
|
||||
suggestion = $"Address vulnerabilities with CVSS >= {options.CvssBlockThreshold:F1}";
|
||||
}
|
||||
else if (cvssResult.Result == DriftGateResultType.Warn)
|
||||
{
|
||||
warnings.Add(cvssResult.Reason);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Evaluate EPSS threshold gate
|
||||
if (blockedBy is null && options.EpssBlockThreshold.HasValue)
|
||||
{
|
||||
var epssResult = EvaluateEpssGate(context, options.EpssBlockThreshold.Value);
|
||||
gateResults.Add(epssResult);
|
||||
if (epssResult.Result == DriftGateResultType.Block)
|
||||
{
|
||||
blockedBy = epssResult.Name;
|
||||
blockReason = epssResult.Reason;
|
||||
suggestion = $"Review high-probability exploit vulnerabilities (EPSS >= {options.EpssBlockThreshold:P0})";
|
||||
}
|
||||
else if (epssResult.Result == DriftGateResultType.Warn)
|
||||
{
|
||||
warnings.Add(epssResult.Reason);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Evaluate custom gates from configuration
|
||||
foreach (var gate in options.Gates)
|
||||
{
|
||||
if (blockedBy is not null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var customResult = EvaluateCustomGate(context, gate);
|
||||
gateResults.Add(customResult);
|
||||
|
||||
if (customResult.Result == DriftGateResultType.Block)
|
||||
{
|
||||
blockedBy = customResult.Name;
|
||||
blockReason = customResult.Reason;
|
||||
suggestion = gate.Message;
|
||||
}
|
||||
else if (customResult.Result == DriftGateResultType.Warn)
|
||||
{
|
||||
warnings.Add(customResult.Reason);
|
||||
}
|
||||
}
|
||||
|
||||
// Build final decision
|
||||
DriftGateDecisionType decision;
|
||||
string? advisory = null;
|
||||
|
||||
if (blockedBy is not null)
|
||||
{
|
||||
if (request.AllowOverride && CanOverride(request))
|
||||
{
|
||||
decision = DriftGateDecisionType.Warn;
|
||||
advisory = $"Override accepted: {request.OverrideJustification}";
|
||||
_logger.LogInformation(
|
||||
"Drift gate {Gate} overridden: {Justification}",
|
||||
blockedBy, request.OverrideJustification);
|
||||
}
|
||||
else
|
||||
{
|
||||
decision = DriftGateDecisionType.Block;
|
||||
_logger.LogInformation(
|
||||
"Drift gate {Gate} blocked drift: {Reason}",
|
||||
blockedBy, blockReason);
|
||||
}
|
||||
}
|
||||
else if (warnings.Count > 0)
|
||||
{
|
||||
decision = DriftGateDecisionType.Warn;
|
||||
advisory = string.Join("; ", warnings);
|
||||
}
|
||||
else
|
||||
{
|
||||
decision = DriftGateDecisionType.Allow;
|
||||
}
|
||||
|
||||
return Task.FromResult(new DriftGateDecision
|
||||
{
|
||||
DecisionId = decisionId,
|
||||
Decision = decision,
|
||||
Gates = gateResults.ToImmutableArray(),
|
||||
Advisory = advisory,
|
||||
BlockedBy = blockedBy,
|
||||
BlockReason = blockReason,
|
||||
Suggestion = suggestion,
|
||||
DecidedAt = now,
|
||||
Context = context
|
||||
});
|
||||
}
|
||||
|
||||
private static DriftGateResult EvaluateKevGate(DriftGateContext context)
|
||||
{
|
||||
if (context.HasKevReachable && context.DeltaReachable > 0)
|
||||
{
|
||||
return new DriftGateResult
|
||||
{
|
||||
Name = "KevReachable",
|
||||
Result = DriftGateResultType.Block,
|
||||
Reason = "Known Exploited Vulnerability (KEV) now reachable",
|
||||
Condition = "is_kev = true AND delta_reachable > 0"
|
||||
};
|
||||
}
|
||||
|
||||
return new DriftGateResult
|
||||
{
|
||||
Name = "KevReachable",
|
||||
Result = DriftGateResultType.Pass,
|
||||
Reason = "No KEV in newly reachable paths",
|
||||
Condition = "is_kev = true AND delta_reachable > 0"
|
||||
};
|
||||
}
|
||||
|
||||
private static DriftGateResult EvaluateAffectedReachableGate(DriftGateContext context)
|
||||
{
|
||||
var hasAffected = context.NewlyReachableVexStatuses.Any(s =>
|
||||
s.Equals("affected", StringComparison.OrdinalIgnoreCase) ||
|
||||
s.Equals("under_investigation", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (hasAffected && context.DeltaReachable > 0)
|
||||
{
|
||||
return new DriftGateResult
|
||||
{
|
||||
Name = "AffectedReachable",
|
||||
Result = DriftGateResultType.Block,
|
||||
Reason = $"New paths to affected vulnerabilities detected ({context.DeltaReachable} newly reachable)",
|
||||
Condition = "delta_reachable > 0 AND vex_status IN ['affected', 'under_investigation']"
|
||||
};
|
||||
}
|
||||
|
||||
if (context.DeltaReachable > 0)
|
||||
{
|
||||
return new DriftGateResult
|
||||
{
|
||||
Name = "AffectedReachable",
|
||||
Result = DriftGateResultType.Warn,
|
||||
Reason = $"New reachable paths detected ({context.DeltaReachable}) - review recommended",
|
||||
Condition = "delta_reachable > 0"
|
||||
};
|
||||
}
|
||||
|
||||
return new DriftGateResult
|
||||
{
|
||||
Name = "AffectedReachable",
|
||||
Result = DriftGateResultType.Pass,
|
||||
Reason = "No new paths to affected vulnerabilities",
|
||||
Condition = "delta_reachable > 0 AND vex_status IN ['affected', 'under_investigation']"
|
||||
};
|
||||
}
|
||||
|
||||
private static DriftGateResult EvaluateCvssGate(DriftGateContext context, double threshold)
|
||||
{
|
||||
if (context.MaxCvss.HasValue && context.MaxCvss.Value >= threshold && context.DeltaReachable > 0)
|
||||
{
|
||||
return new DriftGateResult
|
||||
{
|
||||
Name = "CvssThreshold",
|
||||
Result = DriftGateResultType.Block,
|
||||
Reason = $"High-severity vulnerability (CVSS {context.MaxCvss.Value:F1}) now reachable",
|
||||
Condition = $"max_cvss >= {threshold:F1} AND delta_reachable > 0"
|
||||
};
|
||||
}
|
||||
|
||||
return new DriftGateResult
|
||||
{
|
||||
Name = "CvssThreshold",
|
||||
Result = DriftGateResultType.Pass,
|
||||
Reason = $"No newly reachable vulnerabilities exceed CVSS {threshold:F1}",
|
||||
Condition = $"max_cvss >= {threshold:F1} AND delta_reachable > 0"
|
||||
};
|
||||
}
|
||||
|
||||
private static DriftGateResult EvaluateEpssGate(DriftGateContext context, double threshold)
|
||||
{
|
||||
if (context.MaxEpss.HasValue && context.MaxEpss.Value >= threshold && context.DeltaReachable > 0)
|
||||
{
|
||||
return new DriftGateResult
|
||||
{
|
||||
Name = "EpssThreshold",
|
||||
Result = DriftGateResultType.Block,
|
||||
Reason = $"High-probability exploit (EPSS {context.MaxEpss.Value:P0}) now reachable",
|
||||
Condition = $"max_epss >= {threshold:P0} AND delta_reachable > 0"
|
||||
};
|
||||
}
|
||||
|
||||
return new DriftGateResult
|
||||
{
|
||||
Name = "EpssThreshold",
|
||||
Result = DriftGateResultType.Pass,
|
||||
Reason = $"No newly reachable vulnerabilities exceed EPSS {threshold:P0}",
|
||||
Condition = $"max_epss >= {threshold:P0} AND delta_reachable > 0"
|
||||
};
|
||||
}
|
||||
|
||||
private static DriftGateResult EvaluateCustomGate(DriftGateContext context, DriftGateDefinition gate)
|
||||
{
|
||||
// Simple condition parser for common patterns
|
||||
var matches = EvaluateCondition(context, gate.Condition);
|
||||
|
||||
if (matches)
|
||||
{
|
||||
var resultType = gate.Action switch
|
||||
{
|
||||
DriftGateAction.Block => DriftGateResultType.Block,
|
||||
DriftGateAction.Warn => DriftGateResultType.Warn,
|
||||
DriftGateAction.Allow => DriftGateResultType.Pass,
|
||||
_ => DriftGateResultType.Pass
|
||||
};
|
||||
|
||||
return new DriftGateResult
|
||||
{
|
||||
Name = gate.Id,
|
||||
Result = resultType,
|
||||
Reason = string.IsNullOrEmpty(gate.Message) ? $"Custom gate '{gate.Id}' triggered" : gate.Message,
|
||||
Condition = gate.Condition
|
||||
};
|
||||
}
|
||||
|
||||
return new DriftGateResult
|
||||
{
|
||||
Name = gate.Id,
|
||||
Result = DriftGateResultType.Pass,
|
||||
Reason = $"Custom gate '{gate.Id}' condition not met",
|
||||
Condition = gate.Condition
|
||||
};
|
||||
}
|
||||
|
||||
private static bool EvaluateCondition(DriftGateContext context, string condition)
|
||||
{
|
||||
// Simple condition evaluator for common patterns
|
||||
// Supports: delta_reachable, delta_unreachable, is_kev, max_cvss, max_epss
|
||||
// Operators: >, <, >=, <=, =, AND, OR
|
||||
|
||||
var normalized = condition.ToUpperInvariant().Trim();
|
||||
|
||||
// Handle AND conditions
|
||||
if (normalized.Contains(" AND "))
|
||||
{
|
||||
var parts = normalized.Split(new[] { " AND " }, StringSplitOptions.RemoveEmptyEntries);
|
||||
return parts.All(p => EvaluateCondition(context, p));
|
||||
}
|
||||
|
||||
// Handle OR conditions
|
||||
if (normalized.Contains(" OR "))
|
||||
{
|
||||
var parts = normalized.Split(new[] { " OR " }, StringSplitOptions.RemoveEmptyEntries);
|
||||
return parts.Any(p => EvaluateCondition(context, p));
|
||||
}
|
||||
|
||||
// Handle simple comparisons
|
||||
return normalized switch
|
||||
{
|
||||
var c when c.StartsWith("DELTA_REACHABLE") => EvaluateNumericCondition(context.DeltaReachable, c["DELTA_REACHABLE".Length..]),
|
||||
var c when c.StartsWith("DELTA_UNREACHABLE") => EvaluateNumericCondition(context.DeltaUnreachable, c["DELTA_UNREACHABLE".Length..]),
|
||||
var c when c.StartsWith("IS_KEV") => c.Contains("TRUE") ? context.HasKevReachable : !context.HasKevReachable,
|
||||
var c when c.StartsWith("MAX_CVSS") && context.MaxCvss.HasValue => EvaluateNumericCondition(context.MaxCvss.Value, c["MAX_CVSS".Length..]),
|
||||
var c when c.StartsWith("MAX_EPSS") && context.MaxEpss.HasValue => EvaluateNumericCondition(context.MaxEpss.Value, c["MAX_EPSS".Length..]),
|
||||
var c when c.Contains("VEX_STATUS") => EvaluateVexStatusCondition(context.NewlyReachableVexStatuses, c),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private static bool EvaluateNumericCondition(double value, string remainder)
|
||||
{
|
||||
remainder = remainder.Trim();
|
||||
|
||||
if (remainder.StartsWith(">="))
|
||||
{
|
||||
return double.TryParse(remainder[2..].Trim(), out var threshold) && value >= threshold;
|
||||
}
|
||||
if (remainder.StartsWith("<="))
|
||||
{
|
||||
return double.TryParse(remainder[2..].Trim(), out var threshold) && value <= threshold;
|
||||
}
|
||||
if (remainder.StartsWith(">"))
|
||||
{
|
||||
return double.TryParse(remainder[1..].Trim(), out var threshold) && value > threshold;
|
||||
}
|
||||
if (remainder.StartsWith("<"))
|
||||
{
|
||||
return double.TryParse(remainder[1..].Trim(), out var threshold) && value < threshold;
|
||||
}
|
||||
if (remainder.StartsWith("="))
|
||||
{
|
||||
return double.TryParse(remainder[1..].Trim(), out var threshold) && Math.Abs(value - threshold) < 0.001;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool EvaluateVexStatusCondition(IReadOnlyList<string> statuses, string condition)
|
||||
{
|
||||
// Handle VEX_STATUS IN ['affected', 'under_investigation']
|
||||
var inMatch = condition.IndexOf("IN", StringComparison.OrdinalIgnoreCase);
|
||||
if (inMatch < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var listPart = condition[(inMatch + 2)..].Trim();
|
||||
if (!listPart.StartsWith("[") || !listPart.Contains(']'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var values = listPart
|
||||
.Trim('[', ']', ' ')
|
||||
.Split(',')
|
||||
.Select(v => v.Trim().Trim('\'', '"').ToUpperInvariant())
|
||||
.ToHashSet();
|
||||
|
||||
return statuses.Any(s => values.Contains(s.ToUpperInvariant()));
|
||||
}
|
||||
|
||||
private static bool CanOverride(DriftGateRequest request)
|
||||
{
|
||||
return request.AllowOverride &&
|
||||
!string.IsNullOrWhiteSpace(request.OverrideJustification) &&
|
||||
request.OverrideJustification.Length >= 10;
|
||||
}
|
||||
|
||||
private static DriftGateDecision CreateAllowDecision(
|
||||
string decisionId,
|
||||
DriftGateContext context,
|
||||
DateTimeOffset decidedAt,
|
||||
string reason)
|
||||
{
|
||||
return new DriftGateDecision
|
||||
{
|
||||
DecisionId = decisionId,
|
||||
Decision = DriftGateDecisionType.Allow,
|
||||
Gates = ImmutableArray.Create(new DriftGateResult
|
||||
{
|
||||
Name = "Bypass",
|
||||
Result = DriftGateResultType.Pass,
|
||||
Reason = reason
|
||||
}),
|
||||
Advisory = reason,
|
||||
DecidedAt = decidedAt,
|
||||
Context = context
|
||||
};
|
||||
}
|
||||
}
|
||||
151
src/Policy/StellaOps.Policy.Engine/Gates/DriftGateOptions.cs
Normal file
151
src/Policy/StellaOps.Policy.Engine/Gates/DriftGateOptions.cs
Normal file
@@ -0,0 +1,151 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DriftGateOptions.cs
|
||||
// Sprint: SPRINT_3600_0005_0001_policy_ci_gate_integration
|
||||
// Description: Configuration options for drift gate evaluation.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for drift gate evaluation.
|
||||
/// </summary>
|
||||
public sealed class DriftGateOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "SmartDiff:Gates";
|
||||
|
||||
/// <summary>
|
||||
/// Whether drift gates are enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Custom gate definitions.
|
||||
/// </summary>
|
||||
public List<DriftGateDefinition> Gates { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Default action when no gate matches.
|
||||
/// </summary>
|
||||
public DriftGateAction DefaultAction { get; set; } = DriftGateAction.Warn;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to block on KEV reachable by default.
|
||||
/// </summary>
|
||||
public bool BlockOnKev { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to block when affected vulnerabilities become reachable.
|
||||
/// </summary>
|
||||
public bool BlockOnAffectedReachable { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to auto-emit VEX candidates for unreachable sinks.
|
||||
/// </summary>
|
||||
public bool AutoEmitVexForUnreachable { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum CVSS score to trigger block action.
|
||||
/// </summary>
|
||||
public double? CvssBlockThreshold { get; set; } = 9.0;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum EPSS score to trigger block action.
|
||||
/// </summary>
|
||||
public double? EpssBlockThreshold { get; set; } = 0.5;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A custom gate definition from policy configuration.
|
||||
/// </summary>
|
||||
public sealed class DriftGateDefinition
|
||||
{
|
||||
/// <summary>
|
||||
/// Gate identifier.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Condition expression (e.g., "delta_reachable > 0 AND is_kev = true").
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string Condition { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Action to take when condition matches.
|
||||
/// </summary>
|
||||
public DriftGateAction Action { get; set; } = DriftGateAction.Warn;
|
||||
|
||||
/// <summary>
|
||||
/// Message to display when gate triggers.
|
||||
/// </summary>
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Severity level.
|
||||
/// </summary>
|
||||
public DriftGateSeverity Severity { get; set; } = DriftGateSeverity.Medium;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to auto-mitigate (emit VEX) when condition matches.
|
||||
/// </summary>
|
||||
public bool AutoMitigate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Actions that can be taken by drift gates.
|
||||
/// </summary>
|
||||
public enum DriftGateAction
|
||||
{
|
||||
/// <summary>
|
||||
/// Allow the drift to proceed.
|
||||
/// </summary>
|
||||
Allow,
|
||||
|
||||
/// <summary>
|
||||
/// Allow with a warning.
|
||||
/// </summary>
|
||||
Warn,
|
||||
|
||||
/// <summary>
|
||||
/// Block the drift.
|
||||
/// </summary>
|
||||
Block
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Severity levels for drift gates.
|
||||
/// </summary>
|
||||
public enum DriftGateSeverity
|
||||
{
|
||||
/// <summary>
|
||||
/// Informational.
|
||||
/// </summary>
|
||||
Info,
|
||||
|
||||
/// <summary>
|
||||
/// Low severity.
|
||||
/// </summary>
|
||||
Low,
|
||||
|
||||
/// <summary>
|
||||
/// Medium severity.
|
||||
/// </summary>
|
||||
Medium,
|
||||
|
||||
/// <summary>
|
||||
/// High severity.
|
||||
/// </summary>
|
||||
High,
|
||||
|
||||
/// <summary>
|
||||
/// Critical severity.
|
||||
/// </summary>
|
||||
Critical
|
||||
}
|
||||
@@ -183,8 +183,8 @@ internal sealed class PolicyRuntimeEvaluationService
|
||||
effectiveRequest.Reachability,
|
||||
entropy,
|
||||
evaluationTimestamp,
|
||||
policyDigest: bundle.Digest,
|
||||
provenanceAttested: effectiveRequest.ProvenanceAttested);
|
||||
PolicyDigest: bundle.Digest,
|
||||
ProvenanceAttested: effectiveRequest.ProvenanceAttested);
|
||||
|
||||
var evalRequest = new Evaluation.PolicyEvaluationRequest(document, context);
|
||||
var result = _evaluator.Evaluate(evalRequest);
|
||||
@@ -369,8 +369,8 @@ internal sealed class PolicyRuntimeEvaluationService
|
||||
request.Reachability,
|
||||
entropy,
|
||||
evaluationTimestamp,
|
||||
policyDigest: bundle.Digest,
|
||||
provenanceAttested: request.ProvenanceAttested);
|
||||
PolicyDigest: bundle.Digest,
|
||||
ProvenanceAttested: request.ProvenanceAttested);
|
||||
|
||||
var evalRequest = new Evaluation.PolicyEvaluationRequest(document, context);
|
||||
var result = _evaluator.Evaluate(evalRequest);
|
||||
|
||||
Reference in New Issue
Block a user