feat(policy): Start Epic 3900 - Exception Objects as Auditable Entities
Advisory Processing: - Processed 7 unprocessed advisories and 12 moat documents - Created advisory processing report with 3 new epic recommendations - Identified Epic 3900 (Exception Objects) as highest priority Sprint 3900.0001.0001 - 4/8 tasks completed: - T1: ExceptionObject domain model with full governance fields - T2: ExceptionEvent model for event-sourced audit trail - T4: IExceptionRepository interface with CRUD and query methods - T6: ExceptionEvaluator service with PURL pattern matching New library: StellaOps.Policy.Exceptions - Models: ExceptionObject, ExceptionScope, ExceptionEvent - Enums: ExceptionStatus, ExceptionType, ExceptionReason - Services: ExceptionEvaluator with scope matching and specificity - Repository: IExceptionRepository with filter and history support Remaining tasks: PostgreSQL schema, repository implementation, tests
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy.Exceptions.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Type of exception lifecycle event.
|
||||
/// </summary>
|
||||
public enum ExceptionEventType
|
||||
{
|
||||
/// <summary>Exception was created (Proposed status).</summary>
|
||||
Created,
|
||||
|
||||
/// <summary>Exception details were updated.</summary>
|
||||
Updated,
|
||||
|
||||
/// <summary>Exception was approved by an approver.</summary>
|
||||
Approved,
|
||||
|
||||
/// <summary>Exception was activated (Active status).</summary>
|
||||
Activated,
|
||||
|
||||
/// <summary>Exception expiry was extended.</summary>
|
||||
Extended,
|
||||
|
||||
/// <summary>Exception was explicitly revoked.</summary>
|
||||
Revoked,
|
||||
|
||||
/// <summary>Exception expired automatically.</summary>
|
||||
Expired,
|
||||
|
||||
/// <summary>Evidence was attached to exception.</summary>
|
||||
EvidenceAttached,
|
||||
|
||||
/// <summary>Compensating control was added.</summary>
|
||||
CompensatingControlAdded,
|
||||
|
||||
/// <summary>Exception was rejected before approval.</summary>
|
||||
Rejected
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immutable event recording a state change in an exception's lifecycle.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Exception events form an append-only audit trail that can never be modified.
|
||||
/// Each event captures:
|
||||
/// - What happened (event type)
|
||||
/// - Who did it (actor)
|
||||
/// - When it happened (timestamp)
|
||||
/// - What changed (before/after states)
|
||||
/// - Why it happened (details)
|
||||
/// </remarks>
|
||||
public sealed record ExceptionEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique event identifier.
|
||||
/// </summary>
|
||||
public required Guid EventId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the parent exception.
|
||||
/// </summary>
|
||||
public required string ExceptionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sequence number within this exception's event stream.
|
||||
/// </summary>
|
||||
public required int SequenceNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of event that occurred.
|
||||
/// </summary>
|
||||
public required ExceptionEventType EventType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Identity of the actor who triggered this event.
|
||||
/// May be a user ID, service account, or "system" for automated events.
|
||||
/// </summary>
|
||||
public required string ActorId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this event occurred.
|
||||
/// </summary>
|
||||
public required DateTimeOffset OccurredAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Status before this event (null for Created events).
|
||||
/// </summary>
|
||||
public ExceptionStatus? PreviousStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Status after this event.
|
||||
/// </summary>
|
||||
public required ExceptionStatus NewStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version number after this event.
|
||||
/// </summary>
|
||||
public required int NewVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description of what happened.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional structured details about the event.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Details { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// IP address or client identifier of the actor (for audit).
|
||||
/// </summary>
|
||||
public string? ClientInfo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a "Created" event for a new exception.
|
||||
/// </summary>
|
||||
public static ExceptionEvent ForCreated(
|
||||
string exceptionId,
|
||||
string actorId,
|
||||
string? description = null,
|
||||
string? clientInfo = null) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
ExceptionId = exceptionId,
|
||||
SequenceNumber = 1,
|
||||
EventType = ExceptionEventType.Created,
|
||||
ActorId = actorId,
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
PreviousStatus = null,
|
||||
NewStatus = ExceptionStatus.Proposed,
|
||||
NewVersion = 1,
|
||||
Description = description ?? "Exception created",
|
||||
ClientInfo = clientInfo
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates an "Approved" event.
|
||||
/// </summary>
|
||||
public static ExceptionEvent ForApproved(
|
||||
string exceptionId,
|
||||
int sequenceNumber,
|
||||
string actorId,
|
||||
int newVersion,
|
||||
string? description = null,
|
||||
string? clientInfo = null) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
ExceptionId = exceptionId,
|
||||
SequenceNumber = sequenceNumber,
|
||||
EventType = ExceptionEventType.Approved,
|
||||
ActorId = actorId,
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
PreviousStatus = ExceptionStatus.Proposed,
|
||||
NewStatus = ExceptionStatus.Approved,
|
||||
NewVersion = newVersion,
|
||||
Description = description ?? $"Exception approved by {actorId}",
|
||||
ClientInfo = clientInfo
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates an "Activated" event.
|
||||
/// </summary>
|
||||
public static ExceptionEvent ForActivated(
|
||||
string exceptionId,
|
||||
int sequenceNumber,
|
||||
string actorId,
|
||||
int newVersion,
|
||||
ExceptionStatus previousStatus,
|
||||
string? description = null,
|
||||
string? clientInfo = null) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
ExceptionId = exceptionId,
|
||||
SequenceNumber = sequenceNumber,
|
||||
EventType = ExceptionEventType.Activated,
|
||||
ActorId = actorId,
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
PreviousStatus = previousStatus,
|
||||
NewStatus = ExceptionStatus.Active,
|
||||
NewVersion = newVersion,
|
||||
Description = description ?? "Exception activated",
|
||||
ClientInfo = clientInfo
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a "Revoked" event.
|
||||
/// </summary>
|
||||
public static ExceptionEvent ForRevoked(
|
||||
string exceptionId,
|
||||
int sequenceNumber,
|
||||
string actorId,
|
||||
int newVersion,
|
||||
ExceptionStatus previousStatus,
|
||||
string reason,
|
||||
string? clientInfo = null) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
ExceptionId = exceptionId,
|
||||
SequenceNumber = sequenceNumber,
|
||||
EventType = ExceptionEventType.Revoked,
|
||||
ActorId = actorId,
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
PreviousStatus = previousStatus,
|
||||
NewStatus = ExceptionStatus.Revoked,
|
||||
NewVersion = newVersion,
|
||||
Description = $"Exception revoked: {reason}",
|
||||
Details = ImmutableDictionary<string, string>.Empty.Add("reason", reason),
|
||||
ClientInfo = clientInfo
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates an "Expired" event (typically from system).
|
||||
/// </summary>
|
||||
public static ExceptionEvent ForExpired(
|
||||
string exceptionId,
|
||||
int sequenceNumber,
|
||||
int newVersion) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
ExceptionId = exceptionId,
|
||||
SequenceNumber = sequenceNumber,
|
||||
EventType = ExceptionEventType.Expired,
|
||||
ActorId = "system",
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
PreviousStatus = ExceptionStatus.Active,
|
||||
NewStatus = ExceptionStatus.Expired,
|
||||
NewVersion = newVersion,
|
||||
Description = "Exception expired automatically"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates an "Extended" event.
|
||||
/// </summary>
|
||||
public static ExceptionEvent ForExtended(
|
||||
string exceptionId,
|
||||
int sequenceNumber,
|
||||
string actorId,
|
||||
int newVersion,
|
||||
DateTimeOffset previousExpiry,
|
||||
DateTimeOffset newExpiry,
|
||||
string? reason = null,
|
||||
string? clientInfo = null) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
ExceptionId = exceptionId,
|
||||
SequenceNumber = sequenceNumber,
|
||||
EventType = ExceptionEventType.Extended,
|
||||
ActorId = actorId,
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
PreviousStatus = ExceptionStatus.Active,
|
||||
NewStatus = ExceptionStatus.Active,
|
||||
NewVersion = newVersion,
|
||||
Description = reason ?? $"Exception extended from {previousExpiry:O} to {newExpiry:O}",
|
||||
Details = ImmutableDictionary<string, string>.Empty
|
||||
.Add("previous_expiry", previousExpiry.ToString("O"))
|
||||
.Add("new_expiry", newExpiry.ToString("O")),
|
||||
ClientInfo = clientInfo
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated exception history for audit display.
|
||||
/// </summary>
|
||||
public sealed record ExceptionHistory
|
||||
{
|
||||
/// <summary>
|
||||
/// The exception this history belongs to.
|
||||
/// </summary>
|
||||
public required string ExceptionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// All events in chronological order.
|
||||
/// </summary>
|
||||
public required ImmutableArray<ExceptionEvent> Events { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of events.
|
||||
/// </summary>
|
||||
public int EventCount => Events.Length;
|
||||
|
||||
/// <summary>
|
||||
/// When the exception was first created.
|
||||
/// </summary>
|
||||
public DateTimeOffset? FirstEventAt => Events.Length > 0 ? Events[0].OccurredAt : null;
|
||||
|
||||
/// <summary>
|
||||
/// When the last event occurred.
|
||||
/// </summary>
|
||||
public DateTimeOffset? LastEventAt => Events.Length > 0 ? Events[^1].OccurredAt : null;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy.Exceptions.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Exception lifecycle status following a governed state machine.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// State transitions:
|
||||
/// - Proposed → Approved (via approval workflow)
|
||||
/// - Approved → Active (explicit activation or auto-activate on approval)
|
||||
/// - Active → Expired (automatic when expires_at reached)
|
||||
/// - Active → Revoked (explicit revocation)
|
||||
/// - Proposed → Revoked (rejection before approval)
|
||||
/// </remarks>
|
||||
public enum ExceptionStatus
|
||||
{
|
||||
/// <summary>Exception requested, awaiting approval.</summary>
|
||||
Proposed,
|
||||
|
||||
/// <summary>Exception approved, awaiting activation.</summary>
|
||||
Approved,
|
||||
|
||||
/// <summary>Exception is currently active and will affect policy evaluation.</summary>
|
||||
Active,
|
||||
|
||||
/// <summary>Exception has expired (expires_at reached).</summary>
|
||||
Expired,
|
||||
|
||||
/// <summary>Exception was explicitly revoked before expiry.</summary>
|
||||
Revoked
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of exception being requested.
|
||||
/// </summary>
|
||||
public enum ExceptionType
|
||||
{
|
||||
/// <summary>Exception for a specific vulnerability (CVE/CWE).</summary>
|
||||
Vulnerability,
|
||||
|
||||
/// <summary>Exception for a policy rule bypass.</summary>
|
||||
Policy,
|
||||
|
||||
/// <summary>Exception allowing release despite unknown findings.</summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>Exception for a specific component/package.</summary>
|
||||
Component
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reason code for the exception request.
|
||||
/// </summary>
|
||||
public enum ExceptionReason
|
||||
{
|
||||
/// <summary>Finding is a false positive (not actually present or exploitable).</summary>
|
||||
FalsePositive,
|
||||
|
||||
/// <summary>Risk is accepted given business context.</summary>
|
||||
AcceptedRisk,
|
||||
|
||||
/// <summary>Compensating controls mitigate the risk.</summary>
|
||||
CompensatingControl,
|
||||
|
||||
/// <summary>Only applicable in test/dev environments.</summary>
|
||||
TestOnly,
|
||||
|
||||
/// <summary>Vendor has confirmed not affected.</summary>
|
||||
VendorNotAffected,
|
||||
|
||||
/// <summary>Fix is scheduled within SLA.</summary>
|
||||
ScheduledFix,
|
||||
|
||||
/// <summary>Component is being deprecated/removed.</summary>
|
||||
DeprecationInProgress,
|
||||
|
||||
/// <summary>Runtime environment prevents exploitation.</summary>
|
||||
RuntimeMitigation,
|
||||
|
||||
/// <summary>Network configuration prevents exploitation.</summary>
|
||||
NetworkIsolation,
|
||||
|
||||
/// <summary>Other reason (requires detailed rationale).</summary>
|
||||
Other
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines the scope constraints for an exception.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// At least one scope constraint must be specified. Multiple constraints
|
||||
/// are combined with AND logic (all must match for exception to apply).
|
||||
/// </remarks>
|
||||
public sealed record ExceptionScope
|
||||
{
|
||||
/// <summary>
|
||||
/// Specific artifact digest (sha256:...) this exception applies to.
|
||||
/// If null, applies to any artifact matching other constraints.
|
||||
/// </summary>
|
||||
public string? ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PURL pattern this exception applies to.
|
||||
/// Supports wildcards: pkg:npm/lodash@* or pkg:maven/org.apache.logging.log4j/*
|
||||
/// </summary>
|
||||
public string? PurlPattern { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Specific vulnerability ID (CVE-XXXX-XXXXX) this exception applies to.
|
||||
/// Required for ExceptionType.Vulnerability.
|
||||
/// </summary>
|
||||
public string? VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy rule identifier this exception bypasses.
|
||||
/// Required for ExceptionType.Policy.
|
||||
/// </summary>
|
||||
public string? PolicyRuleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Environments where this exception is valid.
|
||||
/// Empty array means all environments.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Environments { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID for RLS. Required in multi-tenant mode.
|
||||
/// </summary>
|
||||
public Guid? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the scope has at least one constraint.
|
||||
/// </summary>
|
||||
public bool IsValid =>
|
||||
!string.IsNullOrWhiteSpace(ArtifactDigest) ||
|
||||
!string.IsNullOrWhiteSpace(PurlPattern) ||
|
||||
!string.IsNullOrWhiteSpace(VulnerabilityId) ||
|
||||
!string.IsNullOrWhiteSpace(PolicyRuleId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An auditable exception object representing a governed decision to
|
||||
/// suppress, waive, or bypass a security finding or policy rule.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Exception Objects are first-class auditable entities with full lifecycle
|
||||
/// tracking. They are NOT suppression files or UI toggles.
|
||||
///
|
||||
/// Key principles:
|
||||
/// - Attribution: Every action has an authenticated actor
|
||||
/// - Immutability: Edits are new versions; history is never rewritten
|
||||
/// - Least privilege: Scope must be as narrow as possible
|
||||
/// - Time-bounded: All exceptions must expire
|
||||
/// - Deterministic: Given same inputs, evaluation is reproducible
|
||||
/// </remarks>
|
||||
public sealed record ExceptionObject
|
||||
{
|
||||
/// <summary>
|
||||
/// Stable unique identifier for this exception.
|
||||
/// Format: EXC-{ulid} or organization-specific pattern.
|
||||
/// </summary>
|
||||
public required string ExceptionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version number (monotonically increasing).
|
||||
/// Used for optimistic concurrency control.
|
||||
/// </summary>
|
||||
public required int Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current lifecycle status.
|
||||
/// </summary>
|
||||
public required ExceptionStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of exception.
|
||||
/// </summary>
|
||||
public required ExceptionType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Scope constraints defining where this exception applies.
|
||||
/// </summary>
|
||||
public required ExceptionScope Scope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User or team accountable for this exception.
|
||||
/// Must be a valid identity in the organization.
|
||||
/// </summary>
|
||||
public required string OwnerId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User who initiated the exception request.
|
||||
/// </summary>
|
||||
public required string RequesterId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User(s) who approved the exception.
|
||||
/// May be null for Proposed status or auto-approved dev exceptions.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ApproverIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// When the exception was first created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Last update timestamp.
|
||||
/// </summary>
|
||||
public required DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the exception was approved (null if not yet approved).
|
||||
/// </summary>
|
||||
public DateTimeOffset? ApprovedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the exception expires. Required and must be in the future at creation.
|
||||
/// Maximum allowed expiry is typically 1 year from creation.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Categorized reason for the exception.
|
||||
/// </summary>
|
||||
public required ExceptionReason ReasonCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed rationale explaining why this exception is necessary.
|
||||
/// Required, minimum 50 characters.
|
||||
/// </summary>
|
||||
public required string Rationale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressed references to supporting evidence.
|
||||
/// Format: sha256:{hash} or attestation URIs.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> EvidenceRefs { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Compensating controls in place that mitigate the risk.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> CompensatingControls { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata for organization-specific tracking.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Ticket or tracking system reference (e.g., JIRA-1234).
|
||||
/// </summary>
|
||||
public string? TicketRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Determines if this exception is currently effective.
|
||||
/// </summary>
|
||||
public bool IsEffective =>
|
||||
Status == ExceptionStatus.Active &&
|
||||
DateTimeOffset.UtcNow < ExpiresAt;
|
||||
|
||||
/// <summary>
|
||||
/// Determines if this exception has expired.
|
||||
/// </summary>
|
||||
public bool HasExpired =>
|
||||
DateTimeOffset.UtcNow >= ExpiresAt;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
|
||||
namespace StellaOps.Policy.Exceptions.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for exception persistence operations.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// All mutating operations must record audit events transactionally.
|
||||
/// Implementations should use optimistic concurrency via version checking.
|
||||
/// </remarks>
|
||||
public interface IExceptionRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new exception and records a Created event.
|
||||
/// </summary>
|
||||
/// <param name="exception">The exception to create (must have Version = 1).</param>
|
||||
/// <param name="actorId">Identity of the actor creating the exception.</param>
|
||||
/// <param name="clientInfo">Optional client information for audit.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The created exception with assigned ID.</returns>
|
||||
Task<ExceptionObject> CreateAsync(
|
||||
ExceptionObject exception,
|
||||
string actorId,
|
||||
string? clientInfo = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing exception and records an event.
|
||||
/// </summary>
|
||||
/// <param name="exception">The updated exception (version must match current).</param>
|
||||
/// <param name="eventType">Type of event to record.</param>
|
||||
/// <param name="actorId">Identity of the actor making the update.</param>
|
||||
/// <param name="description">Event description.</param>
|
||||
/// <param name="clientInfo">Optional client information for audit.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The updated exception with incremented version.</returns>
|
||||
/// <exception cref="ConcurrencyException">If version doesn't match.</exception>
|
||||
Task<ExceptionObject> UpdateAsync(
|
||||
ExceptionObject exception,
|
||||
ExceptionEventType eventType,
|
||||
string actorId,
|
||||
string? description = null,
|
||||
string? clientInfo = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an exception by its ID.
|
||||
/// </summary>
|
||||
/// <param name="exceptionId">The exception ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The exception if found, null otherwise.</returns>
|
||||
Task<ExceptionObject?> GetByIdAsync(
|
||||
string exceptionId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all exceptions matching the specified filters.
|
||||
/// </summary>
|
||||
/// <param name="filter">Filter criteria.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Matching exceptions.</returns>
|
||||
Task<IReadOnlyList<ExceptionObject>> GetByFilterAsync(
|
||||
ExceptionFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all active exceptions that apply to the given scope.
|
||||
/// </summary>
|
||||
/// <param name="scope">Scope to match against.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Matching active exceptions.</returns>
|
||||
Task<IReadOnlyList<ExceptionObject>> GetActiveByScopeAsync(
|
||||
ExceptionScope scope,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets exceptions that will expire within the specified horizon.
|
||||
/// </summary>
|
||||
/// <param name="horizon">Time horizon from now.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Exceptions expiring within the horizon.</returns>
|
||||
Task<IReadOnlyList<ExceptionObject>> GetExpiringAsync(
|
||||
TimeSpan horizon,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all Active exceptions that have passed their expiry time.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Expired exceptions that need status update.</returns>
|
||||
Task<IReadOnlyList<ExceptionObject>> GetExpiredActiveAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the event history for an exception.
|
||||
/// </summary>
|
||||
/// <param name="exceptionId">The exception ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Event history.</returns>
|
||||
Task<ExceptionHistory> GetHistoryAsync(
|
||||
string exceptionId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Counts exceptions by status.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Optional tenant filter.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Counts by status.</returns>
|
||||
Task<ExceptionCounts> GetCountsAsync(
|
||||
Guid? tenantId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filter criteria for querying exceptions.
|
||||
/// </summary>
|
||||
public sealed record ExceptionFilter
|
||||
{
|
||||
/// <summary>Filter by status.</summary>
|
||||
public ExceptionStatus? Status { get; init; }
|
||||
|
||||
/// <summary>Filter by type.</summary>
|
||||
public ExceptionType? Type { get; init; }
|
||||
|
||||
/// <summary>Filter by vulnerability ID.</summary>
|
||||
public string? VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>Filter by PURL pattern (partial match).</summary>
|
||||
public string? PurlPattern { get; init; }
|
||||
|
||||
/// <summary>Filter by environment.</summary>
|
||||
public string? Environment { get; init; }
|
||||
|
||||
/// <summary>Filter by owner.</summary>
|
||||
public string? OwnerId { get; init; }
|
||||
|
||||
/// <summary>Filter by requester.</summary>
|
||||
public string? RequesterId { get; init; }
|
||||
|
||||
/// <summary>Filter by tenant.</summary>
|
||||
public Guid? TenantId { get; init; }
|
||||
|
||||
/// <summary>Filter for exceptions created after this time.</summary>
|
||||
public DateTimeOffset? CreatedAfter { get; init; }
|
||||
|
||||
/// <summary>Filter for exceptions expiring before this time.</summary>
|
||||
public DateTimeOffset? ExpiringBefore { get; init; }
|
||||
|
||||
/// <summary>Maximum number of results.</summary>
|
||||
public int Limit { get; init; } = 100;
|
||||
|
||||
/// <summary>Offset for pagination.</summary>
|
||||
public int Offset { get; init; } = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary counts of exceptions by status.
|
||||
/// </summary>
|
||||
public sealed record ExceptionCounts
|
||||
{
|
||||
/// <summary>Total exceptions.</summary>
|
||||
public int Total { get; init; }
|
||||
|
||||
/// <summary>Exceptions in Proposed status.</summary>
|
||||
public int Proposed { get; init; }
|
||||
|
||||
/// <summary>Exceptions in Approved status.</summary>
|
||||
public int Approved { get; init; }
|
||||
|
||||
/// <summary>Exceptions in Active status.</summary>
|
||||
public int Active { get; init; }
|
||||
|
||||
/// <summary>Exceptions in Expired status.</summary>
|
||||
public int Expired { get; init; }
|
||||
|
||||
/// <summary>Exceptions in Revoked status.</summary>
|
||||
public int Revoked { get; init; }
|
||||
|
||||
/// <summary>Exceptions expiring within 7 days.</summary>
|
||||
public int ExpiringSoon { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when optimistic concurrency fails.
|
||||
/// </summary>
|
||||
public sealed class ConcurrencyException : Exception
|
||||
{
|
||||
public string ExceptionId { get; }
|
||||
public int ExpectedVersion { get; }
|
||||
public int ActualVersion { get; }
|
||||
|
||||
public ConcurrencyException(string exceptionId, int expectedVersion, int actualVersion)
|
||||
: base($"Concurrency conflict for exception {exceptionId}: expected version {expectedVersion}, actual {actualVersion}")
|
||||
{
|
||||
ExceptionId = exceptionId;
|
||||
ExpectedVersion = expectedVersion;
|
||||
ActualVersion = actualVersion;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using StellaOps.Policy.Exceptions.Repositories;
|
||||
|
||||
namespace StellaOps.Policy.Exceptions.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Result of exception evaluation for a finding.
|
||||
/// </summary>
|
||||
public sealed record ExceptionEvaluationResult
|
||||
{
|
||||
/// <summary>Whether any active exception applies to this finding.</summary>
|
||||
public bool HasException { get; init; }
|
||||
|
||||
/// <summary>The matching exceptions (may be multiple).</summary>
|
||||
public IReadOnlyList<ExceptionObject> MatchingExceptions { get; init; } = [];
|
||||
|
||||
/// <summary>Reason code from the most specific matching exception.</summary>
|
||||
public ExceptionReason? PrimaryReason { get; init; }
|
||||
|
||||
/// <summary>Rationale from the most specific matching exception.</summary>
|
||||
public string? PrimaryRationale { get; init; }
|
||||
|
||||
/// <summary>Evidence references from all matching exceptions.</summary>
|
||||
public IReadOnlyList<string> AllEvidenceRefs { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Creates a result indicating no exception applies.
|
||||
/// </summary>
|
||||
public static ExceptionEvaluationResult NoMatch => new() { HasException = false };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context for evaluating exceptions against a finding.
|
||||
/// </summary>
|
||||
public sealed record FindingContext
|
||||
{
|
||||
/// <summary>Artifact digest (sha256:...) of the scanned artifact.</summary>
|
||||
public string? ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>PURL of the affected package.</summary>
|
||||
public string? Purl { get; init; }
|
||||
|
||||
/// <summary>Vulnerability ID (CVE-XXXX-XXXXX).</summary>
|
||||
public string? VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>Policy rule that flagged this finding.</summary>
|
||||
public string? PolicyRuleId { get; init; }
|
||||
|
||||
/// <summary>Environment where this finding was detected.</summary>
|
||||
public string? Environment { get; init; }
|
||||
|
||||
/// <summary>Tenant ID for RLS.</summary>
|
||||
public Guid? TenantId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for evaluating exceptions against findings.
|
||||
/// </summary>
|
||||
public interface IExceptionEvaluator
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates whether any active exception applies to the given finding.
|
||||
/// </summary>
|
||||
/// <param name="context">Finding context to evaluate against.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Evaluation result with matching exceptions.</returns>
|
||||
Task<ExceptionEvaluationResult> EvaluateAsync(
|
||||
FindingContext context,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates multiple findings in batch.
|
||||
/// </summary>
|
||||
/// <param name="contexts">Finding contexts to evaluate.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Evaluation results keyed by context index.</returns>
|
||||
Task<IReadOnlyDictionary<int, ExceptionEvaluationResult>> EvaluateBatchAsync(
|
||||
IReadOnlyList<FindingContext> contexts,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of exception evaluation logic.
|
||||
/// </summary>
|
||||
public sealed class ExceptionEvaluator : IExceptionEvaluator
|
||||
{
|
||||
private readonly IExceptionRepository _repository;
|
||||
|
||||
public ExceptionEvaluator(IExceptionRepository repository)
|
||||
{
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ExceptionEvaluationResult> EvaluateAsync(
|
||||
FindingContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Build scope from context for repository query
|
||||
var scope = new ExceptionScope
|
||||
{
|
||||
ArtifactDigest = context.ArtifactDigest,
|
||||
PurlPattern = context.Purl,
|
||||
VulnerabilityId = context.VulnerabilityId,
|
||||
PolicyRuleId = context.PolicyRuleId,
|
||||
Environments = context.Environment is not null
|
||||
? [context.Environment]
|
||||
: [],
|
||||
TenantId = context.TenantId
|
||||
};
|
||||
|
||||
// Get active exceptions for this scope
|
||||
var candidates = await _repository.GetActiveByScopeAsync(scope, cancellationToken);
|
||||
|
||||
// Filter to only those that truly match the context
|
||||
var matching = candidates
|
||||
.Where(ex => MatchesContext(ex, context))
|
||||
.OrderByDescending(ex => GetSpecificity(ex))
|
||||
.ToList();
|
||||
|
||||
if (matching.Count == 0)
|
||||
{
|
||||
return ExceptionEvaluationResult.NoMatch;
|
||||
}
|
||||
|
||||
var primary = matching[0];
|
||||
var allEvidence = matching
|
||||
.SelectMany(ex => ex.EvidenceRefs)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
return new ExceptionEvaluationResult
|
||||
{
|
||||
HasException = true,
|
||||
MatchingExceptions = matching,
|
||||
PrimaryReason = primary.ReasonCode,
|
||||
PrimaryRationale = primary.Rationale,
|
||||
AllEvidenceRefs = allEvidence
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyDictionary<int, ExceptionEvaluationResult>> EvaluateBatchAsync(
|
||||
IReadOnlyList<FindingContext> contexts,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = new Dictionary<int, ExceptionEvaluationResult>();
|
||||
|
||||
// For efficiency, we could optimize this with a single query
|
||||
// but for correctness, evaluate each context
|
||||
for (int i = 0; i < contexts.Count; i++)
|
||||
{
|
||||
results[i] = await EvaluateAsync(contexts[i], cancellationToken);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if an exception matches the given finding context.
|
||||
/// </summary>
|
||||
private static bool MatchesContext(ExceptionObject exception, FindingContext context)
|
||||
{
|
||||
var scope = exception.Scope;
|
||||
|
||||
// Check artifact digest (exact match)
|
||||
if (!string.IsNullOrEmpty(scope.ArtifactDigest))
|
||||
{
|
||||
if (context.ArtifactDigest != scope.ArtifactDigest)
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check vulnerability ID (exact match)
|
||||
if (!string.IsNullOrEmpty(scope.VulnerabilityId))
|
||||
{
|
||||
if (context.VulnerabilityId != scope.VulnerabilityId)
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check policy rule ID (exact match)
|
||||
if (!string.IsNullOrEmpty(scope.PolicyRuleId))
|
||||
{
|
||||
if (context.PolicyRuleId != scope.PolicyRuleId)
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check PURL pattern (supports wildcards)
|
||||
if (!string.IsNullOrEmpty(scope.PurlPattern))
|
||||
{
|
||||
if (!MatchesPurlPattern(context.Purl, scope.PurlPattern))
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check environment (must be in allowed list, or list must be empty)
|
||||
if (scope.Environments.Length > 0 && !string.IsNullOrEmpty(context.Environment))
|
||||
{
|
||||
if (!scope.Environments.Contains(context.Environment, StringComparer.OrdinalIgnoreCase))
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check tenant
|
||||
if (scope.TenantId.HasValue && context.TenantId.HasValue)
|
||||
{
|
||||
if (scope.TenantId.Value != context.TenantId.Value)
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if exception is still effective (not expired)
|
||||
if (!exception.IsEffective)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Matches a PURL against a pattern that may contain wildcards.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Supported patterns:
|
||||
/// - Exact: pkg:npm/lodash@4.17.21
|
||||
/// - Version wildcard: pkg:npm/lodash@*
|
||||
/// - Package wildcard: pkg:npm/*
|
||||
/// - Type wildcard: pkg:*
|
||||
/// </remarks>
|
||||
private static bool MatchesPurlPattern(string? purl, string pattern)
|
||||
{
|
||||
if (string.IsNullOrEmpty(purl))
|
||||
return false;
|
||||
|
||||
// Convert PURL pattern to regex
|
||||
// Escape regex special chars except *
|
||||
var escaped = Regex.Escape(pattern).Replace("\\*", ".*");
|
||||
var regex = new Regex($"^{escaped}$", RegexOptions.IgnoreCase);
|
||||
|
||||
return regex.IsMatch(purl);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates specificity score for an exception (higher = more specific).
|
||||
/// More specific exceptions take precedence.
|
||||
/// </summary>
|
||||
private static int GetSpecificity(ExceptionObject exception)
|
||||
{
|
||||
var scope = exception.Scope;
|
||||
var score = 0;
|
||||
|
||||
// Artifact digest is most specific
|
||||
if (!string.IsNullOrEmpty(scope.ArtifactDigest))
|
||||
score += 100;
|
||||
|
||||
// Exact PURL (no wildcard) is very specific
|
||||
if (!string.IsNullOrEmpty(scope.PurlPattern))
|
||||
{
|
||||
if (!scope.PurlPattern.Contains('*'))
|
||||
score += 50;
|
||||
else
|
||||
score += 20; // Pattern is less specific
|
||||
}
|
||||
|
||||
// Vulnerability ID is specific
|
||||
if (!string.IsNullOrEmpty(scope.VulnerabilityId))
|
||||
score += 40;
|
||||
|
||||
// Policy rule ID is specific
|
||||
if (!string.IsNullOrEmpty(scope.PolicyRuleId))
|
||||
score += 30;
|
||||
|
||||
// Environment constraints add specificity
|
||||
if (scope.Environments.Length > 0)
|
||||
score += 10;
|
||||
|
||||
return score;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>StellaOps.Policy.Exceptions</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Collections.Immutable" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user