Refactor code structure for improved readability and maintainability; optimize performance in key functions.

This commit is contained in:
master
2025-12-22 19:06:31 +02:00
parent dfaa2079aa
commit 4602ccc3a3
1444 changed files with 109919 additions and 8058 deletions

View File

@@ -0,0 +1,35 @@
# StellaOps.Policy.Exceptions - Agent Charter
## Mission
- Deliver deterministic Exception Objects, recheck policies, and evidence hook validation that integrate with Policy Engine evaluation and audit trails.
- Keep exception persistence and evaluation reproducible and offline friendly.
## Roles
- Backend / Policy engineer (.NET 10, C# preview).
- QA engineer (unit and integration tests).
## Required Reading (treat as read before DOING)
- `docs/modules/policy/architecture.md`
- `docs/modules/platform/architecture-overview.md`
- `docs/product-advisories/archived/20-Dec-2025 - Moat Explanation - Exception management as auditable objects.md`
- `docs/product-advisories/22-Dec-2026 - UI Patterns for Triage and Replay.md`
- Current sprint file in `docs/implplan/SPRINT_3900_*.md`
## Working Directory & Boundaries
- Primary scope: `src/Policy/__Libraries/StellaOps.Policy.Exceptions/**`.
- Related migrations: `src/Policy/__Libraries/StellaOps.Policy.Storage.Postgres/Migrations`.
- Tests: `src/Policy/__Tests/StellaOps.Policy.Exceptions.Tests/**` and `src/Policy/__Tests/StellaOps.Policy.Storage.Postgres.Tests/**`.
- Avoid cross-module edits unless the sprint explicitly allows.
## Determinism & Offline Rules
- Use UTC timestamps and stable ordering; avoid random or wall-clock based identifiers.
- No external network calls; rely on injected services and local data sources only.
## Testing Expectations
- Add or update unit tests for models and services.
- Add or update integration tests for repository and migration changes.
- Ensure serialization and ordering are deterministic.
## Workflow
- Update task status to `DOING`/`DONE` in the sprint file and `src/Policy/__Libraries/StellaOps.Policy/TASKS.md`.
- Record design decisions in sprint `Decisions & Risks` and update docs when contracts change.

View File

@@ -0,0 +1,185 @@
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Policy.Exceptions.Models;
/// <summary>
/// Evidence hook requiring specific attestations before exception approval.
/// </summary>
public sealed record EvidenceHook
{
/// <summary>
/// Unique identifier for this hook.
/// </summary>
public required string HookId { get; init; }
/// <summary>
/// Type of evidence required.
/// </summary>
public required EvidenceType Type { get; init; }
/// <summary>
/// Human-readable description of the requirement.
/// </summary>
public required string Description { get; init; }
/// <summary>
/// Whether this evidence is mandatory for approval.
/// </summary>
public bool IsMandatory { get; init; } = true;
/// <summary>
/// Schema or predicate type for validation.
/// </summary>
public string? ValidationSchema { get; init; }
/// <summary>
/// Maximum age of evidence (for freshness validation).
/// </summary>
public TimeSpan? MaxAge { get; init; }
/// <summary>
/// Required trust score for evidence source.
/// </summary>
public decimal? MinTrustScore { get; init; }
}
/// <summary>
/// Types of evidence that can be required.
/// </summary>
public enum EvidenceType
{
/// <summary>Feature flag is disabled in target environment.</summary>
FeatureFlagDisabled,
/// <summary>Backport PR has been merged.</summary>
BackportMerged,
/// <summary>Compensating control attestation.</summary>
CompensatingControl,
/// <summary>Security review completed.</summary>
SecurityReview,
/// <summary>Runtime mitigation in place.</summary>
RuntimeMitigation,
/// <summary>WAF rule deployed.</summary>
WAFRuleDeployed,
/// <summary>Custom attestation type.</summary>
CustomAttestation
}
/// <summary>
/// Evidence submitted to satisfy a hook.
/// </summary>
public sealed record SubmittedEvidence
{
/// <summary>
/// Unique identifier for this evidence submission.
/// </summary>
public required string EvidenceId { get; init; }
/// <summary>
/// Hook this evidence satisfies.
/// </summary>
public required string HookId { get; init; }
/// <summary>
/// Type of evidence.
/// </summary>
public required EvidenceType Type { get; init; }
/// <summary>
/// Reference to the evidence (URL, attestation ID, etc.).
/// </summary>
public required string Reference { get; init; }
/// <summary>
/// Evidence content or payload.
/// </summary>
public string? Content { get; init; }
/// <summary>
/// DSSE envelope if signed.
/// </summary>
public string? DsseEnvelope { get; init; }
/// <summary>
/// Whether signature was verified.
/// </summary>
public bool SignatureVerified { get; init; }
/// <summary>
/// Trust score of evidence source.
/// </summary>
public decimal TrustScore { get; init; }
/// <summary>
/// When evidence was submitted.
/// </summary>
public required DateTimeOffset SubmittedAt { get; init; }
/// <summary>
/// Who submitted the evidence.
/// </summary>
public required string SubmittedBy { get; init; }
/// <summary>
/// Validation status.
/// </summary>
public required EvidenceValidationStatus ValidationStatus { get; init; }
/// <summary>
/// Validation error if any.
/// </summary>
public string? ValidationError { get; init; }
}
/// <summary>
/// Status of evidence validation.
/// </summary>
public enum EvidenceValidationStatus
{
Pending,
Valid,
Invalid,
Expired,
InsufficientTrust
}
/// <summary>
/// Registry of required evidence hooks for an exception type.
/// </summary>
public sealed record EvidenceRequirements
{
/// <summary>
/// Required evidence hooks.
/// </summary>
public required ImmutableArray<EvidenceHook> Hooks { get; init; }
/// <summary>
/// Evidence submitted so far.
/// </summary>
public ImmutableArray<SubmittedEvidence> SubmittedEvidence { get; init; } = [];
/// <summary>
/// Whether all mandatory evidence is satisfied.
/// </summary>
public bool IsSatisfied => Hooks
.Where(h => h.IsMandatory)
.All(h => SubmittedEvidence.Any(e =>
e.HookId == h.HookId &&
e.ValidationStatus == EvidenceValidationStatus.Valid));
/// <summary>
/// Missing mandatory evidence.
/// </summary>
public ImmutableArray<EvidenceHook> MissingEvidence => Hooks
.Where(h => h.IsMandatory)
.Where(h => !SubmittedEvidence.Any(e =>
e.HookId == h.HookId &&
e.ValidationStatus == EvidenceValidationStatus.Valid))
.ToImmutableArray();
}

View File

@@ -238,6 +238,11 @@ public sealed record ExceptionObject
/// </summary>
public ImmutableArray<string> EvidenceRefs { get; init; } = [];
/// <summary>
/// Evidence requirements and submissions tied to this exception.
/// </summary>
public EvidenceRequirements? EvidenceRequirements { get; init; }
/// <summary>
/// Compensating controls in place that mitigate the risk.
/// </summary>
@@ -254,6 +259,41 @@ public sealed record ExceptionObject
/// </summary>
public string? TicketRef { get; init; }
/// <summary>
/// Reference to the applied recheck policy configuration.
/// </summary>
public string? RecheckPolicyId { get; init; }
/// <summary>
/// Recheck policy that governs automatic re-evaluation.
/// If null, exception is only invalidated by expiry.
/// </summary>
public RecheckPolicy? RecheckPolicy { get; init; }
/// <summary>
/// Result of last recheck evaluation.
/// </summary>
public RecheckEvaluationResult? LastRecheckResult { get; init; }
/// <summary>
/// When recheck was last evaluated.
/// </summary>
public DateTimeOffset? LastRecheckAt { get; init; }
/// <summary>
/// Whether this exception is blocked by recheck policy.
/// </summary>
public bool IsBlockedByRecheck =>
LastRecheckResult?.IsTriggered == true &&
LastRecheckResult.RecommendedAction == RecheckAction.Block;
/// <summary>
/// Whether this exception requires re-approval.
/// </summary>
public bool RequiresReapproval =>
LastRecheckResult?.IsTriggered == true &&
LastRecheckResult.RecommendedAction == RecheckAction.RequireReapproval;
/// <summary>
/// Determines if this exception is currently effective.
/// </summary>

View File

@@ -0,0 +1,157 @@
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Policy.Exceptions.Models;
/// <summary>
/// Policy defining conditions that trigger exception re-evaluation.
/// When any condition is met, the exception may be invalidated or flagged.
/// </summary>
public sealed record RecheckPolicy
{
/// <summary>
/// Unique identifier for this policy configuration.
/// </summary>
public required string PolicyId { get; init; }
/// <summary>
/// Human-readable name for this policy.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Conditions that trigger recheck.
/// </summary>
public required ImmutableArray<RecheckCondition> Conditions { get; init; }
/// <summary>
/// Default action when any condition is triggered.
/// </summary>
public required RecheckAction DefaultAction { get; init; }
/// <summary>
/// Whether this policy is active.
/// </summary>
public bool IsActive { get; init; } = true;
/// <summary>
/// When this policy was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
}
/// <summary>
/// A single condition that triggers exception re-evaluation.
/// </summary>
public sealed record RecheckCondition
{
/// <summary>
/// Type of condition to check.
/// </summary>
public required RecheckConditionType Type { get; init; }
/// <summary>
/// Threshold value (interpretation depends on Type).
/// </summary>
public decimal? Threshold { get; init; }
/// <summary>
/// Environment scopes where this condition applies.
/// </summary>
public ImmutableArray<string> EnvironmentScope { get; init; } = [];
/// <summary>
/// Action to take when this specific condition is triggered.
/// If null, uses policy's DefaultAction.
/// </summary>
public RecheckAction? Action { get; init; }
/// <summary>
/// Human-readable description of this condition.
/// </summary>
public string? Description { get; init; }
}
/// <summary>
/// Types of recheck conditions.
/// </summary>
public enum RecheckConditionType
{
/// <summary>Reachability graph changes (new paths discovered).</summary>
ReachGraphChange,
/// <summary>EPSS score exceeds threshold.</summary>
EPSSAbove,
/// <summary>CVSS score exceeds threshold.</summary>
CVSSAbove,
/// <summary>Unknown budget exceeds threshold.</summary>
UnknownsAbove,
/// <summary>New CVE added to same package.</summary>
NewCVEInPackage,
/// <summary>KEV (Known Exploited Vulnerability) flag set.</summary>
KEVFlagged,
/// <summary>Exception nearing expiry (days before).</summary>
ExpiryWithin,
/// <summary>VEX status changes (e.g., from NotAffected to Affected).</summary>
VEXStatusChange,
/// <summary>Package version changes.</summary>
PackageVersionChange
}
/// <summary>
/// Action to take when a recheck condition is triggered.
/// </summary>
public enum RecheckAction
{
/// <summary>Log warning but allow exception to remain active.</summary>
Warn,
/// <summary>Require manual re-approval of exception.</summary>
RequireReapproval,
/// <summary>Automatically revoke the exception.</summary>
Revoke,
/// <summary>Block build/deployment pipeline.</summary>
Block
}
/// <summary>
/// Result of evaluating recheck conditions against an exception.
/// </summary>
public sealed record RecheckEvaluationResult
{
/// <summary>Whether any conditions were triggered.</summary>
public required bool IsTriggered { get; init; }
/// <summary>List of triggered conditions with details.</summary>
public required ImmutableArray<TriggeredCondition> TriggeredConditions { get; init; }
/// <summary>Recommended action based on triggered conditions.</summary>
public required RecheckAction? RecommendedAction { get; init; }
/// <summary>When this evaluation was performed.</summary>
public required DateTimeOffset EvaluatedAt { get; init; }
/// <summary>Human-readable summary.</summary>
public string Summary => IsTriggered
? $"{TriggeredConditions.Length} condition(s) triggered: {string.Join(", ", TriggeredConditions.Select(t => t.Type))}"
: "No conditions triggered";
}
/// <summary>
/// Details of a triggered recheck condition.
/// </summary>
public sealed record TriggeredCondition(
RecheckConditionType Type,
string Description,
decimal? CurrentValue,
decimal? ThresholdValue,
RecheckAction Action);

View File

@@ -59,7 +59,7 @@ public sealed class PostgresExceptionRepository : IExceptionRepository
environments, tenant_id, owner_id, requester_id, approver_ids,
created_at, updated_at, approved_at, expires_at,
reason_code, rationale, evidence_refs, compensating_controls,
metadata, ticket_ref
metadata, ticket_ref, recheck_policy_id, last_recheck_result, last_recheck_at
)
VALUES (
@id, @exception_id, @version, @status, @type,
@@ -67,7 +67,7 @@ public sealed class PostgresExceptionRepository : IExceptionRepository
@environments, @tenant_id, @owner_id, @requester_id, @approver_ids,
@created_at, @updated_at, @approved_at, @expires_at,
@reason_code, @rationale, @evidence_refs::jsonb, @compensating_controls::jsonb,
@metadata::jsonb, @ticket_ref
@metadata::jsonb, @ticket_ref, @recheck_policy_id, @last_recheck_result::jsonb, @last_recheck_at
)
RETURNING *
""";
@@ -160,7 +160,10 @@ public sealed class PostgresExceptionRepository : IExceptionRepository
evidence_refs = @evidence_refs::jsonb,
compensating_controls = @compensating_controls::jsonb,
metadata = @metadata::jsonb,
ticket_ref = @ticket_ref
ticket_ref = @ticket_ref,
recheck_policy_id = @recheck_policy_id,
last_recheck_result = @last_recheck_result::jsonb,
last_recheck_at = @last_recheck_at
WHERE exception_id = @exception_id AND version = @old_version
RETURNING *
""";
@@ -658,6 +661,13 @@ public sealed class PostgresExceptionRepository : IExceptionRepository
cmd.Parameters.AddWithValue("compensating_controls", JsonSerializer.Serialize(ex.CompensatingControls, JsonOptions));
cmd.Parameters.AddWithValue("metadata", JsonSerializer.Serialize(ex.Metadata, JsonOptions));
cmd.Parameters.AddWithValue("ticket_ref", (object?)ex.TicketRef ?? DBNull.Value);
cmd.Parameters.AddWithValue("recheck_policy_id", (object?)(ex.RecheckPolicyId ?? ex.RecheckPolicy?.PolicyId) ?? DBNull.Value);
cmd.Parameters.AddWithValue(
"last_recheck_result",
ex.LastRecheckResult is not null
? JsonSerializer.Serialize(ex.LastRecheckResult, JsonOptions)
: DBNull.Value);
cmd.Parameters.AddWithValue("last_recheck_at", (object?)ex.LastRecheckAt ?? DBNull.Value);
}
private static ExceptionObject MapException(NpgsqlDataReader reader)
@@ -675,6 +685,7 @@ public sealed class PostgresExceptionRepository : IExceptionRepository
var evidenceRefs = ParseJsonArray<string>(GetNullableString(reader, "evidence_refs") ?? "[]");
var compensatingControls = ParseJsonArray<string>(GetNullableString(reader, "compensating_controls") ?? "[]");
var metadata = ParseJsonDict(GetNullableString(reader, "metadata") ?? "{}");
var lastRecheckResult = ParseJsonObject<RecheckEvaluationResult>(GetNullableString(reader, "last_recheck_result"));
return new ExceptionObject
{
@@ -695,7 +706,10 @@ public sealed class PostgresExceptionRepository : IExceptionRepository
EvidenceRefs = evidenceRefs.ToImmutableArray(),
CompensatingControls = compensatingControls.ToImmutableArray(),
Metadata = metadata.ToImmutableDictionary(),
TicketRef = GetNullableString(reader, "ticket_ref")
TicketRef = GetNullableString(reader, "ticket_ref"),
RecheckPolicyId = GetNullableString(reader, "recheck_policy_id"),
LastRecheckResult = lastRecheckResult,
LastRecheckAt = GetNullableDateTimeOffset(reader, "last_recheck_at")
};
}
@@ -763,6 +777,23 @@ public sealed class PostgresExceptionRepository : IExceptionRepository
}
}
private static T? ParseJsonObject<T>(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return default;
}
try
{
return JsonSerializer.Deserialize<T>(json, JsonOptions);
}
catch
{
return default;
}
}
private static Dictionary<string, string> ParseJsonDict(string json)
{
try

View File

@@ -0,0 +1,214 @@
using System.Collections.Immutable;
using System.Linq;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Exceptions.Models;
namespace StellaOps.Policy.Exceptions.Services;
/// <summary>
/// Validates that all required evidence is present before exception approval.
/// </summary>
public sealed class EvidenceRequirementValidator : IEvidenceRequirementValidator
{
private readonly IEvidenceHookRegistry _hookRegistry;
private readonly IAttestationVerifier _attestationVerifier;
private readonly ITrustScoreService _trustScoreService;
private readonly IEvidenceSchemaValidator _schemaValidator;
private readonly ILogger<EvidenceRequirementValidator> _logger;
public EvidenceRequirementValidator(
IEvidenceHookRegistry hookRegistry,
IAttestationVerifier attestationVerifier,
ITrustScoreService trustScoreService,
IEvidenceSchemaValidator schemaValidator,
ILogger<EvidenceRequirementValidator> logger)
{
_hookRegistry = hookRegistry ?? throw new ArgumentNullException(nameof(hookRegistry));
_attestationVerifier = attestationVerifier ?? throw new ArgumentNullException(nameof(attestationVerifier));
_trustScoreService = trustScoreService ?? throw new ArgumentNullException(nameof(trustScoreService));
_schemaValidator = schemaValidator ?? throw new ArgumentNullException(nameof(schemaValidator));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Validates that an exception can be approved based on evidence requirements.
/// </summary>
public async Task<EvidenceValidationResult> ValidateForApprovalAsync(
ExceptionObject exception,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(exception);
_logger.LogInformation(
"Validating evidence requirements for exception {ExceptionId}",
exception.ExceptionId);
var requiredHooks = await _hookRegistry
.GetRequiredHooksAsync(exception.Type, exception.Scope, ct)
.ConfigureAwait(false);
if (requiredHooks.Length == 0)
{
return new EvidenceValidationResult
{
IsValid = true,
MissingEvidence = [],
InvalidEvidence = [],
ValidEvidence = [],
Message = "No evidence requirements for this exception type"
};
}
var missingEvidence = new List<EvidenceHook>();
var invalidEvidence = new List<(EvidenceHook Hook, SubmittedEvidence Evidence, string Error)>();
var validEvidence = new List<SubmittedEvidence>();
var submitted = exception.EvidenceRequirements?.SubmittedEvidence ?? [];
foreach (var hook in requiredHooks.Where(h => h.IsMandatory))
{
var evidence = submitted.FirstOrDefault(e => e.HookId == hook.HookId);
if (evidence is null)
{
missingEvidence.Add(hook);
continue;
}
var validation = await ValidateEvidenceAsync(hook, evidence, ct).ConfigureAwait(false);
if (!validation.IsValid)
{
invalidEvidence.Add((hook, evidence, validation.Error ?? "Evidence validation failed"));
}
else
{
validEvidence.Add(evidence);
}
}
var isValid = missingEvidence.Count == 0 && invalidEvidence.Count == 0;
return new EvidenceValidationResult
{
IsValid = isValid,
MissingEvidence = missingEvidence.ToImmutableArray(),
InvalidEvidence = invalidEvidence.Select(e => new InvalidEvidenceEntry(
e.Hook.HookId, e.Evidence.EvidenceId, e.Error)).ToImmutableArray(),
ValidEvidence = validEvidence.ToImmutableArray(),
Message = isValid
? "All evidence requirements satisfied"
: BuildValidationMessage(missingEvidence, invalidEvidence)
};
}
private async Task<(bool IsValid, string? Error)> ValidateEvidenceAsync(
EvidenceHook hook,
SubmittedEvidence evidence,
CancellationToken ct)
{
if (hook.MaxAge.HasValue)
{
var age = DateTimeOffset.UtcNow - evidence.SubmittedAt;
if (age > hook.MaxAge.Value)
{
return (false, $"Evidence is stale (age: {age.TotalHours:F0}h, max: {hook.MaxAge.Value.TotalHours:F0}h)");
}
}
if (hook.MinTrustScore.HasValue)
{
var trustScore = await _trustScoreService.GetScoreAsync(evidence.Reference, ct).ConfigureAwait(false);
if (trustScore < hook.MinTrustScore.Value)
{
return (false, $"Evidence trust score {trustScore:P0} below minimum {hook.MinTrustScore:P0}");
}
}
if (!string.IsNullOrWhiteSpace(hook.ValidationSchema))
{
var schemaResult = await _schemaValidator
.ValidateAsync(hook.ValidationSchema, evidence.Content, ct)
.ConfigureAwait(false);
if (!schemaResult.IsValid)
{
return (false, schemaResult.Error ?? "Schema validation failed");
}
}
if (evidence.DsseEnvelope is not null)
{
var verification = await _attestationVerifier.VerifyAsync(evidence.DsseEnvelope, ct).ConfigureAwait(false);
if (!verification.IsValid)
{
return (false, $"Signature verification failed: {verification.Error}");
}
}
return (true, null);
}
private static string BuildValidationMessage(
IReadOnlyCollection<EvidenceHook> missing,
IReadOnlyCollection<(EvidenceHook Hook, SubmittedEvidence Evidence, string Error)> invalid)
{
var parts = new List<string>();
if (missing.Count > 0)
{
parts.Add($"Missing evidence: {string.Join(", ", missing.Select(h => h.Type))}");
}
if (invalid.Count > 0)
{
parts.Add($"Invalid evidence: {string.Join(", ", invalid.Select(e => $"{e.Hook.Type}: {e.Error}"))}");
}
return string.Join("; ", parts);
}
}
public interface IEvidenceRequirementValidator
{
Task<EvidenceValidationResult> ValidateForApprovalAsync(
ExceptionObject exception,
CancellationToken ct = default);
}
public interface IEvidenceHookRegistry
{
Task<ImmutableArray<EvidenceHook>> GetRequiredHooksAsync(
ExceptionType exceptionType,
ExceptionScope scope,
CancellationToken ct = default);
}
public interface IAttestationVerifier
{
Task<EvidenceVerificationResult> VerifyAsync(string dsseEnvelope, CancellationToken ct = default);
}
public sealed record EvidenceVerificationResult(bool IsValid, string? Error);
public interface ITrustScoreService
{
Task<decimal> GetScoreAsync(string reference, CancellationToken ct = default);
}
public interface IEvidenceSchemaValidator
{
Task<EvidenceSchemaValidationResult> ValidateAsync(
string schemaId,
string? content,
CancellationToken ct = default);
}
public sealed record EvidenceSchemaValidationResult(bool IsValid, string? Error);
public sealed record EvidenceValidationResult
{
public required bool IsValid { get; init; }
public required ImmutableArray<EvidenceHook> MissingEvidence { get; init; }
public required ImmutableArray<InvalidEvidenceEntry> InvalidEvidence { get; init; }
public ImmutableArray<SubmittedEvidence> ValidEvidence { get; init; } = [];
public required string Message { get; init; }
}
public sealed record InvalidEvidenceEntry(string HookId, string EvidenceId, string Error);

View File

@@ -0,0 +1,244 @@
using System.Collections.Immutable;
using StellaOps.Policy.Exceptions.Models;
namespace StellaOps.Policy.Exceptions.Services;
/// <summary>
/// Context for evaluating recheck conditions against current state.
/// </summary>
public sealed record RecheckEvaluationContext
{
/// <summary>Artifact digest under evaluation.</summary>
public string? ArtifactDigest { get; init; }
/// <summary>When this evaluation was performed.</summary>
public required DateTimeOffset EvaluatedAt { get; init; }
/// <summary>Environment name (prod, staging, dev).</summary>
public string? Environment { get; init; }
/// <summary>Reachability graph changed since approval.</summary>
public bool ReachGraphChanged { get; init; }
/// <summary>Current EPSS score for the finding.</summary>
public decimal? EpssScore { get; init; }
/// <summary>Current CVSS score for the finding.</summary>
public decimal? CvssScore { get; init; }
/// <summary>Current unknowns count for the artifact.</summary>
public int? UnknownsCount { get; init; }
/// <summary>New CVE discovered in the same package.</summary>
public bool NewCveInPackage { get; init; }
/// <summary>KEV flag set for the vulnerability.</summary>
public bool KevFlagged { get; init; }
/// <summary>VEX status changed since approval.</summary>
public bool VexStatusChanged { get; init; }
/// <summary>Package version changed since approval.</summary>
public bool PackageVersionChanged { get; init; }
}
/// <summary>
/// Evaluates recheck conditions against current vulnerability state.
/// </summary>
public interface IRecheckEvaluationService
{
/// <summary>
/// Evaluates recheck conditions for an exception.
/// </summary>
/// <param name="exception">Exception to evaluate.</param>
/// <param name="context">Current evaluation context.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Recheck evaluation result.</returns>
Task<RecheckEvaluationResult> EvaluateAsync(
ExceptionObject exception,
RecheckEvaluationContext context,
CancellationToken ct = default);
}
/// <summary>
/// Default implementation of <see cref="IRecheckEvaluationService"/>.
/// </summary>
public sealed class RecheckEvaluationService : IRecheckEvaluationService
{
private static readonly ImmutableDictionary<RecheckAction, int> ActionPriority =
new Dictionary<RecheckAction, int>
{
[RecheckAction.Warn] = 1,
[RecheckAction.RequireReapproval] = 2,
[RecheckAction.Revoke] = 3,
[RecheckAction.Block] = 4
}.ToImmutableDictionary();
/// <inheritdoc />
public Task<RecheckEvaluationResult> EvaluateAsync(
ExceptionObject exception,
RecheckEvaluationContext context,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(exception);
ArgumentNullException.ThrowIfNull(context);
var policy = exception.RecheckPolicy;
if (policy is null || !policy.IsActive)
{
return Task.FromResult(new RecheckEvaluationResult
{
IsTriggered = false,
TriggeredConditions = [],
RecommendedAction = null,
EvaluatedAt = context.EvaluatedAt
});
}
var triggered = new List<TriggeredCondition>();
foreach (var condition in policy.Conditions)
{
if (!AppliesToEnvironment(condition, context.Environment))
{
continue;
}
if (IsTriggered(condition, exception, context, out var triggeredCondition))
{
triggered.Add(triggeredCondition);
}
}
if (triggered.Count == 0)
{
return Task.FromResult(new RecheckEvaluationResult
{
IsTriggered = false,
TriggeredConditions = [],
RecommendedAction = null,
EvaluatedAt = context.EvaluatedAt
});
}
var recommended = triggered
.Select(t => t.Action)
.OrderByDescending(GetActionPriority)
.FirstOrDefault();
return Task.FromResult(new RecheckEvaluationResult
{
IsTriggered = true,
TriggeredConditions = triggered.ToImmutableArray(),
RecommendedAction = recommended,
EvaluatedAt = context.EvaluatedAt
});
}
private static bool AppliesToEnvironment(RecheckCondition condition, string? environment)
{
if (condition.EnvironmentScope.Length == 0)
{
return true;
}
if (string.IsNullOrWhiteSpace(environment))
{
return false;
}
return condition.EnvironmentScope.Contains(environment, StringComparer.OrdinalIgnoreCase);
}
private static bool IsTriggered(
RecheckCondition condition,
ExceptionObject exception,
RecheckEvaluationContext context,
out TriggeredCondition triggered)
{
triggered = default!;
var action = condition.Action ?? exception.RecheckPolicy?.DefaultAction ?? RecheckAction.Warn;
var description = condition.Description ?? $"{condition.Type} triggered";
switch (condition.Type)
{
case RecheckConditionType.ReachGraphChange:
if (context.ReachGraphChanged)
{
triggered = new TriggeredCondition(condition.Type, description, 1, null, action);
return true;
}
return false;
case RecheckConditionType.EPSSAbove:
if (condition.Threshold.HasValue && context.EpssScore.HasValue &&
context.EpssScore.Value >= condition.Threshold.Value)
{
triggered = new TriggeredCondition(condition.Type, description, context.EpssScore, condition.Threshold, action);
return true;
}
return false;
case RecheckConditionType.CVSSAbove:
if (condition.Threshold.HasValue && context.CvssScore.HasValue &&
context.CvssScore.Value >= condition.Threshold.Value)
{
triggered = new TriggeredCondition(condition.Type, description, context.CvssScore, condition.Threshold, action);
return true;
}
return false;
case RecheckConditionType.UnknownsAbove:
if (condition.Threshold.HasValue && context.UnknownsCount.HasValue &&
context.UnknownsCount.Value >= condition.Threshold.Value)
{
triggered = new TriggeredCondition(condition.Type, description, context.UnknownsCount.Value, condition.Threshold, action);
return true;
}
return false;
case RecheckConditionType.NewCVEInPackage:
if (context.NewCveInPackage)
{
triggered = new TriggeredCondition(condition.Type, description, 1, null, action);
return true;
}
return false;
case RecheckConditionType.KEVFlagged:
if (context.KevFlagged)
{
triggered = new TriggeredCondition(condition.Type, description, 1, null, action);
return true;
}
return false;
case RecheckConditionType.ExpiryWithin:
if (condition.Threshold.HasValue)
{
var daysUntilExpiry = (decimal)(exception.ExpiresAt - context.EvaluatedAt).TotalDays;
if (daysUntilExpiry <= condition.Threshold.Value)
{
triggered = new TriggeredCondition(condition.Type, description, daysUntilExpiry, condition.Threshold, action);
return true;
}
}
return false;
case RecheckConditionType.VEXStatusChange:
if (context.VexStatusChanged)
{
triggered = new TriggeredCondition(condition.Type, description, 1, null, action);
return true;
}
return false;
case RecheckConditionType.PackageVersionChange:
if (context.PackageVersionChanged)
{
triggered = new TriggeredCondition(condition.Type, description, 1, null, action);
return true;
}
return false;
default:
return false;
}
}
private static int GetActionPriority(RecheckAction action)
{
return ActionPriority.TryGetValue(action, out var priority) ? priority : 0;
}
}