diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 02af5407b..b3b53188f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,8 @@ "allow": [ "Bash(dotnet build:*)", "Bash(dotnet restore:*)", - "Bash(chmod:*)" + "Bash(chmod:*)", + "Bash(cat:*)" ], "deny": [], "ask": [] diff --git a/docs/implplan/SPRINT_0171_0001_0001_notifier_i.md b/docs/implplan/SPRINT_0171_0001_0001_notifier_i.md index 9e9afd4a4..d2e8e90be 100644 --- a/docs/implplan/SPRINT_0171_0001_0001_notifier_i.md +++ b/docs/implplan/SPRINT_0171_0001_0001_notifier_i.md @@ -22,7 +22,7 @@ | --- | --- | --- | --- | --- | --- | | P1 | PREP-NOTIFY-OBS-51-001-TELEMETRY-SLO-WEBHOOK | DONE (2025-11-19) | Telemetry SLO webhook schema published at `docs/notifications/slo-webhook-schema.md`; share with Telemetry Core for compatibility check. | Notifications Service Guild · Observability Guild | Frozen payload + canonical JSON + validation checklist delivered; ready for NOTIFY-OBS-51-001 implementation once CI restore succeeds. | | 1 | NOTIFY-ATTEST-74-001 | DONE (2025-11-16) | Attestor payload schema + localization tokens (due 2025-11-13). | Notifications Service Guild · Attestor Service Guild (`src/Notifier/StellaOps.Notifier`) | Create notification templates for verification failures, expiring attestations, key revocations, transparency anomalies. | -| 2 | NOTIFY-ATTEST-74-002 | TODO | Depends on 74-001. | Notifications Service Guild · KMS Guild | Wire notifications to key rotation/revocation events and transparency witness failures. | +| 2 | NOTIFY-ATTEST-74-002 | DONE (2025-11-27) | Depends on 74-001. | Notifications Service Guild · KMS Guild | Wire notifications to key rotation/revocation events and transparency witness failures. | | 3 | NOTIFY-OAS-61-001 | DONE (2025-11-17) | Complete OAS sections for quietHours/incident. | Notifications Service Guild · API Contracts Guild | Update Notifier OAS with rules, templates, incidents, quiet hours endpoints using standard error envelope + examples. | | 4 | NOTIFY-OAS-61-002 | DONE (2025-11-17) | Depends on 61-001. | Notifications Service Guild | Implement `/.well-known/openapi` discovery endpoint with scope metadata. | | 5 | NOTIFY-OAS-62-001 | DONE (2025-11-17) | Depends on 61-002. | Notifications Service Guild · SDK Generator Guild | SDK examples for rule CRUD, incident ack, quiet hours; SDK smoke tests. | diff --git a/docs/implplan/SPRINT_0174_0001_0001_telemetry.md b/docs/implplan/SPRINT_0174_0001_0001_telemetry.md index 62517d37a..35e459c22 100644 --- a/docs/implplan/SPRINT_0174_0001_0001_telemetry.md +++ b/docs/implplan/SPRINT_0174_0001_0001_telemetry.md @@ -27,13 +27,15 @@ | 1 | TELEMETRY-OBS-50-001 | DONE (2025-11-19) | Finalize bootstrap + sample host integration. | Telemetry Core Guild (`src/Telemetry/StellaOps.Telemetry.Core`) | Telemetry Core helper in place; sample host wiring + config published in `docs/observability/telemetry-bootstrap.md`. | | 2 | TELEMETRY-OBS-50-002 | DONE (2025-11-27) | Implementation complete; tests pending CI restore. | Telemetry Core Guild | Context propagation middleware/adapters for HTTP, gRPC, background jobs, CLI; carry `trace_id`, `tenant_id`, `actor`, imposed-rule metadata; async resume harness. Prep artefact: `docs/modules/telemetry/prep/2025-11-20-obs-50-002-prep.md`. | | 3 | TELEMETRY-OBS-51-001 | DONE (2025-11-27) | Implementation complete; tests pending CI restore. | Telemetry Core Guild · Observability Guild | Metrics helpers for golden signals with exemplar support and cardinality guards; Roslyn analyzer preventing unsanitised labels. Prep artefact: `docs/modules/telemetry/prep/2025-11-20-obs-51-001-prep.md`. | -| 4 | TELEMETRY-OBS-51-002 | BLOCKED (2025-11-20) | PREP-TELEMETRY-OBS-51-002-DEPENDS-ON-51-001 | Telemetry Core Guild · Security Guild | Redaction/scrubbing filters for secrets/PII at logger sink; per-tenant config with TTL; audit overrides; determinism tests. | -| 5 | TELEMETRY-OBS-55-001 | BLOCKED (2025-11-20) | Depends on TELEMETRY-OBS-51-002 and PREP-CLI-OBS-12-001-INCIDENT-TOGGLE-CONTRACT. | Telemetry Core Guild | Incident mode toggle API adjusting sampling, retention tags; activation trail; honored by hosting templates + feature flags. | -| 6 | TELEMETRY-OBS-56-001 | BLOCKED (2025-11-20) | PREP-TELEMETRY-OBS-56-001-DEPENDS-ON-55-001 | Telemetry Core Guild | Sealed-mode telemetry helpers (drift metrics, seal/unseal spans, offline exporters); disable external exporters when sealed. | +| 4 | TELEMETRY-OBS-51-002 | DONE | Implemented scrubbing with LogRedactor, per-tenant config, audit overrides, determinism tests. | Telemetry Core Guild · Security Guild | Redaction/scrubbing filters for secrets/PII at logger sink; per-tenant config with TTL; audit overrides; determinism tests. | +| 5 | TELEMETRY-OBS-55-001 | DONE (2025-11-27) | Implementation complete with unit tests. | Telemetry Core Guild | Incident mode toggle API adjusting sampling, retention tags; activation trail; honored by hosting templates + feature flags. | +| 6 | TELEMETRY-OBS-56-001 | DONE (2025-11-27) | Implementation complete with unit tests. | Telemetry Core Guild | Sealed-mode telemetry helpers (drift metrics, seal/unseal spans, offline exporters); disable external exporters when sealed. | ## Execution Log | Date (UTC) | Update | Owner | | --- | --- | --- | +| 2025-11-27 | Implemented TELEMETRY-OBS-56-001: Added `ISealedModeTelemetryService` interface, `SealedModeTelemetryOptions` (exporter type, file path, max bytes, sampling limits, force scrub), `SealedModeTelemetryService` with drift metrics, seal/unseal activity spans, external export blocking. Added `SealedModeFileExporter` for append-only OTLP file output with rotation (3-file ring buffer, 0600 permissions). DI registration via `AddSealedModeTelemetry()`. Unit tests in `SealedModeTelemetryServiceTests.cs` and `SealedModeFileExporterTests.cs`. | Telemetry Core Guild | +| 2025-11-27 | Implemented TELEMETRY-OBS-55-001: Added `IIncidentModeService` interface with activation/deactivation/TTL extension methods, `IncidentModeState` record, `IncidentModeOptions` (TTL min/max/default, sampling rate, persistence, audit events), `IncidentModeService` implementation with timer-based expiry, state persistence to `~/.stellaops/incident-mode.json`, CLI/config activation helpers. DI registration via `AddIncidentMode()`. Comprehensive unit tests in `IncidentModeServiceTests.cs`. | Telemetry Core Guild | | 2025-11-27 | Implemented TELEMETRY-OBS-50-002: Added `TelemetryContext`, `TelemetryContextAccessor` (AsyncLocal-based), `TelemetryContextPropagationMiddleware` (HTTP), `TelemetryContextPropagator` (DelegatingHandler), `TelemetryContextInjector` (gRPC/queue helpers), `TelemetryContextJobScope` (async resume harness). DI extensions added via `AddTelemetryContextPropagation()`. | Telemetry Core Guild | | 2025-11-27 | Implemented TELEMETRY-OBS-51-001: Added `GoldenSignalMetrics` (latency histogram, error/request counters, saturation gauge), `GoldenSignalMetricsOptions` (cardinality limits, exemplar toggle, prefix). Includes `MeasureLatency()` scope helper and `Tag()` factory. DI extensions added via `AddGoldenSignalMetrics()`. | Telemetry Core Guild | | 2025-11-27 | Added unit tests for context propagation (`TelemetryContextTests`, `TelemetryContextAccessorTests`) and golden signal metrics (`GoldenSignalMetricsTests`). Build/test blocked by NuGet restore (offline cache issue); implementation validated by code review. | Telemetry Core Guild | diff --git a/docs/implplan/SPRINT_0186_0001_0001_record_deterministic_execution.md b/docs/implplan/SPRINT_0186_0001_0001_record_deterministic_execution.md index 83b1d4215..9e5941abf 100644 --- a/docs/implplan/SPRINT_0186_0001_0001_record_deterministic_execution.md +++ b/docs/implplan/SPRINT_0186_0001_0001_record_deterministic_execution.md @@ -15,6 +15,9 @@ - docs/replay/DETERMINISTIC_REPLAY.md - docs/replay/TEST_STRATEGY.md - docs/modules/scanner/architecture.md +- docs/modules/sbomer/architecture.md (for SPDX 3.0.1 tasks) +- Product advisory: `docs/product-advisories/27-Nov-2025 - Deep Architecture Brief - SBOM‑First, VEX‑Ready Spine.md` (canonical for SPDX/VEX work) +- SPDX 3.0.1 specification: https://spdx.github.io/spdx-spec/v3.0.1/ ## Delivery Tracker | # | Task ID | Status | Key dependency / next step | Owners | Task Definition | @@ -33,12 +36,20 @@ | 12 | SCAN-ENTROPY-186-012 | TODO | Depends on 186-011. | Scanner Guild · Provenance Guild | Generate `entropy.report.json`, image-level penalties; attach evidence to manifests/attestations; expose ratios for policy engines. | | 13 | SCAN-CACHE-186-013 | TODO | Parallel with replay work. | Scanner Guild | Layer-level SBOM/VEX cache keyed by layer digest + manifest hash + tool/feed/policy IDs; re-verify DSSE on cache hits; persist indexes; document referencing 16-Nov-2026 advisory. | | 14 | SCAN-DIFF-CLI-186-014 | TODO | Depends on replay+cache scaffolding. | Scanner Guild · CLI Guild | Deterministic diff-aware rescan workflow (`scan.lock.json`, JSON Patch diffs, CLI verbs `stella scan --emit-diff` / `stella diff`); replayable tests; docs. | -| 15 | SBOM-BRIDGE-186-015 | TODO | Parallel; coordinate with Sbomer. | Sbomer Guild · Scanner Guild | Establish SPDX 3.0.1 as canonical SBOM persistence; deterministic CycloneDX 1.6 exporter; map table/library; wire snapshot hashes into replay manifests. | +| 15 | SBOM-BRIDGE-186-015 | TODO | Parallel; coordinate with Sbomer. | Sbomer Guild · Scanner Guild | Establish SPDX 3.0.1 as canonical SBOM persistence; deterministic CycloneDX 1.6 exporter; map table/library; wire snapshot hashes into replay manifests. See subtasks 15a-15f below. | +| 15a | SPDX-MODEL-186-015A | TODO | Foundational for SBOM-BRIDGE. | Sbomer Guild (`src/Sbomer/StellaOps.Sbomer.Spdx`) | Implement SPDX 3.0.1 data model: `SpdxDocument`, `Package`, `File`, `Snippet`, `Relationship`, `ExternalRef`, `Annotation`. Use SPDX 3.0.1 JSON-LD schema. | +| 15b | SPDX-SERIAL-186-015B | TODO | Depends on 15a. | Sbomer Guild | Implement SPDX 3.0.1 serializers/deserializers: JSON-LD (canonical), Tag-Value (legacy compat), RDF/XML (optional). Ensure deterministic output ordering. | +| 15c | CDX-MAP-186-015C | TODO | Depends on 15a. | Sbomer Guild (`src/Sbomer/StellaOps.Sbomer.CycloneDx`) | Build bidirectional SPDX 3.0.1 ↔ CycloneDX 1.6 mapping table: component→package, dependency→relationship, vulnerability→advisory. Document loss-of-fidelity cases. | +| 15d | SBOM-STORE-186-015D | TODO | Depends on 15a. | Sbomer Guild · Scanner Guild | MongoDB/CAS persistence for SPDX 3.0.1 documents; indexed by artifact digest, component PURL, document SPDXID. Enable efficient lookup for VEX correlation. | +| 15e | SBOM-HASH-186-015E | TODO | Depends on 15b, 15d. | Sbomer Guild | Implement SBOM content hash computation: canonical JSON → BLAKE3 hash; store as `sbom_content_hash` in replay manifests; enable deduplication. | +| 15f | SBOM-TESTS-186-015F | TODO | Depends on 15a-15e. | Sbomer Guild · QA Guild (`src/Sbomer/__Tests`) | Roundtrip tests: SPDX→CDX→SPDX with diff assertion; determinism tests (same input → same hash); SPDX 3.0.1 spec compliance validation. | | 16 | DOCS-REPLAY-186-004 | TODO | After replay schema settled. | Docs Guild | Author `docs/replay/TEST_STRATEGY.md` (golden replay, feed drift, tool upgrade); link from replay docs and Scanner architecture. | +| 17 | DOCS-SBOM-186-017 | TODO | Depends on 15a-15f. | Docs Guild (`docs/modules/sbomer/spdx-3.md`) | Document SPDX 3.0.1 implementation: data model, serialization formats, CDX mapping table, storage schema, hash computation, migration guide from SPDX 2.3. | ## Execution Log | Date (UTC) | Update | Owner | | --- | --- | --- | +| 2025-11-27 | Expanded SBOM-BRIDGE-186-015 with detailed subtasks (15a-15f) for SPDX 3.0.1 implementation per product advisory `27-Nov-2025 - Deep Architecture Brief - SBOM‑First, VEX‑Ready Spine.md`; added DOCS-SBOM-186-017 for documentation. | Product Mgmt | | 2025-11-26 | Wired record-mode attach helper into scan snapshots and replay status; added replay surface test (build run aborted mid-restore, rerun pending). | Scanner Guild | | 2025-11-26 | Marked SCAN-REPLAY-186-001 BLOCKED: WebService lacks access to sealed input/output bundles, feed/policy hashes, and manifest assembly outputs from Worker; need upstream pipeline contract to invoke attach helper with real artifacts. | Scanner Guild | | 2025-11-26 | Started SCAN-ENTROPY-186-011: added deterministic entropy calculator and unit tests; build/test run aborted during restore fan-out, rerun required. | Scanner Guild | @@ -54,6 +65,10 @@ - Signing/verification changes must stay aligned with Provenance library once available. - BLOCKER (186-001): WebService cannot assemble replay manifest/bundles without worker-provided inputs (sealed input/output bundles, feed/policy/tool hashes, CAS locations). Need pipeline contract and data flow from Worker to call the new replay attach helper. - RISK (186-011): Resolved — entropy utilities validated with passing unit tests. Proceed to pipeline integration and evidence emission. +- RISK (SPDX 3.0.1): SPDX 3.0.1 uses JSON-LD which has complex serialization rules; ensure canonical output for deterministic hashing. Reference spec carefully. +- DECISION (SPDX/CDX): SPDX 3.0.1 is canonical storage format; CycloneDX 1.6 is interchange format. Document loss-of-fidelity cases in mapping table (task 15c). ## Next Checkpoints - Kickoff after Replay Core scaffolding begins (date TBD). +- SPDX 3.0.1 data model review (Sbomer Guild, date TBD). +- CDX↔SPDX mapping table draft review (Sbomer Guild, date TBD). diff --git a/docs/implplan/SPRINT_0190_0001_0001_cvss_v4_receipts.md b/docs/implplan/SPRINT_0190_0001_0001_cvss_v4_receipts.md new file mode 100644 index 000000000..5179aa45e --- /dev/null +++ b/docs/implplan/SPRINT_0190_0001_0001_cvss_v4_receipts.md @@ -0,0 +1,74 @@ +# Sprint 0190 · CVSS v4.0 Score Receipts + +## Topic & Scope +- Implement CVSS v4.0 scoring engine with deterministic receipt generation. +- Store CVSS-BTE (Base + Threat + Environmental) scores with full audit trail. +- Enable policy-driven scoring with evidence linkage and DSSE attestations. +- **Working directory:** `src/Policy/StellaOps.Policy.Scoring` (new), `src/Signals/StellaOps.Signals`. + +## Dependencies & Concurrency +- Upstream: Sprint 0127/0128 Policy Engine observability; Sprint 0161 Evidence Locker. +- Concurrency: Data model and scoring engine can proceed in parallel; UI/CLI integration follows. +- Peers: Align with Concelier for vendor-provided CVSS v4.0 vectors; Excititor for VEX score context. + +## Documentation Prerequisites +- `docs/README.md` +- `docs/07_HIGH_LEVEL_ARCHITECTURE.md` +- `docs/modules/policy/architecture.md` +- `docs/modules/signals/architecture.md` +- Product advisory: `docs/product-advisories/25-Nov-2025 - Add CVSS v4.0 Score Receipts for Transparency.md` +- FIRST CVSS v4.0 Specification: https://www.first.org/cvss/v4-0/specification-document +- FIRST CVSS v4.0 Calculator: https://www.first.org/cvss/calculator/4-0 +- Module AGENTS.md: Create `src/Policy/StellaOps.Policy.Scoring/AGENTS.md` as part of task 1 + +## Delivery Tracker +| # | Task ID | Status | Key dependency / next step | Owners | Task Definition | +| --- | --- | --- | --- | --- | --- | +| 1 | CVSS-MODEL-190-001 | TODO | None; foundational. | Policy Guild · Signals Guild (`src/Policy/StellaOps.Policy.Scoring`) | Design and implement CVSS v4.0 data model: `CvssScoreReceipt`, `BaseMetrics`, `ThreatMetrics`, `EnvironmentalMetrics`, `SupplementalMetrics`, `EvidenceItem`, `CvssPolicy`, `ReceiptHistoryEntry`. Include EF Core mappings and MongoDB schema. | +| 2 | CVSS-ENGINE-190-002 | TODO | Depends on 190-001 for types. | Policy Guild (`src/Policy/StellaOps.Policy.Scoring/Engine`) | Implement `CvssV4Engine` with: `ParseVector()`, `ComputeBaseScore()`, `ComputeThreatAdjustedScore()`, `ComputeEnvironmentalAdjustedScore()`, `BuildVector()`. Follow FIRST spec v4.0 exactly for math/rounding. | +| 3 | CVSS-TESTS-190-003 | TODO | Depends on 190-002. | Policy Guild · QA Guild (`src/Policy/__Tests/StellaOps.Policy.Scoring.Tests`) | Unit tests for CVSS v4.0 engine using official FIRST sample vectors; edge cases for missing threat/env; determinism tests (same input → same output). | +| 4 | CVSS-POLICY-190-004 | TODO | Depends on 190-002. | Policy Guild (`src/Policy/StellaOps.Policy.Scoring/Policies`) | Implement `CvssPolicy` loader and validator: JSON schema for policy files, policy versioning, hash computation for determinism tracking. | +| 5 | CVSS-RECEIPT-190-005 | TODO | Depends on 190-002, 190-004. | Policy Guild (`src/Policy/StellaOps.Policy.Scoring/Receipts`) | Implement `ReceiptBuilder` service: `CreateReceipt(vulnId, input, policyId, userId)` that computes scores, builds vector, hashes inputs, and persists receipt with evidence links. | +| 6 | CVSS-DSSE-190-006 | TODO | Depends on 190-005; uses Attestor primitives. | Policy Guild · Attestor Guild (`src/Policy/StellaOps.Policy.Scoring`, `src/Attestor/StellaOps.Attestor.Envelope`) | Attach DSSE attestations to score receipts: create `stella.ops/cvssReceipt@v1` predicate type, sign receipts, store envelope references. | +| 7 | CVSS-HISTORY-190-007 | TODO | Depends on 190-005. | Policy Guild (`src/Policy/StellaOps.Policy.Scoring/History`) | Implement receipt amendment tracking: `AmendReceipt(receiptId, field, newValue, reason, ref)` with history entry creation and re-signing. | +| 8 | CVSS-CONCELIER-190-008 | TODO | Depends on 190-001; coordinate with Concelier. | Concelier Guild · Policy Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Ingest vendor-provided CVSS v4.0 vectors from advisories; parse and store as base receipts; preserve provenance. | +| 9 | CVSS-API-190-009 | TODO | Depends on 190-005, 190-007. | Policy Guild (`src/Policy/StellaOps.Policy.WebService`) | REST/gRPC APIs: `POST /cvss/receipts`, `GET /cvss/receipts/{id}`, `PUT /cvss/receipts/{id}/amend`, `GET /cvss/receipts/{id}/history`, `GET /cvss/policies`. | +| 10 | CVSS-CLI-190-010 | TODO | Depends on 190-009. | CLI Guild (`src/Cli/StellaOps.Cli`) | CLI verbs: `stella cvss score --vuln `, `stella cvss show `, `stella cvss history `, `stella cvss export --format json|pdf`. | +| 11 | CVSS-UI-190-011 | TODO | Depends on 190-009. | UI Guild (`src/UI/StellaOps.UI`) | UI components: Score badge with CVSS-BTE label, tabbed receipt viewer (Base/Threat/Environmental/Supplemental/Evidence/Policy/History), "Recalculate with my env" button, export options. | +| 12 | CVSS-DOCS-190-012 | TODO | Depends on 190-001 through 190-011. | Docs Guild (`docs/modules/policy/cvss-v4.md`, `docs/09_API_CLI_REFERENCE.md`) | Document CVSS v4.0 scoring system: data model, policy format, API reference, CLI usage, UI guide, determinism guarantees. | + +## Wave Coordination +| Wave | Guild owners | Shared prerequisites | Status | Notes | +| --- | --- | --- | --- | --- | +| W1 Foundation | Policy Guild | None | TODO | Tasks 1-4: Data model, engine, tests, policy loader. | +| W2 Receipt Pipeline | Policy Guild · Attestor Guild | W1 complete | TODO | Tasks 5-7: Receipt builder, DSSE, history. | +| W3 Integration | Concelier · Policy · CLI · UI Guilds | W2 complete | TODO | Tasks 8-11: Vendor ingest, APIs, CLI, UI. | +| W4 Documentation | Docs Guild | W3 complete | TODO | Task 12: Full documentation. | + +## Interlocks +- CVSS v4.0 vectors from Concelier must preserve vendor provenance (task 8 depends on Concelier ingestion patterns). +- DSSE attestation format must align with existing `stella.ops/*` predicate catalog (coordinate with Sprint 0401 AUTH-REACH tasks). +- Score receipts should integrate with VEX decisions in Excititor for complete vulnerability context. + +## Upcoming Checkpoints +- TBD: CVSS v4.0 data model review (Policy Guild). +- TBD: Engine implementation demo with FIRST test vectors (Policy Guild). +- TBD: UI wireframe review (UI Guild). + +## Action Tracker +| # | Action | Owner | Due (UTC) | Status | Notes | +| --- | --- | --- | --- | --- | --- | +| 1 | Review FIRST CVSS v4.0 spec and identify implementation gaps. | Policy Guild | TBD | Open | Reference: https://www.first.org/cvss/v4-0/ | +| 2 | Draft CvssPolicy JSON schema for team review. | Policy Guild | TBD | Open | | + +## Decisions & Risks +| ID | Risk | Impact | Mitigation / Owner | +| --- | --- | --- | --- | +| R1 | CVSS v4.0 spec complexity leads to implementation errors. | Incorrect scores, audit failures. | Use official FIRST test vectors; cross-check with FIRST calculator; Policy Guild. | +| R2 | Vendor advisories inconsistently provide v4.0 vectors. | Gaps in base scores; fallback to v3.1 conversion. | Implement v3.1→v4.0 heuristic mapping with explicit "converted" flag; Concelier Guild. | +| R3 | Receipt storage grows large with evidence links. | Storage costs; query performance. | Implement evidence reference deduplication; use CAS URIs; Platform Guild. | + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2025-11-27 | Sprint created from product advisory `25-Nov-2025 - Add CVSS v4.0 Score Receipts for Transparency.md`; 12 tasks defined across 4 waves. | Product Mgmt | diff --git a/docs/implplan/SPRINT_0209_0001_0001_ui_i.md b/docs/implplan/SPRINT_0209_0001_0001_ui_i.md index c5d05d7dc..a61d5c949 100644 --- a/docs/implplan/SPRINT_0209_0001_0001_ui_i.md +++ b/docs/implplan/SPRINT_0209_0001_0001_ui_i.md @@ -28,11 +28,11 @@ ## Delivery Tracker | # | Task ID | Status | Key dependency / next step | Owners | Task Definition | | --- | --- | --- | --- | --- | --- | -| 1 | UI-AOC-19-001 | TODO | Align tiles with AOC service metrics | UI Guild (src/UI/StellaOps.UI) | Add Sources dashboard tiles showing AOC pass/fail, recent violation codes, and ingest throughput per tenant. | -| 2 | UI-AOC-19-002 | TODO | UI-AOC-19-001 | UI Guild (src/UI/StellaOps.UI) | Implement violation drill-down view highlighting offending document fields and provenance metadata. | -| 3 | UI-AOC-19-003 | TODO | UI-AOC-19-002 | UI Guild (src/UI/StellaOps.UI) | Add "Verify last 24h" action triggering AOC verifier endpoint and surfacing CLI parity guidance. | -| 4 | UI-EXC-25-001 | TODO | - | UI Guild; Governance Guild (src/UI/StellaOps.UI) | Build Exception Center (list + kanban) with filters, sorting, workflow transitions, and audit views. | -| 5 | UI-EXC-25-002 | TODO | UI-EXC-25-001 | UI Guild (src/UI/StellaOps.UI) | Implement exception creation wizard with scope preview, justification templates, timebox guardrails. | +| 1 | UI-AOC-19-001 | DONE | Align tiles with AOC service metrics | UI Guild (src/UI/StellaOps.UI) | Add Sources dashboard tiles showing AOC pass/fail, recent violation codes, and ingest throughput per tenant. | +| 2 | UI-AOC-19-002 | DONE | UI-AOC-19-001 | UI Guild (src/UI/StellaOps.UI) | Implement violation drill-down view highlighting offending document fields and provenance metadata. | +| 3 | UI-AOC-19-003 | DONE | UI-AOC-19-002 | UI Guild (src/UI/StellaOps.UI) | Add "Verify last 24h" action triggering AOC verifier endpoint and surfacing CLI parity guidance. | +| 4 | UI-EXC-25-001 | DONE | - | UI Guild; Governance Guild (src/UI/StellaOps.UI) | Build Exception Center (list + kanban) with filters, sorting, workflow transitions, and audit views. | +| 5 | UI-EXC-25-002 | DONE | UI-EXC-25-001 | UI Guild (src/UI/StellaOps.UI) | Implement exception creation wizard with scope preview, justification templates, timebox guardrails. | | 6 | UI-EXC-25-003 | TODO | UI-EXC-25-002 | UI Guild (src/UI/StellaOps.UI) | Add inline exception drafting/proposing from Vulnerability Explorer and Graph detail panels with live simulation. | | 7 | UI-EXC-25-004 | TODO | UI-EXC-25-003 | UI Guild (src/UI/StellaOps.UI) | Surface exception badges, countdown timers, and explain integration across Graph/Vuln Explorer and policy views. | | 8 | UI-EXC-25-005 | TODO | UI-EXC-25-004 | UI Guild; Accessibility Guild (src/UI/StellaOps.UI) | Add keyboard shortcuts (`x`,`a`,`r`) and ensure screen-reader messaging for approvals/revocations. | @@ -43,10 +43,10 @@ | 13 | UI-GRAPH-24-004 | TODO | UI-GRAPH-24-003 | UI Guild (src/UI/StellaOps.UI) | Add side panels (Details, What-if, History) with upgrade simulation integration and SBOM diff viewer. | | 14 | UI-GRAPH-24-006 | TODO | UI-GRAPH-24-004 | UI Guild; Accessibility Guild (src/UI/StellaOps.UI) | Ensure accessibility (keyboard nav, screen reader labels, contrast), add hotkeys (`f`,`e`,`.`), and analytics instrumentation. | | 15 | UI-LNM-22-001 | TODO | - | UI Guild; Policy Guild (src/UI/StellaOps.UI) | Build Evidence panel showing policy decision with advisory observations/linksets side-by-side, conflict badges, AOC chain, and raw doc download links (DOCS-LNM-22-005 awaiting UI screenshots/flows). | -| 16 | UI-SBOM-DET-01 | TODO | - | UI Guild (src/UI/StellaOps.UI) | Add a "Determinism" badge plus drill-down surfacing fragment hashes, `_composition.json`, and Merkle root consistency when viewing scan details. | -| 17 | UI-POLICY-DET-01 | TODO | UI-SBOM-DET-01 | UI Guild; Policy Guild (src/UI/StellaOps.UI) | Wire policy gate indicators and remediation hints into Release/Policy flows, blocking publishes when determinism checks fail; coordinate with Policy Engine schema updates. | -| 18 | UI-ENTROPY-40-001 | TODO | - | UI Guild (src/UI/StellaOps.UI) | Visualise entropy analysis per image (layer donut, file heatmaps, "Why risky?" chips) in Vulnerability Explorer and scan details, including opaque byte ratios and detector hints. | -| 19 | UI-ENTROPY-40-002 | TODO | UI-ENTROPY-40-001 | UI Guild; Policy Guild (src/UI/StellaOps.UI) | Add policy banners/tooltips explaining entropy penalties (block/warn thresholds, mitigation steps) and link to raw `entropy.report.json` evidence downloads. | +| 16 | UI-SBOM-DET-01 | DONE | - | UI Guild (src/UI/StellaOps.UI) | Add a "Determinism" badge plus drill-down surfacing fragment hashes, `_composition.json`, and Merkle root consistency when viewing scan details. | +| 17 | UI-POLICY-DET-01 | DONE | UI-SBOM-DET-01 | UI Guild; Policy Guild (src/UI/StellaOps.UI) | Wire policy gate indicators and remediation hints into Release/Policy flows, blocking publishes when determinism checks fail; coordinate with Policy Engine schema updates. | +| 18 | UI-ENTROPY-40-001 | DONE | - | UI Guild (src/UI/StellaOps.UI) | Visualise entropy analysis per image (layer donut, file heatmaps, "Why risky?" chips) in Vulnerability Explorer and scan details, including opaque byte ratios and detector hints. | +| 19 | UI-ENTROPY-40-002 | DONE | UI-ENTROPY-40-001 | UI Guild; Policy Guild (src/UI/StellaOps.UI) | Add policy banners/tooltips explaining entropy penalties (block/warn thresholds, mitigation steps) and link to raw `entropy.report.json` evidence downloads. | ## Wave Coordination - Single-wave execution; coordinate with UI II/III only for shared component changes and accessibility tokens. @@ -92,3 +92,12 @@ | 2025-11-22 | Deduplicated `tasks-all.md` rows for this sprint (kept first occurrence per Task ID); no status changes. | Project mgmt | | 2025-11-08 | Archived completed/historic tasks to `docs/implplan/archived/tasks.md`. | Planning | | 2025-11-22 | Added SDK interlock (SPRINT_0208_0001_0001_sdk) and Action #5 for parity matrix delivery to UI data providers. | Project mgmt | +| 2025-11-27 | UI-AOC-19-001 DONE: Created Sources dashboard with AOC pass/fail tiles, violation codes, ingest throughput. Files: `aoc.models.ts`, `aoc.client.ts`, `sources-dashboard.component.{ts,html,scss}`. Added route at `/dashboard/sources`. | Claude Code | +| 2025-11-27 | UI-SBOM-DET-01 DONE: Created Determinism badge component with expandable details showing Merkle root, fragment hashes, composition metadata, and issues. Files: `determinism.models.ts`, `determinism-badge.component.{ts,html,scss}`. | Claude Code | +| 2025-11-27 | UI-ENTROPY-40-001 DONE: Created Entropy panel with score ring, layer donut chart, high-entropy files heatmap, and detector hint chips. Files: `entropy.models.ts`, `entropy-panel.component.{ts,html,scss}`. | Claude Code | +| 2025-11-27 | UI-AOC-19-002 DONE: Created violation drill-down with by-violation/by-document views, field highlighting, provenance metadata, and remediation hints. Extended `aoc.models.ts`, created `violation-drilldown.component.{ts,html,scss}`. | Claude Code | +| 2025-11-27 | UI-POLICY-DET-01 DONE: Created policy gate indicator with determinism/entropy details, blocking issue display, and remediation steps. Files: `policy.models.ts`, `policy-gate-indicator.component.{ts,html,scss}`. | Claude Code | +| 2025-11-27 | UI-ENTROPY-40-002 DONE: Created entropy policy banner with threshold visualization, score bar, mitigation steps, and evidence download. Files: `entropy-policy-banner.component.{ts,html,scss}`. | Claude Code | +| 2025-11-27 | UI-AOC-19-003 DONE: Created verify action component with progress, results display, CLI parity guidance panel. Files: `verify-action.component.{ts,html,scss}`. | Claude Code | +| 2025-11-27 | UI-EXC-25-001 DONE: Created Exception Center with list/kanban views, filters, sorting, workflow transitions, status chips. Files: `exception.models.ts`, `exception-center.component.{ts,html,scss}`. | Claude Code | +| 2025-11-27 | UI-EXC-25-002 DONE: Created Exception wizard with 5-step flow (type, scope, justification, timebox, review), templates, timebox presets. Files: `exception-wizard.component.{ts,html,scss}`. | Claude Code | diff --git a/docs/implplan/SPRINT_0513_0001_0001_public_reachability_benchmark.md b/docs/implplan/SPRINT_0513_0001_0001_public_reachability_benchmark.md new file mode 100644 index 000000000..262789b9e --- /dev/null +++ b/docs/implplan/SPRINT_0513_0001_0001_public_reachability_benchmark.md @@ -0,0 +1,85 @@ +# Sprint 0513 · Public Reachability Benchmark + +## Topic & Scope +- Create and publish a public benchmark for evaluating reachability analysis tools. +- Deliver reproducible dataset with ground-truth labels, deterministic builds, and scoring harness. +- Position Stella Ops as industry leader in deterministic vulnerability reachability. +- **Working directory:** `bench/reachability-benchmark/` (new public-facing repo structure). + +## Dependencies & Concurrency +- Upstream: Sprint 0401 Reachability Evidence Chain for internal reachability implementation. +- Upstream: Sprint 0512 Bench for internal performance benchmarks. +- Concurrency: Dataset creation (W1) can proceed in parallel with scorer development (W2). +- Peers: Marketing/PMM for launch messaging; Legal for licensing review. + +## Documentation Prerequisites +- `docs/README.md` +- `docs/07_HIGH_LEVEL_ARCHITECTURE.md` +- `docs/reachability/function-level-evidence.md` +- `docs/reachability/lattice.md` +- `docs/modules/scanner/architecture.md` +- Product advisory: `docs/product-advisories/24-Nov-2025 - Designing a Deterministic Reachability Benchmark.md` +- Related advisory: `docs/product-advisories/archived/23-Nov-2025 - Benchmarking Determinism in Vulnerability Scoring.md` +- Related advisory: `docs/product-advisories/archived/23-Nov-2025 - Publishing a Reachability Benchmark Dataset.md` +- Existing bench prep docs: `docs/benchmarks/signals/bench-determinism.md` + +## Delivery Tracker +| # | Task ID | Status | Key dependency / next step | Owners | Task Definition | +| --- | --- | --- | --- | --- | --- | +| 1 | BENCH-REPO-513-001 | TODO | None; foundational. | Bench Guild · DevOps Guild | Create public repository structure: `benchmark/cases///`, `benchmark/schemas/`, `benchmark/tools/scorer/`, `baselines/`, `ci/`, `website/`. Add LICENSE (Apache-2.0), README, CONTRIBUTING.md. | +| 2 | BENCH-SCHEMA-513-002 | TODO | Depends on 513-001. | Bench Guild | Define and publish schemas: `case.schema.yaml` (component, sink, label, evidence), `entrypoints.schema.yaml`, `truth.schema.yaml`, `submission.schema.json`. Include JSON Schema validation. | +| 3 | BENCH-CASES-JS-513-003 | TODO | Depends on 513-002. | Bench Guild · JS Track (`bench/reachability-benchmark/cases/js`) | Create 5-8 JavaScript/Node.js cases: 2 small (Express), 2 medium (Fastify/Koa), mix of reachable/unreachable. Include Dockerfiles, package-lock.json, unit test oracles, coverage output. | +| 4 | BENCH-CASES-PY-513-004 | TODO | Depends on 513-002. | Bench Guild · Python Track (`bench/reachability-benchmark/cases/py`) | Create 5-8 Python cases: Flask, Django, FastAPI. Include requirements.txt pinned, pytest oracles, coverage.py output. | +| 5 | BENCH-CASES-JAVA-513-005 | TODO | Depends on 513-002. | Bench Guild · Java Track (`bench/reachability-benchmark/cases/java`) | Create 5-8 Java cases: Spring Boot, Micronaut. Include pom.xml locked, JUnit oracles, JaCoCo coverage. | +| 6 | BENCH-CASES-C-513-006 | TODO | Depends on 513-002. | Bench Guild · Native Track (`bench/reachability-benchmark/cases/c`) | Create 3-5 C/ELF cases: small HTTP servers, crypto utilities. Include Makefile, gcov/llvm-cov coverage, deterministic builds (SOURCE_DATE_EPOCH). | +| 7 | BENCH-BUILD-513-007 | TODO | Depends on 513-003 through 513-006. | Bench Guild · DevOps Guild | Implement `build_all.py` and `validate_builds.py`: deterministic Docker builds, hash verification, SBOM generation (syft), attestation stubs. | +| 8 | BENCH-SCORER-513-008 | TODO | Depends on 513-002. | Bench Guild (`bench/reachability-benchmark/tools/scorer`) | Implement `rb-score` CLI: load cases/truth, validate submissions, compute precision/recall/F1, explainability score (0-3), runtime stats, determinism rate. | +| 9 | BENCH-EXPLAIN-513-009 | TODO | Depends on 513-008. | Bench Guild | Implement explainability scoring rules: 0=no context, 1=path with ≥2 nodes, 2=entry+≥3 nodes, 3=guards/constraints included. Unit tests for each level. | +| 10 | BENCH-BASELINE-SEMGREP-513-010 | TODO | Depends on 513-008 and cases. | Bench Guild | Semgrep baseline runner: `baselines/semgrep/run_case.sh`, rule config, output normalization to submission format. | +| 11 | BENCH-BASELINE-CODEQL-513-011 | TODO | Depends on 513-008 and cases. | Bench Guild | CodeQL baseline runner: database creation, reachability queries, output normalization. Document CodeQL license requirements. | +| 12 | BENCH-BASELINE-STELLA-513-012 | TODO | Depends on 513-008 and Sprint 0401 reachability. | Bench Guild · Scanner Guild | Stella Ops baseline runner: invoke `stella scan` with reachability, normalize output, demonstrate determinism advantage. | +| 13 | BENCH-CI-513-013 | TODO | Depends on 513-007, 513-008. | Bench Guild · DevOps Guild | GitHub Actions workflow: lint, test scorer, build cases, run smoke baselines, upload artifacts. | +| 14 | BENCH-LEADERBOARD-513-014 | TODO | Depends on 513-008. | Bench Guild | Implement `rb-score compare` to generate `leaderboard.json` from multiple submissions; breakdown by language and case size. | +| 15 | BENCH-WEBSITE-513-015 | TODO | Depends on 513-014. | UI Guild · Bench Guild (`bench/reachability-benchmark/website`) | Static website: home page, leaderboard rendering, docs (how to run, how to submit), download links. Use Docusaurus or plain HTML. | +| 16 | BENCH-DOCS-513-016 | TODO | Depends on all above. | Docs Guild | CONTRIBUTING.md, submission guide, governance doc (TAC roles, hidden test set rotation), quarterly update cadence. | +| 17 | BENCH-LAUNCH-513-017 | TODO | Depends on 513-015, 513-016. | Marketing · Product (`docs/marketing/`) | Launch materials: blog post announcing benchmark, comparison charts, "Provable Scoring Stability" positioning, social media assets. | + +## Wave Coordination +| Wave | Guild owners | Shared prerequisites | Status | Notes | +| --- | --- | --- | --- | --- | +| W1 Foundation | Bench Guild · DevOps Guild | None | TODO | Tasks 1-2: Repo, schemas. | +| W2 Dataset | Bench Guild (per language track) | W1 complete | TODO | Tasks 3-7: Cases, builds. | +| W3 Scoring | Bench Guild | W1 complete | TODO | Tasks 8-9: Scorer, explainability (parallel with W2). | +| W4 Baselines | Bench Guild · Scanner Guild | W2, W3 complete | TODO | Tasks 10-12: Semgrep, CodeQL, Stella. | +| W5 Publish | All Guilds | W4 complete | TODO | Tasks 13-17: CI, leaderboard, website, docs, launch. | + +## Interlocks +- Stella Ops baseline (task 12) requires Sprint 0401 reachability to be functional. +- Legal review needed for open-source licensing and third-party tool inclusion. +- Marketing coordination for launch timing and messaging. + +## Upcoming Checkpoints +- TBD: Schema review (Bench Guild). +- TBD: First 10 cases complete (language tracks). +- TBD: Scorer MVP demo (Bench Guild). +- TBD: Launch readiness review (Product + Marketing). + +## Action Tracker +| # | Action | Owner | Due (UTC) | Status | Notes | +| --- | --- | --- | --- | --- | --- | +| 1 | Select 8 seed projects (2 per language tier) for v1 cases. | Bench Guild | TBD | Open | | +| 2 | Draft 12 initial sink-cases with unit test oracles. | Language Tracks | TBD | Open | | +| 3 | Legal review of Apache-2.0 licensing for benchmark. | Legal | TBD | Open | | + +## Decisions & Risks +| ID | Risk | Impact | Mitigation / Owner | +| --- | --- | --- | --- | +| R1 | Case quality varies across language tracks. | Inconsistent benchmark validity. | Peer review all cases; require oracle tests; Bench Guild. | +| R2 | Baseline tools have licensing restrictions. | Cannot include in public benchmark. | Document license requirements; exclude or limit usage; Legal. | +| R3 | Hidden test set leakage. | Overfitting by vendors. | Rotate quarterly; governance controls; TAC. | +| R4 | Deterministic builds fail on some platforms. | Reproducibility claims undermined. | Pin all toolchain versions; use SOURCE_DATE_EPOCH; DevOps Guild. | + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2025-11-27 | Sprint created from product advisory `24-Nov-2025 - Designing a Deterministic Reachability Benchmark.md`; 17 tasks defined across 5 waves. | Product Mgmt | diff --git a/docs/implplan/SPRINT_172_notifier_ii.md b/docs/implplan/SPRINT_172_notifier_ii.md index 267babfa5..fd96fd873 100644 --- a/docs/implplan/SPRINT_172_notifier_ii.md +++ b/docs/implplan/SPRINT_172_notifier_ii.md @@ -7,17 +7,17 @@ Depends on: Sprint 170.A - Notifier.I Summary: Notifications & Telemetry focus on Notifier (phase II). Task ID | State | Task description | Owners (Source) --- | --- | --- | --- -NOTIFY-SVC-37-001 | TODO | Define pack approval & policy notification contract, including OpenAPI schema, event payloads, resume token mechanics, and security guidance. | Notifications Service Guild (src/Notifier/StellaOps.Notifier) -NOTIFY-SVC-37-002 | TODO | Implement secure ingestion endpoint, Mongo persistence (`pack_approvals`), idempotent writes, and audit trail for approval events. Dependencies: NOTIFY-SVC-37-001. | Notifications Service Guild (src/Notifier/StellaOps.Notifier) -NOTIFY-SVC-37-003 | TODO | Deliver approval/policy templates, routing predicates, and channel dispatch (email + webhook) with localization + redaction. Dependencies: NOTIFY-SVC-37-002. | Notifications Service Guild (src/Notifier/StellaOps.Notifier) -NOTIFY-SVC-37-004 | TODO | Provide acknowledgement API, Task Runner callback client, metrics for outstanding approvals, and runbook updates. Dependencies: NOTIFY-SVC-37-003. | Notifications Service Guild (src/Notifier/StellaOps.Notifier) -NOTIFY-SVC-38-002 | TODO | Implement channel adapters (email, chat webhook, generic webhook) with retry policies, health checks, and audit logging. Dependencies: NOTIFY-SVC-37-004. | Notifications Service Guild (src/Notifier/StellaOps.Notifier) -NOTIFY-SVC-38-003 | TODO | Deliver template service (versioned templates, localization scaffolding) and renderer with redaction allowlists, Markdown/HTML/JSON outputs, and provenance links. Dependencies: NOTIFY-SVC-38-002. | Notifications Service Guild (src/Notifier/StellaOps.Notifier) -NOTIFY-SVC-38-004 | TODO | Expose REST + WS APIs (rules CRUD, templates preview, incidents list, ack) with audit logging, RBAC checks, and live feed stream. Dependencies: NOTIFY-SVC-38-003. | Notifications Service Guild (src/Notifier/StellaOps.Notifier) -NOTIFY-SVC-39-001 | TODO | Implement correlation engine with pluggable key expressions/windows, throttler (token buckets), quiet hours/maintenance evaluator, and incident lifecycle. Dependencies: NOTIFY-SVC-38-004. | Notifications Service Guild (src/Notifier/StellaOps.Notifier) -NOTIFY-SVC-39-002 | TODO | Build digest generator (queries, formatting) with schedule runner and distribution via existing channels. Dependencies: NOTIFY-SVC-39-001. | Notifications Service Guild (src/Notifier/StellaOps.Notifier) -NOTIFY-SVC-39-003 | TODO | Provide simulation engine/API to dry-run rules against historical events, returning matched actions with explanations. Dependencies: NOTIFY-SVC-39-002. | Notifications Service Guild (src/Notifier/StellaOps.Notifier) -NOTIFY-SVC-39-004 | TODO | Integrate quiet hour calendars and default throttles with audit logging and operator overrides. Dependencies: NOTIFY-SVC-39-003. | Notifications Service Guild (src/Notifier/StellaOps.Notifier) +NOTIFY-SVC-37-001 | DONE (2025-11-27) | Define pack approval & policy notification contract, including OpenAPI schema, event payloads, resume token mechanics, and security guidance. | Notifications Service Guild (src/Notifier/StellaOps.Notifier) +NOTIFY-SVC-37-002 | DONE (2025-11-27) | Implement secure ingestion endpoint, Mongo persistence (`pack_approvals`), idempotent writes, and audit trail for approval events. Dependencies: NOTIFY-SVC-37-001. | Notifications Service Guild (src/Notifier/StellaOps.Notifier) +NOTIFY-SVC-37-003 | DONE (2025-11-27) | Deliver approval/policy templates, routing predicates, and channel dispatch (email + webhook) with localization + redaction. Dependencies: NOTIFY-SVC-37-002. | Notifications Service Guild (src/Notifier/StellaOps.Notifier) +NOTIFY-SVC-37-004 | DONE (2025-11-27) | Provide acknowledgement API, Task Runner callback client, metrics for outstanding approvals, and runbook updates. Dependencies: NOTIFY-SVC-37-003. | Notifications Service Guild (src/Notifier/StellaOps.Notifier) +NOTIFY-SVC-38-002 | DONE (2025-11-27) | Implement channel adapters (email, chat webhook, generic webhook) with retry policies, health checks, and audit logging. Dependencies: NOTIFY-SVC-37-004. | Notifications Service Guild (src/Notifier/StellaOps.Notifier) +NOTIFY-SVC-38-003 | DONE (2025-11-27) | Deliver template service (versioned templates, localization scaffolding) and renderer with redaction allowlists, Markdown/HTML/JSON outputs, and provenance links. Dependencies: NOTIFY-SVC-38-002. | Notifications Service Guild (src/Notifier/StellaOps.Notifier) +NOTIFY-SVC-38-004 | DONE (2025-11-27) | Expose REST + WS APIs (rules CRUD, templates preview, incidents list, ack) with audit logging, RBAC checks, and live feed stream. Dependencies: NOTIFY-SVC-38-003. | Notifications Service Guild (src/Notifier/StellaOps.Notifier) +NOTIFY-SVC-39-001 | DONE (2025-11-27) | Implement correlation engine with pluggable key expressions/windows, throttler (token buckets), quiet hours/maintenance evaluator, and incident lifecycle. Dependencies: NOTIFY-SVC-38-004. | Notifications Service Guild (src/Notifier/StellaOps.Notifier) +NOTIFY-SVC-39-002 | DONE (2025-11-27) | Build digest generator (queries, formatting) with schedule runner and distribution via existing channels. Dependencies: NOTIFY-SVC-39-001. | Notifications Service Guild (src/Notifier/StellaOps.Notifier) +NOTIFY-SVC-39-003 | DONE (2025-11-27) | Provide simulation engine/API to dry-run rules against historical events, returning matched actions with explanations. Dependencies: NOTIFY-SVC-39-002. | Notifications Service Guild (src/Notifier/StellaOps.Notifier) +NOTIFY-SVC-39-004 | DONE (2025-11-27) | Integrate quiet hour calendars and default throttles with audit logging and operator overrides. Dependencies: NOTIFY-SVC-39-003. | Notifications Service Guild (src/Notifier/StellaOps.Notifier) NOTIFY-SVC-40-001 | DONE (2025-11-27) | Implement escalations + on-call schedules, ack bridge, PagerDuty/OpsGenie adapters, and CLI/in-app inbox channels. Dependencies: NOTIFY-SVC-39-004. | Notifications Service Guild (src/Notifier/StellaOps.Notifier) NOTIFY-SVC-40-002 | DONE (2025-11-27) | Add summary storm breaker notifications, localization bundles, and localization fallback handling. Dependencies: NOTIFY-SVC-40-001. | Notifications Service Guild (src/Notifier/StellaOps.Notifier) NOTIFY-SVC-40-003 | SKIPPED | Harden security: signed ack links (KMS), webhook HMAC/IP allowlists, tenant isolation fuzz tests, HTML sanitization. Dependencies: NOTIFY-SVC-40-002. | Notifications Service Guild (src/Notifier/StellaOps.Notifier) diff --git a/docs/implplan/SPRINT_173_notifier_iii.md b/docs/implplan/SPRINT_173_notifier_iii.md index 4d16cd4e9..1bce32b3b 100644 --- a/docs/implplan/SPRINT_173_notifier_iii.md +++ b/docs/implplan/SPRINT_173_notifier_iii.md @@ -7,4 +7,4 @@ Depends on: Sprint 170.A - Notifier.II Summary: Notifications & Telemetry focus on Notifier (phase III). Task ID | State | Task description | Owners (Source) --- | --- | --- | --- -NOTIFY-TEN-48-001 | TODO | Tenant-scope rules/templates/incidents, RLS on storage, tenant-prefixed channels, and inclusion of tenant context in notifications. | Notifications Service Guild (src/Notifier/StellaOps.Notifier) \ No newline at end of file +NOTIFY-TEN-48-001 | DONE (2025-11-27) | Tenant-scope rules/templates/incidents, RLS on storage, tenant-prefixed channels, and inclusion of tenant context in notifications. | Notifications Service Guild (src/Notifier/StellaOps.Notifier) \ No newline at end of file diff --git a/docs/product-advisories/ADVISORY_INDEX.md b/docs/product-advisories/ADVISORY_INDEX.md new file mode 100644 index 000000000..8ab1d30a0 --- /dev/null +++ b/docs/product-advisories/ADVISORY_INDEX.md @@ -0,0 +1,130 @@ +# Product Advisory Index + +This index consolidates the November 2025 product advisories, identifying canonical documents and duplicates. + +## Canonical Advisories (Active) + +These are the authoritative advisories to reference for implementation: + +### CVSS v4.0 +- **Canonical:** `25-Nov-2025 - Add CVSS v4.0 Score Receipts for Transparency.md` +- **Sprint:** SPRINT_0190_0001_0001_cvss_v4_receipts.md +- **Status:** New sprint created + +### SBOM/VEX Pipeline +- **Canonical:** `27-Nov-2025 - Deep Architecture Brief - SBOM‑First, VEX‑Ready Spine.md` +- **Sprint:** SPRINT_0186_0001_0001_record_deterministic_execution.md (tasks 15a-15f) +- **Supersedes:** + - `24-Nov-2025 - Bridging OpenVEX and CycloneDX for .NET.md` → archive + - `25-Nov-2025 - Revisiting Determinism in SBOM→VEX Pipeline.md` → archive + - `26-Nov-2025 - From SBOM to VEX - Building a Transparent Chain.md` → archive + +### Rekor/DSSE Batch Sizing +- **Canonical:** `26-Nov-2025 - Handling Rekor v2 and DSSE Air‑Gap Limits.md` +- **Sprint:** SPRINT_0401_0001_0001_reachability_evidence_chain.md (DSSE tasks) +- **Supersedes:** + - `27-Nov-2025 - Rekor Envelope Size Heuristic.md` → archive (duplicate) + - `27-Nov-2025 - DSSE and Rekor Envelope Size Heuristic.md` → archive (duplicate) + - `27-Nov-2025 - Optimizing DSSE Batch Sizes for Reliable Logging.md` → archive (duplicate) + +### Graph Revision IDs +- **Canonical:** `26-Nov-2025 - Use Graph Revision IDs as Public Trust Anchors.md` +- **Sprint:** SPRINT_0401_0001_0001_reachability_evidence_chain.md (existing tasks) +- **Supersedes:** + - `25-Nov-2025 - Hash‑Stable Graph Revisions Across Systems.md` → archive (earlier version) + +### Reachability Benchmark (Public) +- **Canonical:** `24-Nov-2025 - Designing a Deterministic Reachability Benchmark.md` +- **Sprint:** SPRINT_0513_0001_0001_public_reachability_benchmark.md +- **Related:** + - `26-Nov-2025 - Opening Up a Reachability Dataset.md` → complementary (dataset focus) + +### Unknowns Registry +- **Canonical:** `27-Nov-2025 - Managing Ambiguity Through an Unknowns Registry.md` +- **Sprint:** SPRINT_0140_0001_0001_runtime_signals.md (existing implementation) +- **Extends:** `archived/18-Nov-2025 - Unknowns-Registry.md` +- **Status:** Already implemented in Signals module; advisory validates design + +### Explainability +- **Canonical (Graphs):** `27-Nov-2025 - Making Graphs Understandable to Humans.md` +- **Canonical (Verdicts):** `27-Nov-2025 - Explainability Layer for Vulnerability Verdicts.md` +- **Sprint:** SPRINT_0401_0001_0001_reachability_evidence_chain.md (UI-CLI tasks) +- **Status:** Complementary advisories - graphs cover edge reasons, verdicts cover audit trails + +### VEX Proofs +- **Canonical:** `25-Nov-2025 - Define Safe VEX 'Not Affected' Claims with Proofs.md` +- **Sprint:** SPRINT_0401_0001_0001_reachability_evidence_chain.md (POLICY-VEX tasks) + +### Binary Reachability +- **Canonical:** `27-Nov-2025 - Verifying Binary Reachability via DSSE Envelopes.md` +- **Sprint:** SPRINT_0401_0001_0001_reachability_evidence_chain.md (GRAPH-HYBRID tasks) + +### Scanner Roadmap +- **Canonical:** `27-Nov-2025 - Blueprint for a 2026‑Ready Scanner.md` +- **Sprint:** Multiple sprints (0186, 0401, 0512) +- **Status:** High-level roadmap document + +## Files to Archive + +The following files should be moved to `archived/` as they are superseded: + +``` +# Duplicates/superseded +24-Nov-2025 - Bridging OpenVEX and CycloneDX for .NET.md +25-Nov-2025 - Revisiting Determinism in SBOM→VEX Pipeline.md +25-Nov-2025 - Hash‑Stable Graph Revisions Across Systems.md +26-Nov-2025 - From SBOM to VEX - Building a Transparent Chain.md +27-Nov-2025 - Rekor Envelope Size Heuristic.md +27-Nov-2025 - DSSE and Rekor Envelope Size Heuristic.md +27-Nov-2025 - Optimizing DSSE Batch Sizes for Reliable Logging.md + +# Junk/malformed files +24-Nov-2025 - 1 copy 2.md +24-Nov-2025 - Designing a Deterministic Reachability Benchmarkmd (missing dot) +25-Nov-2025 - Half‑Life Confidence Decay for Unknownsmd (missing dot) +``` + +## Sprint Cross-Reference + +| Advisory Topic | Sprint ID | Status | +|---------------|-----------|--------| +| CVSS v4.0 | SPRINT_0190_0001_0001 | NEW | +| SPDX 3.0.1 / SBOM | SPRINT_0186_0001_0001 | AUGMENTED | +| Reachability Benchmark | SPRINT_0513_0001_0001 | NEW | +| Reachability Evidence | SPRINT_0401_0001_0001 | EXISTING | +| Unknowns Registry | SPRINT_0140_0001_0001 | EXISTING (implemented) | +| Graph Revision IDs | SPRINT_0401_0001_0001 | EXISTING | +| DSSE/Rekor Batching | SPRINT_0401_0001_0001 | EXISTING | + +## Implementation Priority + +Based on gap analysis: + +1. **P0 - CVSS v4.0** (Sprint 0190) - Industry moving to v4.0, genuine gap +2. **P1 - SPDX 3.0.1** (Sprint 0186 tasks 15a-15f) - Standards compliance +3. **P1 - Public Benchmark** (Sprint 0513) - Differentiation/marketing value +4. **P2 - Explainability** (Sprint 0401) - UX enhancement, existing tasks +5. **P3 - Already Implemented** - Unknowns, Graph IDs, DSSE batching + +## Implementer Quick Reference + +For each topic, the implementer should read: + +1. **Sprint file** - Contains task definitions, dependencies, working directories +2. **Documentation Prerequisites** - Listed in each sprint file +3. **Canonical advisory** - Full product context and rationale +4. **Module AGENTS.md** - If exists, contains module-specific coding guidance + +### Key Module Docs to Read Before Implementation + +| Module | Architecture Doc | AGENTS.md | +|--------|-----------------|-----------| +| Policy | `docs/modules/policy/architecture.md` | `src/Policy/*/AGENTS.md` | +| Scanner | `docs/modules/scanner/architecture.md` | `src/Scanner/*/AGENTS.md` | +| Sbomer | `docs/modules/sbomer/architecture.md` | `src/Sbomer/*/AGENTS.md` | +| Signals | `docs/modules/signals/architecture.md` | `src/Signals/*/AGENTS.md` | +| Attestor | `docs/modules/attestor/architecture.md` | `src/Attestor/*/AGENTS.md` | + +--- +*Index created: 2025-11-27* +*Last updated: 2025-11-27* diff --git a/docs/product-advisories/24-Nov-2025 - 1 copy 2.md b/docs/product-advisories/archived/27-Nov-2025-superseded/24-Nov-2025 - 1 copy 2.md similarity index 100% rename from docs/product-advisories/24-Nov-2025 - 1 copy 2.md rename to docs/product-advisories/archived/27-Nov-2025-superseded/24-Nov-2025 - 1 copy 2.md diff --git a/docs/product-advisories/24-Nov-2025 - Bridging OpenVEX and CycloneDX for .NET.md b/docs/product-advisories/archived/27-Nov-2025-superseded/24-Nov-2025 - Bridging OpenVEX and CycloneDX for .NET.md similarity index 100% rename from docs/product-advisories/24-Nov-2025 - Bridging OpenVEX and CycloneDX for .NET.md rename to docs/product-advisories/archived/27-Nov-2025-superseded/24-Nov-2025 - Bridging OpenVEX and CycloneDX for .NET.md diff --git a/docs/product-advisories/25-Nov-2025 - Hash‑Stable Graph Revisions Across Systems.md b/docs/product-advisories/archived/27-Nov-2025-superseded/25-Nov-2025 - Hash‑Stable Graph Revisions Across Systems.md similarity index 100% rename from docs/product-advisories/25-Nov-2025 - Hash‑Stable Graph Revisions Across Systems.md rename to docs/product-advisories/archived/27-Nov-2025-superseded/25-Nov-2025 - Hash‑Stable Graph Revisions Across Systems.md diff --git a/docs/product-advisories/25-Nov-2025 - Revisiting Determinism in SBOM→VEX Pipeline.md b/docs/product-advisories/archived/27-Nov-2025-superseded/25-Nov-2025 - Revisiting Determinism in SBOM→VEX Pipeline.md similarity index 100% rename from docs/product-advisories/25-Nov-2025 - Revisiting Determinism in SBOM→VEX Pipeline.md rename to docs/product-advisories/archived/27-Nov-2025-superseded/25-Nov-2025 - Revisiting Determinism in SBOM→VEX Pipeline.md diff --git a/docs/product-advisories/26-Nov-2025 - From SBOM to VEX - Building a Transparent Chain.md b/docs/product-advisories/archived/27-Nov-2025-superseded/26-Nov-2025 - From SBOM to VEX - Building a Transparent Chain.md similarity index 100% rename from docs/product-advisories/26-Nov-2025 - From SBOM to VEX - Building a Transparent Chain.md rename to docs/product-advisories/archived/27-Nov-2025-superseded/26-Nov-2025 - From SBOM to VEX - Building a Transparent Chain.md diff --git a/docs/product-advisories/27-Nov-2025 - DSSE and Rekor Envelope Size Heuristic.md b/docs/product-advisories/archived/27-Nov-2025-superseded/27-Nov-2025 - DSSE and Rekor Envelope Size Heuristic.md similarity index 100% rename from docs/product-advisories/27-Nov-2025 - DSSE and Rekor Envelope Size Heuristic.md rename to docs/product-advisories/archived/27-Nov-2025-superseded/27-Nov-2025 - DSSE and Rekor Envelope Size Heuristic.md diff --git a/docs/product-advisories/27-Nov-2025 - Optimizing DSSE Batch Sizes for Reliable Logging.md b/docs/product-advisories/archived/27-Nov-2025-superseded/27-Nov-2025 - Optimizing DSSE Batch Sizes for Reliable Logging.md similarity index 100% rename from docs/product-advisories/27-Nov-2025 - Optimizing DSSE Batch Sizes for Reliable Logging.md rename to docs/product-advisories/archived/27-Nov-2025-superseded/27-Nov-2025 - Optimizing DSSE Batch Sizes for Reliable Logging.md diff --git a/docs/product-advisories/27-Nov-2025 - Rekor Envelope Size Heuristic.md b/docs/product-advisories/archived/27-Nov-2025-superseded/27-Nov-2025 - Rekor Envelope Size Heuristic.md similarity index 100% rename from docs/product-advisories/27-Nov-2025 - Rekor Envelope Size Heuristic.md rename to docs/product-advisories/archived/27-Nov-2025-superseded/27-Nov-2025 - Rekor Envelope Size Heuristic.md diff --git a/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core.Tests/IncidentModeServiceTests.cs b/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core.Tests/IncidentModeServiceTests.cs new file mode 100644 index 000000000..41e6092bb --- /dev/null +++ b/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core.Tests/IncidentModeServiceTests.cs @@ -0,0 +1,718 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace StellaOps.Telemetry.Core.Tests; + +public sealed class IncidentModeServiceTests : IDisposable +{ + private readonly FakeTimeProvider _timeProvider; + private readonly Mock _contextAccessor; + private readonly Mock> _logger; + + public IncidentModeServiceTests() + { + _timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); + _contextAccessor = new Mock(); + _logger = new Mock>(); + } + + public void Dispose() + { + // Cleanup if needed + } + + private IncidentModeService CreateService(Action? configure = null) + { + var options = new IncidentModeOptions + { + PersistState = false, // Disable persistence for tests + RestoreOnStartup = false + }; + configure?.Invoke(options); + var monitor = new TestOptionsMonitor(options); + return new IncidentModeService(monitor, _contextAccessor.Object, _logger.Object, _timeProvider); + } + + [Fact] + public async Task ActivateAsync_ValidActor_ReturnsSuccess() + { + using var service = CreateService(); + + var result = await service.ActivateAsync("test-actor"); + + Assert.True(result.Success); + Assert.NotNull(result.State); + Assert.Equal("test-actor", result.State.Actor); + Assert.True(service.IsActive); + } + + [Fact] + public async Task ActivateAsync_NullActor_ThrowsArgumentException() + { + using var service = CreateService(); + + await Assert.ThrowsAsync(() => + service.ActivateAsync(null!)); + } + + [Fact] + public async Task ActivateAsync_EmptyActor_ThrowsArgumentException() + { + using var service = CreateService(); + + await Assert.ThrowsAsync(() => + service.ActivateAsync("")); + } + + [Fact] + public async Task ActivateAsync_WithTenantId_StoresTenantId() + { + using var service = CreateService(); + + var result = await service.ActivateAsync("actor", tenantId: "tenant-123"); + + Assert.True(result.Success); + Assert.NotNull(result.State); + Assert.Equal("tenant-123", result.State.TenantId); + } + + [Fact] + public async Task ActivateAsync_WithReason_StoresReason() + { + using var service = CreateService(); + + var result = await service.ActivateAsync("actor", reason: "Production incident INC-001"); + + Assert.True(result.Success); + Assert.NotNull(result.State); + Assert.Equal("Production incident INC-001", result.State.Reason); + } + + [Fact] + public async Task ActivateAsync_DefaultTtl_UsesConfiguredDefault() + { + using var service = CreateService(opt => opt.DefaultTtl = TimeSpan.FromMinutes(45)); + + var result = await service.ActivateAsync("actor"); + + Assert.True(result.Success); + Assert.NotNull(result.State); + var expectedExpiry = _timeProvider.GetUtcNow() + TimeSpan.FromMinutes(45); + Assert.Equal(expectedExpiry, result.State.ExpiresAt); + } + + [Fact] + public async Task ActivateAsync_CustomTtl_UsesTtlOverride() + { + using var service = CreateService(); + + var result = await service.ActivateAsync("actor", ttlOverride: TimeSpan.FromHours(2)); + + Assert.True(result.Success); + Assert.NotNull(result.State); + var expectedExpiry = _timeProvider.GetUtcNow() + TimeSpan.FromHours(2); + Assert.Equal(expectedExpiry, result.State.ExpiresAt); + } + + [Fact] + public async Task ActivateAsync_TtlBelowMin_ClampedToMin() + { + using var service = CreateService(opt => + { + opt.MinTtl = TimeSpan.FromMinutes(10); + }); + + var result = await service.ActivateAsync("actor", ttlOverride: TimeSpan.FromMinutes(1)); + + Assert.True(result.Success); + Assert.NotNull(result.State); + var expectedExpiry = _timeProvider.GetUtcNow() + TimeSpan.FromMinutes(10); + Assert.Equal(expectedExpiry, result.State.ExpiresAt); + } + + [Fact] + public async Task ActivateAsync_TtlAboveMax_ClampedToMax() + { + using var service = CreateService(opt => + { + opt.MaxTtl = TimeSpan.FromHours(4); + }); + + var result = await service.ActivateAsync("actor", ttlOverride: TimeSpan.FromHours(48)); + + Assert.True(result.Success); + Assert.NotNull(result.State); + var expectedExpiry = _timeProvider.GetUtcNow() + TimeSpan.FromHours(4); + Assert.Equal(expectedExpiry, result.State.ExpiresAt); + } + + [Fact] + public async Task ActivateAsync_AlreadyActive_ExtendsTtlAndReturnsWasAlreadyActive() + { + using var service = CreateService(); + + var firstResult = await service.ActivateAsync("actor1"); + var firstActivationId = firstResult.State!.ActivationId; + + var secondResult = await service.ActivateAsync("actor2"); + + Assert.True(secondResult.Success); + Assert.True(secondResult.WasAlreadyActive); + Assert.Equal(firstActivationId, secondResult.State!.ActivationId); // Same activation + } + + [Fact] + public async Task ActivateAsync_RaisesActivatedEvent() + { + using var service = CreateService(); + IncidentModeActivatedEventArgs? eventArgs = null; + service.Activated += (s, e) => eventArgs = e; + + await service.ActivateAsync("actor"); + + Assert.NotNull(eventArgs); + Assert.NotNull(eventArgs.State); + Assert.False(eventArgs.WasReactivation); + } + + [Fact] + public async Task ActivateAsync_WhenAlreadyActive_RaisesReactivationEvent() + { + using var service = CreateService(); + await service.ActivateAsync("actor1"); + + IncidentModeActivatedEventArgs? eventArgs = null; + service.Activated += (s, e) => eventArgs = e; + + await service.ActivateAsync("actor2"); + + Assert.NotNull(eventArgs); + Assert.True(eventArgs.WasReactivation); + } + + [Fact] + public async Task DeactivateAsync_WhenActive_ReturnsSuccessWithWasActive() + { + using var service = CreateService(); + await service.ActivateAsync("actor"); + + var result = await service.DeactivateAsync("deactivator"); + + Assert.True(result.Success); + Assert.True(result.WasActive); + Assert.Equal(IncidentModeDeactivationReason.Manual, result.Reason); + Assert.False(service.IsActive); + } + + [Fact] + public async Task DeactivateAsync_WhenNotActive_ReturnsSuccessWithWasNotActive() + { + using var service = CreateService(); + + var result = await service.DeactivateAsync("actor"); + + Assert.True(result.Success); + Assert.False(result.WasActive); + } + + [Fact] + public async Task DeactivateAsync_RaisesDeactivatedEvent() + { + using var service = CreateService(); + await service.ActivateAsync("actor"); + + IncidentModeDeactivatedEventArgs? eventArgs = null; + service.Deactivated += (s, e) => eventArgs = e; + + await service.DeactivateAsync("deactivator"); + + Assert.NotNull(eventArgs); + Assert.NotNull(eventArgs.State); + Assert.Equal(IncidentModeDeactivationReason.Manual, eventArgs.Reason); + Assert.Equal("deactivator", eventArgs.DeactivatedBy); + } + + [Fact] + public async Task ExtendTtlAsync_WhenActive_ExtendsExpiry() + { + using var service = CreateService(opt => + { + opt.AllowTtlExtension = true; + opt.DefaultTtl = TimeSpan.FromMinutes(30); + }); + await service.ActivateAsync("actor"); + var originalExpiry = service.CurrentState!.ExpiresAt; + + var newExpiry = await service.ExtendTtlAsync(TimeSpan.FromMinutes(15), "extender"); + + Assert.NotNull(newExpiry); + Assert.Equal(originalExpiry + TimeSpan.FromMinutes(15), newExpiry); + } + + [Fact] + public async Task ExtendTtlAsync_WhenNotActive_ReturnsNull() + { + using var service = CreateService(); + + var result = await service.ExtendTtlAsync(TimeSpan.FromMinutes(15), "actor"); + + Assert.Null(result); + } + + [Fact] + public async Task ExtendTtlAsync_WhenDisabled_ReturnsNull() + { + using var service = CreateService(opt => + { + opt.AllowTtlExtension = false; + }); + await service.ActivateAsync("actor"); + + var result = await service.ExtendTtlAsync(TimeSpan.FromMinutes(15), "actor"); + + Assert.Null(result); + } + + [Fact] + public async Task ExtendTtlAsync_ExceedsMaxExtensions_ReturnsNull() + { + using var service = CreateService(opt => + { + opt.AllowTtlExtension = true; + opt.MaxExtensions = 2; + }); + await service.ActivateAsync("actor"); + + await service.ExtendTtlAsync(TimeSpan.FromMinutes(5), "extender"); + await service.ExtendTtlAsync(TimeSpan.FromMinutes(5), "extender"); + var thirdExtension = await service.ExtendTtlAsync(TimeSpan.FromMinutes(5), "extender"); + + Assert.Null(thirdExtension); + } + + [Fact] + public async Task ExtendTtlAsync_WouldExceedMaxTtl_ClampedToMax() + { + using var service = CreateService(opt => + { + opt.AllowTtlExtension = true; + opt.DefaultTtl = TimeSpan.FromHours(23); + opt.MaxTtl = TimeSpan.FromHours(24); + }); + await service.ActivateAsync("actor"); + var activatedAt = service.CurrentState!.ActivatedAt; + + var result = await service.ExtendTtlAsync(TimeSpan.FromHours(10), "extender"); + + Assert.NotNull(result); + Assert.Equal(activatedAt + TimeSpan.FromHours(24), result); + } + + [Fact] + public async Task GetIncidentTags_WhenActive_ReturnsTagDictionary() + { + using var service = CreateService(opt => + { + opt.IncidentTagName = "incident_mode"; + }); + await service.ActivateAsync("actor", tenantId: "tenant-123"); + + var tags = service.GetIncidentTags(); + + Assert.NotEmpty(tags); + Assert.Equal("true", tags["incident_mode"]); + Assert.Equal("actor", tags["incident_actor"]); + Assert.Equal("tenant-123", tags["incident_tenant"]); + Assert.True(tags.ContainsKey("incident_activation_id")); + } + + [Fact] + public async Task GetIncidentTags_WhenNotActive_ReturnsEmptyDictionary() + { + using var service = CreateService(); + + var tags = service.GetIncidentTags(); + + Assert.Empty(tags); + } + + [Fact] + public async Task GetIncidentTags_WithAdditionalTags_IncludesThem() + { + using var service = CreateService(opt => + { + opt.AdditionalTags["environment"] = "production"; + opt.AdditionalTags["region"] = "us-east-1"; + }); + await service.ActivateAsync("actor"); + + var tags = service.GetIncidentTags(); + + Assert.Equal("production", tags["environment"]); + Assert.Equal("us-east-1", tags["region"]); + } + + [Fact] + public async Task CurrentState_WhenActive_ReturnsState() + { + using var service = CreateService(); + await service.ActivateAsync("actor"); + + var state = service.CurrentState; + + Assert.NotNull(state); + Assert.True(state.Enabled); + Assert.Equal("actor", state.Actor); + Assert.Equal(IncidentModeSource.Api, state.Source); + } + + [Fact] + public void CurrentState_WhenNotActive_ReturnsNull() + { + using var service = CreateService(); + + var state = service.CurrentState; + + Assert.Null(state); + } + + [Fact] + public void IsActive_WhenNotActivated_ReturnsFalse() + { + using var service = CreateService(); + + Assert.False(service.IsActive); + } + + [Fact] + public async Task IsActive_WhenActivated_ReturnsTrue() + { + using var service = CreateService(); + await service.ActivateAsync("actor"); + + Assert.True(service.IsActive); + } + + [Fact] + public async Task IsActive_WhenExpired_ReturnsFalse() + { + using var service = CreateService(opt => + { + opt.DefaultTtl = TimeSpan.FromMinutes(1); + }); + await service.ActivateAsync("actor"); + + // Advance time past expiry + _timeProvider.Advance(TimeSpan.FromMinutes(2)); + + Assert.False(service.IsActive); + } + + [Fact] + public async Task ActivateFromCliAsync_SetsSourceToCli() + { + using var service = CreateService(); + + var result = await service.ActivateFromCliAsync("cli-user"); + + Assert.True(result.Success); + Assert.NotNull(result.State); + Assert.Equal(IncidentModeSource.Cli, result.State.Source); + } + + [Fact] + public async Task ActivateFromConfigAsync_WhenEnabled_Activates() + { + using var service = CreateService(opt => + { + opt.Enabled = true; + }); + + var result = await service.ActivateFromConfigAsync(); + + Assert.True(result.Success); + Assert.NotNull(result.State); + Assert.Equal(IncidentModeSource.Configuration, result.State.Source); + } + + [Fact] + public async Task ActivateFromConfigAsync_WhenDisabled_FailsActivation() + { + using var service = CreateService(opt => + { + opt.Enabled = false; + }); + + var result = await service.ActivateFromConfigAsync(); + + Assert.False(result.Success); + Assert.NotNull(result.Error); + } + + [Fact] + public void IncidentModeOptions_Validate_ValidOptions_ReturnsNoErrors() + { + var options = new IncidentModeOptions(); + + var errors = options.Validate(); + + Assert.Empty(errors); + } + + [Fact] + public void IncidentModeOptions_Validate_DefaultTtlBelowMin_ReturnsError() + { + var options = new IncidentModeOptions + { + DefaultTtl = TimeSpan.FromMinutes(1), + MinTtl = TimeSpan.FromMinutes(5) + }; + + var errors = options.Validate(); + + Assert.Single(errors); + Assert.Contains("DefaultTtl", errors[0]); + } + + [Fact] + public void IncidentModeOptions_Validate_DefaultTtlAboveMax_ReturnsError() + { + var options = new IncidentModeOptions + { + DefaultTtl = TimeSpan.FromHours(48), + MaxTtl = TimeSpan.FromHours(24) + }; + + var errors = options.Validate(); + + Assert.Single(errors); + Assert.Contains("DefaultTtl", errors[0]); + } + + [Fact] + public void IncidentModeOptions_Validate_InvalidSamplingRate_ReturnsError() + { + var options = new IncidentModeOptions + { + IncidentSamplingRate = 1.5 + }; + + var errors = options.Validate(); + + Assert.Single(errors); + Assert.Contains("IncidentSamplingRate", errors[0]); + } + + [Fact] + public void IncidentModeOptions_Validate_NegativeMaxExtensions_ReturnsError() + { + var options = new IncidentModeOptions + { + MaxExtensions = -1 + }; + + var errors = options.Validate(); + + Assert.Single(errors); + Assert.Contains("MaxExtensions", errors[0]); + } + + [Fact] + public void IncidentModeOptions_ClampTtl_BelowMin_ReturnsMin() + { + var options = new IncidentModeOptions + { + MinTtl = TimeSpan.FromMinutes(10), + MaxTtl = TimeSpan.FromHours(24) + }; + + var result = options.ClampTtl(TimeSpan.FromMinutes(1)); + + Assert.Equal(TimeSpan.FromMinutes(10), result); + } + + [Fact] + public void IncidentModeOptions_ClampTtl_AboveMax_ReturnsMax() + { + var options = new IncidentModeOptions + { + MinTtl = TimeSpan.FromMinutes(5), + MaxTtl = TimeSpan.FromHours(4) + }; + + var result = options.ClampTtl(TimeSpan.FromHours(48)); + + Assert.Equal(TimeSpan.FromHours(4), result); + } + + [Fact] + public void IncidentModeOptions_ClampTtl_WithinRange_ReturnsSame() + { + var options = new IncidentModeOptions + { + MinTtl = TimeSpan.FromMinutes(5), + MaxTtl = TimeSpan.FromHours(24) + }; + + var result = options.ClampTtl(TimeSpan.FromHours(2)); + + Assert.Equal(TimeSpan.FromHours(2), result); + } + + [Fact] + public void IncidentModeState_IsExpired_BeforeExpiry_ReturnsFalse() + { + var state = new IncidentModeState + { + Enabled = true, + ActivatedAt = DateTimeOffset.UtcNow, + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1), + Actor = "test", + Source = IncidentModeSource.Api, + ActivationId = "abc123" + }; + + Assert.False(state.IsExpired); + } + + [Fact] + public void IncidentModeState_IsExpired_AfterExpiry_ReturnsTrue() + { + var state = new IncidentModeState + { + Enabled = true, + ActivatedAt = DateTimeOffset.UtcNow.AddHours(-2), + ExpiresAt = DateTimeOffset.UtcNow.AddHours(-1), + Actor = "test", + Source = IncidentModeSource.Api, + ActivationId = "abc123" + }; + + Assert.True(state.IsExpired); + } + + [Fact] + public void IncidentModeState_RemainingTime_WhenNotExpired_ReturnsPositive() + { + var state = new IncidentModeState + { + Enabled = true, + ActivatedAt = DateTimeOffset.UtcNow, + ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30), + Actor = "test", + Source = IncidentModeSource.Api, + ActivationId = "abc123" + }; + + Assert.True(state.RemainingTime > TimeSpan.Zero); + } + + [Fact] + public void IncidentModeState_RemainingTime_WhenExpired_ReturnsZero() + { + var state = new IncidentModeState + { + Enabled = true, + ActivatedAt = DateTimeOffset.UtcNow.AddHours(-2), + ExpiresAt = DateTimeOffset.UtcNow.AddHours(-1), + Actor = "test", + Source = IncidentModeSource.Api, + ActivationId = "abc123" + }; + + Assert.Equal(TimeSpan.Zero, state.RemainingTime); + } + + [Fact] + public void IncidentModeActivationResult_Succeeded_CreatesSuccessResult() + { + var state = new IncidentModeState + { + Enabled = true, + ActivatedAt = DateTimeOffset.UtcNow, + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1), + Actor = "test", + Source = IncidentModeSource.Api, + ActivationId = "abc123" + }; + + var result = IncidentModeActivationResult.Succeeded(state, wasAlreadyActive: true); + + Assert.True(result.Success); + Assert.Same(state, result.State); + Assert.True(result.WasAlreadyActive); + Assert.Null(result.Error); + } + + [Fact] + public void IncidentModeActivationResult_Failed_CreatesFailureResult() + { + var result = IncidentModeActivationResult.Failed("Test error message"); + + Assert.False(result.Success); + Assert.Null(result.State); + Assert.Equal("Test error message", result.Error); + } + + [Fact] + public void IncidentModeDeactivationResult_Succeeded_CreatesSuccessResult() + { + var result = IncidentModeDeactivationResult.Succeeded(wasActive: true, IncidentModeDeactivationReason.Manual); + + Assert.True(result.Success); + Assert.True(result.WasActive); + Assert.Equal(IncidentModeDeactivationReason.Manual, result.Reason); + Assert.Null(result.Error); + } + + [Fact] + public void IncidentModeDeactivationResult_Failed_CreatesFailureResult() + { + var result = IncidentModeDeactivationResult.Failed("Test error"); + + Assert.False(result.Success); + Assert.Equal("Test error", result.Error); + } + + private sealed class TestOptionsMonitor : IOptionsMonitor + { + private readonly T _value; + + public TestOptionsMonitor(T value) + { + _value = value; + } + + public T CurrentValue => _value; + public T Get(string? name) => _value; + public IDisposable? OnChange(Action listener) => null; + } + + private sealed class FakeTimeProvider : TimeProvider + { + private DateTimeOffset _utcNow; + + public FakeTimeProvider(DateTimeOffset initialTime) + { + _utcNow = initialTime; + } + + public override DateTimeOffset GetUtcNow() => _utcNow; + + public void Advance(TimeSpan duration) + { + _utcNow = _utcNow.Add(duration); + } + + public void SetUtcNow(DateTimeOffset time) + { + _utcNow = time; + } + } +} diff --git a/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core.Tests/SealedModeFileExporterTests.cs b/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core.Tests/SealedModeFileExporterTests.cs new file mode 100644 index 000000000..b7b504d25 --- /dev/null +++ b/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core.Tests/SealedModeFileExporterTests.cs @@ -0,0 +1,288 @@ +using System; +using System.IO; +using System.Text; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace StellaOps.Telemetry.Core.Tests; + +public sealed class SealedModeFileExporterTests : IDisposable +{ + private readonly string _testDirectory; + private readonly Mock> _logger; + private readonly FakeTimeProvider _timeProvider; + + public SealedModeFileExporterTests() + { + _testDirectory = Path.Combine(Path.GetTempPath(), $"sealed-mode-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_testDirectory); + _logger = new Mock>(); + _timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 11, 27, 10, 0, 0, TimeSpan.Zero)); + } + + public void Dispose() + { + try + { + if (Directory.Exists(_testDirectory)) + { + Directory.Delete(_testDirectory, recursive: true); + } + } + catch + { + // Ignore cleanup errors in tests + } + } + + private SealedModeFileExporter CreateExporter(Action? configure = null) + { + var options = new SealedModeTelemetryOptions + { + FilePath = Path.Combine(_testDirectory, "telemetry-sealed.otlp"), + MaxBytes = 1024, // Small for testing + MaxRotatedFiles = 3, + FailOnInsecurePermissions = false // Disable for cross-platform testing + }; + configure?.Invoke(options); + var monitor = new TestOptionsMonitor(options); + return new SealedModeFileExporter(monitor, _logger.Object, _timeProvider); + } + + [Fact] + public void Initialize_CreatesFile() + { + using var exporter = CreateExporter(); + + exporter.Initialize(); + + Assert.True(exporter.IsInitialized); + Assert.NotNull(exporter.CurrentFilePath); + Assert.True(File.Exists(exporter.CurrentFilePath)); + } + + [Fact] + public void Initialize_CreatesDirectory_WhenNotExists() + { + var newDir = Path.Combine(_testDirectory, "subdir", "nested"); + using var exporter = CreateExporter(opt => + { + opt.FilePath = Path.Combine(newDir, "telemetry.otlp"); + }); + + exporter.Initialize(); + + Assert.True(Directory.Exists(newDir)); + } + + [Fact] + public void Initialize_CalledMultipleTimes_DoesNotThrow() + { + using var exporter = CreateExporter(); + + exporter.Initialize(); + exporter.Initialize(); + + Assert.True(exporter.IsInitialized); + } + + [Fact] + public void Write_WritesDataToFile() + { + using var exporter = CreateExporter(); + exporter.Initialize(); + var data = Encoding.UTF8.GetBytes("test data"); + + exporter.Write(data, TelemetrySignal.Traces); + + var fileContent = File.ReadAllText(exporter.CurrentFilePath!); + Assert.Contains("test data", fileContent); + Assert.Contains("[Traces]", fileContent); + } + + [Fact] + public void Write_IncludesTimestamp() + { + using var exporter = CreateExporter(); + exporter.Initialize(); + var data = Encoding.UTF8.GetBytes("test"); + + exporter.Write(data, TelemetrySignal.Traces); + + var fileContent = File.ReadAllText(exporter.CurrentFilePath!); + Assert.Contains("2025-11-27", fileContent); + } + + [Fact] + public void Write_AutoInitializesIfNotCalled() + { + using var exporter = CreateExporter(); + var data = Encoding.UTF8.GetBytes("auto-init test"); + + exporter.Write(data, TelemetrySignal.Metrics); + + Assert.True(exporter.IsInitialized); + var fileContent = File.ReadAllText(exporter.CurrentFilePath!); + Assert.Contains("auto-init test", fileContent); + } + + [Fact] + public void WriteRecord_WritesStringData() + { + using var exporter = CreateExporter(); + exporter.Initialize(); + + exporter.WriteRecord("string record data", TelemetrySignal.Logs); + + var fileContent = File.ReadAllText(exporter.CurrentFilePath!); + Assert.Contains("string record data", fileContent); + Assert.Contains("[Logs]", fileContent); + } + + [Fact] + public void Write_RotatesFile_WhenMaxBytesExceeded() + { + using var exporter = CreateExporter(opt => + { + opt.MaxBytes = 100; // Very small for testing rotation + }); + exporter.Initialize(); + var filePath = exporter.CurrentFilePath!; + + // Write enough data to trigger rotation + for (var i = 0; i < 5; i++) + { + exporter.WriteRecord($"Record {i} with some padding data to exceed limit", TelemetrySignal.Traces); + } + + // Check that rotation happened - original file should exist + Assert.True(File.Exists(filePath)); + // And at least one rotated file + Assert.True(File.Exists($"{filePath}.1") || exporter.CurrentSize < 100); + } + + [Fact] + public void CurrentSize_TracksWrittenBytes() + { + using var exporter = CreateExporter(); + exporter.Initialize(); + var initialSize = exporter.CurrentSize; + var data = Encoding.UTF8.GetBytes("test data for size tracking"); + + exporter.Write(data, TelemetrySignal.Traces); + + Assert.True(exporter.CurrentSize > initialSize); + } + + [Fact] + public void Flush_DoesNotThrow() + { + using var exporter = CreateExporter(); + exporter.Initialize(); + exporter.WriteRecord("data", TelemetrySignal.Traces); + + exporter.Flush(); + + // Should not throw + } + + [Fact] + public void Write_AfterDispose_ThrowsObjectDisposedException() + { + var exporter = CreateExporter(); + exporter.Initialize(); + exporter.Dispose(); + + Assert.Throws(() => + exporter.Write(Encoding.UTF8.GetBytes("test"), TelemetrySignal.Traces)); + } + + [Fact] + public void Initialize_WithEmptyFilePath_Throws() + { + using var exporter = CreateExporter(opt => + { + opt.FilePath = ""; + }); + + Assert.Throws(() => exporter.Initialize()); + } + + [Fact] + public void Write_DifferentSignals_IncludesSignalType() + { + using var exporter = CreateExporter(); + exporter.Initialize(); + + exporter.WriteRecord("traces data", TelemetrySignal.Traces); + exporter.WriteRecord("metrics data", TelemetrySignal.Metrics); + exporter.WriteRecord("logs data", TelemetrySignal.Logs); + + var fileContent = File.ReadAllText(exporter.CurrentFilePath!); + Assert.Contains("[Traces]", fileContent); + Assert.Contains("[Metrics]", fileContent); + Assert.Contains("[Logs]", fileContent); + } + + [Fact] + public void Rotation_DeletesOldestFile_WhenMaxRotatedFilesExceeded() + { + using var exporter = CreateExporter(opt => + { + opt.MaxBytes = 50; + opt.MaxRotatedFiles = 2; + }); + exporter.Initialize(); + var basePath = exporter.CurrentFilePath!; + + // Write enough to trigger multiple rotations + for (var i = 0; i < 10; i++) + { + exporter.WriteRecord($"Record {i} with padding to exceed", TelemetrySignal.Traces); + } + + // Should not have more than MaxRotatedFiles rotated files + var rotatedFiles = 0; + for (var i = 1; i <= 5; i++) + { + if (File.Exists($"{basePath}.{i}")) + { + rotatedFiles++; + } + } + Assert.True(rotatedFiles <= 2); + } + + private sealed class TestOptionsMonitor : IOptionsMonitor + { + private readonly T _value; + + public TestOptionsMonitor(T value) + { + _value = value; + } + + public T CurrentValue => _value; + public T Get(string? name) => _value; + public IDisposable? OnChange(Action listener) => null; + } + + private sealed class FakeTimeProvider : TimeProvider + { + private DateTimeOffset _utcNow; + + public FakeTimeProvider(DateTimeOffset initialTime) + { + _utcNow = initialTime; + } + + public override DateTimeOffset GetUtcNow() => _utcNow; + + public void Advance(TimeSpan duration) + { + _utcNow = _utcNow.Add(duration); + } + } +} diff --git a/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core.Tests/SealedModeTelemetryServiceTests.cs b/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core.Tests/SealedModeTelemetryServiceTests.cs new file mode 100644 index 000000000..9aeea3601 --- /dev/null +++ b/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core.Tests/SealedModeTelemetryServiceTests.cs @@ -0,0 +1,509 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using StellaOps.AirGap.Policy; +using Xunit; + +namespace StellaOps.Telemetry.Core.Tests; + +public sealed class SealedModeTelemetryServiceTests : IDisposable +{ + private readonly FakeTimeProvider _timeProvider; + private readonly Mock _egressPolicy; + private readonly Mock _incidentModeService; + private readonly Mock> _logger; + + public SealedModeTelemetryServiceTests() + { + _timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); + _egressPolicy = new Mock(); + _incidentModeService = new Mock(); + _logger = new Mock>(); + } + + public void Dispose() + { + // Cleanup if needed + } + + private SealedModeTelemetryService CreateService( + Action? configure = null, + bool useEgressPolicy = false) + { + var options = new SealedModeTelemetryOptions(); + configure?.Invoke(options); + var monitor = new TestOptionsMonitor(options); + + return new SealedModeTelemetryService( + monitor, + useEgressPolicy ? _egressPolicy.Object : null, + _incidentModeService.Object, + _logger.Object, + _timeProvider); + } + + [Fact] + public void IsSealed_WhenOptionsEnabled_ReturnsTrue() + { + using var service = CreateService(opt => opt.Enabled = true); + + Assert.True(service.IsSealed); + } + + [Fact] + public void IsSealed_WhenOptionsDisabled_ReturnsFalse() + { + using var service = CreateService(opt => opt.Enabled = false); + + Assert.False(service.IsSealed); + } + + [Fact] + public void IsSealed_WhenEgressPolicySealed_ReturnsTrue() + { + _egressPolicy.Setup(p => p.IsSealed).Returns(true); + using var service = CreateService(opt => opt.Enabled = false, useEgressPolicy: true); + + Assert.True(service.IsSealed); + } + + [Fact] + public void IsSealed_WhenEgressPolicyNotSealed_ReturnsFalse() + { + _egressPolicy.Setup(p => p.IsSealed).Returns(false); + using var service = CreateService(opt => opt.Enabled = true, useEgressPolicy: true); + + Assert.False(service.IsSealed); + } + + [Fact] + public void EffectiveSamplingRate_WhenNotSealed_ReturnsFullSampling() + { + using var service = CreateService(opt => opt.Enabled = false); + + Assert.Equal(1.0, service.EffectiveSamplingRate); + } + + [Fact] + public void EffectiveSamplingRate_WhenSealed_ReturnsMaxPercent() + { + using var service = CreateService(opt => + { + opt.Enabled = true; + opt.MaxSamplingPercent = 10; + }); + + Assert.Equal(0.1, service.EffectiveSamplingRate); + } + + [Fact] + public void EffectiveSamplingRate_WhenSealedWithIncidentMode_ReturnsFullSampling() + { + _incidentModeService.Setup(s => s.IsActive).Returns(true); + using var service = CreateService(opt => + { + opt.Enabled = true; + opt.MaxSamplingPercent = 10; + opt.AllowIncidentModeOverride = true; + }); + + Assert.Equal(1.0, service.EffectiveSamplingRate); + } + + [Fact] + public void EffectiveSamplingRate_WhenSealedWithDisabledIncidentOverride_ReturnsCapped() + { + _incidentModeService.Setup(s => s.IsActive).Returns(true); + using var service = CreateService(opt => + { + opt.Enabled = true; + opt.MaxSamplingPercent = 10; + opt.AllowIncidentModeOverride = false; + }); + + Assert.Equal(0.1, service.EffectiveSamplingRate); + } + + [Fact] + public void IsIncidentModeOverrideActive_WhenConditionsMet_ReturnsTrue() + { + _incidentModeService.Setup(s => s.IsActive).Returns(true); + using var service = CreateService(opt => + { + opt.Enabled = true; + opt.AllowIncidentModeOverride = true; + }); + + Assert.True(service.IsIncidentModeOverrideActive); + } + + [Fact] + public void IsIncidentModeOverrideActive_WhenNotSealed_ReturnsFalse() + { + _incidentModeService.Setup(s => s.IsActive).Returns(true); + using var service = CreateService(opt => + { + opt.Enabled = false; + opt.AllowIncidentModeOverride = true; + }); + + Assert.False(service.IsIncidentModeOverrideActive); + } + + [Fact] + public void IsIncidentModeOverrideActive_WhenIncidentNotActive_ReturnsFalse() + { + _incidentModeService.Setup(s => s.IsActive).Returns(false); + using var service = CreateService(opt => + { + opt.Enabled = true; + opt.AllowIncidentModeOverride = true; + }); + + Assert.False(service.IsIncidentModeOverrideActive); + } + + [Fact] + public void GetSealedModeTags_WhenNotSealed_ReturnsEmpty() + { + using var service = CreateService(opt => opt.Enabled = false); + + var tags = service.GetSealedModeTags(); + + Assert.Empty(tags); + } + + [Fact] + public void GetSealedModeTags_WhenSealed_ReturnsSealedTag() + { + using var service = CreateService(opt => + { + opt.Enabled = true; + opt.SealedTagName = "sealed"; + }); + + var tags = service.GetSealedModeTags(); + + Assert.Equal("true", tags["sealed"]); + } + + [Fact] + public void GetSealedModeTags_WhenSealedWithForceScrub_ReturnsScrubbedTag() + { + using var service = CreateService(opt => + { + opt.Enabled = true; + opt.ForceScrub = true; + opt.AddScrubbedTag = true; + }); + + var tags = service.GetSealedModeTags(); + + Assert.Equal("true", tags["scrubbed"]); + } + + [Fact] + public void GetSealedModeTags_WhenSealedWithIncidentOverride_ReturnsOverrideTag() + { + _incidentModeService.Setup(s => s.IsActive).Returns(true); + using var service = CreateService(opt => + { + opt.Enabled = true; + opt.AllowIncidentModeOverride = true; + }); + + var tags = service.GetSealedModeTags(); + + Assert.Equal("true", tags["incident_override"]); + } + + [Fact] + public void GetSealedModeTags_WithAdditionalTags_IncludesThem() + { + using var service = CreateService(opt => + { + opt.Enabled = true; + opt.AdditionalTags["environment"] = "production"; + opt.AdditionalTags["region"] = "us-east-1"; + }); + + var tags = service.GetSealedModeTags(); + + Assert.Equal("production", tags["environment"]); + Assert.Equal("us-east-1", tags["region"]); + } + + [Fact] + public void IsExternalExportAllowed_WhenNotSealed_ReturnsTrue() + { + using var service = CreateService(opt => opt.Enabled = false); + var endpoint = new Uri("https://collector.example.com"); + + var allowed = service.IsExternalExportAllowed(endpoint); + + Assert.True(allowed); + } + + [Fact] + public void IsExternalExportAllowed_WhenSealed_ReturnsFalse() + { + using var service = CreateService(opt => opt.Enabled = true); + var endpoint = new Uri("https://collector.example.com"); + + var allowed = service.IsExternalExportAllowed(endpoint); + + Assert.False(allowed); + } + + [Fact] + public void GetLocalExporterConfig_WhenNotSealed_ReturnsNull() + { + using var service = CreateService(opt => opt.Enabled = false); + + var config = service.GetLocalExporterConfig(); + + Assert.Null(config); + } + + [Fact] + public void GetLocalExporterConfig_WhenSealed_ReturnsConfig() + { + using var service = CreateService(opt => + { + opt.Enabled = true; + opt.Exporter = SealedModeExporterType.File; + opt.FilePath = "./logs/test.otlp"; + opt.MaxBytes = 5_000_000; + opt.MaxRotatedFiles = 5; + }); + + var config = service.GetLocalExporterConfig(); + + Assert.NotNull(config); + Assert.Equal(SealedModeExporterType.File, config.Type); + Assert.Equal("./logs/test.otlp", config.FilePath); + Assert.Equal(5_000_000, config.MaxBytes); + Assert.Equal(5, config.MaxRotatedFiles); + } + + [Fact] + public void RecordSealEvent_RaisesStateChangedEvent() + { + using var service = CreateService(opt => opt.Enabled = true); + SealedModeStateChangedEventArgs? eventArgs = null; + service.StateChanged += (s, e) => eventArgs = e; + + service.RecordSealEvent("Test reason", "test-actor"); + + Assert.NotNull(eventArgs); + Assert.True(eventArgs.IsSealed); + Assert.Equal("Test reason", eventArgs.Reason); + Assert.Equal("test-actor", eventArgs.Actor); + } + + [Fact] + public void RecordUnsealEvent_RaisesStateChangedEvent() + { + using var service = CreateService(opt => opt.Enabled = false); + SealedModeStateChangedEventArgs? eventArgs = null; + service.StateChanged += (s, e) => eventArgs = e; + + service.RecordUnsealEvent("Test unseal", "admin"); + + Assert.NotNull(eventArgs); + Assert.False(eventArgs.IsSealed); + Assert.Equal("Test unseal", eventArgs.Reason); + Assert.Equal("admin", eventArgs.Actor); + } + + [Fact] + public void RecordDriftEvent_DoesNotThrow() + { + using var service = CreateService(opt => opt.Enabled = true); + var endpoint = new Uri("https://collector.example.com"); + + // Should not throw + service.RecordDriftEvent(endpoint, TelemetrySignal.Traces); + } + + [Fact] + public void SealedModeTelemetryOptions_Validate_ValidOptions_ReturnsNoErrors() + { + var options = new SealedModeTelemetryOptions(); + + var errors = options.Validate(); + + Assert.Empty(errors); + } + + [Fact] + public void SealedModeTelemetryOptions_Validate_InvalidSamplingPercent_ReturnsError() + { + var options = new SealedModeTelemetryOptions + { + MaxSamplingPercent = 150 + }; + + var errors = options.Validate(); + + Assert.Single(errors); + Assert.Contains("MaxSamplingPercent", errors[0]); + } + + [Fact] + public void SealedModeTelemetryOptions_Validate_NegativeSamplingPercent_ReturnsError() + { + var options = new SealedModeTelemetryOptions + { + MaxSamplingPercent = -10 + }; + + var errors = options.Validate(); + + Assert.Single(errors); + Assert.Contains("MaxSamplingPercent", errors[0]); + } + + [Fact] + public void SealedModeTelemetryOptions_Validate_InvalidMaxBytes_ReturnsError() + { + var options = new SealedModeTelemetryOptions + { + MaxBytes = 0 + }; + + var errors = options.Validate(); + + Assert.Single(errors); + Assert.Contains("MaxBytes", errors[0]); + } + + [Fact] + public void SealedModeTelemetryOptions_Validate_MissingFilePath_ReturnsError() + { + var options = new SealedModeTelemetryOptions + { + Exporter = SealedModeExporterType.File, + FilePath = "" + }; + + var errors = options.Validate(); + + Assert.Single(errors); + Assert.Contains("FilePath", errors[0]); + } + + [Fact] + public void SealedModeTelemetryOptions_GetEffectiveSamplingRate_WithoutIncident_ReturnsCapped() + { + var options = new SealedModeTelemetryOptions + { + MaxSamplingPercent = 25 + }; + + var rate = options.GetEffectiveSamplingRate(incidentModeActive: false, incidentSamplingRate: 1.0); + + Assert.Equal(0.25, rate); + } + + [Fact] + public void SealedModeTelemetryOptions_GetEffectiveSamplingRate_WithIncidentOverride_ReturnsRequested() + { + var options = new SealedModeTelemetryOptions + { + MaxSamplingPercent = 10, + AllowIncidentModeOverride = true + }; + + var rate = options.GetEffectiveSamplingRate(incidentModeActive: true, incidentSamplingRate: 0.5); + + Assert.Equal(0.5, rate); + } + + [Fact] + public void SealedModeTelemetryOptions_GetEffectiveSamplingRate_WithIncidentOverride_CapsAtOne() + { + var options = new SealedModeTelemetryOptions + { + MaxSamplingPercent = 10, + AllowIncidentModeOverride = true + }; + + var rate = options.GetEffectiveSamplingRate(incidentModeActive: true, incidentSamplingRate: 1.5); + + Assert.Equal(1.0, rate); + } + + [Fact] + public void SealedModeExporterConfig_PropertiesAreSet() + { + var config = new SealedModeExporterConfig + { + Type = SealedModeExporterType.File, + FilePath = "/path/to/file.otlp", + MaxBytes = 10_000_000, + MaxRotatedFiles = 3 + }; + + Assert.Equal(SealedModeExporterType.File, config.Type); + Assert.Equal("/path/to/file.otlp", config.FilePath); + Assert.Equal(10_000_000, config.MaxBytes); + Assert.Equal(3, config.MaxRotatedFiles); + } + + [Fact] + public void SealedModeStateChangedEventArgs_PropertiesAreSet() + { + var timestamp = DateTimeOffset.UtcNow; + var args = new SealedModeStateChangedEventArgs + { + IsSealed = true, + Timestamp = timestamp, + Reason = "Test reason", + Actor = "test-user" + }; + + Assert.True(args.IsSealed); + Assert.Equal(timestamp, args.Timestamp); + Assert.Equal("Test reason", args.Reason); + Assert.Equal("test-user", args.Actor); + } + + private sealed class TestOptionsMonitor : IOptionsMonitor + { + private readonly T _value; + + public TestOptionsMonitor(T value) + { + _value = value; + } + + public T CurrentValue => _value; + public T Get(string? name) => _value; + public IDisposable? OnChange(Action listener) => null; + } + + private sealed class FakeTimeProvider : TimeProvider + { + private DateTimeOffset _utcNow; + + public FakeTimeProvider(DateTimeOffset initialTime) + { + _utcNow = initialTime; + } + + public override DateTimeOffset GetUtcNow() => _utcNow; + + public void Advance(TimeSpan duration) + { + _utcNow = _utcNow.Add(duration); + } + + public void SetUtcNow(DateTimeOffset time) + { + _utcNow = time; + } + } +} diff --git a/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/IIncidentModeService.cs b/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/IIncidentModeService.cs new file mode 100644 index 000000000..8a6b17897 --- /dev/null +++ b/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/IIncidentModeService.cs @@ -0,0 +1,303 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace StellaOps.Telemetry.Core; + +/// +/// Service for managing incident mode state in telemetry. +/// Incident mode increases sampling rates and adds special tags to telemetry data. +/// +public interface IIncidentModeService +{ + /// + /// Gets whether incident mode is currently active. + /// + bool IsActive { get; } + + /// + /// Gets the current incident mode state. + /// + IncidentModeState? CurrentState { get; } + + /// + /// Activates incident mode with optional TTL override. + /// + /// The actor (user/service) activating incident mode. + /// Optional tenant identifier. + /// Optional TTL override (uses default if not specified). + /// Optional reason for activation. + /// Cancellation token. + /// The activation result. + Task ActivateAsync( + string actor, + string? tenantId = null, + TimeSpan? ttlOverride = null, + string? reason = null, + CancellationToken ct = default); + + /// + /// Deactivates incident mode. + /// + /// The actor (user/service) deactivating incident mode. + /// Optional reason for deactivation. + /// Cancellation token. + /// The deactivation result. + Task DeactivateAsync( + string actor, + string? reason = null, + CancellationToken ct = default); + + /// + /// Extends the current incident mode TTL. + /// + /// The time to add to the current TTL. + /// The actor extending the TTL. + /// Cancellation token. + /// The new expiration time, or null if incident mode is not active. + Task ExtendTtlAsync( + TimeSpan extension, + string actor, + CancellationToken ct = default); + + /// + /// Gets tags to add to telemetry when incident mode is active. + /// + /// A dictionary of tags, or empty if incident mode is not active. + IReadOnlyDictionary GetIncidentTags(); + + /// + /// Event raised when incident mode is activated. + /// + event EventHandler? Activated; + + /// + /// Event raised when incident mode is deactivated or expires. + /// + event EventHandler? Deactivated; +} + +/// +/// Represents the current state of incident mode. +/// +public sealed record IncidentModeState +{ + /// + /// Gets whether incident mode is enabled. + /// + public required bool Enabled { get; init; } + + /// + /// Gets the timestamp when incident mode was activated. + /// + public required DateTimeOffset ActivatedAt { get; init; } + + /// + /// Gets the timestamp when incident mode will expire. + /// + public required DateTimeOffset ExpiresAt { get; init; } + + /// + /// Gets the actor who activated incident mode. + /// + public required string Actor { get; init; } + + /// + /// Gets the tenant identifier, if applicable. + /// + public string? TenantId { get; init; } + + /// + /// Gets the source of the activation (CLI, API, config). + /// + public required IncidentModeSource Source { get; init; } + + /// + /// Gets the reason for activation. + /// + public string? Reason { get; init; } + + /// + /// Gets the unique activation ID. + /// + public required string ActivationId { get; init; } + + /// + /// Gets whether this state has expired. + /// + public bool IsExpired => DateTimeOffset.UtcNow >= ExpiresAt; + + /// + /// Gets the remaining time until expiration. + /// + public TimeSpan RemainingTime => IsExpired ? TimeSpan.Zero : ExpiresAt - DateTimeOffset.UtcNow; +} + +/// +/// Source of incident mode activation. +/// +public enum IncidentModeSource +{ + /// CLI flag activation. + Cli, + /// API activation. + Api, + /// Configuration-based activation. + Configuration, + /// Persisted state restoration. + Restored +} + +/// +/// Result of incident mode activation. +/// +public sealed record IncidentModeActivationResult +{ + /// + /// Gets whether activation was successful. + /// + public required bool Success { get; init; } + + /// + /// Gets the activation state if successful. + /// + public IncidentModeState? State { get; init; } + + /// + /// Gets the error message if activation failed. + /// + public string? Error { get; init; } + + /// + /// Gets whether incident mode was already active. + /// + public bool WasAlreadyActive { get; init; } + + /// + /// Creates a successful activation result. + /// + public static IncidentModeActivationResult Succeeded(IncidentModeState state, bool wasAlreadyActive = false) + { + return new IncidentModeActivationResult + { + Success = true, + State = state, + WasAlreadyActive = wasAlreadyActive + }; + } + + /// + /// Creates a failed activation result. + /// + public static IncidentModeActivationResult Failed(string error) + { + return new IncidentModeActivationResult + { + Success = false, + Error = error + }; + } +} + +/// +/// Result of incident mode deactivation. +/// +public sealed record IncidentModeDeactivationResult +{ + /// + /// Gets whether deactivation was successful. + /// + public required bool Success { get; init; } + + /// + /// Gets whether incident mode was active before deactivation. + /// + public bool WasActive { get; init; } + + /// + /// Gets the error message if deactivation failed. + /// + public string? Error { get; init; } + + /// + /// Gets the reason for deactivation. + /// + public IncidentModeDeactivationReason Reason { get; init; } + + /// + /// Creates a successful deactivation result. + /// + public static IncidentModeDeactivationResult Succeeded(bool wasActive, IncidentModeDeactivationReason reason) + { + return new IncidentModeDeactivationResult + { + Success = true, + WasActive = wasActive, + Reason = reason + }; + } + + /// + /// Creates a failed deactivation result. + /// + public static IncidentModeDeactivationResult Failed(string error) + { + return new IncidentModeDeactivationResult + { + Success = false, + Error = error + }; + } +} + +/// +/// Reason for incident mode deactivation. +/// +public enum IncidentModeDeactivationReason +{ + /// Manual deactivation by user/service. + Manual, + /// Deactivation due to TTL expiry. + Expired, + /// Deactivation due to system shutdown. + Shutdown, + /// Deactivation due to sealed mode activation. + SealedMode +} + +/// +/// Event args for incident mode activation. +/// +public sealed class IncidentModeActivatedEventArgs : EventArgs +{ + /// + /// Gets the activation state. + /// + public required IncidentModeState State { get; init; } + + /// + /// Gets whether this was a reactivation (was already active). + /// + public bool WasReactivation { get; init; } +} + +/// +/// Event args for incident mode deactivation. +/// +public sealed class IncidentModeDeactivatedEventArgs : EventArgs +{ + /// + /// Gets the state at time of deactivation. + /// + public required IncidentModeState State { get; init; } + + /// + /// Gets the reason for deactivation. + /// + public required IncidentModeDeactivationReason Reason { get; init; } + + /// + /// Gets the actor who deactivated (if manual). + /// + public string? DeactivatedBy { get; init; } +} diff --git a/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/ISealedModeTelemetryService.cs b/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/ISealedModeTelemetryService.cs new file mode 100644 index 000000000..cca0b17d2 --- /dev/null +++ b/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/ISealedModeTelemetryService.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace StellaOps.Telemetry.Core; + +/// +/// Service for managing sealed-mode telemetry behavior. +/// When sealed mode is active, external exporters are disabled and +/// telemetry is written to local storage instead. +/// +public interface ISealedModeTelemetryService +{ + /// + /// Gets whether sealed mode is currently active. + /// + bool IsSealed { get; } + + /// + /// Gets the current effective sampling rate (0.0-1.0). + /// + double EffectiveSamplingRate { get; } + + /// + /// Gets whether incident mode is currently overriding sealed mode sampling. + /// + bool IsIncidentModeOverrideActive { get; } + + /// + /// Gets tags to add to telemetry when sealed mode is active. + /// + /// A dictionary of tags, or empty if sealed mode is not active. + IReadOnlyDictionary GetSealedModeTags(); + + /// + /// Determines whether an external exporter should be allowed. + /// Always returns false when sealed mode is active. + /// + /// The exporter endpoint. + /// true if external export is allowed. + bool IsExternalExportAllowed(Uri endpoint); + + /// + /// Gets the local exporter configuration for sealed mode. + /// + /// The exporter configuration, or null if sealed mode is not active. + SealedModeExporterConfig? GetLocalExporterConfig(); + + /// + /// Records a seal event (entry into sealed mode). + /// + /// Optional reason for sealing. + /// The actor who initiated the seal. + void RecordSealEvent(string? reason = null, string? actor = null); + + /// + /// Records an unseal event (exit from sealed mode). + /// + /// Optional reason for unsealing. + /// The actor who initiated the unseal. + void RecordUnsealEvent(string? reason = null, string? actor = null); + + /// + /// Records a drift event when external export was blocked. + /// + /// The blocked endpoint. + /// The telemetry signal type. + void RecordDriftEvent(Uri endpoint, TelemetrySignal signal); + + /// + /// Event raised when sealed mode state changes. + /// + event EventHandler? StateChanged; +} + +/// +/// Configuration for the local exporter in sealed mode. +/// +public sealed record SealedModeExporterConfig +{ + /// + /// Gets the exporter type. + /// + public required SealedModeExporterType Type { get; init; } + + /// + /// Gets the file path for file-based exporters. + /// + public string? FilePath { get; init; } + + /// + /// Gets the maximum bytes before rotation. + /// + public long MaxBytes { get; init; } + + /// + /// Gets the maximum number of rotated files. + /// + public int MaxRotatedFiles { get; init; } +} + +/// +/// Event args for sealed mode state changes. +/// +public sealed class SealedModeStateChangedEventArgs : EventArgs +{ + /// + /// Gets whether sealed mode is now active. + /// + public required bool IsSealed { get; init; } + + /// + /// Gets the timestamp of the state change. + /// + public required DateTimeOffset Timestamp { get; init; } + + /// + /// Gets the reason for the state change. + /// + public string? Reason { get; init; } + + /// + /// Gets the actor who initiated the change. + /// + public string? Actor { get; init; } +} diff --git a/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/IncidentModeOptions.cs b/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/IncidentModeOptions.cs new file mode 100644 index 000000000..4ab7092a6 --- /dev/null +++ b/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/IncidentModeOptions.cs @@ -0,0 +1,191 @@ +using System; + +namespace StellaOps.Telemetry.Core; + +/// +/// Options for incident mode configuration. +/// +public sealed class IncidentModeOptions +{ + /// + /// Configuration section name. + /// + public const string SectionName = "Telemetry:Incident"; + + /// + /// Gets or sets whether incident mode is enabled by configuration. + /// CLI flag can override this. + /// + public bool Enabled { get; set; } + + /// + /// Gets or sets the default TTL for incident mode. + /// + public TimeSpan DefaultTtl { get; set; } = TimeSpan.FromMinutes(30); + + /// + /// Gets or sets the maximum allowed TTL for incident mode. + /// + public TimeSpan MaxTtl { get; set; } = TimeSpan.FromHours(24); + + /// + /// Gets or sets the minimum allowed TTL for incident mode. + /// + public TimeSpan MinTtl { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Gets or sets the sampling rate to use during incident mode (0.0-1.0). + /// + public double IncidentSamplingRate { get; set; } = 1.0; + + /// + /// Gets or sets the flush interval for exporters during incident mode. + /// + public TimeSpan IncidentFlushInterval { get; set; } = TimeSpan.FromSeconds(5); + + /// + /// Gets or sets the normal flush interval for comparison. + /// + public TimeSpan NormalFlushInterval { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// Gets or sets whether to persist incident mode state to local file. + /// + public bool PersistState { get; set; } = true; + + /// + /// Gets or sets the state file path. Uses default if not specified. + /// + public string? StateFilePath { get; set; } + + /// + /// Gets or sets whether to emit audit events for activation/deactivation. + /// + public bool EmitAuditEvents { get; set; } = true; + + /// + /// Gets or sets the tag name for incident mode indicator. + /// + public string IncidentTagName { get; set; } = "incident"; + + /// + /// Gets or sets whether sealed mode disables incident mode. + /// + public bool DisableInSealedMode { get; set; } = true; + + /// + /// Gets or sets additional tags to add during incident mode. + /// + public System.Collections.Generic.Dictionary AdditionalTags { get; set; } = new(); + + /// + /// Gets or sets whether to allow TTL extension. + /// + public bool AllowTtlExtension { get; set; } = true; + + /// + /// Gets or sets the maximum number of extensions allowed per activation. + /// + public int MaxExtensions { get; set; } = 5; + + /// + /// Gets or sets whether to restore state from persisted file on startup. + /// + public bool RestoreOnStartup { get; set; } = true; + + /// + /// Validates the options and returns any validation errors. + /// + public System.Collections.Generic.List Validate() + { + var errors = new System.Collections.Generic.List(); + + if (DefaultTtl < MinTtl) + { + errors.Add($"DefaultTtl ({DefaultTtl}) cannot be less than MinTtl ({MinTtl})"); + } + + if (DefaultTtl > MaxTtl) + { + errors.Add($"DefaultTtl ({DefaultTtl}) cannot be greater than MaxTtl ({MaxTtl})"); + } + + if (IncidentSamplingRate < 0.0 || IncidentSamplingRate > 1.0) + { + errors.Add($"IncidentSamplingRate ({IncidentSamplingRate}) must be between 0.0 and 1.0"); + } + + if (IncidentFlushInterval <= TimeSpan.Zero) + { + errors.Add("IncidentFlushInterval must be positive"); + } + + if (MaxExtensions < 0) + { + errors.Add("MaxExtensions cannot be negative"); + } + + return errors; + } + + /// + /// Clamps a TTL value to the allowed range. + /// + public TimeSpan ClampTtl(TimeSpan ttl) + { + if (ttl < MinTtl) return MinTtl; + if (ttl > MaxTtl) return MaxTtl; + return ttl; + } +} + +/// +/// Persisted state for incident mode. +/// +public sealed class PersistedIncidentModeState +{ + /// + /// Gets or sets whether incident mode is enabled. + /// + public bool Enabled { get; set; } + + /// + /// Gets or sets the timestamp when incident mode was activated. + /// + public DateTimeOffset? ActivatedAt { get; set; } + + /// + /// Gets or sets the timestamp when incident mode will expire. + /// + public DateTimeOffset? ExpiresAt { get; set; } + + /// + /// Gets or sets the actor who activated incident mode. + /// + public string? Actor { get; set; } + + /// + /// Gets or sets the tenant identifier. + /// + public string? TenantId { get; set; } + + /// + /// Gets or sets the activation ID. + /// + public string? ActivationId { get; set; } + + /// + /// Gets or sets the source of activation. + /// + public string? Source { get; set; } + + /// + /// Gets or sets the reason for activation. + /// + public string? Reason { get; set; } + + /// + /// Gets or sets the number of TTL extensions applied. + /// + public int ExtensionCount { get; set; } +} diff --git a/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/IncidentModeService.cs b/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/IncidentModeService.cs new file mode 100644 index 000000000..d43ef6c85 --- /dev/null +++ b/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/IncidentModeService.cs @@ -0,0 +1,531 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace StellaOps.Telemetry.Core; + +/// +/// Default implementation of . +/// +public sealed class IncidentModeService : IIncidentModeService, IDisposable +{ + private readonly IOptionsMonitor _optionsMonitor; + private readonly ITelemetryContextAccessor? _contextAccessor; + private readonly ILogger? _logger; + private readonly TimeProvider _timeProvider; + private readonly object _lock = new(); + private readonly Timer _expiryTimer; + + private IncidentModeState? _currentState; + private int _extensionCount; + + /// + public bool IsActive => _currentState is not null && !_currentState.IsExpired; + + /// + public IncidentModeState? CurrentState => _currentState?.IsExpired == true ? null : _currentState; + + /// + public event EventHandler? Activated; + + /// + public event EventHandler? Deactivated; + + /// + /// Initializes a new instance of . + /// + public IncidentModeService( + IOptionsMonitor optionsMonitor, + ITelemetryContextAccessor? contextAccessor = null, + ILogger? logger = null, + TimeProvider? timeProvider = null) + { + _optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor)); + _contextAccessor = contextAccessor; + _logger = logger; + _timeProvider = timeProvider ?? TimeProvider.System; + + _expiryTimer = new Timer(CheckExpiry, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10)); + + // Restore state if configured + if (_optionsMonitor.CurrentValue.RestoreOnStartup) + { + _ = RestoreStateAsync(); + } + } + + /// + public async Task ActivateAsync( + string actor, + string? tenantId = null, + TimeSpan? ttlOverride = null, + string? reason = null, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(actor); + + var options = _optionsMonitor.CurrentValue; + + // Check sealed mode restriction + if (options.DisableInSealedMode && IsSealedModeActive()) + { + return IncidentModeActivationResult.Failed( + "Cannot activate incident mode when sealed mode is active"); + } + + var ttl = ttlOverride.HasValue ? options.ClampTtl(ttlOverride.Value) : options.DefaultTtl; + var now = _timeProvider.GetUtcNow(); + var wasAlreadyActive = false; + + lock (_lock) + { + if (_currentState is not null && !_currentState.IsExpired) + { + wasAlreadyActive = true; + _logger?.LogInformation( + "Incident mode already active (activation {ActivationId}). Extending TTL.", + _currentState.ActivationId); + + // Extend existing activation + _currentState = _currentState with + { + ExpiresAt = now + ttl + }; + } + else + { + // New activation + _currentState = new IncidentModeState + { + Enabled = true, + ActivatedAt = now, + ExpiresAt = now + ttl, + Actor = actor, + TenantId = tenantId ?? _contextAccessor?.Context?.TenantId, + Source = IncidentModeSource.Api, + Reason = reason, + ActivationId = Guid.NewGuid().ToString("N")[..12] + }; + _extensionCount = 0; + } + } + + _logger?.LogInformation( + "Incident mode activated by {Actor} for tenant {TenantId}. Expires at {ExpiresAt}. Activation ID: {ActivationId}", + actor, + _currentState.TenantId ?? "global", + _currentState.ExpiresAt, + _currentState.ActivationId); + + // Persist state + if (options.PersistState) + { + await PersistStateAsync(ct).ConfigureAwait(false); + } + + // Emit audit event + if (options.EmitAuditEvents) + { + EmitActivationAuditEvent(_currentState, wasAlreadyActive); + } + + // Raise event + Activated?.Invoke(this, new IncidentModeActivatedEventArgs + { + State = _currentState, + WasReactivation = wasAlreadyActive + }); + + return IncidentModeActivationResult.Succeeded(_currentState, wasAlreadyActive); + } + + /// + public async Task DeactivateAsync( + string actor, + string? reason = null, + CancellationToken ct = default) + { + var options = _optionsMonitor.CurrentValue; + IncidentModeState? previousState; + bool wasActive; + + lock (_lock) + { + previousState = _currentState; + wasActive = previousState is not null && !previousState.IsExpired; + _currentState = null; + _extensionCount = 0; + } + + if (wasActive && previousState is not null) + { + _logger?.LogInformation( + "Incident mode deactivated by {Actor}. Activation ID: {ActivationId}. Reason: {Reason}", + actor, + previousState.ActivationId, + reason ?? "manual deactivation"); + + // Clear persisted state + if (options.PersistState) + { + await ClearPersistedStateAsync(ct).ConfigureAwait(false); + } + + // Emit audit event + if (options.EmitAuditEvents) + { + EmitDeactivationAuditEvent(previousState, IncidentModeDeactivationReason.Manual, actor); + } + + // Raise event + Deactivated?.Invoke(this, new IncidentModeDeactivatedEventArgs + { + State = previousState, + Reason = IncidentModeDeactivationReason.Manual, + DeactivatedBy = actor + }); + } + + return IncidentModeDeactivationResult.Succeeded(wasActive, IncidentModeDeactivationReason.Manual); + } + + /// + public async Task ExtendTtlAsync( + TimeSpan extension, + string actor, + CancellationToken ct = default) + { + var options = _optionsMonitor.CurrentValue; + + if (!options.AllowTtlExtension) + { + _logger?.LogWarning("TTL extension not allowed by configuration"); + return null; + } + + lock (_lock) + { + if (_currentState is null || _currentState.IsExpired) + { + return null; + } + + if (_extensionCount >= options.MaxExtensions) + { + _logger?.LogWarning( + "Maximum TTL extensions ({MaxExtensions}) reached for activation {ActivationId}", + options.MaxExtensions, + _currentState.ActivationId); + return null; + } + + var newExpiresAt = _currentState.ExpiresAt + extension; + var maxAllowedExpiry = _currentState.ActivatedAt + options.MaxTtl; + + if (newExpiresAt > maxAllowedExpiry) + { + newExpiresAt = maxAllowedExpiry; + } + + _currentState = _currentState with { ExpiresAt = newExpiresAt }; + _extensionCount++; + + _logger?.LogInformation( + "Incident mode TTL extended by {Actor}. New expiry: {ExpiresAt}. Extensions: {Count}/{Max}", + actor, + newExpiresAt, + _extensionCount, + options.MaxExtensions); + + return newExpiresAt; + } + } + + /// + public IReadOnlyDictionary GetIncidentTags() + { + var state = CurrentState; + if (state is null) + { + return new Dictionary(); + } + + var options = _optionsMonitor.CurrentValue; + var tags = new Dictionary + { + [options.IncidentTagName] = "true", + ["incident_activation_id"] = state.ActivationId, + ["incident_actor"] = state.Actor + }; + + if (state.TenantId is not null) + { + tags["incident_tenant"] = state.TenantId; + } + + foreach (var (key, value) in options.AdditionalTags) + { + tags[key] = value; + } + + return tags; + } + + /// + /// Activates incident mode from CLI flag. + /// + public Task ActivateFromCliAsync( + string actor, + TimeSpan? ttl = null, + CancellationToken ct = default) + { + return ActivateInternalAsync(actor, null, ttl, "CLI activation", IncidentModeSource.Cli, ct); + } + + /// + /// Activates incident mode from configuration. + /// + public Task ActivateFromConfigAsync(CancellationToken ct = default) + { + var options = _optionsMonitor.CurrentValue; + if (!options.Enabled) + { + return Task.FromResult(IncidentModeActivationResult.Failed("Incident mode not enabled in configuration")); + } + + return ActivateInternalAsync("configuration", null, null, "Configuration activation", IncidentModeSource.Configuration, ct); + } + + private async Task ActivateInternalAsync( + string actor, + string? tenantId, + TimeSpan? ttl, + string? reason, + IncidentModeSource source, + CancellationToken ct) + { + var result = await ActivateAsync(actor, tenantId, ttl, reason, ct).ConfigureAwait(false); + + if (result.Success && result.State is not null) + { + // Update source + lock (_lock) + { + if (_currentState is not null) + { + _currentState = _currentState with { Source = source }; + } + } + } + + return result; + } + + private void CheckExpiry(object? state) + { + IncidentModeState? expiredState; + + lock (_lock) + { + if (_currentState is null || !_currentState.IsExpired) + { + return; + } + + expiredState = _currentState; + _currentState = null; + _extensionCount = 0; + } + + _logger?.LogInformation( + "Incident mode expired. Activation ID: {ActivationId}", + expiredState.ActivationId); + + var options = _optionsMonitor.CurrentValue; + + // Clear persisted state + if (options.PersistState) + { + _ = ClearPersistedStateAsync(default); + } + + // Emit audit event + if (options.EmitAuditEvents) + { + EmitDeactivationAuditEvent(expiredState, IncidentModeDeactivationReason.Expired, null); + } + + // Raise event + Deactivated?.Invoke(this, new IncidentModeDeactivatedEventArgs + { + State = expiredState, + Reason = IncidentModeDeactivationReason.Expired + }); + } + + private async Task RestoreStateAsync() + { + var options = _optionsMonitor.CurrentValue; + var path = GetStateFilePath(options); + + if (!File.Exists(path)) + { + return; + } + + try + { + var json = await File.ReadAllTextAsync(path).ConfigureAwait(false); + var persisted = JsonSerializer.Deserialize(json); + + if (persisted?.Enabled == true && + persisted.ExpiresAt.HasValue && + persisted.ExpiresAt.Value > _timeProvider.GetUtcNow()) + { + lock (_lock) + { + _currentState = new IncidentModeState + { + Enabled = true, + ActivatedAt = persisted.ActivatedAt ?? _timeProvider.GetUtcNow(), + ExpiresAt = persisted.ExpiresAt.Value, + Actor = persisted.Actor ?? "restored", + TenantId = persisted.TenantId, + Source = IncidentModeSource.Restored, + Reason = persisted.Reason, + ActivationId = persisted.ActivationId ?? Guid.NewGuid().ToString("N")[..12] + }; + _extensionCount = persisted.ExtensionCount; + } + + _logger?.LogInformation( + "Restored incident mode state. Activation ID: {ActivationId}. Expires at: {ExpiresAt}", + _currentState.ActivationId, + _currentState.ExpiresAt); + } + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Failed to restore incident mode state from {Path}", path); + } + } + + private async Task PersistStateAsync(CancellationToken ct) + { + var options = _optionsMonitor.CurrentValue; + var path = GetStateFilePath(options); + var state = _currentState; + + if (state is null) + { + return; + } + + try + { + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + var persisted = new PersistedIncidentModeState + { + Enabled = true, + ActivatedAt = state.ActivatedAt, + ExpiresAt = state.ExpiresAt, + Actor = state.Actor, + TenantId = state.TenantId, + ActivationId = state.ActivationId, + Source = state.Source.ToString(), + Reason = state.Reason, + ExtensionCount = _extensionCount + }; + + var json = JsonSerializer.Serialize(persisted, new JsonSerializerOptions { WriteIndented = true }); + await File.WriteAllTextAsync(path, json, ct).ConfigureAwait(false); + + // Set file permissions (Unix only) + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Failed to persist incident mode state to {Path}", path); + } + } + + private async Task ClearPersistedStateAsync(CancellationToken ct) + { + var options = _optionsMonitor.CurrentValue; + var path = GetStateFilePath(options); + + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Failed to clear incident mode state file {Path}", path); + } + + await Task.CompletedTask; + } + + private static string GetStateFilePath(IncidentModeOptions options) + { + if (!string.IsNullOrEmpty(options.StateFilePath)) + { + return options.StateFilePath; + } + + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return Path.Combine(homeDir, ".stellaops", "incident-mode.json"); + } + + private bool IsSealedModeActive() + { + // This would integrate with the sealed mode service when implemented + // For now, check via options or context + return false; + } + + private void EmitActivationAuditEvent(IncidentModeState state, bool wasReactivation) + { + _logger?.LogInformation( + "Audit: telemetry.incident.{Action} - tenant={Tenant} actor={Actor} source={Source} expires_at={ExpiresAt} activation_id={ActivationId}", + wasReactivation ? "reactivated" : "activated", + state.TenantId ?? "global", + state.Actor, + state.Source, + state.ExpiresAt.ToString("O"), + state.ActivationId); + } + + private void EmitDeactivationAuditEvent(IncidentModeState state, IncidentModeDeactivationReason reason, string? deactivatedBy) + { + _logger?.LogInformation( + "Audit: telemetry.incident.{Action} - tenant={Tenant} reason={Reason} deactivated_by={DeactivatedBy} activation_id={ActivationId}", + reason == IncidentModeDeactivationReason.Expired ? "expired" : "deactivated", + state.TenantId ?? "global", + reason, + deactivatedBy ?? "system", + state.ActivationId); + } + + /// + public void Dispose() + { + _expiryTimer.Dispose(); + } +} diff --git a/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/SealedModeFileExporter.cs b/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/SealedModeFileExporter.cs new file mode 100644 index 000000000..5ffb24dd9 --- /dev/null +++ b/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/SealedModeFileExporter.cs @@ -0,0 +1,304 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace StellaOps.Telemetry.Core; + +/// +/// File-based exporter for sealed mode telemetry. +/// Writes OTLP data to a local file with rotation support. +/// +public sealed class SealedModeFileExporter : IDisposable +{ + private readonly IOptionsMonitor _optionsMonitor; + private readonly ILogger? _logger; + private readonly object _lock = new(); + private readonly TimeProvider _timeProvider; + + private FileStream? _currentStream; + private string? _currentFilePath; + private long _currentSize; + private bool _disposed; + + /// + /// Gets whether the exporter has been initialized. + /// + public bool IsInitialized => _currentStream is not null; + + /// + /// Gets the current file path being written to. + /// + public string? CurrentFilePath => _currentFilePath; + + /// + /// Gets the current file size in bytes. + /// + public long CurrentSize => _currentSize; + + /// + /// Initializes a new instance of . + /// + public SealedModeFileExporter( + IOptionsMonitor optionsMonitor, + ILogger? logger = null, + TimeProvider? timeProvider = null) + { + _optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor)); + _logger = logger; + _timeProvider = timeProvider ?? TimeProvider.System; + } + + /// + /// Initializes the exporter and creates the output file. + /// + /// Thrown if the file path has insecure permissions. + public void Initialize() + { + var options = _optionsMonitor.CurrentValue; + + lock (_lock) + { + if (_currentStream is not null) + { + return; + } + + var filePath = options.FilePath; + if (string.IsNullOrWhiteSpace(filePath)) + { + throw new InvalidOperationException("File path is not configured"); + } + + var directory = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + + // Set directory permissions on Unix + if (!OperatingSystem.IsWindows()) + { + try + { + File.SetUnixFileMode(directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Failed to set directory permissions for {Directory}", directory); + } + } + } + + // Check existing file permissions + if (File.Exists(filePath) && options.FailOnInsecurePermissions && !OperatingSystem.IsWindows()) + { + try + { + var mode = File.GetUnixFileMode(filePath); + if ((mode & (UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.GroupRead | UnixFileMode.GroupWrite)) != 0) + { + throw new InvalidOperationException( + $"Sealed mode telemetry file {filePath} has insecure permissions. " + + "File must not be readable or writable by group or others."); + } + } + catch (InvalidOperationException) + { + throw; + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Failed to check file permissions for {FilePath}", filePath); + } + } + + _currentFilePath = filePath; + _currentStream = new FileStream( + filePath, + FileMode.Append, + FileAccess.Write, + FileShare.Read, + bufferSize: 4096, + FileOptions.WriteThrough); + + _currentSize = _currentStream.Length; + + // Set file permissions on Unix + if (!OperatingSystem.IsWindows()) + { + try + { + File.SetUnixFileMode(filePath, options.FilePermissions); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Failed to set file permissions for {FilePath}", filePath); + } + } + + _logger?.LogInformation( + "Sealed mode file exporter initialized at {FilePath} (current size: {Size} bytes)", + filePath, + _currentSize); + } + } + + /// + /// Writes telemetry data to the file. + /// + /// The binary data to write. + /// The telemetry signal type. + /// Cancellation token. + public void Write(ReadOnlySpan data, TelemetrySignal signal, CancellationToken cancellationToken = default) + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(SealedModeFileExporter)); + } + + var options = _optionsMonitor.CurrentValue; + + lock (_lock) + { + if (_currentStream is null) + { + Initialize(); + } + + // Check if rotation is needed + if (_currentSize + data.Length > options.MaxBytes) + { + RotateFile(); + } + + // Write header with timestamp and signal type + var timestamp = _timeProvider.GetUtcNow(); + var header = $"[{timestamp:O}][{signal}][{data.Length}]\n"; + var headerBytes = Encoding.UTF8.GetBytes(header); + + _currentStream!.Write(headerBytes); + _currentStream.Write(data); + _currentStream.WriteByte((byte)'\n'); + _currentStream.Flush(); + + _currentSize += headerBytes.Length + data.Length + 1; + } + } + + /// + /// Writes a string record to the file. + /// + /// The string record to write. + /// The telemetry signal type. + /// Cancellation token. + public void WriteRecord(string record, TelemetrySignal signal, CancellationToken cancellationToken = default) + { + var bytes = Encoding.UTF8.GetBytes(record); + Write(bytes, signal, cancellationToken); + } + + private void RotateFile() + { + var options = _optionsMonitor.CurrentValue; + var basePath = _currentFilePath!; + + _currentStream?.Dispose(); + _currentStream = null; + + // Rotate existing files + for (var i = options.MaxRotatedFiles; i >= 1; i--) + { + var oldPath = i == 1 ? basePath : $"{basePath}.{i - 1}"; + var newPath = $"{basePath}.{i}"; + + if (File.Exists(oldPath)) + { + if (i == options.MaxRotatedFiles) + { + // Delete oldest file + try + { + File.Delete(oldPath); + _logger?.LogDebug("Deleted oldest rotated file {Path}", oldPath); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Failed to delete rotated file {Path}", oldPath); + } + } + else + { + // Rename to next slot + try + { + if (File.Exists(newPath)) + { + File.Delete(newPath); + } + File.Move(oldPath, newPath); + _logger?.LogDebug("Rotated {OldPath} to {NewPath}", oldPath, newPath); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Failed to rotate {OldPath} to {NewPath}", oldPath, newPath); + } + } + } + } + + // Create new file + _currentStream = new FileStream( + basePath, + FileMode.Create, + FileAccess.Write, + FileShare.Read, + bufferSize: 4096, + FileOptions.WriteThrough); + + _currentSize = 0; + + // Set file permissions on Unix + if (!OperatingSystem.IsWindows()) + { + try + { + File.SetUnixFileMode(basePath, options.FilePermissions); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Failed to set file permissions for {FilePath}", basePath); + } + } + + _logger?.LogInformation("Rotated sealed mode telemetry file. New file: {Path}", basePath); + } + + /// + /// Flushes any buffered data to disk. + /// + public void Flush() + { + lock (_lock) + { + _currentStream?.Flush(); + } + } + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + lock (_lock) + { + _currentStream?.Dispose(); + _currentStream = null; + _disposed = true; + } + } +} diff --git a/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/SealedModeTelemetryOptions.cs b/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/SealedModeTelemetryOptions.cs new file mode 100644 index 000000000..9856a0762 --- /dev/null +++ b/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/SealedModeTelemetryOptions.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace StellaOps.Telemetry.Core; + +/// +/// Options for sealed-mode telemetry behavior. +/// +public sealed class SealedModeTelemetryOptions +{ + /// + /// Configuration section name. + /// + public const string SectionName = "Telemetry:Sealed"; + + /// + /// Gets or sets whether sealed mode telemetry is enabled. + /// This is typically driven by . + /// + public bool Enabled { get; set; } + + /// + /// Gets or sets the exporter type to use in sealed mode. + /// + public SealedModeExporterType Exporter { get; set; } = SealedModeExporterType.File; + + /// + /// Gets or sets the file path for the file exporter. + /// + public string FilePath { get; set; } = "./logs/telemetry-sealed.otlp"; + + /// + /// Gets or sets the maximum bytes for the file exporter before rotation. + /// Default is 10 MB. + /// + public long MaxBytes { get; set; } = 10_485_760; + + /// + /// Gets or sets the maximum number of rotated files to keep. + /// + public int MaxRotatedFiles { get; set; } = 3; + + /// + /// Gets or sets the maximum sampling percentage in sealed mode (0-100). + /// Default is 10%. + /// + public int MaxSamplingPercent { get; set; } = 10; + + /// + /// Gets or sets whether to force scrubbing regardless of default settings. + /// + public bool ForceScrub { get; set; } = true; + + /// + /// Gets or sets whether to suppress exemplars in sealed mode. + /// + public bool SuppressExemplars { get; set; } = true; + + /// + /// Gets or sets the tag name for sealed mode indicator. + /// + public string SealedTagName { get; set; } = "sealed"; + + /// + /// Gets or sets whether to add scrubbed indicator tag. + /// + public bool AddScrubbedTag { get; set; } = true; + + /// + /// Gets or sets additional tags to add in sealed mode. + /// + public Dictionary AdditionalTags { get; set; } = new(); + + /// + /// Gets or sets the maximum clock skew threshold before warning. + /// Default is 500ms. + /// + public TimeSpan ClockSkewThreshold { get; set; } = TimeSpan.FromMilliseconds(500); + + /// + /// Gets or sets whether incident mode can override the sampling ceiling. + /// + public bool AllowIncidentModeOverride { get; set; } = true; + + /// + /// Gets or sets the required file permissions (Unix only). + /// Default is 0600 (owner read/write only). + /// + public UnixFileMode FilePermissions { get; set; } = UnixFileMode.UserRead | UnixFileMode.UserWrite; + + /// + /// Gets or sets whether to fail startup if the file path has insecure permissions. + /// + public bool FailOnInsecurePermissions { get; set; } = true; + + /// + /// Validates the options and returns any validation errors. + /// + public List Validate() + { + var errors = new List(); + + if (MaxSamplingPercent < 0 || MaxSamplingPercent > 100) + { + errors.Add($"MaxSamplingPercent ({MaxSamplingPercent}) must be between 0 and 100"); + } + + if (MaxBytes <= 0) + { + errors.Add("MaxBytes must be positive"); + } + + if (MaxRotatedFiles < 0) + { + errors.Add("MaxRotatedFiles cannot be negative"); + } + + if (string.IsNullOrWhiteSpace(FilePath) && Exporter == SealedModeExporterType.File) + { + errors.Add("FilePath is required when Exporter is File"); + } + + if (ClockSkewThreshold <= TimeSpan.Zero) + { + errors.Add("ClockSkewThreshold must be positive"); + } + + return errors; + } + + /// + /// Gets the effective sampling rate as a decimal (0.0-1.0). + /// + /// Whether incident mode is active. + /// The sampling rate requested by incident mode. + /// The effective sampling rate clamped to sealed mode limits. + public double GetEffectiveSamplingRate(bool incidentModeActive, double incidentSamplingRate) + { + var maxRate = MaxSamplingPercent / 100.0; + + if (incidentModeActive && AllowIncidentModeOverride) + { + // Incident mode can override up to 100% + return Math.Min(incidentSamplingRate, 1.0); + } + + return maxRate; + } +} + +/// +/// Exporter type for sealed mode telemetry. +/// +public enum SealedModeExporterType +{ + /// + /// In-memory ring buffer exporter. + /// + Memory, + + /// + /// File-based OTLP exporter. + /// + File +} diff --git a/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/SealedModeTelemetryService.cs b/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/SealedModeTelemetryService.cs new file mode 100644 index 000000000..5cdfaf716 --- /dev/null +++ b/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/SealedModeTelemetryService.cs @@ -0,0 +1,286 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.AirGap.Policy; + +namespace StellaOps.Telemetry.Core; + +/// +/// Default implementation of . +/// +public sealed class SealedModeTelemetryService : ISealedModeTelemetryService, IDisposable +{ + private static readonly ActivitySource ActivitySource = new("StellaOps.Telemetry.SealedMode", "1.0.0"); + private static readonly Meter Meter = new("StellaOps.Telemetry.SealedMode", "1.0.0"); + + private readonly IOptionsMonitor _optionsMonitor; + private readonly IEgressPolicy? _egressPolicy; + private readonly IIncidentModeService? _incidentModeService; + private readonly ILogger? _logger; + private readonly TimeProvider _timeProvider; + private readonly object _lock = new(); + + private readonly Counter _sealEventsCounter; + private readonly Counter _unsealEventsCounter; + private readonly Counter _driftEventsCounter; + private readonly Counter _blockedExportsCounter; + + private bool _previousSealedState; + private DateTimeOffset? _lastStateChangeTime; + + /// + public bool IsSealed => _egressPolicy?.IsSealed ?? _optionsMonitor.CurrentValue.Enabled; + + /// + public double EffectiveSamplingRate + { + get + { + if (!IsSealed) + { + return 1.0; // Full sampling when not sealed + } + + var options = _optionsMonitor.CurrentValue; + var incidentActive = _incidentModeService?.IsActive ?? false; + var incidentRate = incidentActive ? 1.0 : options.MaxSamplingPercent / 100.0; + + return options.GetEffectiveSamplingRate(incidentActive, incidentRate); + } + } + + /// + public bool IsIncidentModeOverrideActive => + IsSealed && + (_incidentModeService?.IsActive ?? false) && + _optionsMonitor.CurrentValue.AllowIncidentModeOverride; + + /// + public event EventHandler? StateChanged; + + /// + /// Initializes a new instance of . + /// + public SealedModeTelemetryService( + IOptionsMonitor optionsMonitor, + IEgressPolicy? egressPolicy = null, + IIncidentModeService? incidentModeService = null, + ILogger? logger = null, + TimeProvider? timeProvider = null) + { + _optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor)); + _egressPolicy = egressPolicy; + _incidentModeService = incidentModeService; + _logger = logger; + _timeProvider = timeProvider ?? TimeProvider.System; + + // Initialize metrics + _sealEventsCounter = Meter.CreateCounter( + "stellaops.telemetry.sealed.seal_events", + unit: "{event}", + description: "Count of seal events (entries into sealed mode)"); + + _unsealEventsCounter = Meter.CreateCounter( + "stellaops.telemetry.sealed.unseal_events", + unit: "{event}", + description: "Count of unseal events (exits from sealed mode)"); + + _driftEventsCounter = Meter.CreateCounter( + "stellaops.telemetry.sealed.drift_events", + unit: "{event}", + description: "Count of drift events when external export was blocked"); + + _blockedExportsCounter = Meter.CreateCounter( + "stellaops.telemetry.sealed.blocked_exports", + unit: "{request}", + description: "Count of blocked external export requests"); + + _previousSealedState = IsSealed; + + // Monitor for state changes + if (_egressPolicy is null) + { + _optionsMonitor.OnChange(OnOptionsChanged); + } + } + + private void OnOptionsChanged(SealedModeTelemetryOptions options, string? name) + { + var currentSealed = options.Enabled; + bool stateChanged; + + lock (_lock) + { + stateChanged = currentSealed != _previousSealedState; + if (stateChanged) + { + _previousSealedState = currentSealed; + _lastStateChangeTime = _timeProvider.GetUtcNow(); + } + } + + if (stateChanged) + { + if (currentSealed) + { + RecordSealEvent("Configuration change", "system"); + } + else + { + RecordUnsealEvent("Configuration change", "system"); + } + } + } + + /// + public IReadOnlyDictionary GetSealedModeTags() + { + if (!IsSealed) + { + return new Dictionary(); + } + + var options = _optionsMonitor.CurrentValue; + var tags = new Dictionary + { + [options.SealedTagName] = "true" + }; + + if (options.AddScrubbedTag && options.ForceScrub) + { + tags["scrubbed"] = "true"; + } + + if (IsIncidentModeOverrideActive) + { + tags["incident_override"] = "true"; + } + + foreach (var (key, value) in options.AdditionalTags) + { + tags[key] = value; + } + + return tags; + } + + /// + public bool IsExternalExportAllowed(Uri endpoint) + { + if (!IsSealed) + { + return true; + } + + _blockedExportsCounter.Add(1, new KeyValuePair("endpoint_host", endpoint.Host)); + + _logger?.LogDebug( + "External export to {Endpoint} blocked in sealed mode", + endpoint); + + return false; + } + + /// + public SealedModeExporterConfig? GetLocalExporterConfig() + { + if (!IsSealed) + { + return null; + } + + var options = _optionsMonitor.CurrentValue; + return new SealedModeExporterConfig + { + Type = options.Exporter, + FilePath = options.FilePath, + MaxBytes = options.MaxBytes, + MaxRotatedFiles = options.MaxRotatedFiles + }; + } + + /// + public void RecordSealEvent(string? reason = null, string? actor = null) + { + var now = _timeProvider.GetUtcNow(); + + using var activity = ActivitySource.StartActivity("SealMode", ActivityKind.Internal); + activity?.SetTag("sealed.reason", reason ?? "unspecified"); + activity?.SetTag("sealed.actor", actor ?? "unknown"); + activity?.SetTag("sealed.timestamp", now.ToString("O")); + + _sealEventsCounter.Add(1, + new KeyValuePair("reason", reason ?? "unspecified"), + new KeyValuePair("actor", actor ?? "unknown")); + + _logger?.LogInformation( + "Sealed mode activated. Reason: {Reason}, Actor: {Actor}, Timestamp: {Timestamp}", + reason ?? "unspecified", + actor ?? "unknown", + now); + + StateChanged?.Invoke(this, new SealedModeStateChangedEventArgs + { + IsSealed = true, + Timestamp = now, + Reason = reason, + Actor = actor + }); + } + + /// + public void RecordUnsealEvent(string? reason = null, string? actor = null) + { + var now = _timeProvider.GetUtcNow(); + + using var activity = ActivitySource.StartActivity("UnsealMode", ActivityKind.Internal); + activity?.SetTag("sealed.reason", reason ?? "unspecified"); + activity?.SetTag("sealed.actor", actor ?? "unknown"); + activity?.SetTag("sealed.timestamp", now.ToString("O")); + + _unsealEventsCounter.Add(1, + new KeyValuePair("reason", reason ?? "unspecified"), + new KeyValuePair("actor", actor ?? "unknown")); + + _logger?.LogInformation( + "Sealed mode deactivated. Reason: {Reason}, Actor: {Actor}, Timestamp: {Timestamp}", + reason ?? "unspecified", + actor ?? "unknown", + now); + + StateChanged?.Invoke(this, new SealedModeStateChangedEventArgs + { + IsSealed = false, + Timestamp = now, + Reason = reason, + Actor = actor + }); + } + + /// + public void RecordDriftEvent(Uri endpoint, TelemetrySignal signal) + { + using var activity = ActivitySource.StartActivity("SealedModeDrift", ActivityKind.Internal); + activity?.SetTag("drift.endpoint", endpoint.ToString()); + activity?.SetTag("drift.signal", signal.ToString()); + activity?.SetTag("drift.timestamp", _timeProvider.GetUtcNow().ToString("O")); + + _driftEventsCounter.Add(1, + new KeyValuePair("endpoint_host", endpoint.Host), + new KeyValuePair("signal", signal.ToString())); + + _logger?.LogWarning( + "Telemetry drift detected: external {Signal} export to {Endpoint} blocked in sealed mode", + signal, + endpoint); + } + + /// + public void Dispose() + { + // Cleanup if needed + } +} diff --git a/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/TelemetryServiceCollectionExtensions.cs b/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/TelemetryServiceCollectionExtensions.cs index 5344a8233..f67926b1a 100644 --- a/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/TelemetryServiceCollectionExtensions.cs +++ b/src/Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/TelemetryServiceCollectionExtensions.cs @@ -83,6 +83,71 @@ public static class TelemetryServiceCollectionExtensions return services; } + /// + /// Registers incident mode services for toggling enhanced telemetry during incidents. + /// + /// Service collection to mutate. + /// Optional configuration section binding. + /// Optional options configuration. + /// The service collection for chaining. + public static IServiceCollection AddIncidentMode( + this IServiceCollection services, + IConfiguration? configuration = null, + Action? configureOptions = null) + { + ArgumentNullException.ThrowIfNull(services); + + var optionsBuilder = services.AddOptions(); + + if (configuration is not null) + { + optionsBuilder.Bind(configuration.GetSection(IncidentModeOptions.SectionName)); + } + + if (configureOptions is not null) + { + optionsBuilder.Configure(configureOptions); + } + + services.TryAddSingleton(); + services.TryAddSingleton(sp => sp.GetRequiredService()); + + return services; + } + + /// + /// Registers sealed-mode telemetry services. + /// + /// Service collection to mutate. + /// Optional configuration section binding. + /// Optional options configuration. + /// The service collection for chaining. + public static IServiceCollection AddSealedModeTelemetry( + this IServiceCollection services, + IConfiguration? configuration = null, + Action? configureOptions = null) + { + ArgumentNullException.ThrowIfNull(services); + + var optionsBuilder = services.AddOptions(); + + if (configuration is not null) + { + optionsBuilder.Bind(configuration.GetSection(SealedModeTelemetryOptions.SectionName)); + } + + if (configureOptions is not null) + { + optionsBuilder.Configure(configureOptions); + } + + services.TryAddSingleton(); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(); + + return services; + } + /// /// Registers the StellaOps telemetry stack with sealed-mode enforcement. /// diff --git a/src/Web/StellaOps.Web/src/app/app.routes.ts b/src/Web/StellaOps.Web/src/app/app.routes.ts index ab1d76660..09ef4fc1f 100644 --- a/src/Web/StellaOps.Web/src/app/app.routes.ts +++ b/src/Web/StellaOps.Web/src/app/app.routes.ts @@ -1,6 +1,13 @@ import { Routes } from '@angular/router'; export const routes: Routes = [ + { + path: 'dashboard/sources', + loadComponent: () => + import('./features/dashboard/sources-dashboard.component').then( + (m) => m.SourcesDashboardComponent + ), + }, { path: 'console/profile', loadComponent: () => @@ -15,26 +22,26 @@ export const routes: Routes = [ (m) => m.TrivyDbSettingsPageComponent ), }, - { - path: 'scans/:scanId', - loadComponent: () => - import('./features/scans/scan-detail-page.component').then( - (m) => m.ScanDetailPageComponent - ), - }, - { - path: 'welcome', - loadComponent: () => - import('./features/welcome/welcome-page.component').then( - (m) => m.WelcomePageComponent - ), - }, - { - path: 'notify', - loadComponent: () => - import('./features/notify/notify-panel.component').then( - (m) => m.NotifyPanelComponent - ), + { + path: 'scans/:scanId', + loadComponent: () => + import('./features/scans/scan-detail-page.component').then( + (m) => m.ScanDetailPageComponent + ), + }, + { + path: 'welcome', + loadComponent: () => + import('./features/welcome/welcome-page.component').then( + (m) => m.WelcomePageComponent + ), + }, + { + path: 'notify', + loadComponent: () => + import('./features/notify/notify-panel.component').then( + (m) => m.NotifyPanelComponent + ), }, { path: 'auth/callback', diff --git a/src/Web/StellaOps.Web/src/app/core/api/aoc.models.ts b/src/Web/StellaOps.Web/src/app/core/api/aoc.models.ts index 2742b9080..53101cb62 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/aoc.models.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/aoc.models.ts @@ -66,9 +66,32 @@ export interface AocViolationDetail { field?: string; expected?: string; actual?: string; - provenance?: { - sourceId: string; - ingestedAt: string; - digest: string; - }; + provenance?: AocProvenance; +} + +export interface AocProvenance { + sourceId: string; + ingestedAt: string; + digest: string; + sourceType?: 'registry' | 'git' | 'upload' | 'api'; + sourceUrl?: string; + submitter?: string; +} + +export interface AocViolationGroup { + code: string; + description: string; + severity: 'critical' | 'high' | 'medium' | 'low'; + violations: AocViolationDetail[]; + affectedDocuments: number; + remediation?: string; +} + +export interface AocDocumentView { + documentId: string; + documentType: string; + violations: AocViolationDetail[]; + provenance: AocProvenance; + rawContent?: Record; + highlightedFields: string[]; } diff --git a/src/Web/StellaOps.Web/src/app/core/api/determinism.models.ts b/src/Web/StellaOps.Web/src/app/core/api/determinism.models.ts new file mode 100644 index 000000000..0faa428d1 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/api/determinism.models.ts @@ -0,0 +1,77 @@ +/** + * Determinism verification models for SBOM scan details. + */ + +export interface DeterminismStatus { + /** Overall determinism status */ + status: 'verified' | 'warning' | 'failed' | 'unknown'; + + /** Merkle root from _composition.json */ + merkleRoot: string | null; + + /** Whether Merkle root matches computed hash */ + merkleConsistent: boolean; + + /** Fragment hashes with verification status */ + fragments: DeterminismFragment[]; + + /** Composition metadata */ + composition: CompositionMeta | null; + + /** Timestamp of verification */ + verifiedAt: string; + + /** Any issues found */ + issues: DeterminismIssue[]; +} + +export interface DeterminismFragment { + /** Fragment identifier (e.g., layer digest) */ + id: string; + + /** Fragment type */ + type: 'layer' | 'metadata' | 'attestation' | 'sbom'; + + /** Expected hash from composition */ + expectedHash: string; + + /** Computed hash */ + computedHash: string; + + /** Whether hashes match */ + matches: boolean; + + /** Size in bytes */ + size: number; +} + +export interface CompositionMeta { + /** Composition schema version */ + schemaVersion: string; + + /** Scanner version that produced this */ + scannerVersion: string; + + /** Build timestamp */ + buildTimestamp: string; + + /** Total fragments */ + fragmentCount: number; + + /** Composition file hash */ + compositionHash: string; +} + +export interface DeterminismIssue { + /** Issue severity */ + severity: 'error' | 'warning' | 'info'; + + /** Issue code */ + code: string; + + /** Human-readable message */ + message: string; + + /** Affected fragment ID if applicable */ + fragmentId?: string; +} diff --git a/src/Web/StellaOps.Web/src/app/core/api/entropy.models.ts b/src/Web/StellaOps.Web/src/app/core/api/entropy.models.ts new file mode 100644 index 000000000..80ec0adad --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/api/entropy.models.ts @@ -0,0 +1,95 @@ +/** + * Entropy analysis models for image security visualization. + */ + +export interface EntropyAnalysis { + /** Image digest */ + imageDigest: string; + + /** Overall entropy score (0-10, higher = more suspicious) */ + overallScore: number; + + /** Risk level classification */ + riskLevel: 'low' | 'medium' | 'high' | 'critical'; + + /** Per-layer entropy breakdown */ + layers: LayerEntropy[]; + + /** Files with high entropy (potential secrets/malware) */ + highEntropyFiles: HighEntropyFile[]; + + /** Detector hints for suspicious patterns */ + detectorHints: DetectorHint[]; + + /** Analysis timestamp */ + analyzedAt: string; + + /** Link to raw entropy report */ + reportUrl: string; +} + +export interface LayerEntropy { + /** Layer digest */ + digest: string; + + /** Layer command (e.g., COPY, RUN) */ + command: string; + + /** Layer size in bytes */ + size: number; + + /** Average entropy for this layer (0-8 bits) */ + avgEntropy: number; + + /** Percentage of opaque bytes (high entropy) */ + opaqueByteRatio: number; + + /** Number of high-entropy files */ + highEntropyFileCount: number; + + /** Risk contribution to overall score */ + riskContribution: number; +} + +export interface HighEntropyFile { + /** File path in container */ + path: string; + + /** Layer where file was added */ + layerDigest: string; + + /** File size in bytes */ + size: number; + + /** File entropy (0-8 bits) */ + entropy: number; + + /** Classification */ + classification: 'encrypted' | 'compressed' | 'binary' | 'suspicious' | 'unknown'; + + /** Why this file is flagged */ + reason: string; +} + +export interface DetectorHint { + /** Hint ID */ + id: string; + + /** Severity */ + severity: 'critical' | 'high' | 'medium' | 'low'; + + /** Pattern type */ + type: 'credential' | 'key' | 'token' | 'obfuscated' | 'packed' | 'crypto'; + + /** Human-readable description */ + description: string; + + /** Affected file paths */ + affectedPaths: string[]; + + /** Confidence (0-100) */ + confidence: number; + + /** Remediation suggestion */ + remediation: string; +} diff --git a/src/Web/StellaOps.Web/src/app/core/api/exception.models.ts b/src/Web/StellaOps.Web/src/app/core/api/exception.models.ts new file mode 100644 index 000000000..e0deb4a0a --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/api/exception.models.ts @@ -0,0 +1,205 @@ +/** + * Exception management models for the Exception Center. + */ + +export type ExceptionStatus = 'draft' | 'pending' | 'approved' | 'active' | 'expired' | 'revoked'; + +export type ExceptionType = 'vulnerability' | 'license' | 'policy' | 'entropy' | 'determinism'; + +export interface Exception { + /** Unique exception ID */ + id: string; + + /** Short title */ + title: string; + + /** Detailed justification */ + justification: string; + + /** Exception type */ + type: ExceptionType; + + /** Current status */ + status: ExceptionStatus; + + /** Severity being excepted */ + severity: 'critical' | 'high' | 'medium' | 'low'; + + /** Scope definition */ + scope: ExceptionScope; + + /** Time constraints */ + timebox: ExceptionTimebox; + + /** Workflow history */ + workflow: ExceptionWorkflow; + + /** Audit trail */ + auditLog: ExceptionAuditEntry[]; + + /** Associated findings/violations */ + findings: string[]; + + /** Tags for filtering */ + tags: string[]; + + /** Created timestamp */ + createdAt: string; + + /** Last updated timestamp */ + updatedAt: string; +} + +export interface ExceptionScope { + /** Affected images (glob patterns allowed) */ + images?: string[]; + + /** Affected CVEs */ + cves?: string[]; + + /** Affected packages */ + packages?: string[]; + + /** Affected licenses */ + licenses?: string[]; + + /** Affected policy rules */ + policyRules?: string[]; + + /** Tenant scope */ + tenantId?: string; + + /** Environment scope */ + environments?: string[]; +} + +export interface ExceptionTimebox { + /** Start date */ + startsAt: string; + + /** Expiration date */ + expiresAt: string; + + /** Remaining days */ + remainingDays: number; + + /** Is expired */ + isExpired: boolean; + + /** Warning threshold (days before expiry) */ + warnDays: number; + + /** Is in warning period */ + isWarning: boolean; +} + +export interface ExceptionWorkflow { + /** Current workflow state */ + state: ExceptionStatus; + + /** Requested by */ + requestedBy: string; + + /** Requested at */ + requestedAt: string; + + /** Approved by */ + approvedBy?: string; + + /** Approved at */ + approvedAt?: string; + + /** Revoked by */ + revokedBy?: string; + + /** Revoked at */ + revokedAt?: string; + + /** Revocation reason */ + revocationReason?: string; + + /** Required approvers */ + requiredApprovers: string[]; + + /** Current approvals */ + approvals: ExceptionApproval[]; +} + +export interface ExceptionApproval { + /** Approver identity */ + approver: string; + + /** Decision */ + decision: 'approved' | 'rejected'; + + /** Timestamp */ + at: string; + + /** Optional comment */ + comment?: string; +} + +export interface ExceptionAuditEntry { + /** Entry ID */ + id: string; + + /** Action performed */ + action: 'created' | 'submitted' | 'approved' | 'rejected' | 'activated' | 'expired' | 'revoked' | 'edited'; + + /** Actor */ + actor: string; + + /** Timestamp */ + at: string; + + /** Details */ + details?: string; + + /** Previous values (for edits) */ + previousValues?: Record; + + /** New values (for edits) */ + newValues?: Record; +} + +export interface ExceptionFilter { + status?: ExceptionStatus[]; + type?: ExceptionType[]; + severity?: string[]; + search?: string; + tags?: string[]; + expiringSoon?: boolean; + createdAfter?: string; + createdBefore?: string; +} + +export interface ExceptionSortOption { + field: 'createdAt' | 'updatedAt' | 'expiresAt' | 'severity' | 'title'; + direction: 'asc' | 'desc'; +} + +export interface ExceptionTransition { + from: ExceptionStatus; + to: ExceptionStatus; + action: string; + requiresApproval: boolean; + allowedRoles: string[]; +} + +export const EXCEPTION_TRANSITIONS: ExceptionTransition[] = [ + { from: 'draft', to: 'pending', action: 'Submit for Approval', requiresApproval: false, allowedRoles: ['user', 'admin'] }, + { from: 'pending', to: 'approved', action: 'Approve', requiresApproval: true, allowedRoles: ['approver', 'admin'] }, + { from: 'pending', to: 'draft', action: 'Request Changes', requiresApproval: false, allowedRoles: ['approver', 'admin'] }, + { from: 'approved', to: 'active', action: 'Activate', requiresApproval: false, allowedRoles: ['admin'] }, + { from: 'active', to: 'revoked', action: 'Revoke', requiresApproval: false, allowedRoles: ['admin'] }, + { from: 'pending', to: 'revoked', action: 'Reject', requiresApproval: false, allowedRoles: ['approver', 'admin'] }, +]; + +export const KANBAN_COLUMNS: { status: ExceptionStatus; label: string; color: string }[] = [ + { status: 'draft', label: 'Draft', color: '#9ca3af' }, + { status: 'pending', label: 'Pending Approval', color: '#f59e0b' }, + { status: 'approved', label: 'Approved', color: '#3b82f6' }, + { status: 'active', label: 'Active', color: '#10b981' }, + { status: 'expired', label: 'Expired', color: '#6b7280' }, + { status: 'revoked', label: 'Revoked', color: '#ef4444' }, +]; diff --git a/src/Web/StellaOps.Web/src/app/core/api/policy.models.ts b/src/Web/StellaOps.Web/src/app/core/api/policy.models.ts new file mode 100644 index 000000000..efd4d30b3 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/api/policy.models.ts @@ -0,0 +1,163 @@ +/** + * Policy gate models for release flow indicators. + */ + +export interface PolicyGateStatus { + /** Overall gate status */ + status: 'passed' | 'failed' | 'warning' | 'pending' | 'skipped'; + + /** Policy evaluation ID */ + evaluationId: string; + + /** Target artifact (image, SBOM, etc.) */ + targetRef: string; + + /** Policy set that was evaluated */ + policySetId: string; + + /** Individual gate results */ + gates: PolicyGate[]; + + /** Blocking issues preventing publish */ + blockingIssues: PolicyBlockingIssue[]; + + /** Warning-level issues */ + warnings: PolicyWarning[]; + + /** Remediation hints for failures */ + remediationHints: PolicyRemediationHint[]; + + /** Evaluation timestamp */ + evaluatedAt: string; + + /** Can the artifact be published? */ + canPublish: boolean; + + /** Reason if publish is blocked */ + blockReason?: string; +} + +export interface PolicyGate { + /** Gate identifier */ + gateId: string; + + /** Human-readable name */ + name: string; + + /** Gate type */ + type: 'determinism' | 'vulnerability' | 'license' | 'signature' | 'entropy' | 'custom'; + + /** Gate result */ + result: 'passed' | 'failed' | 'warning' | 'skipped'; + + /** Is this gate required for publish? */ + required: boolean; + + /** Gate-specific details */ + details?: Record; + + /** Evidence references */ + evidenceRefs?: string[]; +} + +export interface PolicyBlockingIssue { + /** Issue code */ + code: string; + + /** Gate that produced this issue */ + gateId: string; + + /** Issue severity */ + severity: 'critical' | 'high'; + + /** Issue description */ + message: string; + + /** Affected resource */ + resource?: string; +} + +export interface PolicyWarning { + /** Warning code */ + code: string; + + /** Gate that produced this warning */ + gateId: string; + + /** Warning message */ + message: string; + + /** Affected resource */ + resource?: string; +} + +export interface PolicyRemediationHint { + /** Which gate/issue this remediates */ + forGate: string; + + /** Which issue code */ + forCode?: string; + + /** Hint title */ + title: string; + + /** Step-by-step instructions */ + steps: string[]; + + /** Documentation link */ + docsUrl?: string; + + /** CLI command to run */ + cliCommand?: string; + + /** Estimated effort */ + effort?: 'trivial' | 'easy' | 'moderate' | 'complex'; +} + +export interface DeterminismGateDetails { + /** Merkle root consistency */ + merkleRootConsistent: boolean; + + /** Expected Merkle root */ + expectedMerkleRoot?: string; + + /** Computed Merkle root */ + computedMerkleRoot?: string; + + /** Fragment verification results */ + fragmentResults: { + fragmentId: string; + expected: string; + computed: string; + match: boolean; + }[]; + + /** Composition file present */ + compositionPresent: boolean; + + /** Total fragments */ + totalFragments: number; + + /** Matching fragments */ + matchingFragments: number; +} + +export interface EntropyGateDetails { + /** Overall entropy score */ + entropyScore: number; + + /** Score threshold for warning */ + warnThreshold: number; + + /** Score threshold for block */ + blockThreshold: number; + + /** Action taken based on score */ + action: 'allow' | 'warn' | 'block'; + + /** High entropy files count */ + highEntropyFileCount: number; + + /** Suspicious patterns detected */ + suspiciousPatterns: string[]; +} diff --git a/src/Web/StellaOps.Web/src/app/features/aoc/verify-action.component.html b/src/Web/StellaOps.Web/src/app/features/aoc/verify-action.component.html new file mode 100644 index 000000000..abc1cac1d --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/aoc/verify-action.component.html @@ -0,0 +1,179 @@ +
+ +
+
+ {{ statusIcon() }} +
+

Verify Last {{ windowHours() }} Hours

+

{{ statusLabel() }}

+
+
+ +
+ @if (state() === 'idle' || state() === 'completed' || state() === 'error') { + + } + +
+
+ + + @if (state() === 'running') { +
+
+
+
+ {{ progress() | number:'1.0-0' }}% +
+ } + + + @if (state() === 'error' && error()) { +
+ [X] + {{ error() }} + +
+ } + + + @if (state() === 'completed' && result()) { +
+ +
+
+ {{ result()!.checkedCount | number }} + Documents Checked +
+
+ {{ result()!.passedCount | number }} + Passed +
+
+ {{ result()!.failedCount | number }} + Failed +
+
+ {{ resultSummary()?.passRate }}% + Pass Rate +
+
+ + + @if (result()!.violations.length > 0) { +
+
+ Violations Found + {{ result()!.violations.length }} +
+ + +
+ @for (code of resultSummary()?.uniqueCodes || []; track code) { + + {{ code }} + + {{ result()!.violations.filter(v => v.violationCode === code).length }} + + + } +
+ + +
    + @for (v of result()!.violations.slice(0, 3); track v.documentId + v.violationCode) { +
  • + +
  • + } + @if (result()!.violations.length > 3) { +
  • + + {{ result()!.violations.length - 3 }} more violations +
  • + } +
+
+ } @else { +
+ [+] + No violations found in the last {{ windowHours() }} hours +
+ } + + +
+ ID: {{ result()!.verificationId | slice:0:12 }} + Completed: {{ result()!.completedAt | date:'medium' }} +
+
+ } + + + @if (showCliGuidance()) { +
+
CLI Parity
+

{{ cliGuidance.description }}

+ + +
+ +
+ {{ getCliCommand() }} + +
+
+ + +
+ + + + @for (flag of cliGuidance.flags; track flag.flag) { + + + + + } + +
{{ flag.flag }}{{ flag.description }}
+
+ + +
+ +
+ @for (example of cliGuidance.examples; track example) { +
+ {{ example }} + +
+ } +
+
+ + +
+ [i] + Install CLI: npm install -g @stellaops/cli +
+
+ } +
diff --git a/src/Web/StellaOps.Web/src/app/features/aoc/verify-action.component.scss b/src/Web/StellaOps.Web/src/app/features/aoc/verify-action.component.scss new file mode 100644 index 000000000..3529a645a --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/aoc/verify-action.component.scss @@ -0,0 +1,517 @@ +.verify-action { + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 8px; + background: var(--color-bg-card, white); + overflow: hidden; + + &.state-running { + .action-header { + background: var(--color-info-bg, #f0f9ff); + border-left: 4px solid var(--color-info, #2563eb); + } + .status-icon { color: var(--color-info, #2563eb); } + } + + &.state-completed { + .action-header { + background: var(--color-success-bg, #ecfdf5); + border-left: 4px solid var(--color-success, #059669); + } + .status-icon { color: var(--color-success, #059669); } + } + + &.state-error { + .action-header { + background: var(--color-error-bg, #fef2f2); + border-left: 4px solid var(--color-error, #dc2626); + } + .status-icon { color: var(--color-error, #dc2626); } + } +} + +.action-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 1rem; + background: var(--color-bg-subtle, #f9fafb); + border-left: 4px solid var(--color-border, #e5e7eb); + flex-wrap: wrap; +} + +.action-info { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.status-icon { + font-family: monospace; + font-weight: 700; + font-size: 1rem; + color: var(--color-text-muted, #6b7280); +} + +.action-text { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.action-title { + margin: 0; + font-size: 0.9375rem; + font-weight: 600; + color: var(--color-text, #111827); +} + +.action-desc { + margin: 0; + font-size: 0.8125rem; + color: var(--color-text-muted, #6b7280); +} + +.action-buttons { + display: flex; + gap: 0.5rem; +} + +.btn-verify { + padding: 0.5rem 1rem; + background: var(--color-primary, #2563eb); + border: none; + border-radius: 4px; + font-size: 0.8125rem; + font-weight: 600; + color: white; + cursor: pointer; + + &:hover { + background: var(--color-primary-dark, #1d4ed8); + } +} + +.btn-cli { + padding: 0.5rem 0.75rem; + background: var(--color-bg-card, white); + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 4px; + font-size: 0.8125rem; + font-family: monospace; + cursor: pointer; + color: var(--color-text-muted, #6b7280); + + &:hover { + background: var(--color-bg-hover, #f3f4f6); + } + + &.active { + background: var(--color-primary, #2563eb); + color: white; + border-color: var(--color-primary, #2563eb); + } +} + +// Progress +.progress-section { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + border-top: 1px solid var(--color-border, #e5e7eb); +} + +.progress-bar { + flex: 1; + height: 8px; + background: var(--color-bg-subtle, #e5e7eb); + border-radius: 4px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: var(--color-primary, #2563eb); + border-radius: 4px; + transition: width 0.2s ease; +} + +.progress-text { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-muted, #6b7280); + min-width: 40px; + text-align: right; +} + +// Error +.error-banner { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + background: var(--color-error-bg, #fef2f2); + border-top: 1px solid var(--color-error-border, #fecaca); +} + +.error-icon { + font-family: monospace; + font-weight: 700; + color: var(--color-error, #dc2626); +} + +.error-message { + flex: 1; + font-size: 0.8125rem; + color: var(--color-error, #dc2626); +} + +.btn-retry { + padding: 0.25rem 0.75rem; + background: var(--color-error, #dc2626); + border: none; + border-radius: 4px; + font-size: 0.75rem; + color: white; + cursor: pointer; + + &:hover { + background: var(--color-error-dark, #b91c1c); + } +} + +// Results +.results-section { + padding: 1rem; + border-top: 1px solid var(--color-border, #e5e7eb); +} + +.results-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); + gap: 0.75rem; + margin-bottom: 1rem; +} + +.stat-card { + padding: 0.75rem; + background: var(--color-bg-subtle, #f9fafb); + border-radius: 6px; + text-align: center; + + &.success { + background: var(--color-success-bg, #ecfdf5); + .stat-value { color: var(--color-success, #059669); } + } + + &.error { + background: var(--color-error-bg, #fef2f2); + .stat-value { color: var(--color-error, #dc2626); } + } +} + +.stat-value { + display: block; + font-size: 1.25rem; + font-weight: 700; + color: var(--color-text, #111827); +} + +.stat-label { + font-size: 0.6875rem; + color: var(--color-text-muted, #6b7280); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.violations-preview { + margin-bottom: 1rem; +} + +.preview-title { + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text, #374151); + margin: 0 0 0.5rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.violation-count { + font-size: 0.6875rem; + padding: 0.125rem 0.375rem; + background: var(--color-error-bg, #fef2f2); + color: var(--color-error, #dc2626); + border-radius: 10px; + font-weight: normal; +} + +.code-breakdown { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin-bottom: 0.75rem; +} + +.code-chip { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + background: var(--color-bg-subtle, #f3f4f6); + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 4px; + font-family: monospace; + font-size: 0.75rem; +} + +.code-count { + font-size: 0.625rem; + padding: 0 0.25rem; + background: var(--color-error, #dc2626); + color: white; + border-radius: 8px; +} + +.violations-list { + list-style: none; + padding: 0; + margin: 0; +} + +.violation-item { + border-bottom: 1px solid var(--color-border-light, #f3f4f6); + + &:last-child { + border-bottom: none; + } +} + +.violation-btn { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.5rem; + background: transparent; + border: none; + cursor: pointer; + text-align: left; + + &:hover { + background: var(--color-bg-hover, #f9fafb); + } +} + +.v-code { + font-family: monospace; + font-size: 0.75rem; + font-weight: 600; + color: var(--color-error, #dc2626); +} + +.v-doc { + font-family: monospace; + font-size: 0.75rem; + color: var(--color-text-muted, #6b7280); +} + +.v-field { + font-size: 0.6875rem; + padding: 0.125rem 0.25rem; + background: var(--color-warning-bg, #fef3c7); + border-radius: 2px; + color: var(--color-warning-dark, #92400e); +} + +.more-violations { + padding: 0.5rem; + font-size: 0.75rem; + color: var(--color-text-muted, #9ca3af); + font-style: italic; +} + +.no-violations { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 1rem; + background: var(--color-success-bg, #ecfdf5); + border-radius: 4px; + font-size: 0.875rem; + color: var(--color-success, #059669); + margin-bottom: 1rem; +} + +.success-icon { + font-family: monospace; + font-weight: 700; +} + +.completion-info { + display: flex; + justify-content: space-between; + font-size: 0.6875rem; + color: var(--color-text-muted, #9ca3af); + padding-top: 0.5rem; + border-top: 1px solid var(--color-border-light, #f3f4f6); +} + +.verify-id { + font-family: monospace; +} + +// CLI Guidance +.cli-guidance { + padding: 1rem; + background: var(--color-bg-subtle, #f9fafb); + border-top: 1px solid var(--color-border, #e5e7eb); +} + +.cli-title { + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text, #374151); + margin: 0 0 0.5rem; +} + +.cli-desc { + font-size: 0.8125rem; + color: var(--color-text-muted, #6b7280); + margin: 0 0 1rem; +} + +.cli-command-section, +.cli-flags-section, +.cli-examples-section { + margin-bottom: 1rem; + + &:last-child { + margin-bottom: 0; + } +} + +.cli-label { + display: block; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--color-text-muted, #6b7280); + margin-bottom: 0.375rem; +} + +.cli-command { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: var(--color-bg-code, #1f2937); + border-radius: 4px; + + code { + flex: 1; + font-size: 0.8125rem; + color: #e5e7eb; + white-space: nowrap; + overflow-x: auto; + } +} + +.btn-copy { + padding: 0.25rem 0.375rem; + background: rgba(255, 255, 255, 0.1); + border: none; + border-radius: 3px; + font-family: monospace; + font-size: 0.625rem; + color: #9ca3af; + cursor: pointer; + flex-shrink: 0; + + &:hover { + background: rgba(255, 255, 255, 0.2); + color: white; + } +} + +.flags-table { + width: 100%; + font-size: 0.8125rem; + border-collapse: collapse; + + tr { + border-bottom: 1px solid var(--color-border-light, #f3f4f6); + + &:last-child { + border-bottom: none; + } + } + + td { + padding: 0.375rem 0; + } + + .flag-name { + width: 140px; + + code { + font-size: 0.75rem; + background: var(--color-bg-code, #f3f4f6); + padding: 0.125rem 0.25rem; + border-radius: 2px; + } + } + + .flag-desc { + color: var(--color-text-muted, #6b7280); + } +} + +.examples-list { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.example-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.5rem; + background: var(--color-bg-code, #1f2937); + border-radius: 4px; + + code { + flex: 1; + font-size: 0.75rem; + color: #d1d5db; + white-space: nowrap; + overflow-x: auto; + } +} + +.install-hint { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + background: var(--color-info-bg, #f0f9ff); + border-radius: 4px; + font-size: 0.75rem; + color: var(--color-info, #0284c7); + margin-top: 1rem; + + code { + background: var(--color-bg-code, #e0f2fe); + padding: 0.125rem 0.25rem; + border-radius: 2px; + } +} + +.hint-icon { + font-family: monospace; + font-weight: 600; +} diff --git a/src/Web/StellaOps.Web/src/app/features/aoc/verify-action.component.ts b/src/Web/StellaOps.Web/src/app/features/aoc/verify-action.component.ts new file mode 100644 index 000000000..12a0a40a6 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/aoc/verify-action.component.ts @@ -0,0 +1,184 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + output, + signal, +} from '@angular/core'; +import { AocClient } from '../../core/api/aoc.client'; +import { + AocVerificationRequest, + AocVerificationResult, + AocViolationDetail, +} from '../../core/api/aoc.models'; + +type VerifyState = 'idle' | 'running' | 'completed' | 'error'; + +export interface CliParityGuidance { + command: string; + description: string; + flags: { flag: string; description: string }[]; + examples: string[]; +} + +@Component({ + selector: 'app-verify-action', + standalone: true, + imports: [CommonModule], + templateUrl: './verify-action.component.html', + styleUrls: ['./verify-action.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VerifyActionComponent { + private readonly aocClient = inject(AocClient); + + /** Tenant ID to verify */ + readonly tenantId = input.required(); + + /** Time window in hours (default 24h) */ + readonly windowHours = input(24); + + /** Maximum documents to check */ + readonly limit = input(10000); + + /** Emits when verification completes */ + readonly verified = output(); + + /** Emits when user clicks on a violation */ + readonly selectViolation = output(); + + readonly state = signal('idle'); + readonly result = signal(null); + readonly error = signal(null); + readonly progress = signal(0); + readonly showCliGuidance = signal(false); + + readonly statusIcon = computed(() => { + switch (this.state()) { + case 'idle': + return '[ ]'; + case 'running': + return '[~]'; + case 'completed': + return this.result()?.status === 'passed' ? '[+]' : '[!]'; + case 'error': + return '[X]'; + default: + return '[?]'; + } + }); + + readonly statusLabel = computed(() => { + switch (this.state()) { + case 'idle': + return 'Ready to verify'; + case 'running': + return 'Verification in progress...'; + case 'completed': + const r = this.result(); + if (!r) return 'Completed'; + return r.status === 'passed' + ? 'Verification passed' + : r.status === 'failed' + ? 'Verification failed' + : 'Verification completed with warnings'; + case 'error': + return 'Verification error'; + default: + return ''; + } + }); + + readonly resultSummary = computed(() => { + const r = this.result(); + if (!r) return null; + return { + passRate: ((r.passedCount / r.checkedCount) * 100).toFixed(2), + violationCount: r.violations.length, + uniqueCodes: [...new Set(r.violations.map((v) => v.violationCode))], + }; + }); + + readonly cliGuidance: CliParityGuidance = { + command: 'stella aoc verify', + description: + 'Run the same verification from CLI for automation, CI/CD pipelines, or detailed output.', + flags: [ + { flag: '--tenant', description: 'Tenant ID to verify' }, + { flag: '--since', description: 'Start time (ISO8601 or duration like "24h")' }, + { flag: '--limit', description: 'Maximum documents to check' }, + { flag: '--output', description: 'Output format: json, table, summary' }, + { flag: '--fail-on-violation', description: 'Exit with code 1 if any violations found' }, + { flag: '--verbose', description: 'Show detailed violation information' }, + ], + examples: [ + 'stella aoc verify --tenant $TENANT_ID --since 24h', + 'stella aoc verify --tenant $TENANT_ID --since 24h --output json > report.json', + 'stella aoc verify --tenant $TENANT_ID --since 24h --fail-on-violation', + ], + }; + + async runVerification(): Promise { + if (this.state() === 'running') return; + + this.state.set('running'); + this.error.set(null); + this.result.set(null); + this.progress.set(0); + + // Simulate progress updates + const progressInterval = setInterval(() => { + this.progress.update((p) => Math.min(p + Math.random() * 15, 90)); + }, 200); + + const since = new Date(); + since.setHours(since.getHours() - this.windowHours()); + + const request: AocVerificationRequest = { + tenantId: this.tenantId(), + since: since.toISOString(), + limit: this.limit(), + }; + + this.aocClient.verify(request).subscribe({ + next: (result) => { + clearInterval(progressInterval); + this.progress.set(100); + this.result.set(result); + this.state.set('completed'); + this.verified.emit(result); + }, + error: (err) => { + clearInterval(progressInterval); + this.state.set('error'); + this.error.set(err.message || 'Verification failed'); + }, + }); + } + + reset(): void { + this.state.set('idle'); + this.result.set(null); + this.error.set(null); + this.progress.set(0); + } + + toggleCliGuidance(): void { + this.showCliGuidance.update((v) => !v); + } + + onSelectViolation(violation: AocViolationDetail): void { + this.selectViolation.emit(violation); + } + + copyCommand(command: string): void { + navigator.clipboard.writeText(command); + } + + getCliCommand(): string { + return `stella aoc verify --tenant ${this.tenantId()} --since ${this.windowHours()}h`; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/aoc/violation-drilldown.component.html b/src/Web/StellaOps.Web/src/app/features/aoc/violation-drilldown.component.html new file mode 100644 index 000000000..76e77386a --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/aoc/violation-drilldown.component.html @@ -0,0 +1,279 @@ +
+ +
+
+
+ {{ totalViolations() }} + Violations +
+
+ {{ totalDocuments() }} + Documents +
+
+ @if (severityCounts().critical > 0) { + {{ severityCounts().critical }} critical + } + @if (severityCounts().high > 0) { + {{ severityCounts().high }} high + } + @if (severityCounts().medium > 0) { + {{ severityCounts().medium }} medium + } + @if (severityCounts().low > 0) { + {{ severityCounts().low }} low + } +
+
+ +
+
+ + +
+ +
+
+ + + @if (viewMode() === 'by-violation') { +
+ @for (group of filteredGroups(); track group.code) { +
+ + + @if (expandedCode() === group.code) { +
+ @if (group.remediation) { +
+ Remediation: {{ group.remediation }} +
+ } + + + + + + + + + + + + + + @for (v of group.violations; track v.documentId + v.field) { + + + + + + + + + } + +
DocumentFieldExpectedActualProvenance
+ + + @if (v.field) { + {{ v.field }} + } @else { + - + } + + @if (v.expected) { + {{ v.expected }} + } @else { + - + } + + @if (v.actual) { + {{ v.actual }} + } @else { + - + } + + @if (v.provenance) { +
+ {{ getSourceTypeIcon(v.provenance.sourceType) }} + + {{ v.provenance.sourceId | slice:0:15 }} + + + {{ formatDigest(v.provenance.digest) }} + +
+ } @else { + No provenance + } +
+ +
+
+ } +
+ } + + @if (filteredGroups().length === 0) { +
+ @if (searchFilter()) { +

No violations match "{{ searchFilter() }}"

+ } @else { +

No violations to display

+ } +
+ } +
+ } + + + @if (viewMode() === 'by-document') { +
+ @for (doc of filteredDocuments(); track doc.documentId) { +
+ + + @if (expandedDocId() === doc.documentId) { +
+ +
+

Provenance

+
+
+
Source
+
+ {{ getSourceTypeIcon(doc.provenance.sourceType) }} + {{ doc.provenance.sourceId }} +
+
+
+
Digest
+
{{ doc.provenance.digest }}
+
+
+
Ingested
+
{{ formatDate(doc.provenance.ingestedAt) }}
+
+ @if (doc.provenance.submitter) { +
+
Submitter
+
{{ doc.provenance.submitter }}
+
+ } + @if (doc.provenance.sourceUrl) { +
+
Source URL
+
{{ doc.provenance.sourceUrl }}
+
+ } +
+
+ + +
+

Violations

+
    + @for (v of doc.violations; track v.violationCode + v.field) { +
  • +
    + {{ v.violationCode }} + @if (v.field) { + at + {{ v.field }} + } +
    + @if (v.expected || v.actual) { +
    +
    + Expected: + {{ v.expected || 'N/A' }} +
    +
    + Actual: + {{ v.actual || 'N/A' }} +
    +
    + } +
  • + } +
+
+ + + @if (doc.rawContent) { +
+

+ Document Fields + +

+
+ @for (field of doc.highlightedFields; track field) { +
+ {{ field }} + {{ getFieldValue(doc.rawContent, field) }} +
+ } +
+
+ } +
+ } +
+ } + + @if (filteredDocuments().length === 0) { +
+ @if (searchFilter()) { +

No documents match "{{ searchFilter() }}"

+ } @else { +

No documents to display

+ } +
+ } +
+ } +
diff --git a/src/Web/StellaOps.Web/src/app/features/aoc/violation-drilldown.component.scss b/src/Web/StellaOps.Web/src/app/features/aoc/violation-drilldown.component.scss new file mode 100644 index 000000000..d552a6be2 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/aoc/violation-drilldown.component.scss @@ -0,0 +1,585 @@ +.violation-drilldown { + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 8px; + background: var(--color-bg-card, white); + overflow: hidden; +} + +.drilldown-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + padding: 1rem; + background: var(--color-bg-subtle, #f9fafb); + border-bottom: 1px solid var(--color-border, #e5e7eb); + flex-wrap: wrap; +} + +.summary-stats { + display: flex; + align-items: center; + gap: 1.5rem; + flex-wrap: wrap; +} + +.stat { + display: flex; + flex-direction: column; +} + +.stat-value { + font-size: 1.5rem; + font-weight: 700; + color: var(--color-text, #111827); +} + +.stat-label { + font-size: 0.75rem; + color: var(--color-text-muted, #6b7280); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.severity-breakdown { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.severity-chip { + font-size: 0.6875rem; + padding: 0.125rem 0.5rem; + border-radius: 12px; + font-weight: 500; + + &.critical { + background: var(--color-critical-bg, #fef2f2); + color: var(--color-critical, #dc2626); + } + + &.high { + background: var(--color-error-bg, #fff7ed); + color: var(--color-error, #ea580c); + } + + &.medium { + background: var(--color-warning-bg, #fffbeb); + color: var(--color-warning, #d97706); + } + + &.low { + background: var(--color-info-bg, #f0f9ff); + color: var(--color-info, #0284c7); + } +} + +.controls { + display: flex; + gap: 0.75rem; + align-items: center; + flex-wrap: wrap; +} + +.view-toggle { + display: flex; + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 6px; + overflow: hidden; +} + +.toggle-btn { + padding: 0.375rem 0.75rem; + background: var(--color-bg-card, white); + border: none; + font-size: 0.8125rem; + cursor: pointer; + color: var(--color-text-muted, #6b7280); + + &:hover { + background: var(--color-bg-hover, #f3f4f6); + } + + &.active { + background: var(--color-primary, #2563eb); + color: white; + } + + &:not(:last-child) { + border-right: 1px solid var(--color-border, #e5e7eb); + } +} + +.search-input { + padding: 0.375rem 0.75rem; + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 6px; + font-size: 0.8125rem; + min-width: 200px; + + &:focus { + outline: 2px solid var(--color-primary, #2563eb); + outline-offset: -1px; + } +} + +// Violation List (By Violation View) +.violation-list { + max-height: 600px; + overflow-y: auto; +} + +.violation-group { + border-bottom: 1px solid var(--color-border, #e5e7eb); + + &:last-child { + border-bottom: none; + } + + &.severity-critical { + .group-header { border-left: 3px solid var(--color-critical, #dc2626); } + .severity-icon { color: var(--color-critical, #dc2626); } + } + + &.severity-high { + .group-header { border-left: 3px solid var(--color-error, #ea580c); } + .severity-icon { color: var(--color-error, #ea580c); } + } + + &.severity-medium { + .group-header { border-left: 3px solid var(--color-warning, #d97706); } + .severity-icon { color: var(--color-warning, #d97706); } + } + + &.severity-low { + .group-header { border-left: 3px solid var(--color-info, #0284c7); } + .severity-icon { color: var(--color-info, #0284c7); } + } +} + +.group-header { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + padding: 0.75rem 1rem; + background: transparent; + border: none; + cursor: pointer; + text-align: left; + + &:hover { + background: var(--color-bg-hover, #f9fafb); + } +} + +.severity-icon { + font-weight: 700; + font-size: 0.875rem; + width: 1.5rem; + text-align: center; +} + +.group-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.violation-code { + font-family: monospace; + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text, #111827); +} + +.violation-desc { + font-size: 0.8125rem; + color: var(--color-text-muted, #6b7280); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.affected-count { + font-size: 0.75rem; + color: var(--color-text-muted, #9ca3af); + white-space: nowrap; +} + +.expand-icon { + font-size: 0.625rem; + color: var(--color-text-muted, #9ca3af); + transition: transform 0.2s; + + &.expanded { + transform: rotate(180deg); + } +} + +.group-details { + padding: 0 1rem 1rem; + background: var(--color-bg-subtle, #f9fafb); +} + +.remediation-hint { + font-size: 0.8125rem; + padding: 0.5rem 0.75rem; + margin-bottom: 0.75rem; + background: var(--color-info-bg, #f0f9ff); + border-radius: 4px; + color: var(--color-text, #374151); +} + +.violations-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8125rem; + + th { + text-align: left; + padding: 0.5rem; + font-weight: 600; + color: var(--color-text-muted, #6b7280); + border-bottom: 1px solid var(--color-border, #e5e7eb); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.03em; + } + + td { + padding: 0.5rem; + vertical-align: top; + border-bottom: 1px solid var(--color-border-light, #f3f4f6); + } + + tr:last-child td { + border-bottom: none; + } +} + +.doc-link { + background: none; + border: none; + color: var(--color-primary, #2563eb); + font-family: monospace; + font-size: 0.75rem; + cursor: pointer; + padding: 0; + + &:hover { + text-decoration: underline; + } +} + +.field-path { + font-size: 0.75rem; + padding: 0.125rem 0.25rem; + border-radius: 2px; + + &.highlighted { + background: var(--color-warning-bg, #fef3c7); + color: var(--color-warning-dark, #92400e); + } +} + +.value { + font-size: 0.75rem; + padding: 0.125rem 0.25rem; + border-radius: 2px; + background: var(--color-bg-code, #f3f4f6); + max-width: 150px; + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &.expected { + background: var(--color-success-bg, #ecfdf5); + color: var(--color-success, #059669); + } + + &.actual.error { + background: var(--color-error-bg, #fef2f2); + color: var(--color-error, #dc2626); + } +} + +.no-field, +.no-value, +.no-provenance { + color: var(--color-text-muted, #9ca3af); + font-style: italic; +} + +.provenance-info { + display: flex; + flex-direction: column; + gap: 0.125rem; + font-size: 0.6875rem; +} + +.source-type { + font-family: monospace; + font-weight: 600; +} + +.source-id, +.digest { + color: var(--color-text-muted, #6b7280); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 120px; +} + +.btn-icon { + background: none; + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 4px; + padding: 0.25rem 0.5rem; + font-family: monospace; + font-size: 0.75rem; + cursor: pointer; + color: var(--color-text-muted, #6b7280); + + &:hover { + background: var(--color-bg-hover, #f3f4f6); + color: var(--color-text, #374151); + } +} + +// Document List (By Document View) +.document-list { + max-height: 600px; + overflow-y: auto; +} + +.document-card { + border-bottom: 1px solid var(--color-border, #e5e7eb); + + &:last-child { + border-bottom: none; + } +} + +.doc-header { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + padding: 0.75rem 1rem; + background: transparent; + border: none; + cursor: pointer; + text-align: left; + + &:hover { + background: var(--color-bg-hover, #f9fafb); + } +} + +.doc-type-badge { + font-size: 0.6875rem; + padding: 0.125rem 0.375rem; + border-radius: 3px; + background: var(--color-bg-subtle, #f3f4f6); + color: var(--color-text-muted, #6b7280); + text-transform: uppercase; + font-weight: 500; +} + +.doc-id { + flex: 1; + font-family: monospace; + font-size: 0.8125rem; + color: var(--color-text, #111827); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.violation-count { + font-size: 0.75rem; + color: var(--color-error, #dc2626); + font-weight: 500; +} + +.doc-details { + padding: 0 1rem 1rem; + background: var(--color-bg-subtle, #f9fafb); +} + +.section-title { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--color-text-muted, #6b7280); + margin: 0 0 0.5rem; + display: flex; + justify-content: space-between; + align-items: center; +} + +.btn-link { + background: none; + border: none; + color: var(--color-primary, #2563eb); + font-size: 0.75rem; + cursor: pointer; + padding: 0; + text-transform: none; + letter-spacing: normal; + font-weight: normal; + + &:hover { + text-decoration: underline; + } +} + +.provenance-section, +.violations-section, +.raw-content-section { + margin-bottom: 1rem; + + &:last-child { + margin-bottom: 0; + } +} + +.provenance-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.5rem; + margin: 0; +} + +.prov-item { + dt { + font-size: 0.6875rem; + color: var(--color-text-muted, #9ca3af); + margin-bottom: 0.125rem; + } + + dd { + margin: 0; + font-size: 0.8125rem; + color: var(--color-text, #374151); + + code { + font-size: 0.75rem; + background: var(--color-bg-code, #f3f4f6); + padding: 0.125rem 0.25rem; + border-radius: 2px; + } + + &.url { + font-size: 0.75rem; + word-break: break-all; + } + } +} + +.doc-violations-list { + list-style: none; + padding: 0; + margin: 0; +} + +.doc-violation-item { + padding: 0.5rem; + background: var(--color-bg-card, white); + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 4px; + margin-bottom: 0.5rem; + + &:last-child { + margin-bottom: 0; + } +} + +.violation-header { + display: flex; + align-items: center; + gap: 0.375rem; + flex-wrap: wrap; +} + +.at-field { + font-size: 0.75rem; + color: var(--color-text-muted, #9ca3af); +} + +.value-diff { + margin-top: 0.5rem; + padding: 0.5rem; + background: var(--color-bg-subtle, #f9fafb); + border-radius: 4px; +} + +.expected-row, +.actual-row { + display: flex; + align-items: flex-start; + gap: 0.5rem; + font-size: 0.8125rem; + + .label { + font-size: 0.6875rem; + color: var(--color-text-muted, #9ca3af); + min-width: 60px; + } +} + +.actual-row { + margin-top: 0.25rem; +} + +.field-preview { + background: var(--color-bg-card, white); + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 4px; + overflow: hidden; +} + +.field-row { + display: flex; + padding: 0.375rem 0.5rem; + border-bottom: 1px solid var(--color-border-light, #f3f4f6); + font-size: 0.8125rem; + + &:last-child { + border-bottom: none; + } + + &.error { + background: var(--color-error-bg, #fef2f2); + + .field-name { + color: var(--color-error, #dc2626); + } + } +} + +.field-name { + font-family: monospace; + font-size: 0.75rem; + color: var(--color-text-muted, #6b7280); + min-width: 120px; +} + +.field-value { + font-size: 0.75rem; + color: var(--color-text, #374151); + word-break: break-all; +} + +.empty-state { + padding: 2rem; + text-align: center; + color: var(--color-text-muted, #9ca3af); + font-style: italic; +} diff --git a/src/Web/StellaOps.Web/src/app/features/aoc/violation-drilldown.component.ts b/src/Web/StellaOps.Web/src/app/features/aoc/violation-drilldown.component.ts new file mode 100644 index 000000000..4b84dea52 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/aoc/violation-drilldown.component.ts @@ -0,0 +1,182 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, + signal, +} from '@angular/core'; +import { + AocViolationDetail, + AocViolationGroup, + AocDocumentView, + AocProvenance, +} from '../../core/api/aoc.models'; + +type ViewMode = 'by-violation' | 'by-document'; + +@Component({ + selector: 'app-violation-drilldown', + standalone: true, + imports: [CommonModule], + templateUrl: './violation-drilldown.component.html', + styleUrls: ['./violation-drilldown.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ViolationDrilldownComponent { + /** Violation groups to display */ + readonly violationGroups = input.required(); + + /** Document views for by-document mode */ + readonly documentViews = input([]); + + /** Emits when user clicks on a document */ + readonly selectDocument = output(); + + /** Emits when user wants to view raw document */ + readonly viewRawDocument = output(); + + /** Current view mode */ + readonly viewMode = signal('by-violation'); + + /** Currently expanded violation code */ + readonly expandedCode = signal(null); + + /** Currently expanded document ID */ + readonly expandedDocId = signal(null); + + /** Search filter */ + readonly searchFilter = signal(''); + + readonly filteredGroups = computed(() => { + const filter = this.searchFilter().toLowerCase(); + if (!filter) return this.violationGroups(); + return this.violationGroups().filter( + (g) => + g.code.toLowerCase().includes(filter) || + g.description.toLowerCase().includes(filter) || + g.violations.some( + (v) => + v.documentId.toLowerCase().includes(filter) || + v.field?.toLowerCase().includes(filter) + ) + ); + }); + + readonly filteredDocuments = computed(() => { + const filter = this.searchFilter().toLowerCase(); + if (!filter) return this.documentViews(); + return this.documentViews().filter( + (d) => + d.documentId.toLowerCase().includes(filter) || + d.documentType.toLowerCase().includes(filter) || + d.violations.some( + (v) => + v.violationCode.toLowerCase().includes(filter) || + v.field?.toLowerCase().includes(filter) + ) + ); + }); + + readonly totalViolations = computed(() => + this.violationGroups().reduce((sum, g) => sum + g.violations.length, 0) + ); + + readonly totalDocuments = computed(() => { + const docIds = new Set(); + for (const group of this.violationGroups()) { + for (const v of group.violations) { + docIds.add(v.documentId); + } + } + return docIds.size; + }); + + readonly severityCounts = computed(() => { + const counts = { critical: 0, high: 0, medium: 0, low: 0 }; + for (const group of this.violationGroups()) { + counts[group.severity] += group.violations.length; + } + return counts; + }); + + setViewMode(mode: ViewMode): void { + this.viewMode.set(mode); + } + + toggleGroup(code: string): void { + this.expandedCode.update((current) => (current === code ? null : code)); + } + + toggleDocument(docId: string): void { + this.expandedDocId.update((current) => (current === docId ? null : docId)); + } + + onSearch(event: Event): void { + const input = event.target as HTMLInputElement; + this.searchFilter.set(input.value); + } + + onSelectDocument(docId: string): void { + this.selectDocument.emit(docId); + } + + onViewRaw(docId: string): void { + this.viewRawDocument.emit(docId); + } + + getSeverityIcon(severity: string): string { + switch (severity) { + case 'critical': + return '!!'; + case 'high': + return '!'; + case 'medium': + return '~'; + default: + return '-'; + } + } + + getSourceTypeIcon(sourceType?: string): string { + switch (sourceType) { + case 'registry': + return '[R]'; + case 'git': + return '[G]'; + case 'upload': + return '[U]'; + case 'api': + return '[A]'; + default: + return '[?]'; + } + } + + formatDigest(digest: string, length = 12): string { + if (digest.length <= length) return digest; + return digest.slice(0, length) + '...'; + } + + formatDate(dateStr: string): string { + return new Date(dateStr).toLocaleString(); + } + + isFieldHighlighted(doc: AocDocumentView, field: string): boolean { + return doc.highlightedFields.includes(field); + } + + getFieldValue(content: Record | undefined, path: string): string { + if (!content) return 'N/A'; + const parts = path.split('.'); + let current: unknown = content; + for (const part of parts) { + if (current == null || typeof current !== 'object') return 'N/A'; + current = (current as Record)[part]; + } + if (current == null) return 'null'; + if (typeof current === 'object') return JSON.stringify(current); + return String(current); + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/dashboard/sources-dashboard.component.html b/src/Web/StellaOps.Web/src/app/features/dashboard/sources-dashboard.component.html new file mode 100644 index 000000000..facc00f07 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/dashboard/sources-dashboard.component.html @@ -0,0 +1,148 @@ +
+
+

Sources Dashboard

+
+ + +
+
+ + @if (loading()) { +
+
+

Loading AOC metrics...

+
+ } + + @if (error()) { +
+

{{ error() }}

+ +
+ } + + @if (metrics(); as m) { +
+ +
+

AOC Pass/Fail

+
+
+ {{ passRate() }}% + Pass Rate +
+
+
+ {{ m.passCount | number }} + Passed +
+
+ {{ m.failCount | number }} + Failed +
+
+ {{ m.totalCount | number }} + Total +
+
+
+
+ + +
+

Recent Violations

+
+ @if (m.recentViolations.length === 0) { +

No violations in time window

+ } @else { +
    + @for (v of m.recentViolations; track v.code) { +
  • +
    + {{ v.code }} + {{ v.count }}x +
    +

    {{ v.description }}

    + {{ formatRelativeTime(v.lastSeen) }} +
  • + } +
+ } +
+
+ + +
+

Ingest Throughput

+
+
+
+ {{ m.ingestThroughput.docsPerMinute | number:'1.1-1' }} + docs/min +
+
+ {{ m.ingestThroughput.avgLatencyMs }} + avg ms +
+
+ {{ m.ingestThroughput.p95LatencyMs }} + p95 ms +
+
+ {{ m.ingestThroughput.queueDepth }} + queue +
+
+ {{ m.ingestThroughput.errorRate | number:'1.2-2' }}% + errors +
+
+
+
+
+ + + @if (verificationResult(); as result) { +
+

Verification Complete

+
+ {{ result.status | titlecase }} + Checked: {{ result.checkedCount | number }} + Passed: {{ result.passedCount | number }} + Failed: {{ result.failedCount | number }} +
+ @if (result.violations.length > 0) { +
+ View {{ result.violations.length }} violation(s) +
    + @for (v of result.violations; track v.documentId) { +
  • + {{ v.violationCode }} in {{ v.documentId }} + @if (v.field) { +
    Field: {{ v.field }} (expected: {{ v.expected }}, actual: {{ v.actual }}) + } +
  • + } +
+
+ } +

+ CLI equivalent: stella aoc verify --since=24h --tenant=default +

+
+ } + +

+ Data from {{ m.timeWindow.start | date:'short' }} to {{ m.timeWindow.end | date:'short' }} + ({{ m.timeWindow.durationMinutes / 60 | number:'1.0-0' }}h window) +

+ } +
diff --git a/src/Web/StellaOps.Web/src/app/features/dashboard/sources-dashboard.component.scss b/src/Web/StellaOps.Web/src/app/features/dashboard/sources-dashboard.component.scss new file mode 100644 index 000000000..f1afed654 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/dashboard/sources-dashboard.component.scss @@ -0,0 +1,325 @@ +.sources-dashboard { + padding: 1.5rem; + max-width: 1400px; + margin: 0 auto; +} + +.dashboard-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + + h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 600; + } + + .actions { + display: flex; + gap: 0.5rem; + } +} + +.btn { + padding: 0.5rem 1rem; + border-radius: 4px; + font-size: 0.875rem; + cursor: pointer; + border: 1px solid transparent; + transition: background-color 0.2s, border-color 0.2s; + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &-primary { + background-color: var(--color-primary, #2563eb); + color: white; + + &:hover:not(:disabled) { + background-color: var(--color-primary-hover, #1d4ed8); + } + } + + &-secondary { + background-color: transparent; + border-color: var(--color-border, #d1d5db); + color: var(--color-text, #374151); + + &:hover:not(:disabled) { + background-color: var(--color-bg-hover, #f3f4f6); + } + } +} + +.loading-state, +.error-state { + display: flex; + flex-direction: column; + align-items: center; + padding: 3rem; + text-align: center; +} + +.spinner { + width: 2rem; + height: 2rem; + border: 3px solid var(--color-border, #e5e7eb); + border-top-color: var(--color-primary, #2563eb); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.error-message { + color: var(--color-error, #dc2626); + margin-bottom: 1rem; +} + +.metrics-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 1.5rem; +} + +.tile { + background: var(--color-bg-card, white); + border-radius: 8px; + border: 1px solid var(--color-border, #e5e7eb); + overflow: hidden; + + &-title { + font-size: 0.875rem; + font-weight: 600; + padding: 0.75rem 1rem; + margin: 0; + background: var(--color-bg-subtle, #f9fafb); + border-bottom: 1px solid var(--color-border, #e5e7eb); + } + + &-content { + padding: 1rem; + } +} + +.tile-pass-fail { + &.excellent .metric-large .value { color: var(--color-success, #059669); } + &.good .metric-large .value { color: var(--color-success-muted, #10b981); } + &.warning .metric-large .value { color: var(--color-warning, #d97706); } + &.critical .metric-large .value { color: var(--color-error, #dc2626); } +} + +.metric-large { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 1rem; + + .value { + font-size: 2.5rem; + font-weight: 700; + line-height: 1; + } + + .label { + font-size: 0.75rem; + color: var(--color-text-muted, #6b7280); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-top: 0.25rem; + } +} + +.metric-details { + display: flex; + justify-content: space-around; + padding-top: 1rem; + border-top: 1px solid var(--color-border, #e5e7eb); + + .detail { + display: flex; + flex-direction: column; + align-items: center; + } + + .count { + font-size: 1.25rem; + font-weight: 600; + + &.pass { color: var(--color-success, #059669); } + &.fail { color: var(--color-error, #dc2626); } + &.total { color: var(--color-text, #374151); } + } + + .label { + font-size: 0.75rem; + color: var(--color-text-muted, #6b7280); + } +} + +.violations-list { + list-style: none; + padding: 0; + margin: 0; +} + +.violation-item { + padding: 0.75rem; + border-radius: 4px; + margin-bottom: 0.5rem; + background: var(--color-bg-subtle, #f9fafb); + + &.severity-critical { border-left: 3px solid var(--color-error, #dc2626); } + &.severity-high { border-left: 3px solid var(--color-warning, #d97706); } + &.severity-medium { border-left: 3px solid var(--color-info, #2563eb); } + &.severity-low { border-left: 3px solid var(--color-text-muted, #9ca3af); } +} + +.violation-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.25rem; +} + +.violation-code { + font-family: monospace; + font-size: 0.8125rem; + font-weight: 600; +} + +.violation-count { + font-size: 0.75rem; + color: var(--color-text-muted, #6b7280); +} + +.violation-desc { + font-size: 0.8125rem; + margin: 0 0 0.25rem; + color: var(--color-text, #374151); +} + +.violation-time { + font-size: 0.6875rem; + color: var(--color-text-muted, #9ca3af); +} + +.empty-state { + color: var(--color-text-muted, #6b7280); + font-style: italic; + text-align: center; + padding: 1rem; +} + +.throughput-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(80px, 1fr)); + gap: 1rem; + text-align: center; +} + +.throughput-item { + display: flex; + flex-direction: column; + + .value { + font-size: 1.5rem; + font-weight: 600; + color: var(--color-text, #374151); + } + + .label { + font-size: 0.6875rem; + color: var(--color-text-muted, #6b7280); + text-transform: uppercase; + } +} + +.tile-throughput { + &.critical .throughput-item .value { color: var(--color-error, #dc2626); } + &.warning .throughput-item .value { color: var(--color-warning, #d97706); } +} + +.verification-result { + margin-top: 1.5rem; + padding: 1rem; + border-radius: 8px; + border: 1px solid var(--color-border, #e5e7eb); + + &.status-passed { background: var(--color-success-bg, #ecfdf5); border-color: var(--color-success, #059669); } + &.status-failed { background: var(--color-error-bg, #fef2f2); border-color: var(--color-error, #dc2626); } + &.status-partial { background: var(--color-warning-bg, #fffbeb); border-color: var(--color-warning, #d97706); } + + h3 { + margin: 0 0 0.5rem; + font-size: 1rem; + } + + .result-summary { + display: flex; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 0.75rem; + } + + .status-badge { + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + } + + &.status-passed .status-badge { background: var(--color-success, #059669); color: white; } + &.status-failed .status-badge { background: var(--color-error, #dc2626); color: white; } + &.status-partial .status-badge { background: var(--color-warning, #d97706); color: white; } +} + +.violations-details { + margin: 0.75rem 0; + + summary { + cursor: pointer; + color: var(--color-primary, #2563eb); + font-size: 0.875rem; + } + + .violation-list { + margin-top: 0.5rem; + padding-left: 1.25rem; + font-size: 0.8125rem; + + li { + margin-bottom: 0.5rem; + } + } +} + +.cli-hint { + margin: 0.75rem 0 0; + font-size: 0.75rem; + color: var(--color-text-muted, #6b7280); + + code { + background: var(--color-bg-code, #f3f4f6); + padding: 0.125rem 0.375rem; + border-radius: 3px; + font-family: monospace; + font-size: 0.6875rem; + } +} + +.time-window { + margin-top: 1rem; + font-size: 0.75rem; + color: var(--color-text-muted, #6b7280); + text-align: center; +} diff --git a/src/Web/StellaOps.Web/src/app/features/exceptions/exception-center.component.html b/src/Web/StellaOps.Web/src/app/features/exceptions/exception-center.component.html new file mode 100644 index 000000000..988dae4ec --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/exceptions/exception-center.component.html @@ -0,0 +1,302 @@ +
+ +
+
+

Exception Center

+
+ @for (col of kanbanColumns; track col.status) { + + {{ col.label }} + {{ statusCounts()[col.status] || 0 }} + + } +
+
+ +
+
+ + +
+ + +
+
+ + + @if (showFilters()) { +
+
+
+ + +
+ +
+ +
+ @for (type of ['vulnerability', 'license', 'policy', 'entropy', 'determinism']; track type) { + + } +
+
+ +
+ +
+ @for (sev of ['critical', 'high', 'medium', 'low']; track sev) { + + } +
+
+ +
+ +
+ @for (tag of allTags().slice(0, 8); track tag) { + + } +
+
+ +
+ +
+
+ + +
+ } + + + @if (viewMode() === 'list') { +
+ +
+ + + Status + + + Actions +
+ + +
+ @for (exc of filteredExceptions(); track exc.id) { +
+ + +
+ @for (trans of getAvailableTransitions(exc); track trans.to) { + + } + +
+
+ } + + @if (filteredExceptions().length === 0) { +
+

No exceptions match the current filters

+ +
+ } +
+
+ } + + + @if (viewMode() === 'kanban') { +
+ @for (col of kanbanColumns; track col.status) { +
+
+ {{ col.label }} + {{ exceptionsByStatus().get(col.status)?.length || 0 }} +
+ +
+ @for (exc of exceptionsByStatus().get(col.status) || []; track exc.id) { +
+ + +
+ @for (trans of getAvailableTransitions(exc); track trans.to) { + + } +
+
+ } + + @if ((exceptionsByStatus().get(col.status)?.length || 0) === 0) { +
No exceptions
+ } +
+
+ } +
+ } + + +
+ + {{ filteredExceptions().length }} of {{ exceptions().length }} exceptions + +
+
diff --git a/src/Web/StellaOps.Web/src/app/features/exceptions/exception-center.component.scss b/src/Web/StellaOps.Web/src/app/features/exceptions/exception-center.component.scss new file mode 100644 index 000000000..f349c3e11 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/exceptions/exception-center.component.scss @@ -0,0 +1,636 @@ +.exception-center { + display: flex; + flex-direction: column; + height: 100%; + background: var(--color-bg, #f9fafb); +} + +// Header +.center-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + padding: 1rem; + background: var(--color-bg-card, white); + border-bottom: 1px solid var(--color-border, #e5e7eb); + flex-wrap: wrap; +} + +.header-left { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.center-title { + margin: 0; + font-size: 1.25rem; + font-weight: 700; + color: var(--color-text, #111827); +} + +.status-chips { + display: flex; + gap: 0.375rem; + flex-wrap: wrap; +} + +.status-chip { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + border: 1px solid; + border-radius: 4px; + font-size: 0.6875rem; + cursor: pointer; + background: var(--color-bg-card, white); + color: var(--color-text-muted, #6b7280); + + &:hover { + background: var(--color-bg-hover, #f3f4f6); + } + + &.active { + background: var(--color-bg-subtle, #f3f4f6); + font-weight: 600; + } +} + +.chip-count { + font-size: 0.625rem; + padding: 0 0.25rem; + background: var(--color-bg-subtle, #e5e7eb); + border-radius: 8px; +} + +.header-right { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.view-toggle { + display: flex; + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 4px; + overflow: hidden; +} + +.toggle-btn { + padding: 0.375rem 0.625rem; + background: var(--color-bg-card, white); + border: none; + font-family: monospace; + font-size: 0.875rem; + cursor: pointer; + color: var(--color-text-muted, #6b7280); + + &:hover { + background: var(--color-bg-hover, #f3f4f6); + } + + &.active { + background: var(--color-primary, #2563eb); + color: white; + } + + &:not(:last-child) { + border-right: 1px solid var(--color-border, #e5e7eb); + } +} + +.btn-filter { + padding: 0.375rem 0.75rem; + background: var(--color-bg-card, white); + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 4px; + font-size: 0.8125rem; + cursor: pointer; + color: var(--color-text, #374151); + + &:hover { + background: var(--color-bg-hover, #f3f4f6); + } + + &.active { + background: var(--color-primary-bg, #eff6ff); + border-color: var(--color-primary, #2563eb); + color: var(--color-primary, #2563eb); + } +} + +.btn-create { + padding: 0.375rem 1rem; + background: var(--color-primary, #2563eb); + border: none; + border-radius: 4px; + font-size: 0.8125rem; + font-weight: 600; + color: white; + cursor: pointer; + + &:hover { + background: var(--color-primary-dark, #1d4ed8); + } +} + +// Filters Panel +.filters-panel { + padding: 1rem; + background: var(--color-bg-subtle, #f9fafb); + border-bottom: 1px solid var(--color-border, #e5e7eb); +} + +.filter-row { + display: flex; + gap: 1.5rem; + flex-wrap: wrap; + align-items: flex-start; +} + +.filter-group { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.filter-label { + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--color-text-muted, #6b7280); +} + +.filter-input { + padding: 0.375rem 0.75rem; + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 4px; + font-size: 0.8125rem; + min-width: 200px; + + &:focus { + outline: 2px solid var(--color-primary, #2563eb); + outline-offset: -1px; + } +} + +.filter-chips { + display: flex; + gap: 0.25rem; + flex-wrap: wrap; + + &.tags { + max-width: 300px; + } +} + +.filter-chip { + padding: 0.25rem 0.5rem; + background: var(--color-bg-card, white); + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 4px; + font-size: 0.75rem; + cursor: pointer; + color: var(--color-text-muted, #6b7280); + + &:hover { + background: var(--color-bg-hover, #f3f4f6); + } + + &.active { + background: var(--color-primary, #2563eb); + color: white; + border-color: var(--color-primary, #2563eb); + } + + &.sev-critical.active { background: var(--color-critical, #dc2626); border-color: var(--color-critical, #dc2626); } + &.sev-high.active { background: var(--color-error, #ea580c); border-color: var(--color-error, #ea580c); } + &.sev-medium.active { background: var(--color-warning, #d97706); border-color: var(--color-warning, #d97706); } + &.sev-low.active { background: var(--color-info, #0284c7); border-color: var(--color-info, #0284c7); } + + &.tag { + font-size: 0.6875rem; + } +} + +.filter-checkbox { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.8125rem; + color: var(--color-text, #374151); + cursor: pointer; +} + +.btn-clear-filters { + margin-top: 0.75rem; + padding: 0.25rem 0.5rem; + background: none; + border: none; + font-size: 0.75rem; + color: var(--color-primary, #2563eb); + cursor: pointer; + + &:hover { + text-decoration: underline; + } +} + +// List View +.list-view { + flex: 1; + overflow: auto; + background: var(--color-bg-card, white); +} + +.list-header { + display: grid; + grid-template-columns: 2fr 100px 120px 100px 100px 150px; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: var(--color-bg-subtle, #f9fafb); + border-bottom: 1px solid var(--color-border, #e5e7eb); + position: sticky; + top: 0; + z-index: 1; +} + +.sort-btn { + background: none; + border: none; + padding: 0.25rem; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--color-text-muted, #6b7280); + cursor: pointer; + text-align: left; + display: flex; + align-items: center; + gap: 0.25rem; + + &:hover { + color: var(--color-text, #374151); + } +} + +.sort-icon { + font-size: 0.5rem; +} + +.col-header { + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--color-text-muted, #6b7280); + padding: 0.25rem; +} + +.list-body { + display: flex; + flex-direction: column; +} + +.exception-row { + display: flex; + align-items: center; + border-bottom: 1px solid var(--color-border-light, #f3f4f6); + + &:hover { + background: var(--color-bg-hover, #f9fafb); + } + + &.status-draft { border-left: 3px solid #9ca3af; } + &.status-pending { border-left: 3px solid #f59e0b; } + &.status-approved { border-left: 3px solid #3b82f6; } + &.status-active { border-left: 3px solid #10b981; } + &.status-expired { border-left: 3px solid #6b7280; } + &.status-revoked { border-left: 3px solid #ef4444; } +} + +.row-main { + display: grid; + grid-template-columns: 2fr 100px 120px 100px 100px; + gap: 0.5rem; + flex: 1; + padding: 0.75rem 1rem; + background: none; + border: none; + cursor: pointer; + text-align: left; +} + +.exc-title-cell { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; +} + +.type-badge { + width: 1.5rem; + height: 1.5rem; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-bg-subtle, #f3f4f6); + border-radius: 4px; + font-family: monospace; + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-muted, #6b7280); + flex-shrink: 0; +} + +.exc-title-info { + display: flex; + flex-direction: column; + min-width: 0; +} + +.exc-title { + font-size: 0.875rem; + font-weight: 500; + color: var(--color-text, #111827); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.exc-id { + font-size: 0.6875rem; + font-family: monospace; + color: var(--color-text-muted, #9ca3af); +} + +.severity-badge { + display: inline-block; + padding: 0.125rem 0.375rem; + border-radius: 3px; + font-size: 0.6875rem; + font-weight: 600; + + &.severity-critical { background: #fef2f2; color: #dc2626; } + &.severity-high { background: #fff7ed; color: #ea580c; } + &.severity-medium { background: #fffbeb; color: #d97706; } + &.severity-low { background: #f0f9ff; color: #0284c7; } +} + +.status-badge { + font-size: 0.75rem; + font-family: monospace; + + &.status-draft { color: #6b7280; } + &.status-pending { color: #d97706; } + &.status-approved { color: #2563eb; } + &.status-active { color: #059669; } + &.status-expired { color: #6b7280; } + &.status-revoked { color: #dc2626; } +} + +.expires-text { + font-size: 0.75rem; + color: var(--color-text-muted, #6b7280); + + &.warning { color: var(--color-warning, #d97706); font-weight: 500; } + &.expired { color: var(--color-error, #dc2626); font-weight: 500; } +} + +.exc-updated-cell { + font-size: 0.75rem; + color: var(--color-text-muted, #9ca3af); +} + +.row-actions { + display: flex; + gap: 0.25rem; + padding: 0.5rem; +} + +.action-btn { + padding: 0.25rem 0.5rem; + background: var(--color-bg-subtle, #f3f4f6); + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 3px; + font-size: 0.6875rem; + cursor: pointer; + color: var(--color-text, #374151); + + &:hover { + background: var(--color-bg-hover, #e5e7eb); + } + + &.audit { + font-family: monospace; + } +} + +.empty-state { + padding: 3rem; + text-align: center; + color: var(--color-text-muted, #9ca3af); + + .btn-link { + background: none; + border: none; + color: var(--color-primary, #2563eb); + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } +} + +// Kanban View +.kanban-view { + display: flex; + gap: 1rem; + padding: 1rem; + flex: 1; + overflow-x: auto; +} + +.kanban-column { + flex: 0 0 280px; + display: flex; + flex-direction: column; + background: var(--color-bg-subtle, #f3f4f6); + border-radius: 8px; + max-height: 100%; +} + +.column-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem; + border-bottom: 3px solid; + background: var(--color-bg-card, white); + border-radius: 8px 8px 0 0; +} + +.column-title { + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text, #374151); +} + +.column-count { + font-size: 0.75rem; + padding: 0.125rem 0.5rem; + background: var(--color-bg-subtle, #e5e7eb); + border-radius: 10px; + color: var(--color-text-muted, #6b7280); +} + +.column-body { + flex: 1; + overflow-y: auto; + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.kanban-card { + background: var(--color-bg-card, white); + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 6px; + overflow: hidden; + + &.severity-critical { border-left: 3px solid #dc2626; } + &.severity-high { border-left: 3px solid #ea580c; } + &.severity-medium { border-left: 3px solid #d97706; } + &.severity-low { border-left: 3px solid #0284c7; } +} + +.card-main { + display: block; + width: 100%; + padding: 0.75rem; + background: none; + border: none; + cursor: pointer; + text-align: left; + + &:hover { + background: var(--color-bg-hover, #f9fafb); + } +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.severity-dot { + width: 8px; + height: 8px; + border-radius: 50%; + + &.severity-critical { background: #dc2626; } + &.severity-high { background: #ea580c; } + &.severity-medium { background: #d97706; } + &.severity-low { background: #0284c7; } +} + +.card-title { + margin: 0 0 0.25rem; + font-size: 0.875rem; + font-weight: 500; + color: var(--color-text, #111827); +} + +.card-id { + margin: 0 0 0.5rem; + font-size: 0.6875rem; + font-family: monospace; + color: var(--color-text-muted, #9ca3af); +} + +.card-meta { + margin-bottom: 0.5rem; +} + +.expires-badge { + font-size: 0.6875rem; + padding: 0.125rem 0.375rem; + background: var(--color-bg-subtle, #f3f4f6); + border-radius: 3px; + color: var(--color-text-muted, #6b7280); + + &.warning { + background: var(--color-warning-bg, #fef3c7); + color: var(--color-warning, #d97706); + } + + &.expired { + background: var(--color-error-bg, #fef2f2); + color: var(--color-error, #dc2626); + } +} + +.card-tags { + display: flex; + gap: 0.25rem; + flex-wrap: wrap; +} + +.tag { + font-size: 0.625rem; + padding: 0.0625rem 0.25rem; + background: var(--color-bg-subtle, #e5e7eb); + border-radius: 2px; + color: var(--color-text-muted, #6b7280); +} + +.card-actions { + display: flex; + gap: 0.25rem; + padding: 0.5rem 0.75rem; + background: var(--color-bg-subtle, #f9fafb); + border-top: 1px solid var(--color-border-light, #f3f4f6); +} + +.card-action-btn { + flex: 1; + padding: 0.25rem; + background: var(--color-bg-card, white); + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 3px; + font-size: 0.625rem; + cursor: pointer; + color: var(--color-text, #374151); + + &:hover { + background: var(--color-bg-hover, #f3f4f6); + } +} + +.column-empty { + padding: 1rem; + text-align: center; + font-size: 0.75rem; + color: var(--color-text-muted, #9ca3af); + font-style: italic; +} + +// Footer +.center-footer { + padding: 0.5rem 1rem; + background: var(--color-bg-card, white); + border-top: 1px solid var(--color-border, #e5e7eb); +} + +.total-count { + font-size: 0.75rem; + color: var(--color-text-muted, #6b7280); +} diff --git a/src/Web/StellaOps.Web/src/app/features/exceptions/exception-center.component.ts b/src/Web/StellaOps.Web/src/app/features/exceptions/exception-center.component.ts new file mode 100644 index 000000000..4da9a79d1 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/exceptions/exception-center.component.ts @@ -0,0 +1,246 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, + signal, +} from '@angular/core'; +import { + Exception, + ExceptionStatus, + ExceptionType, + ExceptionFilter, + ExceptionSortOption, + ExceptionTransition, + EXCEPTION_TRANSITIONS, + KANBAN_COLUMNS, +} from '../../core/api/exception.models'; + +type ViewMode = 'list' | 'kanban'; + +@Component({ + selector: 'app-exception-center', + standalone: true, + imports: [CommonModule], + templateUrl: './exception-center.component.html', + styleUrls: ['./exception-center.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ExceptionCenterComponent { + /** All exceptions */ + readonly exceptions = input.required(); + + /** Current user role for transition permissions */ + readonly userRole = input('user'); + + /** Emits when creating new exception */ + readonly create = output(); + + /** Emits when selecting an exception */ + readonly select = output(); + + /** Emits when performing a workflow transition */ + readonly transition = output<{ exception: Exception; to: ExceptionStatus }>(); + + /** Emits when viewing audit log */ + readonly viewAudit = output(); + + readonly viewMode = signal('list'); + readonly filter = signal({}); + readonly sort = signal({ field: 'updatedAt', direction: 'desc' }); + readonly expandedId = signal(null); + readonly showFilters = signal(false); + + readonly kanbanColumns = KANBAN_COLUMNS; + + readonly filteredExceptions = computed(() => { + let result = [...this.exceptions()]; + const f = this.filter(); + + // Apply filters + if (f.status && f.status.length > 0) { + result = result.filter((e) => f.status!.includes(e.status)); + } + if (f.type && f.type.length > 0) { + result = result.filter((e) => f.type!.includes(e.type)); + } + if (f.severity && f.severity.length > 0) { + result = result.filter((e) => f.severity!.includes(e.severity)); + } + if (f.search) { + const search = f.search.toLowerCase(); + result = result.filter( + (e) => + e.title.toLowerCase().includes(search) || + e.justification.toLowerCase().includes(search) || + e.id.toLowerCase().includes(search) + ); + } + if (f.tags && f.tags.length > 0) { + result = result.filter((e) => f.tags!.some((t) => e.tags.includes(t))); + } + if (f.expiringSoon) { + result = result.filter((e) => e.timebox.isWarning && !e.timebox.isExpired); + } + + // Apply sort + const s = this.sort(); + result.sort((a, b) => { + let cmp = 0; + switch (s.field) { + case 'createdAt': + cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + break; + case 'updatedAt': + cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime(); + break; + case 'expiresAt': + cmp = new Date(a.timebox.expiresAt).getTime() - new Date(b.timebox.expiresAt).getTime(); + break; + case 'severity': + const sevOrder = { critical: 0, high: 1, medium: 2, low: 3 }; + cmp = sevOrder[a.severity] - sevOrder[b.severity]; + break; + case 'title': + cmp = a.title.localeCompare(b.title); + break; + } + return s.direction === 'asc' ? cmp : -cmp; + }); + + return result; + }); + + readonly exceptionsByStatus = computed(() => { + const byStatus = new Map(); + for (const col of KANBAN_COLUMNS) { + byStatus.set(col.status, []); + } + for (const exc of this.filteredExceptions()) { + const list = byStatus.get(exc.status) || []; + list.push(exc); + byStatus.set(exc.status, list); + } + return byStatus; + }); + + readonly statusCounts = computed(() => { + const counts: Record = {}; + for (const exc of this.exceptions()) { + counts[exc.status] = (counts[exc.status] || 0) + 1; + } + return counts; + }); + + readonly allTags = computed(() => { + const tags = new Set(); + for (const exc of this.exceptions()) { + for (const tag of exc.tags) { + tags.add(tag); + } + } + return Array.from(tags).sort(); + }); + + setViewMode(mode: ViewMode): void { + this.viewMode.set(mode); + } + + toggleFilters(): void { + this.showFilters.update((v) => !v); + } + + updateFilter(key: keyof ExceptionFilter, value: unknown): void { + this.filter.update((f) => ({ ...f, [key]: value })); + } + + clearFilters(): void { + this.filter.set({}); + } + + setSort(field: ExceptionSortOption['field']): void { + this.sort.update((s) => ({ + field, + direction: s.field === field && s.direction === 'desc' ? 'asc' : 'desc', + })); + } + + toggleExpand(id: string): void { + this.expandedId.update((current) => (current === id ? null : id)); + } + + onCreate(): void { + this.create.emit(); + } + + onSelect(exc: Exception): void { + this.select.emit(exc); + } + + onTransition(exc: Exception, to: ExceptionStatus): void { + this.transition.emit({ exception: exc, to }); + } + + onViewAudit(exc: Exception): void { + this.viewAudit.emit(exc); + } + + getAvailableTransitions(exc: Exception): ExceptionTransition[] { + return EXCEPTION_TRANSITIONS.filter( + (t) => t.from === exc.status && t.allowedRoles.includes(this.userRole()) + ); + } + + getStatusIcon(status: ExceptionStatus): string { + switch (status) { + case 'draft': + return '[D]'; + case 'pending': + return '[?]'; + case 'approved': + return '[+]'; + case 'active': + return '[*]'; + case 'expired': + return '[X]'; + case 'revoked': + return '[!]'; + default: + return '[-]'; + } + } + + getTypeIcon(type: ExceptionType): string { + switch (type) { + case 'vulnerability': + return 'V'; + case 'license': + return 'L'; + case 'policy': + return 'P'; + case 'entropy': + return 'E'; + case 'determinism': + return 'D'; + default: + return '?'; + } + } + + getSeverityClass(severity: string): string { + return 'severity-' + severity; + } + + formatDate(dateStr: string): string { + return new Date(dateStr).toLocaleDateString(); + } + + formatRemainingDays(days: number): string { + if (days < 0) return 'Expired'; + if (days === 0) return 'Expires today'; + if (days === 1) return '1 day left'; + return days + ' days left'; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/exceptions/exception-wizard.component.html b/src/Web/StellaOps.Web/src/app/features/exceptions/exception-wizard.component.html new file mode 100644 index 000000000..f72e17832 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/exceptions/exception-wizard.component.html @@ -0,0 +1,406 @@ +
+ +
+ @for (step of steps; track step; let i = $index) { + + @if (i < steps.length - 1) { +
+ } + } +
+ + +
+ + @if (currentStep() === 'type') { +
+

What type of exception do you need?

+

Select the category that best matches your exception request.

+ +
+ @for (type of exceptionTypes; track type.type) { + + } +
+
+ } + + + @if (currentStep() === 'scope') { +
+

Define the exception scope

+

Specify what this exception applies to. Be as specific as possible.

+ +
+ @if (draft().type === 'vulnerability') { +
+ + +
+ +
+ + +
+ } + + @if (draft().type === 'license') { +
+ + +
+ } + + @if (draft().type === 'policy') { +
+ + +
+ } + +
+ + + Use * for wildcards. Leave empty to apply to all images. +
+ +
+ +
+ @for (env of ['development', 'staging', 'production']; track env) { + + } +
+
+ + @if (scopePreview().length > 0) { +
+ Scope preview: + {{ scopePreview().join(', ') }} +
+ } +
+
+ } + + + @if (currentStep() === 'justification') { +
+

Provide justification

+

Explain why this exception is needed. Use a template or write your own.

+ +
+
+ + +
+ +
+ +
+ @for (sev of ['critical', 'high', 'medium', 'low']; track sev) { + + } +
+
+ + @if (applicableTemplates().length > 0) { +
+ +
+ @for (tpl of applicableTemplates(); track tpl.id) { + + } +
+
+ } + +
+ + +
+ +
+ +
+
+ @for (tag of draft().tags; track tag) { + + {{ tag }} + + + } +
+ +
+
+
+
+ } + + + @if (currentStep() === 'timebox') { +
+

Set exception duration

+

+ Exceptions must have an expiration date. Maximum duration: {{ maxDurationDays() }} days. +

+ +
+
+ @for (preset of timeboxPresets; track preset.days) { + + } +
+ +
+ + +
+ +
+
+ Expires on: + {{ formatDate(expirationDate()) }} +
+
+ Duration: + {{ draft().expiresInDays }} days +
+
+ + @if (timeboxWarning()) { +
+ [!] + {{ timeboxWarning() }} +
+ } +
+
+ } + + + @if (currentStep() === 'review') { +
+

Review and submit

+

Please review your exception request before submitting.

+ +
+
+

Type & Severity

+
+ Type: + {{ draft().type | titlecase }} +
+
+ Severity: + + {{ draft().severity | titlecase }} + +
+
+ +
+

Scope

+ @if (draft().scope.cves?.length) { +
+ CVEs: + {{ draft().scope.cves?.join(', ') }} +
+ } + @if (draft().scope.packages?.length) { +
+ Packages: + {{ draft().scope.packages?.join(', ') }} +
+ } + @if (draft().scope.licenses?.length) { +
+ Licenses: + {{ draft().scope.licenses?.join(', ') }} +
+ } + @if (draft().scope.policyRules?.length) { +
+ Policy Rules: + {{ draft().scope.policyRules?.join(', ') }} +
+ } + @if (draft().scope.images?.length) { +
+ Images: + {{ draft().scope.images?.join(', ') }} +
+ } + @if (draft().scope.environments?.length) { +
+ Environments: + {{ draft().scope.environments?.join(', ') }} +
+ } +
+ +
+

Details

+
+ Title: + {{ draft().title }} +
+
+ Justification: +

{{ draft().justification }}

+
+ @if (draft().tags.length > 0) { +
+ Tags: +
+ @for (tag of draft().tags; track tag) { + {{ tag }} + } +
+
+ } +
+ +
+

Timebox

+
+ Duration: + {{ draft().expiresInDays }} days +
+
+ Expires: + {{ formatDate(expirationDate()) }} +
+
+
+
+ } +
+ + + +
diff --git a/src/Web/StellaOps.Web/src/app/features/exceptions/exception-wizard.component.scss b/src/Web/StellaOps.Web/src/app/features/exceptions/exception-wizard.component.scss new file mode 100644 index 000000000..9a13dfccd --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/exceptions/exception-wizard.component.scss @@ -0,0 +1,652 @@ +.exception-wizard { + display: flex; + flex-direction: column; + height: 100%; + max-width: 800px; + margin: 0 auto; + background: var(--color-bg-card, white); + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 8px; + overflow: hidden; +} + +// Progress Steps +.wizard-progress { + display: flex; + align-items: center; + padding: 1.5rem; + background: var(--color-bg-subtle, #f9fafb); + border-bottom: 1px solid var(--color-border, #e5e7eb); +} + +.progress-step { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; + background: none; + border: none; + cursor: pointer; + padding: 0.5rem; + + &:disabled { + cursor: not-allowed; + } + + &.active .step-number { + background: var(--color-primary, #2563eb); + color: white; + } + + &.completed .step-number { + background: var(--color-success, #059669); + color: white; + } + + &.disabled { + .step-number { background: var(--color-bg-subtle, #e5e7eb); } + .step-label { color: var(--color-text-muted, #9ca3af); } + } +} + +.step-number { + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-border, #e5e7eb); + border-radius: 50%; + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text-muted, #6b7280); +} + +.step-label { + font-size: 0.6875rem; + color: var(--color-text, #374151); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.step-connector { + flex: 1; + height: 2px; + background: var(--color-border, #e5e7eb); + margin: 0 0.5rem; + + &.completed { + background: var(--color-success, #059669); + } +} + +// Content +.wizard-content { + flex: 1; + overflow-y: auto; + padding: 1.5rem; +} + +.step-panel { + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.step-title { + margin: 0 0 0.5rem; + font-size: 1.125rem; + font-weight: 600; + color: var(--color-text, #111827); +} + +.step-desc { + margin: 0 0 1.5rem; + font-size: 0.875rem; + color: var(--color-text-muted, #6b7280); +} + +// Type Selection +.type-grid { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.type-card { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem; + background: var(--color-bg-card, white); + border: 2px solid var(--color-border, #e5e7eb); + border-radius: 8px; + cursor: pointer; + text-align: left; + + &:hover { + border-color: var(--color-primary-light, #93c5fd); + } + + &.selected { + border-color: var(--color-primary, #2563eb); + background: var(--color-primary-bg, #eff6ff); + } +} + +.type-icon { + width: 2.5rem; + height: 2.5rem; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-bg-subtle, #f3f4f6); + border-radius: 8px; + font-family: monospace; + font-size: 1.25rem; + font-weight: 700; + color: var(--color-text-muted, #6b7280); + + .selected & { + background: var(--color-primary, #2563eb); + color: white; + } +} + +.type-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.type-label { + font-weight: 600; + color: var(--color-text, #374151); +} + +.type-desc { + font-size: 0.8125rem; + color: var(--color-text-muted, #6b7280); +} + +.selected-check { + font-family: monospace; + font-weight: 700; + color: var(--color-primary, #2563eb); +} + +// Scope Form +.scope-form { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.scope-field { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.field-label { + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text, #374151); +} + +.field-textarea { + padding: 0.75rem; + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 6px; + font-size: 0.875rem; + font-family: monospace; + min-height: 80px; + resize: vertical; + + &:focus { + outline: 2px solid var(--color-primary, #2563eb); + outline-offset: -1px; + } + + &.large { + min-height: 200px; + font-family: inherit; + } +} + +.field-hint { + font-size: 0.75rem; + color: var(--color-text-muted, #9ca3af); +} + +.env-chips { + display: flex; + gap: 0.5rem; +} + +.env-chip { + padding: 0.375rem 0.75rem; + background: var(--color-bg-card, white); + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 4px; + font-size: 0.8125rem; + cursor: pointer; + + &:hover { + background: var(--color-bg-hover, #f3f4f6); + } + + &.selected { + background: var(--color-primary, #2563eb); + color: white; + border-color: var(--color-primary, #2563eb); + } +} + +.scope-preview { + padding: 0.75rem; + background: var(--color-bg-subtle, #f3f4f6); + border-radius: 4px; + font-size: 0.8125rem; +} + +.preview-label { + color: var(--color-text-muted, #6b7280); +} + +.preview-text { + color: var(--color-text, #374151); + font-weight: 500; +} + +// Justification Form +.justification-form { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.form-field { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.field-input { + padding: 0.625rem 0.75rem; + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 6px; + font-size: 0.875rem; + + &:focus { + outline: 2px solid var(--color-primary, #2563eb); + outline-offset: -1px; + } +} + +.severity-options { + display: flex; + gap: 0.5rem; +} + +.severity-btn { + padding: 0.375rem 0.75rem; + background: var(--color-bg-card, white); + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 4px; + font-size: 0.8125rem; + cursor: pointer; + + &:hover { + background: var(--color-bg-hover, #f3f4f6); + } + + &.selected { + color: white; + + &.sev-critical { background: #dc2626; border-color: #dc2626; } + &.sev-high { background: #ea580c; border-color: #ea580c; } + &.sev-medium { background: #d97706; border-color: #d97706; } + &.sev-low { background: #0284c7; border-color: #0284c7; } + } +} + +.template-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.template-btn { + display: flex; + flex-direction: column; + gap: 0.125rem; + padding: 0.75rem; + background: var(--color-bg-card, white); + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 6px; + cursor: pointer; + text-align: left; + + &:hover { + background: var(--color-bg-hover, #f9fafb); + } + + &.selected { + border-color: var(--color-primary, #2563eb); + background: var(--color-primary-bg, #eff6ff); + } +} + +.tpl-name { + font-weight: 600; + font-size: 0.875rem; + color: var(--color-text, #374151); +} + +.tpl-desc { + font-size: 0.75rem; + color: var(--color-text-muted, #6b7280); +} + +.char-count { + font-weight: normal; + font-size: 0.75rem; + color: var(--color-text-muted, #9ca3af); + margin-left: 0.5rem; +} + +.tags-input { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + padding: 0.5rem; + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 6px; + min-height: 44px; +} + +.current-tags { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +.tag { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + background: var(--color-bg-subtle, #f3f4f6); + border-radius: 4px; + font-size: 0.75rem; + color: var(--color-text, #374151); +} + +.tag-remove { + background: none; + border: none; + font-size: 0.75rem; + cursor: pointer; + color: var(--color-text-muted, #9ca3af); + padding: 0; + + &:hover { + color: var(--color-error, #dc2626); + } +} + +.tag-input { + flex: 1; + min-width: 100px; + border: none; + outline: none; + font-size: 0.875rem; +} + +// Timebox Form +.timebox-form { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.timebox-presets { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 0.75rem; +} + +.preset-btn { + display: flex; + flex-direction: column; + gap: 0.125rem; + padding: 0.75rem; + background: var(--color-bg-card, white); + border: 2px solid var(--color-border, #e5e7eb); + border-radius: 6px; + cursor: pointer; + text-align: left; + + &:hover:not(:disabled) { + border-color: var(--color-primary-light, #93c5fd); + } + + &.selected { + border-color: var(--color-primary, #2563eb); + background: var(--color-primary-bg, #eff6ff); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.preset-label { + font-weight: 600; + font-size: 0.9375rem; + color: var(--color-text, #374151); +} + +.preset-desc { + font-size: 0.75rem; + color: var(--color-text-muted, #6b7280); +} + +.custom-duration { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.duration-input { + max-width: 120px; +} + +.timebox-preview { + padding: 1rem; + background: var(--color-bg-subtle, #f9fafb); + border-radius: 6px; +} + +.preview-row { + display: flex; + justify-content: space-between; + padding: 0.25rem 0; +} + +.preview-label { + color: var(--color-text-muted, #6b7280); +} + +.preview-value { + font-weight: 500; + color: var(--color-text, #374151); +} + +.timebox-warning { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem; + background: var(--color-warning-bg, #fef3c7); + border-radius: 4px; + font-size: 0.875rem; + color: var(--color-warning-dark, #92400e); +} + +.warning-icon { + font-family: monospace; + font-weight: 700; +} + +// Review +.review-summary { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.review-section { + padding-bottom: 1rem; + border-bottom: 1px solid var(--color-border-light, #f3f4f6); + + &:last-child { + border-bottom: none; + padding-bottom: 0; + } +} + +.section-title { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--color-text-muted, #6b7280); + margin: 0 0 0.75rem; +} + +.review-row { + display: flex; + gap: 1rem; + padding: 0.25rem 0; + + &.full { + flex-direction: column; + gap: 0.25rem; + } +} + +.review-label { + min-width: 100px; + font-size: 0.8125rem; + color: var(--color-text-muted, #6b7280); +} + +.review-value { + font-size: 0.8125rem; + color: var(--color-text, #374151); + + &.severity-badge { + padding: 0.125rem 0.375rem; + border-radius: 3px; + font-size: 0.75rem; + font-weight: 600; + + &.sev-critical { background: #fef2f2; color: #dc2626; } + &.sev-high { background: #fff7ed; color: #ea580c; } + &.sev-medium { background: #fffbeb; color: #d97706; } + &.sev-low { background: #f0f9ff; color: #0284c7; } + } +} + +.review-justification { + margin: 0; + padding: 0.75rem; + background: var(--color-bg-subtle, #f9fafb); + border-radius: 4px; + font-size: 0.8125rem; + white-space: pre-wrap; +} + +.review-tags { + display: flex; + gap: 0.25rem; + flex-wrap: wrap; +} + +// Footer +.wizard-footer { + display: flex; + justify-content: space-between; + padding: 1rem 1.5rem; + background: var(--color-bg-subtle, #f9fafb); + border-top: 1px solid var(--color-border, #e5e7eb); +} + +.footer-right { + display: flex; + gap: 0.5rem; +} + +.btn-cancel { + padding: 0.5rem 1rem; + background: none; + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 4px; + font-size: 0.875rem; + cursor: pointer; + color: var(--color-text-muted, #6b7280); + + &:hover { + background: var(--color-bg-hover, #f3f4f6); + } +} + +.btn-back { + padding: 0.5rem 1rem; + background: var(--color-bg-card, white); + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 4px; + font-size: 0.875rem; + cursor: pointer; + color: var(--color-text, #374151); + + &:hover { + background: var(--color-bg-hover, #f3f4f6); + } +} + +.btn-next, +.btn-submit { + padding: 0.5rem 1.5rem; + background: var(--color-primary, #2563eb); + border: none; + border-radius: 4px; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + color: white; + + &:hover:not(:disabled) { + background: var(--color-primary-dark, #1d4ed8); + } + + &:disabled { + background: var(--color-text-muted, #9ca3af); + cursor: not-allowed; + } +} + +.btn-submit { + background: var(--color-success, #059669); + + &:hover:not(:disabled) { + background: var(--color-success-dark, #047857); + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/exceptions/exception-wizard.component.ts b/src/Web/StellaOps.Web/src/app/features/exceptions/exception-wizard.component.ts new file mode 100644 index 000000000..3a1d7f8ff --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/exceptions/exception-wizard.component.ts @@ -0,0 +1,296 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, + signal, +} from '@angular/core'; +import { + Exception, + ExceptionType, + ExceptionScope, +} from '../../core/api/exception.models'; + +type WizardStep = 'type' | 'scope' | 'justification' | 'timebox' | 'review'; + +export interface JustificationTemplate { + id: string; + name: string; + description: string; + template: string; + type: ExceptionType[]; +} + +export interface TimeboxPreset { + label: string; + days: number; + description: string; +} + +export interface ExceptionDraft { + type: ExceptionType | null; + severity: 'critical' | 'high' | 'medium' | 'low'; + title: string; + justification: string; + scope: Partial; + expiresInDays: number; + tags: string[]; +} + +@Component({ + selector: 'app-exception-wizard', + standalone: true, + imports: [CommonModule], + templateUrl: './exception-wizard.component.html', + styleUrls: ['./exception-wizard.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ExceptionWizardComponent { + /** Pre-selected type (e.g., from vulnerability view) */ + readonly preselectedType = input(); + + /** Pre-filled scope (e.g., specific CVE) */ + readonly prefilledScope = input>(); + + /** Available justification templates */ + readonly templates = input(this.defaultTemplates); + + /** Maximum allowed exception duration in days */ + readonly maxDurationDays = input(90); + + /** Emits when wizard is cancelled */ + readonly cancel = output(); + + /** Emits when exception is created */ + readonly create = output(); + + readonly steps: WizardStep[] = ['type', 'scope', 'justification', 'timebox', 'review']; + readonly currentStep = signal('type'); + + readonly draft = signal({ + type: null, + severity: 'medium', + title: '', + justification: '', + scope: {}, + expiresInDays: 30, + tags: [], + }); + + readonly scopePreview = signal([]); + readonly selectedTemplate = signal(null); + readonly newTag = signal(''); + + readonly timeboxPresets: TimeboxPreset[] = [ + { label: '7 days', days: 7, description: 'Short-term exception for urgent fixes' }, + { label: '14 days', days: 14, description: 'Sprint-length exception' }, + { label: '30 days', days: 30, description: 'Standard exception duration' }, + { label: '60 days', days: 60, description: 'Extended exception for complex remediation' }, + { label: '90 days', days: 90, description: 'Maximum allowed duration' }, + ]; + + readonly exceptionTypes: { type: ExceptionType; label: string; icon: string; description: string }[] = [ + { type: 'vulnerability', label: 'Vulnerability', icon: 'V', description: 'Exception for specific CVEs or vulnerability findings' }, + { type: 'license', label: 'License', icon: 'L', description: 'Exception for license compliance violations' }, + { type: 'policy', label: 'Policy', icon: 'P', description: 'Exception for policy rule violations' }, + { type: 'entropy', label: 'Entropy', icon: 'E', description: 'Exception for high entropy findings' }, + { type: 'determinism', label: 'Determinism', icon: 'D', description: 'Exception for determinism check failures' }, + ]; + + readonly defaultTemplates: JustificationTemplate[] = [ + { + id: 'false-positive', + name: 'False Positive', + description: 'The finding is a false positive and does not represent a real risk', + template: 'This finding has been determined to be a false positive because:\n\n[Explain why this is a false positive]\n\nEvidence:\n- [Evidence 1]\n- [Evidence 2]', + type: ['vulnerability', 'entropy', 'license'], + }, + { + id: 'mitigated', + name: 'Mitigating Controls', + description: 'Risk is mitigated by other security controls', + template: 'The risk associated with this finding is mitigated by the following controls:\n\n1. [Control 1]\n2. [Control 2]\n\nResidual risk assessment: [Low/Medium]', + type: ['vulnerability', 'policy'], + }, + { + id: 'planned-fix', + name: 'Planned Remediation', + description: 'Fix is planned but requires time to implement', + template: 'Remediation is planned with the following timeline:\n\nPlanned fix date: [Date]\nAssigned to: [Team/Person]\nTracking ticket: [Ticket ID]\n\nReason for delay:\n[Explain why immediate fix is not possible]', + type: ['vulnerability', 'license', 'policy', 'entropy', 'determinism'], + }, + { + id: 'business-need', + name: 'Business Requirement', + description: 'Required for critical business functionality', + template: 'This exception is required for the following business reason:\n\n[Explain business requirement]\n\nImpact if not granted:\n- [Impact 1]\n- [Impact 2]\n\nApproved by: [Business Owner]', + type: ['license', 'policy'], + }, + ]; + + readonly currentStepIndex = computed(() => this.steps.indexOf(this.currentStep())); + + readonly canGoNext = computed(() => { + const step = this.currentStep(); + const d = this.draft(); + + switch (step) { + case 'type': + return d.type !== null; + case 'scope': + return this.hasValidScope(); + case 'justification': + return d.title.trim().length > 0 && d.justification.trim().length > 20; + case 'timebox': + return d.expiresInDays > 0 && d.expiresInDays <= this.maxDurationDays(); + case 'review': + return true; + default: + return false; + } + }); + + readonly canGoBack = computed(() => this.currentStepIndex() > 0); + + readonly applicableTemplates = computed(() => { + const type = this.draft().type; + if (!type) return []; + return (this.templates() || this.defaultTemplates).filter((t) => t.type.includes(type)); + }); + + readonly expirationDate = computed(() => { + const days = this.draft().expiresInDays; + const date = new Date(); + date.setDate(date.getDate() + days); + return date; + }); + + readonly timeboxWarning = computed(() => { + const days = this.draft().expiresInDays; + if (days > 60) return 'Extended exceptions require additional justification'; + if (days > 30) return 'Consider if a shorter duration is sufficient'; + return null; + }); + + ngOnInit(): void { + // Apply preselected values + if (this.preselectedType()) { + this.updateDraft('type', this.preselectedType()!); + this.currentStep.set('scope'); + } + if (this.prefilledScope()) { + this.updateDraft('scope', this.prefilledScope()!); + } + } + + private hasValidScope(): boolean { + const scope = this.draft().scope; + return !!( + (scope.cves && scope.cves.length > 0) || + (scope.packages && scope.packages.length > 0) || + (scope.images && scope.images.length > 0) || + (scope.licenses && scope.licenses.length > 0) || + (scope.policyRules && scope.policyRules.length > 0) + ); + } + + updateDraft(key: K, value: ExceptionDraft[K]): void { + this.draft.update((d) => ({ ...d, [key]: value })); + } + + updateScope(key: K, value: ExceptionScope[K]): void { + this.draft.update((d) => ({ + ...d, + scope: { ...d.scope, [key]: value }, + })); + this.updateScopePreview(); + } + + private updateScopePreview(): void { + const scope = this.draft().scope; + const preview: string[] = []; + + if (scope.cves?.length) preview.push(`${scope.cves.length} CVE(s)`); + if (scope.packages?.length) preview.push(`${scope.packages.length} package(s)`); + if (scope.images?.length) preview.push(`${scope.images.length} image(s)`); + if (scope.licenses?.length) preview.push(`${scope.licenses.length} license(s)`); + if (scope.policyRules?.length) preview.push(`${scope.policyRules.length} rule(s)`); + + this.scopePreview.set(preview); + } + + selectType(type: ExceptionType): void { + this.updateDraft('type', type); + } + + selectTemplate(templateId: string): void { + const template = this.applicableTemplates().find((t) => t.id === templateId); + if (template) { + this.selectedTemplate.set(templateId); + this.updateDraft('justification', template.template); + } + } + + selectTimebox(days: number): void { + this.updateDraft('expiresInDays', days); + } + + addTag(): void { + const tag = this.newTag().trim(); + if (tag && !this.draft().tags.includes(tag)) { + this.updateDraft('tags', [...this.draft().tags, tag]); + this.newTag.set(''); + } + } + + removeTag(tag: string): void { + this.updateDraft('tags', this.draft().tags.filter((t) => t !== tag)); + } + + goNext(): void { + if (!this.canGoNext()) return; + const idx = this.currentStepIndex(); + if (idx < this.steps.length - 1) { + this.currentStep.set(this.steps[idx + 1]); + } + } + + goBack(): void { + if (!this.canGoBack()) return; + const idx = this.currentStepIndex(); + if (idx > 0) { + this.currentStep.set(this.steps[idx - 1]); + } + } + + goToStep(step: WizardStep): void { + const targetIdx = this.steps.indexOf(step); + if (targetIdx <= this.currentStepIndex()) { + this.currentStep.set(step); + } + } + + onCancel(): void { + this.cancel.emit(); + } + + onSubmit(): void { + if (this.canGoNext()) { + this.create.emit(this.draft()); + } + } + + formatDate(date: Date): string { + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + } + + onTagInput(event: Event): void { + this.newTag.set((event.target as HTMLInputElement).value); + } +} diff --git a/src/Web/StellaOps.Web/src/app/shared/components/determinism-badge.component.html b/src/Web/StellaOps.Web/src/app/shared/components/determinism-badge.component.html new file mode 100644 index 000000000..91ab8b4c0 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/determinism-badge.component.html @@ -0,0 +1,130 @@ +
+ + + + + @if (isExpanded()) { +
+ +
+

Merkle Root

+
+ @if (status().merkleRoot) { + + {{ formatHash(status().merkleRoot!, 24) }} + + + {{ status().merkleConsistent ? 'Consistent' : 'Mismatch' }} + + } @else { + No Merkle root available + } +
+
+ + + @if (status().composition; as comp) { +
+

Composition

+
+
+
Schema
+
{{ comp.schemaVersion }}
+
+
+
Scanner
+
{{ comp.scannerVersion }}
+
+
+
Built
+
{{ comp.buildTimestamp | date:'short' }}
+
+
+
Hash
+
{{ formatHash(comp.compositionHash) }}
+
+
+ +
+ } + + +
+

+ Fragment Hashes + + ({{ fragmentStats().percentage | number:'1.0-0' }}% match) + +

+
+ @for (fragment of status().fragments; track fragment.id) { +
+ {{ getFragmentIcon(fragment) }} +
+ + {{ fragment.type }}: {{ formatHash(fragment.id, 16) }} + + {{ formatBytes(fragment.size) }} +
+
+ + E: {{ formatHash(fragment.expectedHash) }} + + + C: {{ formatHash(fragment.computedHash) }} + +
+
+ } +
+
+ + + @if (status().issues.length > 0) { +
+

+ Issues + @if (issuesByLevel().errors.length > 0) { + {{ issuesByLevel().errors.length }} errors + } + @if (issuesByLevel().warnings.length > 0) { + {{ issuesByLevel().warnings.length }} warnings + } +

+
    + @for (issue of status().issues; track issue.code) { +
  • + {{ getIssueIcon(issue) }} +
    + {{ issue.code }} + {{ issue.message }} + @if (issue.fragmentId) { + Fragment: {{ formatHash(issue.fragmentId) }} + } +
    +
  • + } +
+
+ } + +

+ Verified {{ status().verifiedAt | date:'medium' }} +

+
+ } +
diff --git a/src/Web/StellaOps.Web/src/app/shared/components/determinism-badge.component.scss b/src/Web/StellaOps.Web/src/app/shared/components/determinism-badge.component.scss new file mode 100644 index 000000000..f547bebf0 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/determinism-badge.component.scss @@ -0,0 +1,322 @@ +.determinism-badge { + font-size: 0.875rem; + border-radius: 6px; + border: 1px solid var(--color-border, #e5e7eb); + background: var(--color-bg-card, white); + overflow: hidden; + + &.status-verified { + .badge-trigger { border-left: 3px solid var(--color-success, #059669); } + .badge-icon { color: var(--color-success, #059669); } + } + + &.status-warning { + .badge-trigger { border-left: 3px solid var(--color-warning, #d97706); } + .badge-icon { color: var(--color-warning, #d97706); } + } + + &.status-failed { + .badge-trigger { border-left: 3px solid var(--color-error, #dc2626); } + .badge-icon { color: var(--color-error, #dc2626); } + } + + &.status-unknown { + .badge-trigger { border-left: 3px solid var(--color-text-muted, #9ca3af); } + .badge-icon { color: var(--color-text-muted, #9ca3af); } + } +} + +.badge-trigger { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.5rem 0.75rem; + background: transparent; + border: none; + cursor: pointer; + text-align: left; + + &:hover { + background: var(--color-bg-hover, #f9fafb); + } + + &:focus-visible { + outline: 2px solid var(--color-primary, #2563eb); + outline-offset: -2px; + } +} + +.badge-icon { + font-size: 1rem; + font-weight: bold; +} + +.badge-label { + font-weight: 600; + color: var(--color-text, #374151); +} + +.badge-stats { + color: var(--color-text-muted, #6b7280); + font-size: 0.75rem; + margin-left: auto; +} + +.badge-expand-icon { + color: var(--color-text-muted, #9ca3af); + font-size: 0.625rem; + transition: transform 0.2s; + + &.expanded { + transform: rotate(180deg); + } +} + +.badge-details { + border-top: 1px solid var(--color-border, #e5e7eb); + padding: 0.75rem; + background: var(--color-bg-subtle, #f9fafb); +} + +.detail-section { + margin-bottom: 1rem; + + &:last-of-type { + margin-bottom: 0.5rem; + } +} + +.section-title { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--color-text-muted, #6b7280); + margin: 0 0 0.5rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.fragment-count { + font-weight: normal; + text-transform: none; +} + +.merkle-info { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.hash { + font-family: monospace; + font-size: 0.75rem; + background: var(--color-bg-code, #f3f4f6); + padding: 0.125rem 0.375rem; + border-radius: 3px; + color: var(--color-text, #374151); +} + +.consistency-badge { + font-size: 0.6875rem; + padding: 0.125rem 0.375rem; + border-radius: 3px; + font-weight: 600; + background: var(--color-error-bg, #fef2f2); + color: var(--color-error, #dc2626); + + &.consistent { + background: var(--color-success-bg, #ecfdf5); + color: var(--color-success, #059669); + } +} + +.no-data { + font-style: italic; + color: var(--color-text-muted, #9ca3af); +} + +.composition-meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 0.5rem; + margin: 0 0 0.5rem; +} + +.meta-item { + dt { + font-size: 0.6875rem; + color: var(--color-text-muted, #9ca3af); + } + + dd { + margin: 0; + font-size: 0.8125rem; + color: var(--color-text, #374151); + } +} + +.btn-link { + background: none; + border: none; + color: var(--color-primary, #2563eb); + font-size: 0.75rem; + cursor: pointer; + padding: 0; + + &:hover { + text-decoration: underline; + } +} + +.fragments-list { + display: flex; + flex-direction: column; + gap: 0.375rem; + max-height: 200px; + overflow-y: auto; +} + +.fragment-item { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.375rem; + border-radius: 4px; + background: var(--color-bg-card, white); + border: 1px solid var(--color-border, #e5e7eb); + + &.mismatch { + border-color: var(--color-error, #dc2626); + background: var(--color-error-bg, #fef2f2); + } +} + +.fragment-icon { + font-size: 0.75rem; + font-weight: bold; + color: var(--color-success, #059669); + + .mismatch & { + color: var(--color-error, #dc2626); + } +} + +.fragment-info { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; +} + +.fragment-id { + font-size: 0.75rem; + font-weight: 500; + color: var(--color-text, #374151); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.fragment-size { + font-size: 0.6875rem; + color: var(--color-text-muted, #9ca3af); +} + +.fragment-hashes { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.hash.expected { + opacity: 0.7; +} + +.hash.computed { + .mismatch & { + color: var(--color-error, #dc2626); + } +} + +.issues-section { + .section-title { + flex-wrap: wrap; + } +} + +.issue-count { + font-size: 0.625rem; + padding: 0.125rem 0.25rem; + border-radius: 2px; + font-weight: normal; + text-transform: none; + + &.error { + background: var(--color-error-bg, #fef2f2); + color: var(--color-error, #dc2626); + } + + &.warning { + background: var(--color-warning-bg, #fffbeb); + color: var(--color-warning, #d97706); + } +} + +.issues-list { + list-style: none; + padding: 0; + margin: 0; +} + +.issue-item { + display: flex; + gap: 0.375rem; + padding: 0.25rem 0; + border-bottom: 1px solid var(--color-border, #e5e7eb); + + &:last-child { + border-bottom: none; + } + + &.severity-error .issue-icon { color: var(--color-error, #dc2626); } + &.severity-warning .issue-icon { color: var(--color-warning, #d97706); } + &.severity-info .issue-icon { color: var(--color-info, #2563eb); } +} + +.issue-icon { + font-size: 0.75rem; +} + +.issue-content { + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 0; +} + +.issue-code { + font-family: monospace; + font-size: 0.6875rem; + color: var(--color-text-muted, #6b7280); +} + +.issue-message { + font-size: 0.8125rem; + color: var(--color-text, #374151); +} + +.issue-fragment { + font-size: 0.6875rem; + color: var(--color-text-muted, #9ca3af); +} + +.verified-at { + font-size: 0.6875rem; + color: var(--color-text-muted, #9ca3af); + margin: 0; + text-align: right; +} diff --git a/src/Web/StellaOps.Web/src/app/shared/components/determinism-badge.component.ts b/src/Web/StellaOps.Web/src/app/shared/components/determinism-badge.component.ts new file mode 100644 index 000000000..3beda4958 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/determinism-badge.component.ts @@ -0,0 +1,118 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, + signal, +} from '@angular/core'; +import { + DeterminismStatus, + DeterminismFragment, + DeterminismIssue, +} from '../../core/api/determinism.models'; + +@Component({ + selector: 'app-determinism-badge', + standalone: true, + imports: [CommonModule], + templateUrl: './determinism-badge.component.html', + styleUrls: ['./determinism-badge.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DeterminismBadgeComponent { + /** Determinism status data */ + readonly status = input.required(); + + /** Whether to show expanded details by default */ + readonly expanded = input(false); + + /** Emits when user clicks to view full composition */ + readonly viewComposition = output(); + + /** Local expanded state */ + readonly isExpanded = signal(false); + + readonly statusIcon = computed(() => { + switch (this.status().status) { + case 'verified': + return '✓'; + case 'warning': + return '⚠'; + case 'failed': + return '✗'; + default: + return '?'; + } + }); + + readonly statusLabel = computed(() => { + switch (this.status().status) { + case 'verified': + return 'Deterministic'; + case 'warning': + return 'Partial'; + case 'failed': + return 'Non-deterministic'; + default: + return 'Unknown'; + } + }); + + readonly fragmentStats = computed(() => { + const fragments = this.status().fragments; + const matched = fragments.filter((f) => f.matches).length; + const total = fragments.length; + return { matched, total, percentage: total > 0 ? (matched / total) * 100 : 0 }; + }); + + readonly issuesByLevel = computed(() => { + const issues = this.status().issues; + return { + errors: issues.filter((i) => i.severity === 'error'), + warnings: issues.filter((i) => i.severity === 'warning'), + info: issues.filter((i) => i.severity === 'info'), + }; + }); + + constructor() { + // Initialize expanded state from input + this.isExpanded.set(this.expanded()); + } + + toggleExpanded(): void { + this.isExpanded.update((v) => !v); + } + + onViewComposition(): void { + this.viewComposition.emit(); + } + + formatHash(hash: string, length = 12): string { + if (!hash) return 'N/A'; + if (hash.length <= length) return hash; + return hash.substring(0, length) + '...'; + } + + formatBytes(bytes: number): string { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + } + + getFragmentIcon(fragment: DeterminismFragment): string { + return fragment.matches ? '✓' : '✗'; + } + + getIssueIcon(issue: DeterminismIssue): string { + switch (issue.severity) { + case 'error': + return '✗'; + case 'warning': + return '⚠'; + default: + return 'ℹ'; + } + } +} diff --git a/src/Web/StellaOps.Web/src/app/shared/components/entropy-panel.component.html b/src/Web/StellaOps.Web/src/app/shared/components/entropy-panel.component.html new file mode 100644 index 000000000..48c9d72b2 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/entropy-panel.component.html @@ -0,0 +1,160 @@ +
+ +
+
+
+ + + + + {{ analysis().overallScore | number:'1.1-1' }} +
+
+

{{ analysis().riskLevel | titlecase }} Risk

+

{{ scoreDescription() }}

+
+
+ +
+ +
+ +
+

Layer Entropy Distribution

+
+
+ + @for (layer of layerDonutData(); track layer.digest) { + + } + + + {{ analysis().layers.length }} layers + +
+
+ @for (layer of analysis().layers; track layer.digest) { + + } +
+
+
+ + +
+

+ High Entropy Files + {{ analysis().highEntropyFiles.length }} +

+ @if (topHighEntropyFiles().length === 0) { +

No high entropy files detected

+ } @else { +
+ @for (file of topHighEntropyFiles(); track file.path) { + + } +
+ @if (analysis().highEntropyFiles.length > 10) { +

+ + {{ analysis().highEntropyFiles.length - 10 }} more files +

+ } + } +
+ + +
+

Why Risky?

+ @if (analysis().detectorHints.length === 0) { +

No specific risks detected

+ } @else { +
+ @for (group of detectorHintsByType(); track group.type) { + + } +
+
    + @for (hint of analysis().detectorHints.slice(0, 5); track hint.id) { +
  • +
    + {{ getHintTypeIcon(hint.type) }} + {{ hint.type | titlecase }} + {{ hint.confidence }}% confidence +
    +

    {{ hint.description }}

    +

    + Fix: {{ hint.remediation }} +

    + @if (hint.affectedPaths.length > 0) { +
    + {{ hint.affectedPaths.length }} affected file(s) +
      + @for (path of hint.affectedPaths.slice(0, 3); track path) { +
    • {{ formatPath(path, 50) }}
    • + } + @if (hint.affectedPaths.length > 3) { +
    • + {{ hint.affectedPaths.length - 3 }} more
    • + } +
    +
    + } +
  • + } +
+ @if (analysis().detectorHints.length > 5) { +

+ + {{ analysis().detectorHints.length - 5 }} more hints in report +

+ } + } +
+
+ +
+ Analyzed {{ analysis().analyzedAt | date:'medium' }} +
+
diff --git a/src/Web/StellaOps.Web/src/app/shared/components/entropy-panel.component.scss b/src/Web/StellaOps.Web/src/app/shared/components/entropy-panel.component.scss new file mode 100644 index 000000000..780b99616 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/entropy-panel.component.scss @@ -0,0 +1,431 @@ +.entropy-panel { + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 8px; + background: var(--color-bg-card, white); + overflow: hidden; + + &.risk-low { --risk-color: var(--color-success, #059669); } + &.risk-medium { --risk-color: var(--color-warning, #d97706); } + &.risk-high { --risk-color: var(--color-error, #ea580c); } + &.risk-critical { --risk-color: var(--color-critical, #dc2626); } +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 1rem; + background: var(--color-bg-subtle, #f9fafb); + border-bottom: 1px solid var(--color-border, #e5e7eb); +} + +.score-section { + display: flex; + align-items: center; + gap: 1rem; +} + +.score-ring { + position: relative; + width: 64px; + height: 64px; +} + +.score-svg { + width: 100%; + height: 100%; + transform: rotate(-90deg); +} + +.score-bg { + fill: none; + stroke: var(--color-border, #e5e7eb); + stroke-width: 8; +} + +.score-fill { + fill: none; + stroke: var(--risk-color); + stroke-width: 8; + stroke-linecap: round; + transition: stroke-dasharray 0.5s ease; +} + +.score-value { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 1.25rem; + font-weight: 700; + color: var(--risk-color); +} + +.score-info { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.risk-label { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--risk-color); +} + +.score-desc { + margin: 0; + font-size: 0.8125rem; + color: var(--color-text-muted, #6b7280); +} + +.btn-report { + background: none; + border: none; + color: var(--color-primary, #2563eb); + font-size: 0.8125rem; + cursor: pointer; + padding: 0.25rem 0.5rem; + + &:hover { + text-decoration: underline; + } +} + +.panel-content { + padding: 1rem; +} + +.section { + margin-bottom: 1.5rem; + + &:last-child { + margin-bottom: 0; + } +} + +.section-title { + font-size: 0.8125rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--color-text-muted, #6b7280); + margin: 0 0 0.75rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.count-badge { + font-size: 0.6875rem; + padding: 0.125rem 0.375rem; + border-radius: 10px; + background: var(--color-bg-subtle, #f3f4f6); + color: var(--color-text, #374151); + font-weight: normal; +} + +.layer-visualization { + display: grid; + grid-template-columns: 120px 1fr; + gap: 1rem; + align-items: start; +} + +.donut-chart { + position: relative; + width: 100px; + height: 100px; +} + +.donut-svg { + width: 100%; + height: 100%; +} + +.donut-segment { + fill: none; + stroke-width: 16; + cursor: pointer; + transition: opacity 0.2s; + + &:hover { + opacity: 0.8; + } +} + +.donut-center { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 0.75rem; + color: var(--color-text-muted, #6b7280); + text-align: center; +} + +.layer-legend { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.legend-item { + display: flex; + align-items: center; + gap: 0.5rem; + background: none; + border: none; + padding: 0.25rem 0.5rem; + border-radius: 4px; + cursor: pointer; + text-align: left; + + &:hover { + background: var(--color-bg-hover, #f9fafb); + } +} + +.legend-color { + width: 12px; + height: 12px; + border-radius: 2px; + flex-shrink: 0; +} + +.legend-label { + font-size: 0.75rem; + color: var(--color-text, #374151); + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.legend-value { + font-size: 0.6875rem; + color: var(--color-text-muted, #9ca3af); +} + +.empty-state { + font-style: italic; + color: var(--color-text-muted, #9ca3af); + text-align: center; + padding: 1rem; +} + +.files-heatmap { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.file-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + border-radius: 4px; + background: var(--color-bg-subtle, #f9fafb); + border: 1px solid transparent; + cursor: pointer; + text-align: left; + + &:hover { + border-color: var(--color-border, #e5e7eb); + } + + &.entropy-low { border-left: 3px solid var(--color-success, #059669); } + &.entropy-medium { border-left: 3px solid var(--color-warning, #d97706); } + &.entropy-high { border-left: 3px solid var(--color-error, #ea580c); } + &.entropy-critical { border-left: 3px solid var(--color-critical, #dc2626); } +} + +.file-icon { + font-size: 1.25rem; +} + +.file-info { + flex: 1; + min-width: 0; +} + +.file-path { + display: block; + font-size: 0.8125rem; + font-family: monospace; + color: var(--color-text, #374151); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.file-meta { + display: flex; + gap: 0.5rem; + font-size: 0.6875rem; + color: var(--color-text-muted, #9ca3af); +} + +.entropy-bar-container { + width: 100px; + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.entropy-bar { + height: 6px; + border-radius: 3px; + background: linear-gradient(to right, var(--color-success, #059669), var(--color-warning, #d97706), var(--color-critical, #dc2626)); +} + +.entropy-value { + font-size: 0.625rem; + color: var(--color-text-muted, #9ca3af); + text-align: right; +} + +.more-files, +.more-hints { + font-size: 0.75rem; + color: var(--color-text-muted, #9ca3af); + text-align: center; + margin: 0.5rem 0 0; +} + +.hint-chips { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.hint-chip { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + border-radius: 16px; + background: var(--color-bg-subtle, #f3f4f6); + border: 1px solid var(--color-border, #e5e7eb); + font-size: 0.75rem; + cursor: pointer; + + &:hover { + background: var(--color-bg-hover, #e5e7eb); + } + + &.severity-critical { border-color: var(--color-critical, #dc2626); background: #fef2f2; } + &.severity-high { border-color: var(--color-error, #ea580c); background: #fff7ed; } + &.severity-medium { border-color: var(--color-warning, #d97706); background: #fffbeb; } +} + +.chip-icon { + font-size: 0.875rem; +} + +.chip-label { + font-weight: 500; +} + +.chip-count { + background: rgba(0, 0, 0, 0.1); + padding: 0 0.25rem; + border-radius: 8px; + font-size: 0.625rem; +} + +.hints-list { + list-style: none; + padding: 0; + margin: 0; +} + +.hint-item { + padding: 0.75rem; + border-radius: 4px; + margin-bottom: 0.5rem; + background: var(--color-bg-subtle, #f9fafb); + + &.severity-critical { border-left: 3px solid var(--color-critical, #dc2626); } + &.severity-high { border-left: 3px solid var(--color-error, #ea580c); } + &.severity-medium { border-left: 3px solid var(--color-warning, #d97706); } + &.severity-low { border-left: 3px solid var(--color-text-muted, #9ca3af); } +} + +.hint-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.25rem; +} + +.hint-icon { + font-size: 1rem; +} + +.hint-type { + font-weight: 600; + font-size: 0.8125rem; +} + +.hint-confidence { + margin-left: auto; + font-size: 0.6875rem; + color: var(--color-text-muted, #9ca3af); +} + +.hint-desc { + margin: 0 0 0.5rem; + font-size: 0.8125rem; + color: var(--color-text, #374151); +} + +.hint-remediation { + margin: 0; + font-size: 0.75rem; + color: var(--color-text-muted, #6b7280); +} + +.affected-paths { + margin-top: 0.5rem; + + summary { + font-size: 0.75rem; + color: var(--color-primary, #2563eb); + cursor: pointer; + } + + ul { + margin: 0.25rem 0 0; + padding-left: 1rem; + font-size: 0.75rem; + } + + code { + font-family: monospace; + background: var(--color-bg-code, #f3f4f6); + padding: 0 0.25rem; + border-radius: 2px; + } + + .more { + color: var(--color-text-muted, #9ca3af); + font-style: italic; + list-style: none; + } +} + +.panel-footer { + padding: 0.5rem 1rem; + border-top: 1px solid var(--color-border, #e5e7eb); + background: var(--color-bg-subtle, #f9fafb); +} + +.analyzed-at { + font-size: 0.6875rem; + color: var(--color-text-muted, #9ca3af); +} diff --git a/src/Web/StellaOps.Web/src/app/shared/components/entropy-panel.component.ts b/src/Web/StellaOps.Web/src/app/shared/components/entropy-panel.component.ts new file mode 100644 index 000000000..f311c1a62 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/entropy-panel.component.ts @@ -0,0 +1,179 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, +} from '@angular/core'; +import { + EntropyAnalysis, + LayerEntropy, + HighEntropyFile, + DetectorHint, +} from '../../core/api/entropy.models'; + +@Component({ + selector: 'app-entropy-panel', + standalone: true, + imports: [CommonModule], + templateUrl: './entropy-panel.component.html', + styleUrls: ['./entropy-panel.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EntropyPanelComponent { + /** Entropy analysis data */ + readonly analysis = input.required(); + + /** Emits when user wants to view raw report */ + readonly viewReport = output(); + + /** Emits when user clicks on a layer */ + readonly selectLayer = output(); + + /** Emits when user clicks on a file */ + readonly selectFile = output(); + + readonly riskClass = computed(() => 'risk-' + this.analysis().riskLevel); + + readonly scoreDescription = computed(() => { + const score = this.analysis().overallScore; + if (score <= 2) return 'Minimal entropy detected'; + if (score <= 4) return 'Normal entropy levels'; + if (score <= 6) return 'Elevated entropy - review recommended'; + if (score <= 8) return 'High entropy - potential secrets detected'; + return 'Critical entropy - likely obfuscated content'; + }); + + readonly layerDonutData = computed(() => { + const layers = this.analysis().layers; + const total = layers.reduce((sum, l) => sum + l.riskContribution, 0); + return layers.map((layer, index) => ({ + ...layer, + percentage: total > 0 ? (layer.riskContribution / total) * 100 : 0, + startAngle: this.calculateStartAngle(layers, index, total), + color: this.getLayerColor(layer.riskContribution, total / layers.length), + })); + }); + + readonly topHighEntropyFiles = computed(() => { + return [...this.analysis().highEntropyFiles] + .sort((a, b) => b.entropy - a.entropy) + .slice(0, 10); + }); + + readonly detectorHintsByType = computed(() => { + const hints = this.analysis().detectorHints; + const byType = new Map(); + for (const hint of hints) { + const existing = byType.get(hint.type) || []; + existing.push(hint); + byType.set(hint.type, existing); + } + return Array.from(byType.entries()).map(([type, items]) => ({ + type, + items, + count: items.length, + maxSeverity: this.getMaxSeverity(items), + })); + }); + + private calculateStartAngle( + layers: LayerEntropy[], + index: number, + total: number + ): number { + let angle = 0; + for (let i = 0; i < index; i++) { + angle += total > 0 ? (layers[i].riskContribution / total) * 360 : 0; + } + return angle; + } + + private getLayerColor(contribution: number, avg: number): string { + const ratio = contribution / (avg || 1); + if (ratio > 2) return 'var(--color-entropy-critical, #dc2626)'; + if (ratio > 1.5) return 'var(--color-entropy-high, #ea580c)'; + if (ratio > 1) return 'var(--color-entropy-medium, #d97706)'; + return 'var(--color-entropy-low, #65a30d)'; + } + + private getMaxSeverity(hints: DetectorHint[]): string { + const severityOrder = ['critical', 'high', 'medium', 'low']; + for (const severity of severityOrder) { + if (hints.some((h) => h.severity === severity)) { + return severity; + } + } + return 'low'; + } + + getEntropyBarWidth(entropy: number): string { + // Entropy is 0-8 bits, normalize to percentage + return Math.min(entropy / 8 * 100, 100) + '%'; + } + + getEntropyClass(entropy: number): string { + if (entropy >= 7) return 'entropy-critical'; + if (entropy >= 6) return 'entropy-high'; + if (entropy >= 4.5) return 'entropy-medium'; + return 'entropy-low'; + } + + getClassificationIcon(classification: HighEntropyFile['classification']): string { + switch (classification) { + case 'encrypted': + return '🔐'; + case 'compressed': + return '📦'; + case 'binary': + return '⚙️'; + case 'suspicious': + return '⚠️'; + default: + return '❓'; + } + } + + getHintTypeIcon(type: DetectorHint['type']): string { + switch (type) { + case 'credential': + return '🔑'; + case 'key': + return '🔏'; + case 'token': + return '🎫'; + case 'obfuscated': + return '🎭'; + case 'packed': + return '📦'; + case 'crypto': + return '🔐'; + default: + return '❓'; + } + } + + formatBytes(bytes: number): string { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + } + + formatPath(path: string, maxLength = 40): string { + if (path.length <= maxLength) return path; + return '...' + path.slice(-maxLength + 3); + } + + onViewReport(): void { + this.viewReport.emit(); + } + + onSelectLayer(digest: string): void { + this.selectLayer.emit(digest); + } + + onSelectFile(path: string): void { + this.selectFile.emit(path); + } +} diff --git a/src/Web/StellaOps.Web/src/app/shared/components/entropy-policy-banner.component.html b/src/Web/StellaOps.Web/src/app/shared/components/entropy-policy-banner.component.html new file mode 100644 index 000000000..29d6bad6c --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/entropy-policy-banner.component.html @@ -0,0 +1,200 @@ +
+ + + + +
+
+
+ +
+
+
+ + +
+ Warn +
+
+ Block +
+ + +
+ {{ config().currentScore | number:'1.1-1' }} +
+
+ +
+ 0 + 2 + 4 + 6 + 8 + 10 +
+
+ +
+ + + Allow (< {{ config().warnThreshold }}) + + + + Warn ({{ config().warnThreshold }} - {{ config().blockThreshold }}) + + + + Block (> {{ config().blockThreshold }}) + +
+
+ + + @if (expanded()) { + + } +
diff --git a/src/Web/StellaOps.Web/src/app/shared/components/entropy-policy-banner.component.scss b/src/Web/StellaOps.Web/src/app/shared/components/entropy-policy-banner.component.scss new file mode 100644 index 000000000..7e3ce1fa2 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/entropy-policy-banner.component.scss @@ -0,0 +1,489 @@ +.entropy-policy-banner { + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 8px; + background: var(--color-bg-card, white); + overflow: hidden; + + &.action-allow { + border-color: var(--color-success-border, #a7f3d0); + + .banner-main { + background: var(--color-success-bg, #ecfdf5); + border-left: 4px solid var(--color-success, #059669); + } + + .banner-icon { + color: var(--color-success, #059669); + } + } + + &.action-warn { + border-color: var(--color-warning-border, #fde68a); + + .banner-main { + background: var(--color-warning-bg, #fffbeb); + border-left: 4px solid var(--color-warning, #d97706); + } + + .banner-icon { + color: var(--color-warning, #d97706); + } + } + + &.action-block { + border-color: var(--color-error-border, #fecaca); + + .banner-main { + background: var(--color-error-bg, #fef2f2); + border-left: 4px solid var(--color-error, #dc2626); + } + + .banner-icon { + color: var(--color-error, #dc2626); + } + } +} + +.banner-main { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + flex-wrap: wrap; +} + +.banner-icon { + font-family: monospace; + font-weight: 700; + font-size: 1.125rem; +} + +.banner-content { + flex: 1; + min-width: 200px; +} + +.banner-title { + margin: 0; + font-size: 0.9375rem; + font-weight: 600; + color: var(--color-text, #111827); +} + +.banner-message { + margin: 0.25rem 0 0; + font-size: 0.8125rem; + color: var(--color-text-muted, #6b7280); +} + +.banner-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.btn-secondary { + padding: 0.375rem 0.75rem; + background: var(--color-bg-card, white); + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 4px; + font-size: 0.8125rem; + cursor: pointer; + color: var(--color-text, #374151); + + &:hover { + background: var(--color-bg-hover, #f3f4f6); + } +} + +.btn-expand { + padding: 0.375rem 0.75rem; + background: transparent; + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 4px; + font-size: 0.8125rem; + cursor: pointer; + color: var(--color-primary, #2563eb); + + &:hover { + background: var(--color-primary-bg, #eff6ff); + } +} + +// Score Visualization +.score-visualization { + padding: 0.75rem 1rem; + border-top: 1px solid var(--color-border, #e5e7eb); +} + +.score-bar { + margin-bottom: 0.5rem; +} + +.score-track { + position: relative; + height: 24px; + background: var(--color-bg-subtle, #f3f4f6); + border-radius: 4px; + overflow: visible; +} + +.zone { + position: absolute; + top: 0; + height: 100%; + + &.allow { + background: var(--color-success-bg, #dcfce7); + border-radius: 4px 0 0 4px; + } + + &.warn { + background: var(--color-warning-bg, #fef3c7); + } + + &.block { + background: var(--color-error-bg, #fee2e2); + border-radius: 0 4px 4px 0; + } +} + +.threshold-line { + position: absolute; + top: 0; + width: 2px; + height: 100%; + background: currentColor; + transform: translateX(-1px); + + &.warn { + color: var(--color-warning, #d97706); + } + + &.block { + color: var(--color-error, #dc2626); + } + + .threshold-label { + position: absolute; + top: -18px; + left: 50%; + transform: translateX(-50%); + font-size: 0.625rem; + font-weight: 600; + white-space: nowrap; + } +} + +.score-marker { + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + z-index: 1; + + &.action-allow { + .score-value { + background: var(--color-success, #059669); + } + } + + &.action-warn { + .score-value { + background: var(--color-warning, #d97706); + } + } + + &.action-block { + .score-value { + background: var(--color-error, #dc2626); + } + } +} + +.score-value { + display: block; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 700; + color: white; + white-space: nowrap; +} + +.scale-labels { + display: flex; + justify-content: space-between; + font-size: 0.625rem; + color: var(--color-text-muted, #9ca3af); + padding: 0 2px; +} + +.score-legend { + display: flex; + gap: 1rem; + justify-content: center; + margin-top: 0.5rem; + flex-wrap: wrap; +} + +.legend-item { + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 0.6875rem; + color: var(--color-text-muted, #6b7280); + + &.allow .legend-dot { background: var(--color-success, #059669); } + &.warn .legend-dot { background: var(--color-warning, #d97706); } + &.block .legend-dot { background: var(--color-error, #dc2626); } +} + +.legend-dot { + width: 8px; + height: 8px; + border-radius: 2px; +} + +// Expanded Details +.banner-details { + border-top: 1px solid var(--color-border, #e5e7eb); + padding: 1rem; + background: var(--color-bg-subtle, #f9fafb); +} + +.section-title { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--color-text-muted, #6b7280); + margin: 0 0 0.75rem; +} + +.policy-info, +.threshold-explanation, +.mitigation-section, +.report-section { + margin-bottom: 1.25rem; + + &:last-child { + margin-bottom: 0; + } +} + +.info-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 0.75rem; + margin: 0; +} + +.info-item { + dt { + font-size: 0.6875rem; + color: var(--color-text-muted, #9ca3af); + margin-bottom: 0.125rem; + } + + dd { + margin: 0; + font-size: 0.8125rem; + color: var(--color-text, #374151); + + code { + font-size: 0.75rem; + background: var(--color-bg-code, #f3f4f6); + padding: 0.125rem 0.25rem; + border-radius: 2px; + } + } +} + +.explanation-content { + background: var(--color-bg-card, white); + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 4px; + padding: 0.75rem; + + p { + margin: 0 0 0.5rem; + font-size: 0.8125rem; + color: var(--color-text, #374151); + } +} + +.entropy-indicators { + list-style: none; + padding: 0; + margin: 0 0 0.5rem; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 0.25rem; + + li { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.8125rem; + } +} + +.indicator-icon { + font-family: monospace; + font-weight: 600; + font-size: 0.6875rem; + color: var(--color-text-muted, #6b7280); +} + +.explanation-note { + font-size: 0.75rem; + color: var(--color-text-muted, #6b7280); + font-style: italic; + margin-bottom: 0; +} + +.mitigation-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.mitigation-card { + background: var(--color-bg-card, white); + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 4px; + padding: 0.75rem; +} + +.mitigation-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.5rem; + flex-wrap: wrap; +} + +.mitigation-title { + font-weight: 600; + font-size: 0.875rem; + color: var(--color-text, #374151); +} + +.mitigation-badges { + display: flex; + gap: 0.375rem; +} + +.badge { + font-size: 0.625rem; + padding: 0.125rem 0.375rem; + border-radius: 10px; + font-weight: 500; + + &.impact { + &.impact-high { + background: var(--color-success-bg, #ecfdf5); + color: var(--color-success, #059669); + } + + &.impact-medium { + background: var(--color-info-bg, #f0f9ff); + color: var(--color-info, #0284c7); + } + + &.impact-low { + background: var(--color-bg-subtle, #f3f4f6); + color: var(--color-text-muted, #6b7280); + } + } + + &.effort { + background: var(--color-bg-subtle, #f3f4f6); + color: var(--color-text-muted, #6b7280); + } +} + +.mitigation-desc { + margin: 0 0 0.5rem; + font-size: 0.8125rem; + color: var(--color-text-muted, #6b7280); +} + +.mitigation-command { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + padding: 0.5rem; + background: var(--color-bg-code, #1f2937); + border-radius: 4px; + + code { + flex: 1; + font-size: 0.75rem; + color: #e5e7eb; + white-space: nowrap; + overflow-x: auto; + } + + .btn-run { + padding: 0.25rem 0.5rem; + background: var(--color-primary, #2563eb); + border: none; + border-radius: 3px; + font-size: 0.6875rem; + color: white; + cursor: pointer; + flex-shrink: 0; + + &:hover { + background: var(--color-primary-dark, #1d4ed8); + } + } +} + +.docs-link { + font-size: 0.75rem; + color: var(--color-primary, #2563eb); + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +.report-info { + background: var(--color-bg-card, white); + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 4px; + padding: 0.75rem; +} + +.report-label { + font-family: monospace; + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text, #374151); +} + +.report-desc { + margin: 0.5rem 0; + font-size: 0.8125rem; + color: var(--color-text-muted, #6b7280); +} + +.btn-download { + padding: 0.5rem 1rem; + background: var(--color-primary, #2563eb); + border: none; + border-radius: 4px; + font-size: 0.8125rem; + font-weight: 500; + color: white; + cursor: pointer; + + &:hover { + background: var(--color-primary-dark, #1d4ed8); + } +} diff --git a/src/Web/StellaOps.Web/src/app/shared/components/entropy-policy-banner.component.ts b/src/Web/StellaOps.Web/src/app/shared/components/entropy-policy-banner.component.ts new file mode 100644 index 000000000..4ca93bf1a --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/entropy-policy-banner.component.ts @@ -0,0 +1,215 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, + signal, +} from '@angular/core'; + +export interface EntropyPolicyConfig { + /** Warn threshold (0-10) */ + warnThreshold: number; + + /** Block threshold (0-10) */ + blockThreshold: number; + + /** Current entropy score */ + currentScore: number; + + /** Action taken */ + action: 'allow' | 'warn' | 'block'; + + /** Policy ID */ + policyId: string; + + /** Policy name */ + policyName: string; + + /** High entropy file count */ + highEntropyFileCount: number; + + /** Link to entropy report */ + reportUrl?: string; +} + +export interface EntropyMitigationStep { + id: string; + title: string; + description: string; + impact: 'high' | 'medium' | 'low'; + effort: 'trivial' | 'easy' | 'moderate' | 'complex'; + command?: string; + docsUrl?: string; +} + +@Component({ + selector: 'app-entropy-policy-banner', + standalone: true, + imports: [CommonModule], + templateUrl: './entropy-policy-banner.component.html', + styleUrls: ['./entropy-policy-banner.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EntropyPolicyBannerComponent { + /** Policy configuration and current state */ + readonly config = input.required(); + + /** Custom mitigation steps */ + readonly mitigationSteps = input([]); + + /** Emits when user wants to download entropy report */ + readonly downloadReport = output(); + + /** Emits when user runs a mitigation command */ + readonly runMitigation = output(); + + /** Emits when user wants to view detailed analysis */ + readonly viewAnalysis = output(); + + /** Show expanded details */ + readonly expanded = signal(false); + + readonly bannerClass = computed(() => 'action-' + this.config().action); + + readonly bannerIcon = computed(() => { + switch (this.config().action) { + case 'allow': + return '[OK]'; + case 'warn': + return '[!]'; + case 'block': + return '[X]'; + default: + return '[?]'; + } + }); + + readonly bannerTitle = computed(() => { + switch (this.config().action) { + case 'allow': + return 'Entropy Check Passed'; + case 'warn': + return 'Entropy Warning'; + case 'block': + return 'Entropy Block'; + default: + return 'Entropy Status Unknown'; + } + }); + + readonly bannerMessage = computed(() => { + const cfg = this.config(); + switch (cfg.action) { + case 'allow': + return 'Entropy score ' + cfg.currentScore.toFixed(1) + ' is within acceptable limits.'; + case 'warn': + return 'Entropy score ' + cfg.currentScore.toFixed(1) + ' exceeds warning threshold (' + cfg.warnThreshold + '). Review recommended.'; + case 'block': + return 'Entropy score ' + cfg.currentScore.toFixed(1) + ' exceeds block threshold (' + cfg.blockThreshold + '). Publication blocked.'; + default: + return ''; + } + }); + + readonly scorePercentage = computed(() => + (this.config().currentScore / 10) * 100 + ); + + readonly warnPercentage = computed(() => + (this.config().warnThreshold / 10) * 100 + ); + + readonly blockPercentage = computed(() => + (this.config().blockThreshold / 10) * 100 + ); + + readonly defaultMitigationSteps: EntropyMitigationStep[] = [ + { + id: 'review-files', + title: 'Review High-Entropy Files', + description: 'Examine files flagged as high entropy to identify false positives or legitimate concerns.', + impact: 'high', + effort: 'easy', + docsUrl: '/docs/security/entropy-analysis', + }, + { + id: 'exclude-known', + title: 'Exclude Known Binary Artifacts', + description: 'Add exclusion patterns for legitimate compressed files, fonts, or compiled assets.', + impact: 'medium', + effort: 'trivial', + command: 'stella policy entropy exclude --pattern "*.woff2" --pattern "*.gz"', + }, + { + id: 'investigate-secrets', + title: 'Investigate Potential Secrets', + description: 'Check if high-entropy content contains accidentally committed secrets or credentials.', + impact: 'high', + effort: 'moderate', + command: 'stella scan secrets --image $IMAGE_REF', + docsUrl: '/docs/security/secret-detection', + }, + { + id: 'adjust-threshold', + title: 'Adjust Policy Thresholds', + description: 'If false positives are common, consider adjusting warn/block thresholds for this policy.', + impact: 'medium', + effort: 'easy', + command: 'stella policy entropy set-threshold --policy $POLICY_ID --warn 7.0 --block 8.5', + }, + ]; + + readonly effectiveMitigationSteps = computed(() => { + const custom = this.mitigationSteps(); + return custom.length > 0 ? custom : this.defaultMitigationSteps; + }); + + toggleExpanded(): void { + this.expanded.update((v) => !v); + } + + onDownloadReport(): void { + const url = this.config().reportUrl; + if (url) { + this.downloadReport.emit(url); + } + } + + onViewAnalysis(): void { + this.viewAnalysis.emit(); + } + + onRunMitigation(step: EntropyMitigationStep): void { + this.runMitigation.emit(step); + } + + getImpactLabel(impact: string): string { + switch (impact) { + case 'high': + return 'High Impact'; + case 'medium': + return 'Medium Impact'; + case 'low': + return 'Low Impact'; + default: + return ''; + } + } + + getEffortLabel(effort: string): string { + switch (effort) { + case 'trivial': + return '< 5 min'; + case 'easy': + return '5-15 min'; + case 'moderate': + return '15-60 min'; + case 'complex': + return '> 1 hour'; + default: + return ''; + } + } +} diff --git a/src/Web/StellaOps.Web/src/app/shared/components/policy-gate-indicator.component.html b/src/Web/StellaOps.Web/src/app/shared/components/policy-gate-indicator.component.html new file mode 100644 index 000000000..a906c47ae --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/policy-gate-indicator.component.html @@ -0,0 +1,277 @@ +
+ +
+ {{ statusIcon() }} +
+ {{ statusLabel() }} + + {{ passedGates().length }}/{{ gateStatus().gates.length }} gates passed + @if (warningGates().length > 0) { + ({{ warningGates().length }} warnings) + } + +
+ +
+ @if (!gateStatus().canPublish && gateStatus().remediationHints.length > 0) { + + } + +
+
+ + + @if (!gateStatus().canPublish && gateStatus().blockReason) { +
+ [!] + {{ gateStatus().blockReason }} +
+ } + + + @if (!compact()) { +
+ @for (gate of gateStatus().gates; track gate.gateId) { +
+ + + @if (expandedGate() === gate.gateId) { +
+ + @if (gate.type === 'determinism' && getDeterminismDetails(gate)) { + @let det = getDeterminismDetails(gate)!; +
+
+ Merkle Root + + @if (det.merkleRootConsistent) { + [+] Consistent + } @else { + [x] Mismatch + } + +
+ + @if (!det.merkleRootConsistent) { +
+
+ Expected: + {{ formatHash(det.expectedMerkleRoot, 24) }} +
+
+ Computed: + {{ formatHash(det.computedMerkleRoot, 24) }} +
+
+ } + +
+ Fragments + + {{ det.matchingFragments }}/{{ det.totalFragments }} verified + +
+ +
+ Composition File + + {{ det.compositionPresent ? 'Present' : 'Missing' }} + +
+ + @if (det.fragmentResults.length > 0) { +
+ Fragment Verification ({{ det.fragmentResults.length }}) +
    + @for (frag of det.fragmentResults.slice(0, 5); track frag.fragmentId) { +
  • + {{ frag.fragmentId }} + {{ frag.match ? '+' : 'x' }} +
  • + } + @if (det.fragmentResults.length > 5) { +
  • + {{ det.fragmentResults.length - 5 }} more
  • + } +
+
+ } +
+ } + + + @if (gate.type === 'entropy' && getEntropyDetails(gate)) { + @let ent = getEntropyDetails(gate)!; +
+
+ Entropy Score + + {{ ent.entropyScore | number:'1.1-1' }} / 10 + +
+ +
+
+
+
+
+
+
+ 0 + Warn ({{ ent.warnThreshold }}) + Block ({{ ent.blockThreshold }}) + 10 +
+
+ +
+ High Entropy Files + {{ ent.highEntropyFileCount }} +
+ + @if (ent.suspiciousPatterns.length > 0) { +
+ Suspicious Patterns +
    + @for (pattern of ent.suspiciousPatterns; track pattern) { +
  • {{ pattern }}
  • + } +
+
+ } +
+ } + + + @if (gate.evidenceRefs && gate.evidenceRefs.length > 0) { + + } + + + @if (gate.result === 'failed') { + @let hints = getHintsForGate(gate.gateId); + @if (hints.length > 0) { +
+
How to Fix
+ @for (hint of hints; track hint.title) { +
+
+ {{ hint.title }} + @if (hint.effort) { + {{ getEffortLabel(hint.effort) }} + } +
+
    + @for (step of hint.steps; track step) { +
  1. {{ step }}
  2. + } +
+ @if (hint.cliCommand) { +
+ {{ hint.cliCommand }} + +
+ } + @if (hint.docsUrl) { + + Documentation -> + + } +
+ } +
+ } + } +
+ } +
+ } +
+ } + + + @if (gateStatus().blockingIssues.length > 0) { +
+

Blocking Issues ({{ gateStatus().blockingIssues.length }})

+
    + @for (issue of gateStatus().blockingIssues; track issue.code) { +
  • + {{ issue.code }} + {{ issue.message }} + @if (issue.resource) { + {{ issue.resource }} + } +
  • + } +
+
+ } + + + @if (showRemediation() && gateStatus().remediationHints.length > 0) { +
+

Remediation Steps

+ @for (hint of gateStatus().remediationHints; track hint.title) { +
+
+ {{ hint.forGate }} + {{ hint.title }} + @if (hint.effort) { + {{ getEffortLabel(hint.effort) }} + } +
+
    + @for (step of hint.steps; track step) { +
  1. {{ step }}
  2. + } +
+ @if (hint.cliCommand) { +
+ {{ hint.cliCommand }} + +
+ } +
+ } +
+ } + + +
+ Evaluation: {{ gateStatus().evaluationId | slice:0:12 }} + {{ gateStatus().evaluatedAt | date:'medium' }} +
+
diff --git a/src/Web/StellaOps.Web/src/app/shared/components/policy-gate-indicator.component.scss b/src/Web/StellaOps.Web/src/app/shared/components/policy-gate-indicator.component.scss new file mode 100644 index 000000000..cdf9fd6d3 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/policy-gate-indicator.component.scss @@ -0,0 +1,673 @@ +.policy-gate-indicator { + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 8px; + background: var(--color-bg-card, white); + overflow: hidden; + + &.status-passed { + .status-banner { border-left: 4px solid var(--color-success, #059669); } + .status-icon { color: var(--color-success, #059669); } + } + + &.status-failed { + .status-banner { border-left: 4px solid var(--color-error, #dc2626); } + .status-icon { color: var(--color-error, #dc2626); } + } + + &.status-warning { + .status-banner { border-left: 4px solid var(--color-warning, #d97706); } + .status-icon { color: var(--color-warning, #d97706); } + } + + &.status-pending { + .status-banner { border-left: 4px solid var(--color-info, #2563eb); } + .status-icon { color: var(--color-info, #2563eb); } + } + + &.status-skipped { + .status-banner { border-left: 4px solid var(--color-text-muted, #9ca3af); } + .status-icon { color: var(--color-text-muted, #9ca3af); } + } + + &.compact { + .gates-list, + .blocking-issues, + .remediation-panel { + display: none; + } + } +} + +.status-banner { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + background: var(--color-bg-subtle, #f9fafb); +} + +.status-icon { + font-family: monospace; + font-weight: 700; + font-size: 1rem; +} + +.status-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.status-label { + font-weight: 600; + color: var(--color-text, #111827); +} + +.gate-summary { + font-size: 0.8125rem; + color: var(--color-text-muted, #6b7280); +} + +.warning-count { + color: var(--color-warning, #d97706); +} + +.status-actions { + display: flex; + gap: 0.5rem; +} + +.btn-remediation { + padding: 0.375rem 0.75rem; + background: var(--color-bg-card, white); + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 4px; + font-size: 0.8125rem; + cursor: pointer; + color: var(--color-text, #374151); + + &:hover { + background: var(--color-bg-hover, #f3f4f6); + } +} + +.btn-publish { + padding: 0.375rem 1rem; + background: var(--color-success, #059669); + border: none; + border-radius: 4px; + font-size: 0.8125rem; + font-weight: 600; + cursor: pointer; + color: white; + + &:hover:not(:disabled) { + background: var(--color-success-dark, #047857); + } + + &:disabled { + background: var(--color-text-muted, #9ca3af); + cursor: not-allowed; + } +} + +.block-banner { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: var(--color-error-bg, #fef2f2); + border-bottom: 1px solid var(--color-error-border, #fecaca); +} + +.block-icon { + font-family: monospace; + font-weight: 700; + color: var(--color-error, #dc2626); +} + +.block-message { + font-size: 0.8125rem; + color: var(--color-error, #dc2626); +} + +.gates-list { + border-top: 1px solid var(--color-border, #e5e7eb); +} + +.gate-item { + border-bottom: 1px solid var(--color-border-light, #f3f4f6); + + &:last-child { + border-bottom: none; + } + + &.result-passed { + .result-icon { color: var(--color-success, #059669); } + } + + &.result-failed { + .result-icon { color: var(--color-error, #dc2626); } + .gate-header { background: var(--color-error-bg, #fef2f2); } + } + + &.result-warning { + .result-icon { color: var(--color-warning, #d97706); } + .gate-header { background: var(--color-warning-bg, #fffbeb); } + } + + &.result-skipped { + .result-icon { color: var(--color-text-muted, #9ca3af); } + .gate-name { color: var(--color-text-muted, #9ca3af); } + } +} + +.gate-header { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.625rem 1rem; + background: transparent; + border: none; + cursor: pointer; + text-align: left; + + &:hover { + background: var(--color-bg-hover, #f9fafb); + } +} + +.gate-type-icon { + font-family: monospace; + font-weight: 600; + font-size: 0.75rem; + width: 1.25rem; + height: 1.25rem; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-bg-subtle, #f3f4f6); + border-radius: 3px; + color: var(--color-text-muted, #6b7280); +} + +.result-icon { + font-family: monospace; + font-weight: 700; + font-size: 0.875rem; +} + +.gate-info { + flex: 1; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.gate-name { + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-text, #374151); +} + +.required-badge { + font-size: 0.625rem; + padding: 0.0625rem 0.25rem; + border-radius: 2px; + background: var(--color-warning-bg, #fef3c7); + color: var(--color-warning-dark, #92400e); + text-transform: uppercase; + font-weight: 600; +} + +.expand-icon { + font-size: 0.625rem; + color: var(--color-text-muted, #9ca3af); + transition: transform 0.2s; + + &.expanded { + transform: rotate(180deg); + } +} + +.gate-details { + padding: 0.75rem 1rem; + background: var(--color-bg-subtle, #f9fafb); + border-top: 1px solid var(--color-border-light, #f3f4f6); +} + +.detail-row { + display: flex; + justify-content: space-between; + padding: 0.25rem 0; + font-size: 0.8125rem; +} + +.detail-label { + color: var(--color-text-muted, #6b7280); +} + +.detail-value { + font-weight: 500; + color: var(--color-text, #374151); + + &.mismatch, + &.missing { + color: var(--color-error, #dc2626); + } + + &.score { + font-family: monospace; + + &.action-allow { color: var(--color-success, #059669); } + &.action-warn { color: var(--color-warning, #d97706); } + &.action-block { color: var(--color-error, #dc2626); } + } +} + +.match-icon { + color: var(--color-success, #059669); +} + +.mismatch-icon { + color: var(--color-error, #dc2626); +} + +.hash-comparison { + margin: 0.5rem 0; + padding: 0.5rem; + background: var(--color-bg-card, white); + border-radius: 4px; + border: 1px solid var(--color-border, #e5e7eb); +} + +.hash-row { + display: flex; + gap: 0.5rem; + font-size: 0.75rem; + margin-bottom: 0.25rem; + + &:last-child { + margin-bottom: 0; + } +} + +.hash-label { + color: var(--color-text-muted, #9ca3af); + min-width: 70px; +} + +.hash { + font-family: monospace; + background: var(--color-bg-code, #f3f4f6); + padding: 0.125rem 0.25rem; + border-radius: 2px; + + &.mismatch { + background: var(--color-error-bg, #fef2f2); + color: var(--color-error, #dc2626); + } +} + +.fragment-details { + margin-top: 0.5rem; + + summary { + font-size: 0.75rem; + color: var(--color-primary, #2563eb); + cursor: pointer; + } +} + +.fragment-list { + list-style: none; + padding: 0; + margin: 0.25rem 0 0; + + li { + display: flex; + justify-content: space-between; + padding: 0.125rem 0; + font-size: 0.75rem; + + &.mismatch { + color: var(--color-error, #dc2626); + } + + &.more { + color: var(--color-text-muted, #9ca3af); + font-style: italic; + } + } +} + +.frag-id { + font-family: monospace; +} + +.frag-status { + font-weight: 700; +} + +// Entropy Details +.threshold-bar { + margin: 0.75rem 0; +} + +.threshold-track { + position: relative; + height: 8px; + background: linear-gradient(to right, + var(--color-success, #059669) 0%, + var(--color-warning, #d97706) 50%, + var(--color-error, #dc2626) 100% + ); + border-radius: 4px; +} + +.threshold-marker { + position: absolute; + top: -2px; + width: 2px; + height: 12px; + background: var(--color-text, #374151); + + &.warn::after, + &.block::after { + content: ''; + position: absolute; + top: -4px; + left: -3px; + width: 8px; + height: 8px; + border-radius: 50%; + } + + &.warn::after { + background: var(--color-warning, #d97706); + } + + &.block::after { + background: var(--color-error, #dc2626); + } +} + +.score-marker { + position: absolute; + top: -4px; + width: 16px; + height: 16px; + background: white; + border: 2px solid var(--color-text, #374151); + border-radius: 50%; + transform: translateX(-50%); +} + +.threshold-labels { + display: flex; + justify-content: space-between; + font-size: 0.625rem; + color: var(--color-text-muted, #9ca3af); + margin-top: 0.25rem; +} + +.warn-label { + color: var(--color-warning, #d97706); +} + +.block-label { + color: var(--color-error, #dc2626); +} + +.suspicious-patterns { + margin-top: 0.5rem; +} + +.pattern-list { + list-style: disc; + margin: 0.25rem 0 0 1rem; + padding: 0; + + li { + font-size: 0.75rem; + color: var(--color-warning, #d97706); + } +} + +.evidence-links { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.75rem; + padding-top: 0.5rem; + border-top: 1px solid var(--color-border-light, #f3f4f6); + flex-wrap: wrap; +} + +.evidence-label { + font-size: 0.75rem; + color: var(--color-text-muted, #6b7280); +} + +.evidence-link { + background: none; + border: none; + color: var(--color-primary, #2563eb); + font-size: 0.75rem; + font-family: monospace; + cursor: pointer; + padding: 0; + + &:hover { + text-decoration: underline; + } +} + +.gate-remediation { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--color-border-light, #f3f4f6); +} + +.remediation-title { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-muted, #6b7280); + margin: 0 0 0.5rem; +} + +.hint-card, +.remediation-card { + background: var(--color-bg-card, white); + border: 1px solid var(--color-border, #e5e7eb); + border-radius: 4px; + padding: 0.75rem; + margin-bottom: 0.5rem; + + &:last-child { + margin-bottom: 0; + } +} + +.hint-header, +.remediation-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + flex-wrap: wrap; +} + +.hint-title, +.remediation-title { + font-weight: 600; + font-size: 0.8125rem; + color: var(--color-text, #374151); +} + +.remediation-for { + font-size: 0.6875rem; + padding: 0.125rem 0.375rem; + background: var(--color-bg-subtle, #f3f4f6); + border-radius: 3px; + font-family: monospace; +} + +.effort-badge { + font-size: 0.625rem; + padding: 0.125rem 0.375rem; + background: var(--color-info-bg, #f0f9ff); + color: var(--color-info, #0284c7); + border-radius: 10px; + margin-left: auto; +} + +.hint-steps, +.remediation-steps { + margin: 0; + padding-left: 1.25rem; + + li { + font-size: 0.8125rem; + color: var(--color-text, #374151); + margin-bottom: 0.25rem; + + &:last-child { + margin-bottom: 0; + } + } +} + +.cli-command { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.5rem; + padding: 0.5rem; + background: var(--color-bg-code, #1f2937); + border-radius: 4px; + + code { + flex: 1; + font-size: 0.75rem; + color: #e5e7eb; + white-space: nowrap; + overflow-x: auto; + } + + .btn-run { + padding: 0.25rem 0.5rem; + background: var(--color-primary, #2563eb); + border: none; + border-radius: 3px; + font-size: 0.6875rem; + color: white; + cursor: pointer; + + &:hover { + background: var(--color-primary-dark, #1d4ed8); + } + } +} + +.docs-link { + display: inline-block; + margin-top: 0.5rem; + font-size: 0.75rem; + color: var(--color-primary, #2563eb); + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +.blocking-issues { + padding: 0.75rem 1rem; + background: var(--color-error-bg, #fef2f2); + border-top: 1px solid var(--color-error-border, #fecaca); +} + +.issues-title { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-error, #dc2626); + margin: 0 0 0.5rem; +} + +.issues-list { + list-style: none; + padding: 0; + margin: 0; +} + +.issue-item { + display: flex; + gap: 0.5rem; + padding: 0.375rem 0; + font-size: 0.8125rem; + border-bottom: 1px solid var(--color-error-border, #fecaca); + flex-wrap: wrap; + + &:last-child { + border-bottom: none; + } + + &.severity-critical .issue-code { + background: var(--color-critical, #dc2626); + color: white; + } + + &.severity-high .issue-code { + background: var(--color-error, #ea580c); + color: white; + } +} + +.issue-code { + font-family: monospace; + font-size: 0.6875rem; + padding: 0.125rem 0.375rem; + border-radius: 2px; + font-weight: 600; +} + +.issue-message { + flex: 1; + color: var(--color-text, #374151); +} + +.issue-resource { + font-family: monospace; + font-size: 0.75rem; + color: var(--color-text-muted, #6b7280); +} + +.remediation-panel { + padding: 0.75rem 1rem; + background: var(--color-info-bg, #f0f9ff); + border-top: 1px solid var(--color-info-border, #bae6fd); +} + +.remediation-panel-title { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-info-dark, #0369a1); + margin: 0 0 0.75rem; +} + +.indicator-footer { + display: flex; + justify-content: space-between; + padding: 0.5rem 1rem; + background: var(--color-bg-subtle, #f9fafb); + border-top: 1px solid var(--color-border, #e5e7eb); + font-size: 0.6875rem; + color: var(--color-text-muted, #9ca3af); +} + +.eval-id { + font-family: monospace; +} diff --git a/src/Web/StellaOps.Web/src/app/shared/components/policy-gate-indicator.component.ts b/src/Web/StellaOps.Web/src/app/shared/components/policy-gate-indicator.component.ts new file mode 100644 index 000000000..f9cf52173 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/policy-gate-indicator.component.ts @@ -0,0 +1,190 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, + signal, +} from '@angular/core'; +import { + PolicyGateStatus, + PolicyGate, + PolicyRemediationHint, + DeterminismGateDetails, + EntropyGateDetails, +} from '../../core/api/policy.models'; + +@Component({ + selector: 'app-policy-gate-indicator', + standalone: true, + imports: [CommonModule], + templateUrl: './policy-gate-indicator.component.html', + styleUrls: ['./policy-gate-indicator.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PolicyGateIndicatorComponent { + /** Policy gate status data */ + readonly gateStatus = input.required(); + + /** Show compact view */ + readonly compact = input(false); + + /** Emits when user clicks publish (if allowed) */ + readonly publish = output(); + + /** Emits when user wants to view evidence */ + readonly viewEvidence = output(); + + /** Emits when user wants to run remediation */ + readonly runRemediation = output(); + + /** Currently expanded gate */ + readonly expandedGate = signal(null); + + /** Show remediation panel */ + readonly showRemediation = signal(false); + + readonly statusClass = computed(() => 'status-' + this.gateStatus().status); + + readonly statusIcon = computed(() => { + switch (this.gateStatus().status) { + case 'passed': + return '[OK]'; + case 'failed': + return '[X]'; + case 'warning': + return '[!]'; + case 'pending': + return '[...]'; + case 'skipped': + return '[-]'; + default: + return '[?]'; + } + }); + + readonly statusLabel = computed(() => { + switch (this.gateStatus().status) { + case 'passed': + return 'All Gates Passed'; + case 'failed': + return 'Gates Failed'; + case 'warning': + return 'Gates Passed with Warnings'; + case 'pending': + return 'Evaluation Pending'; + case 'skipped': + return 'Gates Skipped'; + default: + return 'Unknown Status'; + } + }); + + readonly passedGates = computed(() => + this.gateStatus().gates.filter((g) => g.result === 'passed') + ); + + readonly failedGates = computed(() => + this.gateStatus().gates.filter((g) => g.result === 'failed') + ); + + readonly warningGates = computed(() => + this.gateStatus().gates.filter((g) => g.result === 'warning') + ); + + readonly determinismGate = computed(() => + this.gateStatus().gates.find((g) => g.type === 'determinism') + ); + + readonly entropyGate = computed(() => + this.gateStatus().gates.find((g) => g.type === 'entropy') + ); + + toggleGate(gateId: string): void { + this.expandedGate.update((current) => (current === gateId ? null : gateId)); + } + + toggleRemediation(): void { + this.showRemediation.update((v) => !v); + } + + onPublish(): void { + if (this.gateStatus().canPublish) { + this.publish.emit(this.gateStatus().evaluationId); + } + } + + onViewEvidence(ref: string): void { + this.viewEvidence.emit(ref); + } + + onRunRemediation(hint: PolicyRemediationHint): void { + this.runRemediation.emit(hint); + } + + getGateIcon(type: string): string { + switch (type) { + case 'determinism': + return '#'; + case 'vulnerability': + return '!'; + case 'license': + return 'L'; + case 'signature': + return 'S'; + case 'entropy': + return 'E'; + default: + return '?'; + } + } + + getResultIcon(result: string): string { + switch (result) { + case 'passed': + return '+'; + case 'failed': + return 'x'; + case 'warning': + return '!'; + case 'skipped': + return '-'; + default: + return '?'; + } + } + + getEffortLabel(effort?: string): string { + switch (effort) { + case 'trivial': + return '< 5 min'; + case 'easy': + return '5-15 min'; + case 'moderate': + return '15-60 min'; + case 'complex': + return '> 1 hour'; + default: + return ''; + } + } + + getDeterminismDetails(gate: PolicyGate): DeterminismGateDetails | null { + return gate.details as DeterminismGateDetails | null; + } + + getEntropyDetails(gate: PolicyGate): EntropyGateDetails | null { + return gate.details as EntropyGateDetails | null; + } + + formatHash(hash: string | undefined, length = 12): string { + if (!hash) return 'N/A'; + if (hash.length <= length) return hash; + return hash.slice(0, length) + '...'; + } + + getHintsForGate(gateId: string): PolicyRemediationHint[] { + return this.gateStatus().remediationHints.filter((h) => h.forGate === gateId); + } +}