Refactor code structure for improved readability and maintainability; optimize performance in key functions.
This commit is contained in:
35
src/Policy/__Libraries/StellaOps.Policy.Exceptions/AGENTS.md
Normal file
35
src/Policy/__Libraries/StellaOps.Policy.Exceptions/AGENTS.md
Normal 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.
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user