diff --git a/docs/implplan/ADVISORY_PROCESSING_REPORT_20251220.md b/docs/implplan/ADVISORY_PROCESSING_REPORT_20251220.md new file mode 100644 index 000000000..d920037b7 --- /dev/null +++ b/docs/implplan/ADVISORY_PROCESSING_REPORT_20251220.md @@ -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 diff --git a/docs/implplan/SPRINT_3500_0001_0001_deeper_moat_master.md b/docs/implplan/SPRINT_3500_0001_0001_deeper_moat_master.md index 854a32331..d58fcce5e 100644 --- a/docs/implplan/SPRINT_3500_0001_0001_deeper_moat_master.md +++ b/docs/implplan/SPRINT_3500_0001_0001_deeper_moat_master.md @@ -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.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.0002 | TODO | 0% | — | Wireframes complete | -| 3500.0004.0003 | TODO | 0% | — | — | -| 3500.0004.0004 | TODO | 0% | — | — | +| 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 | DONE | 100% | — | Integration Tests + Corpus — 8/8 tasks done. 74 test methods, golden corpus (12 cases), CI gates, perf baselines | +| 3500.0004.0004 | DONE | 100% | — | Documentation + Handoff — 8/8 tasks done. 17 documents: runbooks (5), training (6), release notes, OpenAPI, handoff checklist | --- diff --git a/docs/implplan/SPRINT_3500_SUMMARY.md b/docs/implplan/SPRINT_3500_SUMMARY.md index 45fe752d8..c46eefea7 100644 --- a/docs/implplan/SPRINT_3500_SUMMARY.md +++ b/docs/implplan/SPRINT_3500_SUMMARY.md @@ -18,7 +18,7 @@ | **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.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.0004** | Documentation + Handoff | 2 weeks | DONE | Runbooks (5), training (6 docs), release notes, OpenAPI, handoff checklist — 8/8 tasks | diff --git a/docs/implplan/SPRINT_3900_0001_0001_exception_objects_schema_model.md b/docs/implplan/SPRINT_3900_0001_0001_exception_objects_schema_model.md new file mode 100644 index 000000000..51513180d --- /dev/null +++ b/docs/implplan/SPRINT_3900_0001_0001_exception_objects_schema_model.md @@ -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 EvidenceRefs { get; init; } + public ImmutableDictionary 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 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) diff --git a/docs/implplan/SPRINT_3900_0001_0002_exception_objects_api_workflow.md b/docs/implplan/SPRINT_3900_0001_0002_exception_objects_api_workflow.md new file mode 100644 index 000000000..d1f90893a --- /dev/null +++ b/docs/implplan/SPRINT_3900_0001_0002_exception_objects_api_workflow.md @@ -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 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) diff --git a/docs/market/moat-strategy-summary.md b/docs/market/moat-strategy-summary.md new file mode 100644 index 000000000..c556ced50 --- /dev/null +++ b/docs/market/moat-strategy-summary.md @@ -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 diff --git a/src/Policy/__Libraries/StellaOps.Policy.Exceptions/Models/ExceptionEvent.cs b/src/Policy/__Libraries/StellaOps.Policy.Exceptions/Models/ExceptionEvent.cs new file mode 100644 index 000000000..1a766b7fb --- /dev/null +++ b/src/Policy/__Libraries/StellaOps.Policy.Exceptions/Models/ExceptionEvent.cs @@ -0,0 +1,293 @@ +using System.Collections.Immutable; + +namespace StellaOps.Policy.Exceptions.Models; + +/// +/// Type of exception lifecycle event. +/// +public enum ExceptionEventType +{ + /// Exception was created (Proposed status). + Created, + + /// Exception details were updated. + Updated, + + /// Exception was approved by an approver. + Approved, + + /// Exception was activated (Active status). + Activated, + + /// Exception expiry was extended. + Extended, + + /// Exception was explicitly revoked. + Revoked, + + /// Exception expired automatically. + Expired, + + /// Evidence was attached to exception. + EvidenceAttached, + + /// Compensating control was added. + CompensatingControlAdded, + + /// Exception was rejected before approval. + Rejected +} + +/// +/// Immutable event recording a state change in an exception's lifecycle. +/// +/// +/// 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) +/// +public sealed record ExceptionEvent +{ + /// + /// Unique event identifier. + /// + public required Guid EventId { get; init; } + + /// + /// Reference to the parent exception. + /// + public required string ExceptionId { get; init; } + + /// + /// Sequence number within this exception's event stream. + /// + public required int SequenceNumber { get; init; } + + /// + /// Type of event that occurred. + /// + public required ExceptionEventType EventType { get; init; } + + /// + /// Identity of the actor who triggered this event. + /// May be a user ID, service account, or "system" for automated events. + /// + public required string ActorId { get; init; } + + /// + /// When this event occurred. + /// + public required DateTimeOffset OccurredAt { get; init; } + + /// + /// Status before this event (null for Created events). + /// + public ExceptionStatus? PreviousStatus { get; init; } + + /// + /// Status after this event. + /// + public required ExceptionStatus NewStatus { get; init; } + + /// + /// Version number after this event. + /// + public required int NewVersion { get; init; } + + /// + /// Human-readable description of what happened. + /// + public string? Description { get; init; } + + /// + /// Additional structured details about the event. + /// + public ImmutableDictionary Details { get; init; } = + ImmutableDictionary.Empty; + + /// + /// IP address or client identifier of the actor (for audit). + /// + public string? ClientInfo { get; init; } + + /// + /// Creates a "Created" event for a new exception. + /// + 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 + }; + + /// + /// Creates an "Approved" event. + /// + 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 + }; + + /// + /// Creates an "Activated" event. + /// + 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 + }; + + /// + /// Creates a "Revoked" event. + /// + 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.Empty.Add("reason", reason), + ClientInfo = clientInfo + }; + + /// + /// Creates an "Expired" event (typically from system). + /// + 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" + }; + + /// + /// Creates an "Extended" event. + /// + 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.Empty + .Add("previous_expiry", previousExpiry.ToString("O")) + .Add("new_expiry", newExpiry.ToString("O")), + ClientInfo = clientInfo + }; +} + +/// +/// Aggregated exception history for audit display. +/// +public sealed record ExceptionHistory +{ + /// + /// The exception this history belongs to. + /// + public required string ExceptionId { get; init; } + + /// + /// All events in chronological order. + /// + public required ImmutableArray Events { get; init; } + + /// + /// Total number of events. + /// + public int EventCount => Events.Length; + + /// + /// When the exception was first created. + /// + public DateTimeOffset? FirstEventAt => Events.Length > 0 ? Events[0].OccurredAt : null; + + /// + /// When the last event occurred. + /// + public DateTimeOffset? LastEventAt => Events.Length > 0 ? Events[^1].OccurredAt : null; +} diff --git a/src/Policy/__Libraries/StellaOps.Policy.Exceptions/Models/ExceptionObject.cs b/src/Policy/__Libraries/StellaOps.Policy.Exceptions/Models/ExceptionObject.cs new file mode 100644 index 000000000..2451699fa --- /dev/null +++ b/src/Policy/__Libraries/StellaOps.Policy.Exceptions/Models/ExceptionObject.cs @@ -0,0 +1,269 @@ +using System.Collections.Immutable; + +namespace StellaOps.Policy.Exceptions.Models; + +/// +/// Exception lifecycle status following a governed state machine. +/// +/// +/// 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) +/// +public enum ExceptionStatus +{ + /// Exception requested, awaiting approval. + Proposed, + + /// Exception approved, awaiting activation. + Approved, + + /// Exception is currently active and will affect policy evaluation. + Active, + + /// Exception has expired (expires_at reached). + Expired, + + /// Exception was explicitly revoked before expiry. + Revoked +} + +/// +/// Type of exception being requested. +/// +public enum ExceptionType +{ + /// Exception for a specific vulnerability (CVE/CWE). + Vulnerability, + + /// Exception for a policy rule bypass. + Policy, + + /// Exception allowing release despite unknown findings. + Unknown, + + /// Exception for a specific component/package. + Component +} + +/// +/// Reason code for the exception request. +/// +public enum ExceptionReason +{ + /// Finding is a false positive (not actually present or exploitable). + FalsePositive, + + /// Risk is accepted given business context. + AcceptedRisk, + + /// Compensating controls mitigate the risk. + CompensatingControl, + + /// Only applicable in test/dev environments. + TestOnly, + + /// Vendor has confirmed not affected. + VendorNotAffected, + + /// Fix is scheduled within SLA. + ScheduledFix, + + /// Component is being deprecated/removed. + DeprecationInProgress, + + /// Runtime environment prevents exploitation. + RuntimeMitigation, + + /// Network configuration prevents exploitation. + NetworkIsolation, + + /// Other reason (requires detailed rationale). + Other +} + +/// +/// Defines the scope constraints for an exception. +/// +/// +/// At least one scope constraint must be specified. Multiple constraints +/// are combined with AND logic (all must match for exception to apply). +/// +public sealed record ExceptionScope +{ + /// + /// Specific artifact digest (sha256:...) this exception applies to. + /// If null, applies to any artifact matching other constraints. + /// + public string? ArtifactDigest { get; init; } + + /// + /// PURL pattern this exception applies to. + /// Supports wildcards: pkg:npm/lodash@* or pkg:maven/org.apache.logging.log4j/* + /// + public string? PurlPattern { get; init; } + + /// + /// Specific vulnerability ID (CVE-XXXX-XXXXX) this exception applies to. + /// Required for ExceptionType.Vulnerability. + /// + public string? VulnerabilityId { get; init; } + + /// + /// Policy rule identifier this exception bypasses. + /// Required for ExceptionType.Policy. + /// + public string? PolicyRuleId { get; init; } + + /// + /// Environments where this exception is valid. + /// Empty array means all environments. + /// + public ImmutableArray Environments { get; init; } = []; + + /// + /// Tenant ID for RLS. Required in multi-tenant mode. + /// + public Guid? TenantId { get; init; } + + /// + /// Validates that the scope has at least one constraint. + /// + public bool IsValid => + !string.IsNullOrWhiteSpace(ArtifactDigest) || + !string.IsNullOrWhiteSpace(PurlPattern) || + !string.IsNullOrWhiteSpace(VulnerabilityId) || + !string.IsNullOrWhiteSpace(PolicyRuleId); +} + +/// +/// An auditable exception object representing a governed decision to +/// suppress, waive, or bypass a security finding or policy rule. +/// +/// +/// 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 +/// +public sealed record ExceptionObject +{ + /// + /// Stable unique identifier for this exception. + /// Format: EXC-{ulid} or organization-specific pattern. + /// + public required string ExceptionId { get; init; } + + /// + /// Version number (monotonically increasing). + /// Used for optimistic concurrency control. + /// + public required int Version { get; init; } + + /// + /// Current lifecycle status. + /// + public required ExceptionStatus Status { get; init; } + + /// + /// Type of exception. + /// + public required ExceptionType Type { get; init; } + + /// + /// Scope constraints defining where this exception applies. + /// + public required ExceptionScope Scope { get; init; } + + /// + /// User or team accountable for this exception. + /// Must be a valid identity in the organization. + /// + public required string OwnerId { get; init; } + + /// + /// User who initiated the exception request. + /// + public required string RequesterId { get; init; } + + /// + /// User(s) who approved the exception. + /// May be null for Proposed status or auto-approved dev exceptions. + /// + public ImmutableArray ApproverIds { get; init; } = []; + + /// + /// When the exception was first created. + /// + public required DateTimeOffset CreatedAt { get; init; } + + /// + /// Last update timestamp. + /// + public required DateTimeOffset UpdatedAt { get; init; } + + /// + /// When the exception was approved (null if not yet approved). + /// + public DateTimeOffset? ApprovedAt { get; init; } + + /// + /// When the exception expires. Required and must be in the future at creation. + /// Maximum allowed expiry is typically 1 year from creation. + /// + public required DateTimeOffset ExpiresAt { get; init; } + + /// + /// Categorized reason for the exception. + /// + public required ExceptionReason ReasonCode { get; init; } + + /// + /// Detailed rationale explaining why this exception is necessary. + /// Required, minimum 50 characters. + /// + public required string Rationale { get; init; } + + /// + /// Content-addressed references to supporting evidence. + /// Format: sha256:{hash} or attestation URIs. + /// + public ImmutableArray EvidenceRefs { get; init; } = []; + + /// + /// Compensating controls in place that mitigate the risk. + /// + public ImmutableArray CompensatingControls { get; init; } = []; + + /// + /// Additional metadata for organization-specific tracking. + /// + public ImmutableDictionary Metadata { get; init; } = + ImmutableDictionary.Empty; + + /// + /// Ticket or tracking system reference (e.g., JIRA-1234). + /// + public string? TicketRef { get; init; } + + /// + /// Determines if this exception is currently effective. + /// + public bool IsEffective => + Status == ExceptionStatus.Active && + DateTimeOffset.UtcNow < ExpiresAt; + + /// + /// Determines if this exception has expired. + /// + public bool HasExpired => + DateTimeOffset.UtcNow >= ExpiresAt; +} diff --git a/src/Policy/__Libraries/StellaOps.Policy.Exceptions/Repositories/IExceptionRepository.cs b/src/Policy/__Libraries/StellaOps.Policy.Exceptions/Repositories/IExceptionRepository.cs new file mode 100644 index 000000000..04ecc7086 --- /dev/null +++ b/src/Policy/__Libraries/StellaOps.Policy.Exceptions/Repositories/IExceptionRepository.cs @@ -0,0 +1,201 @@ +using StellaOps.Policy.Exceptions.Models; + +namespace StellaOps.Policy.Exceptions.Repositories; + +/// +/// Repository interface for exception persistence operations. +/// +/// +/// All mutating operations must record audit events transactionally. +/// Implementations should use optimistic concurrency via version checking. +/// +public interface IExceptionRepository +{ + /// + /// Creates a new exception and records a Created event. + /// + /// The exception to create (must have Version = 1). + /// Identity of the actor creating the exception. + /// Optional client information for audit. + /// Cancellation token. + /// The created exception with assigned ID. + Task CreateAsync( + ExceptionObject exception, + string actorId, + string? clientInfo = null, + CancellationToken cancellationToken = default); + + /// + /// Updates an existing exception and records an event. + /// + /// The updated exception (version must match current). + /// Type of event to record. + /// Identity of the actor making the update. + /// Event description. + /// Optional client information for audit. + /// Cancellation token. + /// The updated exception with incremented version. + /// If version doesn't match. + Task UpdateAsync( + ExceptionObject exception, + ExceptionEventType eventType, + string actorId, + string? description = null, + string? clientInfo = null, + CancellationToken cancellationToken = default); + + /// + /// Gets an exception by its ID. + /// + /// The exception ID. + /// Cancellation token. + /// The exception if found, null otherwise. + Task GetByIdAsync( + string exceptionId, + CancellationToken cancellationToken = default); + + /// + /// Gets all exceptions matching the specified filters. + /// + /// Filter criteria. + /// Cancellation token. + /// Matching exceptions. + Task> GetByFilterAsync( + ExceptionFilter filter, + CancellationToken cancellationToken = default); + + /// + /// Gets all active exceptions that apply to the given scope. + /// + /// Scope to match against. + /// Cancellation token. + /// Matching active exceptions. + Task> GetActiveByScopeAsync( + ExceptionScope scope, + CancellationToken cancellationToken = default); + + /// + /// Gets exceptions that will expire within the specified horizon. + /// + /// Time horizon from now. + /// Cancellation token. + /// Exceptions expiring within the horizon. + Task> GetExpiringAsync( + TimeSpan horizon, + CancellationToken cancellationToken = default); + + /// + /// Gets all Active exceptions that have passed their expiry time. + /// + /// Cancellation token. + /// Expired exceptions that need status update. + Task> GetExpiredActiveAsync( + CancellationToken cancellationToken = default); + + /// + /// Gets the event history for an exception. + /// + /// The exception ID. + /// Cancellation token. + /// Event history. + Task GetHistoryAsync( + string exceptionId, + CancellationToken cancellationToken = default); + + /// + /// Counts exceptions by status. + /// + /// Optional tenant filter. + /// Cancellation token. + /// Counts by status. + Task GetCountsAsync( + Guid? tenantId = null, + CancellationToken cancellationToken = default); +} + +/// +/// Filter criteria for querying exceptions. +/// +public sealed record ExceptionFilter +{ + /// Filter by status. + public ExceptionStatus? Status { get; init; } + + /// Filter by type. + public ExceptionType? Type { get; init; } + + /// Filter by vulnerability ID. + public string? VulnerabilityId { get; init; } + + /// Filter by PURL pattern (partial match). + public string? PurlPattern { get; init; } + + /// Filter by environment. + public string? Environment { get; init; } + + /// Filter by owner. + public string? OwnerId { get; init; } + + /// Filter by requester. + public string? RequesterId { get; init; } + + /// Filter by tenant. + public Guid? TenantId { get; init; } + + /// Filter for exceptions created after this time. + public DateTimeOffset? CreatedAfter { get; init; } + + /// Filter for exceptions expiring before this time. + public DateTimeOffset? ExpiringBefore { get; init; } + + /// Maximum number of results. + public int Limit { get; init; } = 100; + + /// Offset for pagination. + public int Offset { get; init; } = 0; +} + +/// +/// Summary counts of exceptions by status. +/// +public sealed record ExceptionCounts +{ + /// Total exceptions. + public int Total { get; init; } + + /// Exceptions in Proposed status. + public int Proposed { get; init; } + + /// Exceptions in Approved status. + public int Approved { get; init; } + + /// Exceptions in Active status. + public int Active { get; init; } + + /// Exceptions in Expired status. + public int Expired { get; init; } + + /// Exceptions in Revoked status. + public int Revoked { get; init; } + + /// Exceptions expiring within 7 days. + public int ExpiringSoon { get; init; } +} + +/// +/// Exception thrown when optimistic concurrency fails. +/// +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; + } +} diff --git a/src/Policy/__Libraries/StellaOps.Policy.Exceptions/Services/ExceptionEvaluator.cs b/src/Policy/__Libraries/StellaOps.Policy.Exceptions/Services/ExceptionEvaluator.cs new file mode 100644 index 000000000..bbfe83923 --- /dev/null +++ b/src/Policy/__Libraries/StellaOps.Policy.Exceptions/Services/ExceptionEvaluator.cs @@ -0,0 +1,275 @@ +using System.Text.RegularExpressions; +using StellaOps.Policy.Exceptions.Models; +using StellaOps.Policy.Exceptions.Repositories; + +namespace StellaOps.Policy.Exceptions.Services; + +/// +/// Result of exception evaluation for a finding. +/// +public sealed record ExceptionEvaluationResult +{ + /// Whether any active exception applies to this finding. + public bool HasException { get; init; } + + /// The matching exceptions (may be multiple). + public IReadOnlyList MatchingExceptions { get; init; } = []; + + /// Reason code from the most specific matching exception. + public ExceptionReason? PrimaryReason { get; init; } + + /// Rationale from the most specific matching exception. + public string? PrimaryRationale { get; init; } + + /// Evidence references from all matching exceptions. + public IReadOnlyList AllEvidenceRefs { get; init; } = []; + + /// + /// Creates a result indicating no exception applies. + /// + public static ExceptionEvaluationResult NoMatch => new() { HasException = false }; +} + +/// +/// Context for evaluating exceptions against a finding. +/// +public sealed record FindingContext +{ + /// Artifact digest (sha256:...) of the scanned artifact. + public string? ArtifactDigest { get; init; } + + /// PURL of the affected package. + public string? Purl { get; init; } + + /// Vulnerability ID (CVE-XXXX-XXXXX). + public string? VulnerabilityId { get; init; } + + /// Policy rule that flagged this finding. + public string? PolicyRuleId { get; init; } + + /// Environment where this finding was detected. + public string? Environment { get; init; } + + /// Tenant ID for RLS. + public Guid? TenantId { get; init; } +} + +/// +/// Interface for evaluating exceptions against findings. +/// +public interface IExceptionEvaluator +{ + /// + /// Evaluates whether any active exception applies to the given finding. + /// + /// Finding context to evaluate against. + /// Cancellation token. + /// Evaluation result with matching exceptions. + Task EvaluateAsync( + FindingContext context, + CancellationToken cancellationToken = default); + + /// + /// Evaluates multiple findings in batch. + /// + /// Finding contexts to evaluate. + /// Cancellation token. + /// Evaluation results keyed by context index. + Task> EvaluateBatchAsync( + IReadOnlyList contexts, + CancellationToken cancellationToken = default); +} + +/// +/// Implementation of exception evaluation logic. +/// +public sealed class ExceptionEvaluator : IExceptionEvaluator +{ + private readonly IExceptionRepository _repository; + + public ExceptionEvaluator(IExceptionRepository repository) + { + _repository = repository; + } + + /// + public async Task 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 + }; + } + + /// + public async Task> EvaluateBatchAsync( + IReadOnlyList contexts, + CancellationToken cancellationToken = default) + { + var results = new Dictionary(); + + // 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; + } + + /// + /// Determines if an exception matches the given finding context. + /// + 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; + } + + /// + /// Matches a PURL against a pattern that may contain wildcards. + /// + /// + /// Supported patterns: + /// - Exact: pkg:npm/lodash@4.17.21 + /// - Version wildcard: pkg:npm/lodash@* + /// - Package wildcard: pkg:npm/* + /// - Type wildcard: pkg:* + /// + 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); + } + + /// + /// Calculates specificity score for an exception (higher = more specific). + /// More specific exceptions take precedence. + /// + 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; + } +} diff --git a/src/Policy/__Libraries/StellaOps.Policy.Exceptions/StellaOps.Policy.Exceptions.csproj b/src/Policy/__Libraries/StellaOps.Policy.Exceptions/StellaOps.Policy.Exceptions.csproj new file mode 100644 index 000000000..76be4c4e5 --- /dev/null +++ b/src/Policy/__Libraries/StellaOps.Policy.Exceptions/StellaOps.Policy.Exceptions.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + enable + enable + preview + StellaOps.Policy.Exceptions + + + + + + +