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:
228
docs/implplan/ADVISORY_PROCESSING_REPORT_20251220.md
Normal file
228
docs/implplan/ADVISORY_PROCESSING_REPORT_20251220.md
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
# Advisory Processing Report — 2025-12-20
|
||||||
|
|
||||||
|
**Role**: Product Manager
|
||||||
|
**Date**: 2025-12-20
|
||||||
|
**Status**: ANALYZED
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
Reviewed **7 unprocessed advisories** and **12 moat documents** from `docs/product-advisories/unprocessed/`. After cross-referencing with existing sprints, archived advisories, and implemented code, identified **3 new epic-level initiatives** and **5 enhancement opportunities** for existing features.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Advisories Reviewed
|
||||||
|
|
||||||
|
| File | Date | Primary Topic | Status |
|
||||||
|
|------|------|---------------|--------|
|
||||||
|
| Reimagining Proof‑Linked UX in Security Workflows | 2025-12-16 | Narrative-First Triage UX | ALREADY PROCESSED |
|
||||||
|
| Reachability Drift Detection | 2025-12-17 | Call graph drift between versions | NEW - ACTIONABLE |
|
||||||
|
| Designing Explainable Triage and Proof‑Linked Evidence | 2025-12-18 | Evidence-linked approvals | OVERLAPS w/ 12/16 |
|
||||||
|
| Branch · UX patterns worth borrowing | 2025-12-20 | Competitor UX analysis | REFERENCE ONLY |
|
||||||
|
| Testing strategy | 2025-12-20 | E2E testing strategy | NEW - ACTIONABLE |
|
||||||
|
| Moat #1 (Security Delta) | 2025-12-19 | Delta Verdicts as governance | NEW - STRATEGIC |
|
||||||
|
| Moat - Exception management | 2025-12-20 | Auditable exceptions | NEW - ACTIONABLE |
|
||||||
|
| Moat - Signed Replayable Verdicts | 2025-12-20 | Verdict attestations | PARTIAL OVERLAP |
|
||||||
|
| Moat - Knowledge Snapshots | 2025-12-20 | Time-travel replay | NEW - ACTIONABLE |
|
||||||
|
| Moat - Risk Budgets | 2025-12-20 | Diff-aware release gates | PARTIAL OVERLAP |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Cross-Reference with Existing Work
|
||||||
|
|
||||||
|
### 2.1 Already Implemented (Do Not Duplicate)
|
||||||
|
|
||||||
|
| Topic | Existing Implementation | Location |
|
||||||
|
|-------|------------------------|----------|
|
||||||
|
| Proof Ledger | ProofLedgerViewComponent | Sprint 3500.0004.0002 T1 |
|
||||||
|
| Reachability Explain | ReachabilityExplainWidget | Sprint 3500.0004.0002 T3 |
|
||||||
|
| Score Comparison | ScoreComparisonComponent | Sprint 3500.0004.0002 T4 |
|
||||||
|
| Proof Replay | ProofReplayDashboard | Sprint 3500.0004.0002 T5 |
|
||||||
|
| Material Risk Changes | MaterialRiskChangeDetector | Scanner.SmartDiff.Detection |
|
||||||
|
| VEX Lattice Merge | Excititor module | src/Excititor |
|
||||||
|
| Unknowns Registry | UnknownsService | Sprint 3500.0002.0002 |
|
||||||
|
| Call Graph Extraction | DotNetCallGraphExtractor, JavaCallGraphExtractor | Sprint 3500.0003.x |
|
||||||
|
| Semantic Entrypoints | Sprint 0411 | EntryTrace module |
|
||||||
|
| Temporal/Mesh Analysis | Sprint 0412 | EntryTrace module |
|
||||||
|
| Binary Intelligence | Sprint 0414 | EntryTrace module |
|
||||||
|
| Risk Scoring | Sprint 0415 | EntryTrace module |
|
||||||
|
|
||||||
|
### 2.2 Gaps Identified (New Work Required)
|
||||||
|
|
||||||
|
| Gap | Advisory Source | Priority | Complexity |
|
||||||
|
|-----|----------------|----------|------------|
|
||||||
|
| **Reachability Drift Detection** | 17-Dec advisory | HIGH | HIGH |
|
||||||
|
| **Exception Objects (Auditable)** | Moat Exception mgmt | HIGH | MEDIUM |
|
||||||
|
| **Knowledge Snapshots + Time-Travel** | Moat Knowledge Snapshots | HIGH | HIGH |
|
||||||
|
| **Delta Verdict Attestations** | Moat #1 | MEDIUM | MEDIUM |
|
||||||
|
| **Offline E2E Test Suite** | Testing strategy | MEDIUM | MEDIUM |
|
||||||
|
| **Code Change Facts Table** | 17-Dec advisory | MEDIUM | LOW |
|
||||||
|
| **Path Viewer UI Enhancement** | 17-Dec advisory | LOW | LOW |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Recommended New Epics
|
||||||
|
|
||||||
|
### Epic 3800: Reachability Drift Detection
|
||||||
|
|
||||||
|
**Justification**: The 17-Dec advisory identifies that reachability can change between versions even when vulnerability count stays the same. This is a significant moat differentiator.
|
||||||
|
|
||||||
|
**What's Missing** (per advisory gap analysis):
|
||||||
|
- `scanner.code_changes` table for AST-level diff facts
|
||||||
|
- `scanner.call_graph_snapshots` for per-scan graph cache
|
||||||
|
- `DriftCauseExplainer` service to attribute causes to code changes
|
||||||
|
- Cross-scan function-level drift (state drift exists, function-level doesn't)
|
||||||
|
|
||||||
|
**Scope**:
|
||||||
|
- Sprint 3800.0001.0001: Schema + Code Changes Table
|
||||||
|
- Sprint 3800.0001.0002: Call Graph Snapshot Service
|
||||||
|
- Sprint 3800.0002.0001: Drift Cause Explainer
|
||||||
|
- Sprint 3800.0002.0002: UI Integration
|
||||||
|
|
||||||
|
**Estimated Duration**: 4 weeks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Epic 3900: Exception Management as Auditable Objects
|
||||||
|
|
||||||
|
**Justification**: The moat advisory explicitly states "Exception Objects" should be first-class, governed decisions — not .ignore files or UI toggles. This is critical for enterprise customers.
|
||||||
|
|
||||||
|
**What's Missing**:
|
||||||
|
- `policy.exceptions` table with full governance fields
|
||||||
|
- Exception lifecycle (proposed → approved → active → expired → revoked)
|
||||||
|
- Scope constraints (artifact digest, purl, environment)
|
||||||
|
- Time-bounded expiry enforcement
|
||||||
|
- Approval workflow integration
|
||||||
|
- Signed exception attestations
|
||||||
|
|
||||||
|
**Scope**:
|
||||||
|
- Sprint 3900.0001.0001: Schema + Exception Object Model
|
||||||
|
- Sprint 3900.0001.0002: Exception API (CRUD + approval workflow)
|
||||||
|
- Sprint 3900.0002.0001: Policy Engine Integration
|
||||||
|
- Sprint 3900.0002.0002: UI + Audit Pack Export
|
||||||
|
|
||||||
|
**Estimated Duration**: 4 weeks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Epic 4000: Knowledge Snapshots + Time-Travel Replay
|
||||||
|
|
||||||
|
**Justification**: Multiple advisories emphasize that replayability requires pinned knowledge state (vuln feeds, VEX, policies). Current replay works for scores but not for full "time-travel" to a past knowledge state.
|
||||||
|
|
||||||
|
**What's Missing**:
|
||||||
|
- Content-addressed knowledge snapshot bundles
|
||||||
|
- Snapshot manifest with feed digests + policy versions
|
||||||
|
- Time-travel replay API that loads historical snapshots
|
||||||
|
- Evidence that the same inputs produce the same verdict
|
||||||
|
|
||||||
|
**Scope**:
|
||||||
|
- Sprint 4000.0001.0001: Knowledge Snapshot Model + Storage
|
||||||
|
- Sprint 4000.0001.0002: Snapshot Creation Service
|
||||||
|
- Sprint 4000.0002.0001: Time-Travel Replay API
|
||||||
|
- Sprint 4000.0002.0002: Verification + Audit Integration
|
||||||
|
|
||||||
|
**Estimated Duration**: 4 weeks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Enhancement Opportunities (Existing Features)
|
||||||
|
|
||||||
|
### 4.1 Delta Verdict Attestations
|
||||||
|
|
||||||
|
**Current State**: Score proofs exist and are signed via DSSE. Material risk changes are detected.
|
||||||
|
|
||||||
|
**Enhancement**: Create a formal "Delta Verdict" attestation that wraps:
|
||||||
|
- Baseline snapshot digest
|
||||||
|
- Target snapshot digest
|
||||||
|
- Delta categories (SBOM/VEX/Reachability/Decision changes)
|
||||||
|
- Policy outcome with explanation
|
||||||
|
- Signed envelope
|
||||||
|
|
||||||
|
**Effort**: ~1 sprint (add to existing attestation infrastructure)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 Offline E2E Test Suite
|
||||||
|
|
||||||
|
**Current State**: Integration tests exist (Sprint 3500.0004.0003). Air-gap tests are ad-hoc.
|
||||||
|
|
||||||
|
**Enhancement**: Formalize per the Testing Strategy advisory:
|
||||||
|
- Offline bundle spec (`bundle.json` with digests)
|
||||||
|
- No-egress CI jobs
|
||||||
|
- SBOM round-trip tests (Syft → cosign → Grype)
|
||||||
|
- Router backpressure chaos tests
|
||||||
|
|
||||||
|
**Effort**: ~1 sprint
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.3 VEX Conflict Studio UI
|
||||||
|
|
||||||
|
**Current State**: VEX merge happens in Excititor with lattice logic. No UI for conflict visualization.
|
||||||
|
|
||||||
|
**Enhancement**: Per UX advisory, add side-by-side VEX conflict view:
|
||||||
|
- Left: Vendor statement + provenance
|
||||||
|
- Right: Internal statement + provenance
|
||||||
|
- Middle: Merge result + rule that decided
|
||||||
|
- Evidence hooks checklist
|
||||||
|
|
||||||
|
**Effort**: ~1 sprint
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Recommendations
|
||||||
|
|
||||||
|
### Immediate Actions (Next 2 Weeks)
|
||||||
|
|
||||||
|
1. **Create Sprint files for Epic 3800** (Reachability Drift) — highest impact moat
|
||||||
|
2. **Archive processed advisories** — move 16-Dec and 18-Dec to archive (already processed)
|
||||||
|
3. **Update moat.md** — sync key-features with new moat explanations
|
||||||
|
|
||||||
|
### Medium-Term (Next 4 Weeks)
|
||||||
|
|
||||||
|
4. **Create Sprint files for Epic 3900** (Exception Objects)
|
||||||
|
5. **Create Sprint files for Epic 4000** (Knowledge Snapshots)
|
||||||
|
6. **Add Delta Verdict attestation to existing proof infrastructure**
|
||||||
|
|
||||||
|
### Deferred (Roadmap)
|
||||||
|
|
||||||
|
7. Offline E2E test formalization
|
||||||
|
8. VEX Conflict Studio UI
|
||||||
|
9. Fleet-level blast radius visualization
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Decision Required
|
||||||
|
|
||||||
|
**Question for Stakeholders**: Which epic should be prioritized first?
|
||||||
|
|
||||||
|
| Option | Epic | Business Value | Technical Risk |
|
||||||
|
|--------|------|----------------|----------------|
|
||||||
|
| A | 3800 Reachability Drift | HIGH (differentiator) | MEDIUM |
|
||||||
|
| B | 3900 Exception Objects | HIGH (enterprise) | LOW |
|
||||||
|
| C | 4000 Knowledge Snapshots | MEDIUM (audit) | HIGH |
|
||||||
|
|
||||||
|
**Recommendation**: Start with **Epic 3900 (Exception Objects)** due to lower risk and clear enterprise demand, then **Epic 3800 (Reachability Drift)** for moat differentiation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: Files to Archive
|
||||||
|
|
||||||
|
These advisories have been processed or are reference-only:
|
||||||
|
|
||||||
|
```
|
||||||
|
docs/product-advisories/unprocessed/16-Dec-2025 - Reimagining Proof‑Linked UX in Security Workflows.md
|
||||||
|
→ Already processed (Status: PROCESSED in file)
|
||||||
|
|
||||||
|
docs/product-advisories/unprocessed/18-Dec-2025 - Designing Explainable Triage and Proof‑Linked Evidence.md
|
||||||
|
→ Overlaps with 16-Dec, consolidate
|
||||||
|
|
||||||
|
docs/product-advisories/unprocessed/20-Dec-2025 - Branch · UX patterns worth borrowing from top scanners.md
|
||||||
|
→ Reference only, no actionable tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Report Generated By**: StellaOps Agent (Product Manager Role)
|
||||||
|
**Next Step**: Await stakeholder decision on epic prioritization
|
||||||
@@ -511,9 +511,9 @@ stella unknowns export --format csv --out unknowns.csv
|
|||||||
| 3500.0003.0002 | DONE | 100% | — | Java Reachability — Implemented via SPRINT_3610_0001_0001 (Java Call Graph). JavaCallGraphExtractor with Spring Boot entrypoint detection complete. |
|
| 3500.0003.0002 | DONE | 100% | — | Java Reachability — Implemented via SPRINT_3610_0001_0001 (Java Call Graph). JavaCallGraphExtractor with Spring Boot entrypoint detection complete. |
|
||||||
| 3500.0003.0003 | DONE | 100% | — | Graph Attestations + Rekor — RichGraphAttestationService complete. APIs (CallGraphEndpoints, ReachabilityEndpoints) complete. Rekor integration via Attestor module. Budget policy: docs/operations/rekor-policy.md |
|
| 3500.0003.0003 | DONE | 100% | — | Graph Attestations + Rekor — RichGraphAttestationService complete. APIs (CallGraphEndpoints, ReachabilityEndpoints) complete. Rekor integration via Attestor module. Budget policy: docs/operations/rekor-policy.md |
|
||||||
| 3500.0004.0001 | DONE | 100% | — | CLI verbs + offline bundles complete. 8/8 tasks done. ScoreReplayCommandGroup, ProofCommandGroup, ScanGraphCommandGroup, UnknownsCommandGroup. 183 CLI tests pass. |
|
| 3500.0004.0001 | DONE | 100% | — | CLI verbs + offline bundles complete. 8/8 tasks done. ScoreReplayCommandGroup, ProofCommandGroup, ScanGraphCommandGroup, UnknownsCommandGroup. 183 CLI tests pass. |
|
||||||
| 3500.0004.0002 | TODO | 0% | — | Wireframes complete |
|
| 3500.0004.0002 | DONE | 100% | — | UI Components + Visualization — 8/8 tasks done. ProofLedgerView, UnknownsQueue, ReachabilityExplain, ScoreComparison, ProofReplayDashboard, API services, accessibility utils. Completed 2025-12-20 |
|
||||||
| 3500.0004.0003 | TODO | 0% | — | — |
|
| 3500.0004.0003 | DONE | 100% | — | Integration Tests + Corpus — 8/8 tasks done. 74 test methods, golden corpus (12 cases), CI gates, perf baselines |
|
||||||
| 3500.0004.0004 | TODO | 0% | — | — |
|
| 3500.0004.0004 | DONE | 100% | — | Documentation + Handoff — 8/8 tasks done. 17 documents: runbooks (5), training (6), release notes, OpenAPI, handoff checklist |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
| **3500.0003.0002** | Reachability Java Integration | 2 weeks | DONE | Implemented via SPRINT_3610_0001_0001 (JavaCallGraphExtractor, Spring Boot) |
|
| **3500.0003.0002** | Reachability Java Integration | 2 weeks | DONE | Implemented via SPRINT_3610_0001_0001 (JavaCallGraphExtractor, Spring Boot) |
|
||||||
| **3500.0003.0003** | Graph Attestations + Rekor | 2 weeks | DONE | RichGraphAttestationService, Rekor via Attestor module, budget policy documented |
|
| **3500.0003.0003** | Graph Attestations + Rekor | 2 weeks | DONE | RichGraphAttestationService, Rekor via Attestor module, budget policy documented |
|
||||||
| **3500.0004.0001** | CLI Verbs + Offline Bundles | 2 weeks | DONE | `stella score`, `stella graph`, `stella unknowns`, offline kit, corpus — 8/8 tasks, 183 tests pass |
|
| **3500.0004.0001** | CLI Verbs + Offline Bundles | 2 weeks | DONE | `stella score`, `stella graph`, `stella unknowns`, offline kit, corpus — 8/8 tasks, 183 tests pass |
|
||||||
| **3500.0004.0002** | UI Components + Visualization | 2 weeks | IN PROGRESS | T6 DOING: API models done. T1-T5, T7-T8 TODO |
|
| **3500.0004.0002** | UI Components + Visualization | 2 weeks | DONE | All 8 components: Proof Ledger, Unknowns Queue, Reachability Explain, Score Comparison, Proof Replay, API Services, Accessibility, Tests |
|
||||||
| **3500.0004.0003** | Integration Tests + Corpus | 2 weeks | DONE | Golden corpus (12 cases), 6 test projects (74 test methods), CI gates, perf baselines |
|
| **3500.0004.0003** | Integration Tests + Corpus | 2 weeks | DONE | Golden corpus (12 cases), 6 test projects (74 test methods), CI gates, perf baselines |
|
||||||
| **3500.0004.0004** | Documentation + Handoff | 2 weeks | DONE | Runbooks (5), training (6 docs), release notes, OpenAPI, handoff checklist — 8/8 tasks |
|
| **3500.0004.0004** | Documentation + Handoff | 2 weeks | DONE | Runbooks (5), training (6 docs), release notes, OpenAPI, handoff checklist — 8/8 tasks |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
# Sprint 3900.0001.0001 · Exception Objects — Schema & Model
|
||||||
|
|
||||||
|
## Topic & Scope
|
||||||
|
- Implement auditable Exception Objects as first-class entities with full governance lifecycle.
|
||||||
|
- Create PostgreSQL schema for `policy.exceptions` table with attribution, scoping, and time-bounded expiry.
|
||||||
|
- Build C# domain model for exception management.
|
||||||
|
- **Working directory:** `src/Policy/__Libraries/StellaOps.Policy.Exceptions/` and `src/Policy/Migrations/`
|
||||||
|
|
||||||
|
## Dependencies & Concurrency
|
||||||
|
- **Upstream**: None (foundational sprint)
|
||||||
|
- **Downstream**: Sprint 3900.0001.0002 (Exception API) depends on this
|
||||||
|
- **Safe to parallelize with**: Unrelated epics
|
||||||
|
|
||||||
|
## Documentation Prerequisites
|
||||||
|
- `docs/product-advisories/unprocessed/moats/20-Dec-2025 - Moat Explanation - Exception management as auditable objects.md`
|
||||||
|
- `docs/modules/policy/architecture.md`
|
||||||
|
- `docs/db/SPECIFICATION.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### T1: Exception Object Domain Model
|
||||||
|
|
||||||
|
**Assignee**: Policy Team
|
||||||
|
**Story Points**: 5
|
||||||
|
**Status**: DONE
|
||||||
|
|
||||||
|
**Description**:
|
||||||
|
Create the core Exception Object domain model with all required fields per the moat advisory.
|
||||||
|
|
||||||
|
**Implementation Path**: `src/Policy/__Libraries/StellaOps.Policy.Exceptions/Models/`
|
||||||
|
|
||||||
|
**Acceptance Criteria**:
|
||||||
|
- [ ] `ExceptionObject` record with all required fields
|
||||||
|
- [ ] `ExceptionStatus` enum: Proposed, Approved, Active, Expired, Revoked
|
||||||
|
- [ ] `ExceptionType` enum: Vulnerability, Policy, Unknown, Component
|
||||||
|
- [ ] `ExceptionScope` record: artifact digest, purl pattern, environment constraints
|
||||||
|
- [ ] `ExceptionReason` enum: FalsePositive, AcceptedRisk, CompensatingControl, TestOnly, etc.
|
||||||
|
- [ ] Immutable history via event-sourced versioning
|
||||||
|
|
||||||
|
**Domain Model Spec**:
|
||||||
|
```csharp
|
||||||
|
public sealed record ExceptionObject
|
||||||
|
{
|
||||||
|
public required string ExceptionId { get; init; }
|
||||||
|
public required int Version { get; init; }
|
||||||
|
public required ExceptionStatus Status { get; init; }
|
||||||
|
public required ExceptionType Type { get; init; }
|
||||||
|
public required ExceptionScope Scope { get; init; }
|
||||||
|
public required string OwnerId { get; init; }
|
||||||
|
public required string RequesterId { get; init; }
|
||||||
|
public string? ApproverId { get; init; }
|
||||||
|
public required DateTimeOffset CreatedAt { get; init; }
|
||||||
|
public DateTimeOffset? ApprovedAt { get; init; }
|
||||||
|
public required DateTimeOffset ExpiresAt { get; init; }
|
||||||
|
public required ExceptionReason ReasonCode { get; init; }
|
||||||
|
public required string Rationale { get; init; }
|
||||||
|
public ImmutableArray<string> EvidenceRefs { get; init; }
|
||||||
|
public ImmutableDictionary<string, string> Metadata { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ExceptionScope
|
||||||
|
{
|
||||||
|
public string? ArtifactDigest { get; init; } // sha256:...
|
||||||
|
public string? PurlPattern { get; init; } // pkg:npm/lodash@*
|
||||||
|
public string? VulnerabilityId { get; init; } // CVE-2024-XXXX
|
||||||
|
public string? PolicyRuleId { get; init; } // rule identifier
|
||||||
|
public ImmutableArray<string> Environments { get; init; } // prod, staging, dev
|
||||||
|
public string? TenantId { get; init; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T2: Exception Event Model
|
||||||
|
|
||||||
|
**Assignee**: Policy Team
|
||||||
|
**Story Points**: 3
|
||||||
|
**Status**: DONE
|
||||||
|
|
||||||
|
**Description**:
|
||||||
|
Create event-sourced history model for exception lifecycle tracking.
|
||||||
|
|
||||||
|
**Acceptance Criteria**:
|
||||||
|
- [ ] `ExceptionEvent` record for all state transitions
|
||||||
|
- [ ] `ExceptionEventType` enum: Created, Approved, Activated, Extended, Revoked, Expired
|
||||||
|
- [ ] Event includes actor, timestamp, and previous state
|
||||||
|
- [ ] Audit trail is immutable (append-only)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T3: PostgreSQL Schema Migration
|
||||||
|
|
||||||
|
**Assignee**: Policy Team
|
||||||
|
**Story Points**: 5
|
||||||
|
**Status**: TODO
|
||||||
|
|
||||||
|
**Description**:
|
||||||
|
Create database migration for exception storage.
|
||||||
|
|
||||||
|
**Implementation Path**: `src/Policy/Migrations/`
|
||||||
|
|
||||||
|
**Acceptance Criteria**:
|
||||||
|
- [ ] `policy.exceptions` table with all fields
|
||||||
|
- [ ] `policy.exception_events` table for audit trail
|
||||||
|
- [ ] Indexes on: exception_id, status, expires_at, scope fields
|
||||||
|
- [ ] Foreign keys to tenant (if applicable)
|
||||||
|
- [ ] BRIN index on created_at for time-based queries
|
||||||
|
|
||||||
|
**Schema Spec**:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE policy.exceptions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
exception_id TEXT NOT NULL UNIQUE,
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('proposed', 'approved', 'active', 'expired', 'revoked')),
|
||||||
|
type TEXT NOT NULL CHECK (type IN ('vulnerability', 'policy', 'unknown', 'component')),
|
||||||
|
|
||||||
|
-- Scope
|
||||||
|
artifact_digest TEXT,
|
||||||
|
purl_pattern TEXT,
|
||||||
|
vulnerability_id TEXT,
|
||||||
|
policy_rule_id TEXT,
|
||||||
|
environments TEXT[] DEFAULT '{}',
|
||||||
|
tenant_id UUID,
|
||||||
|
|
||||||
|
-- Attribution
|
||||||
|
owner_id TEXT NOT NULL,
|
||||||
|
requester_id TEXT NOT NULL,
|
||||||
|
approver_id TEXT,
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
approved_at TIMESTAMPTZ,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
|
||||||
|
-- Reason
|
||||||
|
reason_code TEXT NOT NULL,
|
||||||
|
rationale TEXT NOT NULL,
|
||||||
|
evidence_refs JSONB DEFAULT '[]',
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
CONSTRAINT valid_scope CHECK (
|
||||||
|
artifact_digest IS NOT NULL OR
|
||||||
|
purl_pattern IS NOT NULL OR
|
||||||
|
vulnerability_id IS NOT NULL OR
|
||||||
|
policy_rule_id IS NOT NULL
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE policy.exception_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
exception_id TEXT NOT NULL REFERENCES policy.exceptions(exception_id),
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
actor_id TEXT NOT NULL,
|
||||||
|
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
previous_status TEXT,
|
||||||
|
new_status TEXT,
|
||||||
|
details JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
CONSTRAINT fk_exception FOREIGN KEY (exception_id)
|
||||||
|
REFERENCES policy.exceptions(exception_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_exceptions_status ON policy.exceptions(status);
|
||||||
|
CREATE INDEX idx_exceptions_expires ON policy.exceptions(expires_at);
|
||||||
|
CREATE INDEX idx_exceptions_vuln ON policy.exceptions(vulnerability_id) WHERE vulnerability_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_exceptions_purl ON policy.exceptions(purl_pattern) WHERE purl_pattern IS NOT NULL;
|
||||||
|
CREATE INDEX idx_exception_events_exception ON policy.exception_events(exception_id);
|
||||||
|
CREATE INDEX idx_exception_events_time USING BRIN ON policy.exception_events(occurred_at);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T4: Exception Repository Interface
|
||||||
|
|
||||||
|
**Assignee**: Policy Team
|
||||||
|
**Story Points**: 3
|
||||||
|
**Status**: DONE
|
||||||
|
|
||||||
|
**Description**:
|
||||||
|
Create repository interface for exception persistence.
|
||||||
|
|
||||||
|
**Acceptance Criteria**:
|
||||||
|
- [ ] `IExceptionRepository` interface
|
||||||
|
- [ ] Methods: Create, Update, GetById, GetByScope, GetActive, GetExpiring
|
||||||
|
- [ ] Support for optimistic concurrency via version
|
||||||
|
- [ ] Audit event recording on all mutations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T5: PostgreSQL Repository Implementation
|
||||||
|
|
||||||
|
**Assignee**: Policy Team
|
||||||
|
**Story Points**: 5
|
||||||
|
**Status**: TODO
|
||||||
|
|
||||||
|
**Description**:
|
||||||
|
Implement PostgreSQL repository for exceptions.
|
||||||
|
|
||||||
|
**Implementation Path**: `src/Policy/__Libraries/StellaOps.Policy.Storage.Postgres/`
|
||||||
|
|
||||||
|
**Acceptance Criteria**:
|
||||||
|
- [ ] `PostgresExceptionRepository` implementation
|
||||||
|
- [ ] Uses Npgsql with Dapper or raw ADO.NET
|
||||||
|
- [ ] Transactional event recording
|
||||||
|
- [ ] Efficient scope matching queries
|
||||||
|
- [ ] Expiry check queries
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T6: Exception Evaluator Service
|
||||||
|
|
||||||
|
**Assignee**: Policy Team
|
||||||
|
**Story Points**: 5
|
||||||
|
**Status**: DONE
|
||||||
|
|
||||||
|
**Description**:
|
||||||
|
Create service that evaluates whether an exception applies to a given finding.
|
||||||
|
|
||||||
|
**Acceptance Criteria**:
|
||||||
|
- [ ] `IExceptionEvaluator` interface
|
||||||
|
- [ ] `ExceptionEvaluator` implementation
|
||||||
|
- [ ] Scope matching: digest exact match, purl pattern match, vuln ID match
|
||||||
|
- [ ] Status check: only Active exceptions apply
|
||||||
|
- [ ] Expiry check: auto-mark expired if past expires_at
|
||||||
|
- [ ] Environment matching
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T7: Unit Tests
|
||||||
|
|
||||||
|
**Assignee**: Policy Team
|
||||||
|
**Story Points**: 3
|
||||||
|
**Status**: TODO
|
||||||
|
|
||||||
|
**Description**:
|
||||||
|
Comprehensive unit tests for exception domain model and evaluator.
|
||||||
|
|
||||||
|
**Acceptance Criteria**:
|
||||||
|
- [ ] Model construction and validation tests
|
||||||
|
- [ ] Scope matching tests (positive and negative cases)
|
||||||
|
- [ ] Status transition tests
|
||||||
|
- [ ] Expiry boundary tests
|
||||||
|
- [ ] Event generation tests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T8: Integration Tests
|
||||||
|
|
||||||
|
**Assignee**: Policy Team
|
||||||
|
**Story Points**: 3
|
||||||
|
**Status**: TODO
|
||||||
|
|
||||||
|
**Description**:
|
||||||
|
Integration tests for PostgreSQL repository.
|
||||||
|
|
||||||
|
**Acceptance Criteria**:
|
||||||
|
- [ ] Repository CRUD tests
|
||||||
|
- [ ] Concurrent update handling
|
||||||
|
- [ ] Event audit trail verification
|
||||||
|
- [ ] Scope query performance tests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Delivery Tracker
|
||||||
|
|
||||||
|
| # | Task ID | Status | Dependency | Owners | Task Definition |
|
||||||
|
|---|---------|--------|------------|--------|-----------------|
|
||||||
|
| 1 | T1 | DONE | — | Policy Team | Exception Object Domain Model |
|
||||||
|
| 2 | T2 | DONE | T1 | Policy Team | Exception Event Model |
|
||||||
|
| 3 | T3 | TODO | T1, T2 | Policy Team | PostgreSQL Schema Migration |
|
||||||
|
| 4 | T4 | DONE | T1 | Policy Team | Exception Repository Interface |
|
||||||
|
| 5 | T5 | TODO | T3, T4 | Policy Team | PostgreSQL Repository Implementation |
|
||||||
|
| 6 | T6 | DONE | T1 | Policy Team | Exception Evaluator Service |
|
||||||
|
| 7 | T7 | TODO | T1-T6 | Policy Team | Unit Tests |
|
||||||
|
| 8 | T8 | TODO | T5 | Policy Team | Integration Tests |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Log
|
||||||
|
|
||||||
|
| Date (UTC) | Update | Owner |
|
||||||
|
|------------|--------|-------|
|
||||||
|
| 2025-12-20 | Sprint file created based on advisory processing report. | Agent |
|
||||||
|
| 2025-12-20 | T1, T2, T4, T6 completed: Domain models, event model, repository interface, evaluator service. | Agent |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions & Risks
|
||||||
|
|
||||||
|
| Item | Type | Owner | Notes |
|
||||||
|
|------|------|-------|-------|
|
||||||
|
| Event sourcing vs CRUD | Decision | Policy Team | Using event-sourced audit trail but CRUD for current state |
|
||||||
|
| Scope matching complexity | Risk | Policy Team | PURL pattern matching may need optimization for large exception sets |
|
||||||
|
| Expiry enforcement | Decision | Policy Team | Lazy expiry check on read + scheduled background job for proactive marking |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Sprint Status**: IN PROGRESS (4/8 tasks done)
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
# Sprint 3900.0001.0002 · Exception Objects — API & Workflow
|
||||||
|
|
||||||
|
## Topic & Scope
|
||||||
|
- Implement REST API for Exception Object lifecycle management.
|
||||||
|
- Create approval workflow with multi-party authorization support.
|
||||||
|
- Add OpenAPI specification and client generation.
|
||||||
|
- **Working directory:** `src/Policy/StellaOps.Policy.WebService/` and `src/Api/`
|
||||||
|
|
||||||
|
## Dependencies & Concurrency
|
||||||
|
- **Upstream**: Sprint 3900.0001.0001 (Schema & Model) — MUST BE DONE
|
||||||
|
- **Downstream**: Sprint 3900.0002.0001 (Policy Engine Integration)
|
||||||
|
- **Safe to parallelize with**: Unrelated epics
|
||||||
|
|
||||||
|
## Documentation Prerequisites
|
||||||
|
- Sprint 3900.0001.0001 completion
|
||||||
|
- `docs/api/` for API conventions
|
||||||
|
- `docs/modules/policy/architecture.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### T1: Exception API Controller
|
||||||
|
|
||||||
|
**Assignee**: Policy Team
|
||||||
|
**Story Points**: 5
|
||||||
|
**Status**: TODO
|
||||||
|
|
||||||
|
**Description**:
|
||||||
|
Create REST API controller for exception CRUD operations.
|
||||||
|
|
||||||
|
**Implementation Path**: `src/Policy/StellaOps.Policy.WebService/Controllers/ExceptionsController.cs`
|
||||||
|
|
||||||
|
**Acceptance Criteria**:
|
||||||
|
- [ ] `POST /api/v1/policy/exceptions` — Create exception (returns Proposed status)
|
||||||
|
- [ ] `GET /api/v1/policy/exceptions/{id}` — Get exception by ID
|
||||||
|
- [ ] `GET /api/v1/policy/exceptions` — List exceptions with filters
|
||||||
|
- [ ] `PUT /api/v1/policy/exceptions/{id}` — Update exception (rationale, metadata)
|
||||||
|
- [ ] `DELETE /api/v1/policy/exceptions/{id}` — Revoke exception
|
||||||
|
- [ ] `POST /api/v1/policy/exceptions/{id}/approve` — Approve exception
|
||||||
|
- [ ] `POST /api/v1/policy/exceptions/{id}/activate` — Activate approved exception
|
||||||
|
- [ ] `POST /api/v1/policy/exceptions/{id}/extend` — Extend expiry
|
||||||
|
- [ ] All endpoints require authentication
|
||||||
|
- [ ] All mutations record events
|
||||||
|
|
||||||
|
**API Spec**:
|
||||||
|
```yaml
|
||||||
|
paths:
|
||||||
|
/api/v1/policy/exceptions:
|
||||||
|
post:
|
||||||
|
summary: Create a new exception
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CreateExceptionRequest'
|
||||||
|
responses:
|
||||||
|
201:
|
||||||
|
description: Exception created
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ExceptionObject'
|
||||||
|
get:
|
||||||
|
summary: List exceptions
|
||||||
|
parameters:
|
||||||
|
- name: status
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [proposed, approved, active, expired, revoked]
|
||||||
|
- name: type
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: vulnerabilityId
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: environment
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T2: Exception Service Layer
|
||||||
|
|
||||||
|
**Assignee**: Policy Team
|
||||||
|
**Story Points**: 5
|
||||||
|
**Status**: TODO
|
||||||
|
|
||||||
|
**Description**:
|
||||||
|
Create service layer with business logic for exception lifecycle.
|
||||||
|
|
||||||
|
**Acceptance Criteria**:
|
||||||
|
- [ ] `IExceptionService` interface
|
||||||
|
- [ ] `ExceptionService` implementation
|
||||||
|
- [ ] Validation: scope must be specific enough
|
||||||
|
- [ ] Validation: expiry must be in future, max 1 year
|
||||||
|
- [ ] Validation: rationale required, min 50 characters
|
||||||
|
- [ ] Status transitions follow state machine
|
||||||
|
- [ ] Notifications on status changes (event bus)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T3: Approval Workflow
|
||||||
|
|
||||||
|
**Assignee**: Policy Team
|
||||||
|
**Story Points**: 5
|
||||||
|
**Status**: TODO
|
||||||
|
|
||||||
|
**Description**:
|
||||||
|
Implement approval workflow with configurable requirements.
|
||||||
|
|
||||||
|
**Acceptance Criteria**:
|
||||||
|
- [ ] `ApprovalPolicy` configuration per environment
|
||||||
|
- [ ] Dev: auto-approve or single approver
|
||||||
|
- [ ] Staging: single approver required
|
||||||
|
- [ ] Prod: two approvers required (configurable)
|
||||||
|
- [ ] Approver cannot be requester
|
||||||
|
- [ ] Approval deadline with auto-reject
|
||||||
|
- [ ] Approval notification integration
|
||||||
|
|
||||||
|
**Approval Policy Model**:
|
||||||
|
```csharp
|
||||||
|
public sealed record ApprovalPolicy
|
||||||
|
{
|
||||||
|
public required string Environment { get; init; }
|
||||||
|
public required int RequiredApprovers { get; init; }
|
||||||
|
public required bool RequesterCanApprove { get; init; }
|
||||||
|
public required TimeSpan ApprovalDeadline { get; init; }
|
||||||
|
public ImmutableArray<string> AllowedApproverRoles { get; init; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T4: Exception Query Service
|
||||||
|
|
||||||
|
**Assignee**: Policy Team
|
||||||
|
**Story Points**: 3
|
||||||
|
**Status**: TODO
|
||||||
|
|
||||||
|
**Description**:
|
||||||
|
Create optimized query service for exception lookup.
|
||||||
|
|
||||||
|
**Acceptance Criteria**:
|
||||||
|
- [ ] `IExceptionQueryService` interface
|
||||||
|
- [ ] `GetApplicableExceptions(finding)` — returns matching active exceptions
|
||||||
|
- [ ] `GetExpiringExceptions(horizon)` — returns exceptions expiring within horizon
|
||||||
|
- [ ] `GetExceptionsByScope(scope)` — returns exceptions for specific scope
|
||||||
|
- [ ] Caching layer for hot paths
|
||||||
|
- [ ] Efficient PURL pattern matching
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T5: Exception DTO Models
|
||||||
|
|
||||||
|
**Assignee**: Policy Team
|
||||||
|
**Story Points**: 2
|
||||||
|
**Status**: TODO
|
||||||
|
|
||||||
|
**Description**:
|
||||||
|
Create DTOs for API requests/responses.
|
||||||
|
|
||||||
|
**Acceptance Criteria**:
|
||||||
|
- [ ] `CreateExceptionRequest` DTO
|
||||||
|
- [ ] `UpdateExceptionRequest` DTO
|
||||||
|
- [ ] `ApproveExceptionRequest` DTO
|
||||||
|
- [ ] `ExtendExceptionRequest` DTO
|
||||||
|
- [ ] `ExceptionResponse` DTO
|
||||||
|
- [ ] `ExceptionListResponse` DTO with pagination
|
||||||
|
- [ ] Validation attributes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T6: OpenAPI Specification
|
||||||
|
|
||||||
|
**Assignee**: Policy Team
|
||||||
|
**Story Points**: 2
|
||||||
|
**Status**: TODO
|
||||||
|
|
||||||
|
**Description**:
|
||||||
|
Add exception endpoints to OpenAPI spec.
|
||||||
|
|
||||||
|
**Implementation Path**: `src/Api/StellaOps.Api.OpenApi/policy/exceptions.yaml`
|
||||||
|
|
||||||
|
**Acceptance Criteria**:
|
||||||
|
- [ ] All endpoints documented
|
||||||
|
- [ ] Request/response schemas defined
|
||||||
|
- [ ] Error responses documented
|
||||||
|
- [ ] Examples included
|
||||||
|
- [ ] Generated client compiles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T7: Expiry Background Job
|
||||||
|
|
||||||
|
**Assignee**: Policy Team
|
||||||
|
**Story Points**: 3
|
||||||
|
**Status**: TODO
|
||||||
|
|
||||||
|
**Description**:
|
||||||
|
Create background job to mark expired exceptions.
|
||||||
|
|
||||||
|
**Acceptance Criteria**:
|
||||||
|
- [ ] Scheduled job runs every hour
|
||||||
|
- [ ] Finds all Active exceptions with expires_at < now
|
||||||
|
- [ ] Transitions to Expired status
|
||||||
|
- [ ] Records expiry event
|
||||||
|
- [ ] Sends expiry notifications
|
||||||
|
- [ ] Uses Scheduler.JobClient abstraction
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T8: Unit Tests
|
||||||
|
|
||||||
|
**Assignee**: Policy Team
|
||||||
|
**Story Points**: 3
|
||||||
|
**Status**: TODO
|
||||||
|
|
||||||
|
**Description**:
|
||||||
|
Unit tests for service layer and workflow.
|
||||||
|
|
||||||
|
**Acceptance Criteria**:
|
||||||
|
- [ ] Service method tests
|
||||||
|
- [ ] Approval workflow tests
|
||||||
|
- [ ] State transition tests
|
||||||
|
- [ ] Validation tests
|
||||||
|
- [ ] Query service tests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T9: Integration Tests
|
||||||
|
|
||||||
|
**Assignee**: Policy Team
|
||||||
|
**Story Points**: 3
|
||||||
|
**Status**: TODO
|
||||||
|
|
||||||
|
**Description**:
|
||||||
|
API integration tests.
|
||||||
|
|
||||||
|
**Acceptance Criteria**:
|
||||||
|
- [ ] Full lifecycle API test
|
||||||
|
- [ ] Approval workflow integration test
|
||||||
|
- [ ] Concurrent modification handling
|
||||||
|
- [ ] Authorization tests
|
||||||
|
- [ ] Error handling tests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Delivery Tracker
|
||||||
|
|
||||||
|
| # | Task ID | Status | Dependency | Owners | Task Definition |
|
||||||
|
|---|---------|--------|------------|--------|-----------------|
|
||||||
|
| 1 | T1 | TODO | Sprint 3900.0001.0001 | Policy Team | Exception API Controller |
|
||||||
|
| 2 | T2 | TODO | Sprint 3900.0001.0001 | Policy Team | Exception Service Layer |
|
||||||
|
| 3 | T3 | TODO | T2 | Policy Team | Approval Workflow |
|
||||||
|
| 4 | T4 | TODO | Sprint 3900.0001.0001 | Policy Team | Exception Query Service |
|
||||||
|
| 5 | T5 | TODO | — | Policy Team | Exception DTO Models |
|
||||||
|
| 6 | T6 | TODO | T1, T5 | Policy Team | OpenAPI Specification |
|
||||||
|
| 7 | T7 | TODO | T2 | Policy Team | Expiry Background Job |
|
||||||
|
| 8 | T8 | TODO | T1-T7 | Policy Team | Unit Tests |
|
||||||
|
| 9 | T9 | TODO | T1-T7 | Policy Team | Integration Tests |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Log
|
||||||
|
|
||||||
|
| Date (UTC) | Update | Owner |
|
||||||
|
|------------|--------|-------|
|
||||||
|
| 2025-12-20 | Sprint file created. Depends on Sprint 3900.0001.0001. | Agent |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions & Risks
|
||||||
|
|
||||||
|
| Item | Type | Owner | Notes |
|
||||||
|
|------|------|-------|-------|
|
||||||
|
| Multi-approver workflow | Decision | Policy Team | Configurable per environment; start with simple approval |
|
||||||
|
| Caching strategy | Risk | Policy Team | May need Valkey for cross-instance consistency |
|
||||||
|
| Notification integration | Decision | Policy Team | Use existing Notify module event bus |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Sprint Status**: TODO (0/9 tasks)
|
||||||
71
docs/market/moat-strategy-summary.md
Normal file
71
docs/market/moat-strategy-summary.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# StellaOps Moat Strategy Summary
|
||||||
|
|
||||||
|
**Date**: 2025-12-20
|
||||||
|
**Source**: Product Advisories (19-Dec-2025 Moat Series)
|
||||||
|
**Status**: DOCUMENTED
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
StellaOps competitive moats are built on **decision integrity** - deterministic, attestable, replayable security verdicts - not just scanner features.
|
||||||
|
|
||||||
|
## Moat Strength Rankings
|
||||||
|
|
||||||
|
| Moat Level | Feature | Defensibility |
|
||||||
|
|------------|---------|---------------|
|
||||||
|
| **5 (Structural)** | Signed, replayable risk verdicts | Highest - requires deterministic eval + proof schema + knowledge snapshots |
|
||||||
|
| **4 (Strong)** | VEX decisioning engine | Formal conflict resolution, provenance-aware trust weighting |
|
||||||
|
| **4 (Strong)** | Reachability with proofs | Portable proofs, artifact-level mapping, deterministic replay |
|
||||||
|
| **4 (Strong)** | Smart-Diff (semantic risk delta) | Graph-based diff over SBOM + reachability + VEX |
|
||||||
|
| **4 (Strong)** | Unknowns as first-class state | Uncertainty budgets in policies, scoring, attestations |
|
||||||
|
| **4 (Strong)** | Air-gapped epistemic mode | Sealed knowledge snapshots, offline reproducibility |
|
||||||
|
| **3 (Moderate)** | SBOM ledger + lineage | Table stakes; differentiate via semantic diff + evidence joins |
|
||||||
|
| **3 (Moderate)** | Policy engine with proofs | Common; moat is proof output + deterministic replay |
|
||||||
|
| **1-2 (Commodity)** | Integrations everywhere | Necessary but not defensible |
|
||||||
|
|
||||||
|
## Core Moat Thesis (One-Liners)
|
||||||
|
|
||||||
|
- **Deterministic signed verdicts:** "We don't output findings; we output an attestable decision that can be replayed."
|
||||||
|
- **VEX decisioning:** "We treat VEX as a logical claim system, not a suppression file."
|
||||||
|
- **Reachability proofs:** "We provide proof of exploitability in *this* artifact, not just a badge."
|
||||||
|
- **Smart-Diff:** "We explain what changed in exploitable surface area, not what changed in CVE count."
|
||||||
|
- **Unknowns modeling:** "We quantify uncertainty and gate on it."
|
||||||
|
|
||||||
|
## Implementation Status
|
||||||
|
|
||||||
|
| Feature | Sprint(s) | Status |
|
||||||
|
|---------|-----------|--------|
|
||||||
|
| Signed verdicts | 3500.0002.* | ✅ DONE |
|
||||||
|
| VEX decisioning | Existing lattice engine | ✅ DONE |
|
||||||
|
| Reachability proofs | 3500.0003.*, 3600.* | ✅ DONE |
|
||||||
|
| Smart-Diff | 3500.0001.* (archived) | ✅ DONE |
|
||||||
|
| Unknowns | 3500.0002.0002 | ✅ DONE |
|
||||||
|
| Air-gapped mode | 3500.0004.0001 (offline bundles) | ✅ DONE |
|
||||||
|
| Reachability Drift | Proposed | 🎯 NEXT |
|
||||||
|
|
||||||
|
## Competitor Positioning
|
||||||
|
|
||||||
|
### Avoid Head-On Fights With:
|
||||||
|
- **Snyk**: Developer adoption + reachability prioritization
|
||||||
|
- **Prisma Cloud**: CNAPP breadth + graph-based investigation
|
||||||
|
- **Anchore**: SBOM operations maturity
|
||||||
|
- **Aqua/Trivy**: Runtime protection + VEX Hub network
|
||||||
|
|
||||||
|
### Win With:
|
||||||
|
- **Decision integrity** (deterministic, attestable, replayable)
|
||||||
|
- **Proof portability** (offline audits, evidence bundles)
|
||||||
|
- **Semantic change control** (risk deltas, not CVE counts)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Source Documents
|
||||||
|
|
||||||
|
See `docs/product-advisories/unprocessed/moats/` for full advisory content:
|
||||||
|
- 19-Dec-2025 - Moat #1 through #7
|
||||||
|
- 19-Dec-2025 - Stella Ops candidate features mapped to moat strength
|
||||||
|
- 19-Dec-2025 - Benchmarking Container Scanners Against Stella Ops
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated**: 2025-12-20
|
||||||
@@ -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