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:
StellaOps Bot
2025-12-20 23:44:55 +02:00
parent ad193449a7
commit d55a353481
11 changed files with 1946 additions and 4 deletions

View File

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

View File

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