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:
StellaOps Bot
2025-12-22 23:21:21 +02:00
parent 3ba7157b00
commit 5146204f1b
529 changed files with 73579 additions and 5985 deletions

View File

@@ -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>();
}

View 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
};
}

View 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; }
}

View 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));
}
}
}

View 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);
}

View File

@@ -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";
}
}

View File

@@ -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;

View 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

View 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
}

View 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
};
}
}

View 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
}

View File

@@ -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);