feat: add entropy policy banner and policy gate indicator components
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Implemented EntropyPolicyBannerComponent with configuration for entropy policies, including thresholds, current scores, and mitigation steps.
- Created PolicyGateIndicatorComponent to display the status of policy gates, including passed, failed, and warning gates, with detailed views for determinism and entropy gates.
- Added HTML and SCSS for both components to ensure proper styling and layout.
- Introduced computed properties and signals for reactive state management in Angular.
- Included remediation hints and actions for user interaction within the policy gate indicator.
This commit is contained in:
master
2025-11-27 16:44:29 +02:00
parent e950474a77
commit 4c55b01222
61 changed files with 12747 additions and 52 deletions

View File

@@ -3,7 +3,8 @@
"allow": [
"Bash(dotnet build:*)",
"Bash(dotnet restore:*)",
"Bash(chmod:*)"
"Bash(chmod:*)",
"Bash(cat:*)"
],
"deny": [],
"ask": []

View File

@@ -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. |

View File

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

View File

@@ -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 - SBOMFirst, VEXReady 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 - SBOMFirst, VEXReady 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).

View File

@@ -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 <id>`, `stella cvss show <receiptId>`, `stella cvss history <receiptId>`, `stella cvss export <receiptId> --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 |

View File

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

View File

@@ -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/<lang>/<project>/`, `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 |

View File

@@ -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)

View File

@@ -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)
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)

View File

@@ -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 - SBOMFirst, VEXReady 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 AirGap 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 - HashStable 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 2026Ready 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 - HashStable 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 - HalfLife 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*

View File

@@ -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<ITelemetryContextAccessor> _contextAccessor;
private readonly Mock<ILogger<IncidentModeService>> _logger;
public IncidentModeServiceTests()
{
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
_contextAccessor = new Mock<ITelemetryContextAccessor>();
_logger = new Mock<ILogger<IncidentModeService>>();
}
public void Dispose()
{
// Cleanup if needed
}
private IncidentModeService CreateService(Action<IncidentModeOptions>? configure = null)
{
var options = new IncidentModeOptions
{
PersistState = false, // Disable persistence for tests
RestoreOnStartup = false
};
configure?.Invoke(options);
var monitor = new TestOptionsMonitor<IncidentModeOptions>(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<ArgumentException>(() =>
service.ActivateAsync(null!));
}
[Fact]
public async Task ActivateAsync_EmptyActor_ThrowsArgumentException()
{
using var service = CreateService();
await Assert.ThrowsAsync<ArgumentException>(() =>
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<T> : IOptionsMonitor<T>
{
private readonly T _value;
public TestOptionsMonitor(T value)
{
_value = value;
}
public T CurrentValue => _value;
public T Get(string? name) => _value;
public IDisposable? OnChange(Action<T, string?> 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;
}
}
}

View File

@@ -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<ILogger<SealedModeFileExporter>> _logger;
private readonly FakeTimeProvider _timeProvider;
public SealedModeFileExporterTests()
{
_testDirectory = Path.Combine(Path.GetTempPath(), $"sealed-mode-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(_testDirectory);
_logger = new Mock<ILogger<SealedModeFileExporter>>();
_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<SealedModeTelemetryOptions>? 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<SealedModeTelemetryOptions>(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<ObjectDisposedException>(() =>
exporter.Write(Encoding.UTF8.GetBytes("test"), TelemetrySignal.Traces));
}
[Fact]
public void Initialize_WithEmptyFilePath_Throws()
{
using var exporter = CreateExporter(opt =>
{
opt.FilePath = "";
});
Assert.Throws<InvalidOperationException>(() => 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<T> : IOptionsMonitor<T>
{
private readonly T _value;
public TestOptionsMonitor(T value)
{
_value = value;
}
public T CurrentValue => _value;
public T Get(string? name) => _value;
public IDisposable? OnChange(Action<T, string?> 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);
}
}
}

View File

@@ -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<IEgressPolicy> _egressPolicy;
private readonly Mock<IIncidentModeService> _incidentModeService;
private readonly Mock<ILogger<SealedModeTelemetryService>> _logger;
public SealedModeTelemetryServiceTests()
{
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
_egressPolicy = new Mock<IEgressPolicy>();
_incidentModeService = new Mock<IIncidentModeService>();
_logger = new Mock<ILogger<SealedModeTelemetryService>>();
}
public void Dispose()
{
// Cleanup if needed
}
private SealedModeTelemetryService CreateService(
Action<SealedModeTelemetryOptions>? configure = null,
bool useEgressPolicy = false)
{
var options = new SealedModeTelemetryOptions();
configure?.Invoke(options);
var monitor = new TestOptionsMonitor<SealedModeTelemetryOptions>(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<T> : IOptionsMonitor<T>
{
private readonly T _value;
public TestOptionsMonitor(T value)
{
_value = value;
}
public T CurrentValue => _value;
public T Get(string? name) => _value;
public IDisposable? OnChange(Action<T, string?> 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;
}
}
}

View File

@@ -0,0 +1,303 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Telemetry.Core;
/// <summary>
/// Service for managing incident mode state in telemetry.
/// Incident mode increases sampling rates and adds special tags to telemetry data.
/// </summary>
public interface IIncidentModeService
{
/// <summary>
/// Gets whether incident mode is currently active.
/// </summary>
bool IsActive { get; }
/// <summary>
/// Gets the current incident mode state.
/// </summary>
IncidentModeState? CurrentState { get; }
/// <summary>
/// Activates incident mode with optional TTL override.
/// </summary>
/// <param name="actor">The actor (user/service) activating incident mode.</param>
/// <param name="tenantId">Optional tenant identifier.</param>
/// <param name="ttlOverride">Optional TTL override (uses default if not specified).</param>
/// <param name="reason">Optional reason for activation.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The activation result.</returns>
Task<IncidentModeActivationResult> ActivateAsync(
string actor,
string? tenantId = null,
TimeSpan? ttlOverride = null,
string? reason = null,
CancellationToken ct = default);
/// <summary>
/// Deactivates incident mode.
/// </summary>
/// <param name="actor">The actor (user/service) deactivating incident mode.</param>
/// <param name="reason">Optional reason for deactivation.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The deactivation result.</returns>
Task<IncidentModeDeactivationResult> DeactivateAsync(
string actor,
string? reason = null,
CancellationToken ct = default);
/// <summary>
/// Extends the current incident mode TTL.
/// </summary>
/// <param name="extension">The time to add to the current TTL.</param>
/// <param name="actor">The actor extending the TTL.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The new expiration time, or null if incident mode is not active.</returns>
Task<DateTimeOffset?> ExtendTtlAsync(
TimeSpan extension,
string actor,
CancellationToken ct = default);
/// <summary>
/// Gets tags to add to telemetry when incident mode is active.
/// </summary>
/// <returns>A dictionary of tags, or empty if incident mode is not active.</returns>
IReadOnlyDictionary<string, string> GetIncidentTags();
/// <summary>
/// Event raised when incident mode is activated.
/// </summary>
event EventHandler<IncidentModeActivatedEventArgs>? Activated;
/// <summary>
/// Event raised when incident mode is deactivated or expires.
/// </summary>
event EventHandler<IncidentModeDeactivatedEventArgs>? Deactivated;
}
/// <summary>
/// Represents the current state of incident mode.
/// </summary>
public sealed record IncidentModeState
{
/// <summary>
/// Gets whether incident mode is enabled.
/// </summary>
public required bool Enabled { get; init; }
/// <summary>
/// Gets the timestamp when incident mode was activated.
/// </summary>
public required DateTimeOffset ActivatedAt { get; init; }
/// <summary>
/// Gets the timestamp when incident mode will expire.
/// </summary>
public required DateTimeOffset ExpiresAt { get; init; }
/// <summary>
/// Gets the actor who activated incident mode.
/// </summary>
public required string Actor { get; init; }
/// <summary>
/// Gets the tenant identifier, if applicable.
/// </summary>
public string? TenantId { get; init; }
/// <summary>
/// Gets the source of the activation (CLI, API, config).
/// </summary>
public required IncidentModeSource Source { get; init; }
/// <summary>
/// Gets the reason for activation.
/// </summary>
public string? Reason { get; init; }
/// <summary>
/// Gets the unique activation ID.
/// </summary>
public required string ActivationId { get; init; }
/// <summary>
/// Gets whether this state has expired.
/// </summary>
public bool IsExpired => DateTimeOffset.UtcNow >= ExpiresAt;
/// <summary>
/// Gets the remaining time until expiration.
/// </summary>
public TimeSpan RemainingTime => IsExpired ? TimeSpan.Zero : ExpiresAt - DateTimeOffset.UtcNow;
}
/// <summary>
/// Source of incident mode activation.
/// </summary>
public enum IncidentModeSource
{
/// <summary>CLI flag activation.</summary>
Cli,
/// <summary>API activation.</summary>
Api,
/// <summary>Configuration-based activation.</summary>
Configuration,
/// <summary>Persisted state restoration.</summary>
Restored
}
/// <summary>
/// Result of incident mode activation.
/// </summary>
public sealed record IncidentModeActivationResult
{
/// <summary>
/// Gets whether activation was successful.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Gets the activation state if successful.
/// </summary>
public IncidentModeState? State { get; init; }
/// <summary>
/// Gets the error message if activation failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Gets whether incident mode was already active.
/// </summary>
public bool WasAlreadyActive { get; init; }
/// <summary>
/// Creates a successful activation result.
/// </summary>
public static IncidentModeActivationResult Succeeded(IncidentModeState state, bool wasAlreadyActive = false)
{
return new IncidentModeActivationResult
{
Success = true,
State = state,
WasAlreadyActive = wasAlreadyActive
};
}
/// <summary>
/// Creates a failed activation result.
/// </summary>
public static IncidentModeActivationResult Failed(string error)
{
return new IncidentModeActivationResult
{
Success = false,
Error = error
};
}
}
/// <summary>
/// Result of incident mode deactivation.
/// </summary>
public sealed record IncidentModeDeactivationResult
{
/// <summary>
/// Gets whether deactivation was successful.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Gets whether incident mode was active before deactivation.
/// </summary>
public bool WasActive { get; init; }
/// <summary>
/// Gets the error message if deactivation failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Gets the reason for deactivation.
/// </summary>
public IncidentModeDeactivationReason Reason { get; init; }
/// <summary>
/// Creates a successful deactivation result.
/// </summary>
public static IncidentModeDeactivationResult Succeeded(bool wasActive, IncidentModeDeactivationReason reason)
{
return new IncidentModeDeactivationResult
{
Success = true,
WasActive = wasActive,
Reason = reason
};
}
/// <summary>
/// Creates a failed deactivation result.
/// </summary>
public static IncidentModeDeactivationResult Failed(string error)
{
return new IncidentModeDeactivationResult
{
Success = false,
Error = error
};
}
}
/// <summary>
/// Reason for incident mode deactivation.
/// </summary>
public enum IncidentModeDeactivationReason
{
/// <summary>Manual deactivation by user/service.</summary>
Manual,
/// <summary>Deactivation due to TTL expiry.</summary>
Expired,
/// <summary>Deactivation due to system shutdown.</summary>
Shutdown,
/// <summary>Deactivation due to sealed mode activation.</summary>
SealedMode
}
/// <summary>
/// Event args for incident mode activation.
/// </summary>
public sealed class IncidentModeActivatedEventArgs : EventArgs
{
/// <summary>
/// Gets the activation state.
/// </summary>
public required IncidentModeState State { get; init; }
/// <summary>
/// Gets whether this was a reactivation (was already active).
/// </summary>
public bool WasReactivation { get; init; }
}
/// <summary>
/// Event args for incident mode deactivation.
/// </summary>
public sealed class IncidentModeDeactivatedEventArgs : EventArgs
{
/// <summary>
/// Gets the state at time of deactivation.
/// </summary>
public required IncidentModeState State { get; init; }
/// <summary>
/// Gets the reason for deactivation.
/// </summary>
public required IncidentModeDeactivationReason Reason { get; init; }
/// <summary>
/// Gets the actor who deactivated (if manual).
/// </summary>
public string? DeactivatedBy { get; init; }
}

View File

@@ -0,0 +1,127 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Telemetry.Core;
/// <summary>
/// Service for managing sealed-mode telemetry behavior.
/// When sealed mode is active, external exporters are disabled and
/// telemetry is written to local storage instead.
/// </summary>
public interface ISealedModeTelemetryService
{
/// <summary>
/// Gets whether sealed mode is currently active.
/// </summary>
bool IsSealed { get; }
/// <summary>
/// Gets the current effective sampling rate (0.0-1.0).
/// </summary>
double EffectiveSamplingRate { get; }
/// <summary>
/// Gets whether incident mode is currently overriding sealed mode sampling.
/// </summary>
bool IsIncidentModeOverrideActive { get; }
/// <summary>
/// Gets tags to add to telemetry when sealed mode is active.
/// </summary>
/// <returns>A dictionary of tags, or empty if sealed mode is not active.</returns>
IReadOnlyDictionary<string, string> GetSealedModeTags();
/// <summary>
/// Determines whether an external exporter should be allowed.
/// Always returns false when sealed mode is active.
/// </summary>
/// <param name="endpoint">The exporter endpoint.</param>
/// <returns><c>true</c> if external export is allowed.</returns>
bool IsExternalExportAllowed(Uri endpoint);
/// <summary>
/// Gets the local exporter configuration for sealed mode.
/// </summary>
/// <returns>The exporter configuration, or null if sealed mode is not active.</returns>
SealedModeExporterConfig? GetLocalExporterConfig();
/// <summary>
/// Records a seal event (entry into sealed mode).
/// </summary>
/// <param name="reason">Optional reason for sealing.</param>
/// <param name="actor">The actor who initiated the seal.</param>
void RecordSealEvent(string? reason = null, string? actor = null);
/// <summary>
/// Records an unseal event (exit from sealed mode).
/// </summary>
/// <param name="reason">Optional reason for unsealing.</param>
/// <param name="actor">The actor who initiated the unseal.</param>
void RecordUnsealEvent(string? reason = null, string? actor = null);
/// <summary>
/// Records a drift event when external export was blocked.
/// </summary>
/// <param name="endpoint">The blocked endpoint.</param>
/// <param name="signal">The telemetry signal type.</param>
void RecordDriftEvent(Uri endpoint, TelemetrySignal signal);
/// <summary>
/// Event raised when sealed mode state changes.
/// </summary>
event EventHandler<SealedModeStateChangedEventArgs>? StateChanged;
}
/// <summary>
/// Configuration for the local exporter in sealed mode.
/// </summary>
public sealed record SealedModeExporterConfig
{
/// <summary>
/// Gets the exporter type.
/// </summary>
public required SealedModeExporterType Type { get; init; }
/// <summary>
/// Gets the file path for file-based exporters.
/// </summary>
public string? FilePath { get; init; }
/// <summary>
/// Gets the maximum bytes before rotation.
/// </summary>
public long MaxBytes { get; init; }
/// <summary>
/// Gets the maximum number of rotated files.
/// </summary>
public int MaxRotatedFiles { get; init; }
}
/// <summary>
/// Event args for sealed mode state changes.
/// </summary>
public sealed class SealedModeStateChangedEventArgs : EventArgs
{
/// <summary>
/// Gets whether sealed mode is now active.
/// </summary>
public required bool IsSealed { get; init; }
/// <summary>
/// Gets the timestamp of the state change.
/// </summary>
public required DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Gets the reason for the state change.
/// </summary>
public string? Reason { get; init; }
/// <summary>
/// Gets the actor who initiated the change.
/// </summary>
public string? Actor { get; init; }
}

View File

@@ -0,0 +1,191 @@
using System;
namespace StellaOps.Telemetry.Core;
/// <summary>
/// Options for incident mode configuration.
/// </summary>
public sealed class IncidentModeOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Telemetry:Incident";
/// <summary>
/// Gets or sets whether incident mode is enabled by configuration.
/// CLI flag can override this.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Gets or sets the default TTL for incident mode.
/// </summary>
public TimeSpan DefaultTtl { get; set; } = TimeSpan.FromMinutes(30);
/// <summary>
/// Gets or sets the maximum allowed TTL for incident mode.
/// </summary>
public TimeSpan MaxTtl { get; set; } = TimeSpan.FromHours(24);
/// <summary>
/// Gets or sets the minimum allowed TTL for incident mode.
/// </summary>
public TimeSpan MinTtl { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Gets or sets the sampling rate to use during incident mode (0.0-1.0).
/// </summary>
public double IncidentSamplingRate { get; set; } = 1.0;
/// <summary>
/// Gets or sets the flush interval for exporters during incident mode.
/// </summary>
public TimeSpan IncidentFlushInterval { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Gets or sets the normal flush interval for comparison.
/// </summary>
public TimeSpan NormalFlushInterval { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Gets or sets whether to persist incident mode state to local file.
/// </summary>
public bool PersistState { get; set; } = true;
/// <summary>
/// Gets or sets the state file path. Uses default if not specified.
/// </summary>
public string? StateFilePath { get; set; }
/// <summary>
/// Gets or sets whether to emit audit events for activation/deactivation.
/// </summary>
public bool EmitAuditEvents { get; set; } = true;
/// <summary>
/// Gets or sets the tag name for incident mode indicator.
/// </summary>
public string IncidentTagName { get; set; } = "incident";
/// <summary>
/// Gets or sets whether sealed mode disables incident mode.
/// </summary>
public bool DisableInSealedMode { get; set; } = true;
/// <summary>
/// Gets or sets additional tags to add during incident mode.
/// </summary>
public System.Collections.Generic.Dictionary<string, string> AdditionalTags { get; set; } = new();
/// <summary>
/// Gets or sets whether to allow TTL extension.
/// </summary>
public bool AllowTtlExtension { get; set; } = true;
/// <summary>
/// Gets or sets the maximum number of extensions allowed per activation.
/// </summary>
public int MaxExtensions { get; set; } = 5;
/// <summary>
/// Gets or sets whether to restore state from persisted file on startup.
/// </summary>
public bool RestoreOnStartup { get; set; } = true;
/// <summary>
/// Validates the options and returns any validation errors.
/// </summary>
public System.Collections.Generic.List<string> Validate()
{
var errors = new System.Collections.Generic.List<string>();
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;
}
/// <summary>
/// Clamps a TTL value to the allowed range.
/// </summary>
public TimeSpan ClampTtl(TimeSpan ttl)
{
if (ttl < MinTtl) return MinTtl;
if (ttl > MaxTtl) return MaxTtl;
return ttl;
}
}
/// <summary>
/// Persisted state for incident mode.
/// </summary>
public sealed class PersistedIncidentModeState
{
/// <summary>
/// Gets or sets whether incident mode is enabled.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Gets or sets the timestamp when incident mode was activated.
/// </summary>
public DateTimeOffset? ActivatedAt { get; set; }
/// <summary>
/// Gets or sets the timestamp when incident mode will expire.
/// </summary>
public DateTimeOffset? ExpiresAt { get; set; }
/// <summary>
/// Gets or sets the actor who activated incident mode.
/// </summary>
public string? Actor { get; set; }
/// <summary>
/// Gets or sets the tenant identifier.
/// </summary>
public string? TenantId { get; set; }
/// <summary>
/// Gets or sets the activation ID.
/// </summary>
public string? ActivationId { get; set; }
/// <summary>
/// Gets or sets the source of activation.
/// </summary>
public string? Source { get; set; }
/// <summary>
/// Gets or sets the reason for activation.
/// </summary>
public string? Reason { get; set; }
/// <summary>
/// Gets or sets the number of TTL extensions applied.
/// </summary>
public int ExtensionCount { get; set; }
}

View File

@@ -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;
/// <summary>
/// Default implementation of <see cref="IIncidentModeService"/>.
/// </summary>
public sealed class IncidentModeService : IIncidentModeService, IDisposable
{
private readonly IOptionsMonitor<IncidentModeOptions> _optionsMonitor;
private readonly ITelemetryContextAccessor? _contextAccessor;
private readonly ILogger<IncidentModeService>? _logger;
private readonly TimeProvider _timeProvider;
private readonly object _lock = new();
private readonly Timer _expiryTimer;
private IncidentModeState? _currentState;
private int _extensionCount;
/// <inheritdoc/>
public bool IsActive => _currentState is not null && !_currentState.IsExpired;
/// <inheritdoc/>
public IncidentModeState? CurrentState => _currentState?.IsExpired == true ? null : _currentState;
/// <inheritdoc/>
public event EventHandler<IncidentModeActivatedEventArgs>? Activated;
/// <inheritdoc/>
public event EventHandler<IncidentModeDeactivatedEventArgs>? Deactivated;
/// <summary>
/// Initializes a new instance of <see cref="IncidentModeService"/>.
/// </summary>
public IncidentModeService(
IOptionsMonitor<IncidentModeOptions> optionsMonitor,
ITelemetryContextAccessor? contextAccessor = null,
ILogger<IncidentModeService>? 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();
}
}
/// <inheritdoc/>
public async Task<IncidentModeActivationResult> 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);
}
/// <inheritdoc/>
public async Task<IncidentModeDeactivationResult> 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);
}
/// <inheritdoc/>
public async Task<DateTimeOffset?> 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;
}
}
/// <inheritdoc/>
public IReadOnlyDictionary<string, string> GetIncidentTags()
{
var state = CurrentState;
if (state is null)
{
return new Dictionary<string, string>();
}
var options = _optionsMonitor.CurrentValue;
var tags = new Dictionary<string, string>
{
[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;
}
/// <summary>
/// Activates incident mode from CLI flag.
/// </summary>
public Task<IncidentModeActivationResult> ActivateFromCliAsync(
string actor,
TimeSpan? ttl = null,
CancellationToken ct = default)
{
return ActivateInternalAsync(actor, null, ttl, "CLI activation", IncidentModeSource.Cli, ct);
}
/// <summary>
/// Activates incident mode from configuration.
/// </summary>
public Task<IncidentModeActivationResult> 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<IncidentModeActivationResult> 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<PersistedIncidentModeState>(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);
}
/// <inheritdoc/>
public void Dispose()
{
_expiryTimer.Dispose();
}
}

View File

@@ -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;
/// <summary>
/// File-based exporter for sealed mode telemetry.
/// Writes OTLP data to a local file with rotation support.
/// </summary>
public sealed class SealedModeFileExporter : IDisposable
{
private readonly IOptionsMonitor<SealedModeTelemetryOptions> _optionsMonitor;
private readonly ILogger<SealedModeFileExporter>? _logger;
private readonly object _lock = new();
private readonly TimeProvider _timeProvider;
private FileStream? _currentStream;
private string? _currentFilePath;
private long _currentSize;
private bool _disposed;
/// <summary>
/// Gets whether the exporter has been initialized.
/// </summary>
public bool IsInitialized => _currentStream is not null;
/// <summary>
/// Gets the current file path being written to.
/// </summary>
public string? CurrentFilePath => _currentFilePath;
/// <summary>
/// Gets the current file size in bytes.
/// </summary>
public long CurrentSize => _currentSize;
/// <summary>
/// Initializes a new instance of <see cref="SealedModeFileExporter"/>.
/// </summary>
public SealedModeFileExporter(
IOptionsMonitor<SealedModeTelemetryOptions> optionsMonitor,
ILogger<SealedModeFileExporter>? logger = null,
TimeProvider? timeProvider = null)
{
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// Initializes the exporter and creates the output file.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown if the file path has insecure permissions.</exception>
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);
}
}
/// <summary>
/// Writes telemetry data to the file.
/// </summary>
/// <param name="data">The binary data to write.</param>
/// <param name="signal">The telemetry signal type.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public void Write(ReadOnlySpan<byte> 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;
}
}
/// <summary>
/// Writes a string record to the file.
/// </summary>
/// <param name="record">The string record to write.</param>
/// <param name="signal">The telemetry signal type.</param>
/// <param name="cancellationToken">Cancellation token.</param>
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);
}
/// <summary>
/// Flushes any buffered data to disk.
/// </summary>
public void Flush()
{
lock (_lock)
{
_currentStream?.Flush();
}
}
/// <inheritdoc/>
public void Dispose()
{
if (_disposed)
{
return;
}
lock (_lock)
{
_currentStream?.Dispose();
_currentStream = null;
_disposed = true;
}
}
}

View File

@@ -0,0 +1,166 @@
using System;
using System.Collections.Generic;
using System.IO;
namespace StellaOps.Telemetry.Core;
/// <summary>
/// Options for sealed-mode telemetry behavior.
/// </summary>
public sealed class SealedModeTelemetryOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Telemetry:Sealed";
/// <summary>
/// Gets or sets whether sealed mode telemetry is enabled.
/// This is typically driven by <see cref="StellaOps.AirGap.Policy.IEgressPolicy.IsSealed"/>.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Gets or sets the exporter type to use in sealed mode.
/// </summary>
public SealedModeExporterType Exporter { get; set; } = SealedModeExporterType.File;
/// <summary>
/// Gets or sets the file path for the file exporter.
/// </summary>
public string FilePath { get; set; } = "./logs/telemetry-sealed.otlp";
/// <summary>
/// Gets or sets the maximum bytes for the file exporter before rotation.
/// Default is 10 MB.
/// </summary>
public long MaxBytes { get; set; } = 10_485_760;
/// <summary>
/// Gets or sets the maximum number of rotated files to keep.
/// </summary>
public int MaxRotatedFiles { get; set; } = 3;
/// <summary>
/// Gets or sets the maximum sampling percentage in sealed mode (0-100).
/// Default is 10%.
/// </summary>
public int MaxSamplingPercent { get; set; } = 10;
/// <summary>
/// Gets or sets whether to force scrubbing regardless of default settings.
/// </summary>
public bool ForceScrub { get; set; } = true;
/// <summary>
/// Gets or sets whether to suppress exemplars in sealed mode.
/// </summary>
public bool SuppressExemplars { get; set; } = true;
/// <summary>
/// Gets or sets the tag name for sealed mode indicator.
/// </summary>
public string SealedTagName { get; set; } = "sealed";
/// <summary>
/// Gets or sets whether to add scrubbed indicator tag.
/// </summary>
public bool AddScrubbedTag { get; set; } = true;
/// <summary>
/// Gets or sets additional tags to add in sealed mode.
/// </summary>
public Dictionary<string, string> AdditionalTags { get; set; } = new();
/// <summary>
/// Gets or sets the maximum clock skew threshold before warning.
/// Default is 500ms.
/// </summary>
public TimeSpan ClockSkewThreshold { get; set; } = TimeSpan.FromMilliseconds(500);
/// <summary>
/// Gets or sets whether incident mode can override the sampling ceiling.
/// </summary>
public bool AllowIncidentModeOverride { get; set; } = true;
/// <summary>
/// Gets or sets the required file permissions (Unix only).
/// Default is 0600 (owner read/write only).
/// </summary>
public UnixFileMode FilePermissions { get; set; } = UnixFileMode.UserRead | UnixFileMode.UserWrite;
/// <summary>
/// Gets or sets whether to fail startup if the file path has insecure permissions.
/// </summary>
public bool FailOnInsecurePermissions { get; set; } = true;
/// <summary>
/// Validates the options and returns any validation errors.
/// </summary>
public List<string> Validate()
{
var errors = new List<string>();
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;
}
/// <summary>
/// Gets the effective sampling rate as a decimal (0.0-1.0).
/// </summary>
/// <param name="incidentModeActive">Whether incident mode is active.</param>
/// <param name="incidentSamplingRate">The sampling rate requested by incident mode.</param>
/// <returns>The effective sampling rate clamped to sealed mode limits.</returns>
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;
}
}
/// <summary>
/// Exporter type for sealed mode telemetry.
/// </summary>
public enum SealedModeExporterType
{
/// <summary>
/// In-memory ring buffer exporter.
/// </summary>
Memory,
/// <summary>
/// File-based OTLP exporter.
/// </summary>
File
}

View File

@@ -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;
/// <summary>
/// Default implementation of <see cref="ISealedModeTelemetryService"/>.
/// </summary>
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<SealedModeTelemetryOptions> _optionsMonitor;
private readonly IEgressPolicy? _egressPolicy;
private readonly IIncidentModeService? _incidentModeService;
private readonly ILogger<SealedModeTelemetryService>? _logger;
private readonly TimeProvider _timeProvider;
private readonly object _lock = new();
private readonly Counter<long> _sealEventsCounter;
private readonly Counter<long> _unsealEventsCounter;
private readonly Counter<long> _driftEventsCounter;
private readonly Counter<long> _blockedExportsCounter;
private bool _previousSealedState;
private DateTimeOffset? _lastStateChangeTime;
/// <inheritdoc/>
public bool IsSealed => _egressPolicy?.IsSealed ?? _optionsMonitor.CurrentValue.Enabled;
/// <inheritdoc/>
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);
}
}
/// <inheritdoc/>
public bool IsIncidentModeOverrideActive =>
IsSealed &&
(_incidentModeService?.IsActive ?? false) &&
_optionsMonitor.CurrentValue.AllowIncidentModeOverride;
/// <inheritdoc/>
public event EventHandler<SealedModeStateChangedEventArgs>? StateChanged;
/// <summary>
/// Initializes a new instance of <see cref="SealedModeTelemetryService"/>.
/// </summary>
public SealedModeTelemetryService(
IOptionsMonitor<SealedModeTelemetryOptions> optionsMonitor,
IEgressPolicy? egressPolicy = null,
IIncidentModeService? incidentModeService = null,
ILogger<SealedModeTelemetryService>? 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<long>(
"stellaops.telemetry.sealed.seal_events",
unit: "{event}",
description: "Count of seal events (entries into sealed mode)");
_unsealEventsCounter = Meter.CreateCounter<long>(
"stellaops.telemetry.sealed.unseal_events",
unit: "{event}",
description: "Count of unseal events (exits from sealed mode)");
_driftEventsCounter = Meter.CreateCounter<long>(
"stellaops.telemetry.sealed.drift_events",
unit: "{event}",
description: "Count of drift events when external export was blocked");
_blockedExportsCounter = Meter.CreateCounter<long>(
"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");
}
}
}
/// <inheritdoc/>
public IReadOnlyDictionary<string, string> GetSealedModeTags()
{
if (!IsSealed)
{
return new Dictionary<string, string>();
}
var options = _optionsMonitor.CurrentValue;
var tags = new Dictionary<string, string>
{
[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;
}
/// <inheritdoc/>
public bool IsExternalExportAllowed(Uri endpoint)
{
if (!IsSealed)
{
return true;
}
_blockedExportsCounter.Add(1, new KeyValuePair<string, object?>("endpoint_host", endpoint.Host));
_logger?.LogDebug(
"External export to {Endpoint} blocked in sealed mode",
endpoint);
return false;
}
/// <inheritdoc/>
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
};
}
/// <inheritdoc/>
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<string, object?>("reason", reason ?? "unspecified"),
new KeyValuePair<string, object?>("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
});
}
/// <inheritdoc/>
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<string, object?>("reason", reason ?? "unspecified"),
new KeyValuePair<string, object?>("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
});
}
/// <inheritdoc/>
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<string, object?>("endpoint_host", endpoint.Host),
new KeyValuePair<string, object?>("signal", signal.ToString()));
_logger?.LogWarning(
"Telemetry drift detected: external {Signal} export to {Endpoint} blocked in sealed mode",
signal,
endpoint);
}
/// <inheritdoc/>
public void Dispose()
{
// Cleanup if needed
}
}

View File

@@ -83,6 +83,71 @@ public static class TelemetryServiceCollectionExtensions
return services;
}
/// <summary>
/// Registers incident mode services for toggling enhanced telemetry during incidents.
/// </summary>
/// <param name="services">Service collection to mutate.</param>
/// <param name="configuration">Optional configuration section binding.</param>
/// <param name="configureOptions">Optional options configuration.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddIncidentMode(
this IServiceCollection services,
IConfiguration? configuration = null,
Action<IncidentModeOptions>? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(services);
var optionsBuilder = services.AddOptions<IncidentModeOptions>();
if (configuration is not null)
{
optionsBuilder.Bind(configuration.GetSection(IncidentModeOptions.SectionName));
}
if (configureOptions is not null)
{
optionsBuilder.Configure(configureOptions);
}
services.TryAddSingleton<IncidentModeService>();
services.TryAddSingleton<IIncidentModeService>(sp => sp.GetRequiredService<IncidentModeService>());
return services;
}
/// <summary>
/// Registers sealed-mode telemetry services.
/// </summary>
/// <param name="services">Service collection to mutate.</param>
/// <param name="configuration">Optional configuration section binding.</param>
/// <param name="configureOptions">Optional options configuration.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddSealedModeTelemetry(
this IServiceCollection services,
IConfiguration? configuration = null,
Action<SealedModeTelemetryOptions>? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(services);
var optionsBuilder = services.AddOptions<SealedModeTelemetryOptions>();
if (configuration is not null)
{
optionsBuilder.Bind(configuration.GetSection(SealedModeTelemetryOptions.SectionName));
}
if (configureOptions is not null)
{
optionsBuilder.Configure(configureOptions);
}
services.TryAddSingleton<SealedModeTelemetryService>();
services.TryAddSingleton<ISealedModeTelemetryService>(sp => sp.GetRequiredService<SealedModeTelemetryService>());
services.TryAddSingleton<SealedModeFileExporter>();
return services;
}
/// <summary>
/// Registers the StellaOps telemetry stack with sealed-mode enforcement.
/// </summary>

View File

@@ -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: () =>

View File

@@ -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<string, unknown>;
highlightedFields: string[];
}

View File

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

View File

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

View File

@@ -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<string, unknown>;
/** New values (for edits) */
newValues?: Record<string, unknown>;
}
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' },
];

View File

@@ -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<string, unknown>;
/** 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[];
}

View File

@@ -0,0 +1,179 @@
<div class="verify-action" [class]="'state-' + state()">
<!-- Action Header -->
<div class="action-header">
<div class="action-info">
<span class="status-icon">{{ statusIcon() }}</span>
<div class="action-text">
<h4 class="action-title">Verify Last {{ windowHours() }} Hours</h4>
<p class="action-desc">{{ statusLabel() }}</p>
</div>
</div>
<div class="action-buttons">
@if (state() === 'idle' || state() === 'completed' || state() === 'error') {
<button class="btn-verify" (click)="runVerification()">
@if (state() === 'idle') {
Run Verification
} @else {
Re-run
}
</button>
}
<button class="btn-cli" (click)="toggleCliGuidance()" [class.active]="showCliGuidance()">
CLI
</button>
</div>
</div>
<!-- Progress Bar -->
@if (state() === 'running') {
<div class="progress-section">
<div class="progress-bar">
<div class="progress-fill" [style.width]="progress() + '%'"></div>
</div>
<span class="progress-text">{{ progress() | number:'1.0-0' }}%</span>
</div>
}
<!-- Error State -->
@if (state() === 'error' && error()) {
<div class="error-banner">
<span class="error-icon">[X]</span>
<span class="error-message">{{ error() }}</span>
<button class="btn-retry" (click)="runVerification()">Retry</button>
</div>
}
<!-- Results -->
@if (state() === 'completed' && result()) {
<div class="results-section">
<!-- Summary Stats -->
<div class="results-summary">
<div class="stat-card" [class.success]="result()!.status === 'passed'">
<span class="stat-value">{{ result()!.checkedCount | number }}</span>
<span class="stat-label">Documents Checked</span>
</div>
<div class="stat-card success">
<span class="stat-value">{{ result()!.passedCount | number }}</span>
<span class="stat-label">Passed</span>
</div>
<div class="stat-card" [class.error]="result()!.failedCount > 0">
<span class="stat-value">{{ result()!.failedCount | number }}</span>
<span class="stat-label">Failed</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ resultSummary()?.passRate }}%</span>
<span class="stat-label">Pass Rate</span>
</div>
</div>
<!-- Violations Preview -->
@if (result()!.violations.length > 0) {
<div class="violations-preview">
<h5 class="preview-title">
Violations Found
<span class="violation-count">{{ result()!.violations.length }}</span>
</h5>
<!-- Violation codes breakdown -->
<div class="code-breakdown">
@for (code of resultSummary()?.uniqueCodes || []; track code) {
<span class="code-chip">
{{ code }}
<span class="code-count">
{{ result()!.violations.filter(v => v.violationCode === code).length }}
</span>
</span>
}
</div>
<!-- Sample violations -->
<ul class="violations-list">
@for (v of result()!.violations.slice(0, 3); track v.documentId + v.violationCode) {
<li class="violation-item">
<button class="violation-btn" (click)="onSelectViolation(v)">
<span class="v-code">{{ v.violationCode }}</span>
<span class="v-doc">{{ v.documentId | slice:0:20 }}...</span>
@if (v.field) {
<span class="v-field">{{ v.field }}</span>
}
</button>
</li>
}
@if (result()!.violations.length > 3) {
<li class="more-violations">
+ {{ result()!.violations.length - 3 }} more violations
</li>
}
</ul>
</div>
} @else {
<div class="no-violations">
<span class="success-icon">[+]</span>
<span>No violations found in the last {{ windowHours() }} hours</span>
</div>
}
<!-- Completion Info -->
<div class="completion-info">
<span class="verify-id">ID: {{ result()!.verificationId | slice:0:12 }}</span>
<span class="verify-time">Completed: {{ result()!.completedAt | date:'medium' }}</span>
</div>
</div>
}
<!-- CLI Guidance Panel -->
@if (showCliGuidance()) {
<div class="cli-guidance">
<h5 class="cli-title">CLI Parity</h5>
<p class="cli-desc">{{ cliGuidance.description }}</p>
<!-- Current Command -->
<div class="cli-command-section">
<label class="cli-label">Equivalent Command</label>
<div class="cli-command">
<code>{{ getCliCommand() }}</code>
<button class="btn-copy" (click)="copyCommand(getCliCommand())" title="Copy">
[C]
</button>
</div>
</div>
<!-- Available Flags -->
<div class="cli-flags-section">
<label class="cli-label">Available Flags</label>
<table class="flags-table">
<tbody>
@for (flag of cliGuidance.flags; track flag.flag) {
<tr>
<td class="flag-name"><code>{{ flag.flag }}</code></td>
<td class="flag-desc">{{ flag.description }}</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Examples -->
<div class="cli-examples-section">
<label class="cli-label">Examples</label>
<div class="examples-list">
@for (example of cliGuidance.examples; track example) {
<div class="example-item">
<code>{{ example }}</code>
<button class="btn-copy" (click)="copyCommand(example)" title="Copy">
[C]
</button>
</div>
}
</div>
</div>
<!-- Install hint -->
<div class="install-hint">
<span class="hint-icon">[i]</span>
<span>Install CLI: <code>npm install -g @stellaops/cli</code></span>
</div>
</div>
}
</div>

View File

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

View File

@@ -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<string>();
/** 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<AocVerificationResult>();
/** Emits when user clicks on a violation */
readonly selectViolation = output<AocViolationDetail>();
readonly state = signal<VerifyState>('idle');
readonly result = signal<AocVerificationResult | null>(null);
readonly error = signal<string | null>(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<void> {
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`;
}
}

View File

@@ -0,0 +1,279 @@
<div class="violation-drilldown">
<!-- Header with Summary -->
<header class="drilldown-header">
<div class="summary-stats">
<div class="stat">
<span class="stat-value">{{ totalViolations() }}</span>
<span class="stat-label">Violations</span>
</div>
<div class="stat">
<span class="stat-value">{{ totalDocuments() }}</span>
<span class="stat-label">Documents</span>
</div>
<div class="severity-breakdown">
@if (severityCounts().critical > 0) {
<span class="severity-chip critical">{{ severityCounts().critical }} critical</span>
}
@if (severityCounts().high > 0) {
<span class="severity-chip high">{{ severityCounts().high }} high</span>
}
@if (severityCounts().medium > 0) {
<span class="severity-chip medium">{{ severityCounts().medium }} medium</span>
}
@if (severityCounts().low > 0) {
<span class="severity-chip low">{{ severityCounts().low }} low</span>
}
</div>
</div>
<div class="controls">
<div class="view-toggle">
<button
class="toggle-btn"
[class.active]="viewMode() === 'by-violation'"
(click)="setViewMode('by-violation')"
>
By Violation
</button>
<button
class="toggle-btn"
[class.active]="viewMode() === 'by-document'"
(click)="setViewMode('by-document')"
>
By Document
</button>
</div>
<input
type="search"
class="search-input"
placeholder="Filter violations..."
[value]="searchFilter()"
(input)="onSearch($event)"
/>
</div>
</header>
<!-- By Violation View -->
@if (viewMode() === 'by-violation') {
<div class="violation-list">
@for (group of filteredGroups(); track group.code) {
<div class="violation-group" [class]="'severity-' + group.severity">
<button
class="group-header"
(click)="toggleGroup(group.code)"
[attr.aria-expanded]="expandedCode() === group.code"
>
<span class="severity-icon">{{ getSeverityIcon(group.severity) }}</span>
<div class="group-info">
<span class="violation-code">{{ group.code }}</span>
<span class="violation-desc">{{ group.description }}</span>
</div>
<span class="affected-count">{{ group.affectedDocuments }} doc(s)</span>
<span class="expand-icon" [class.expanded]="expandedCode() === group.code">v</span>
</button>
@if (expandedCode() === group.code) {
<div class="group-details">
@if (group.remediation) {
<div class="remediation-hint">
<strong>Remediation:</strong> {{ group.remediation }}
</div>
}
<table class="violations-table">
<thead>
<tr>
<th>Document</th>
<th>Field</th>
<th>Expected</th>
<th>Actual</th>
<th>Provenance</th>
<th></th>
</tr>
</thead>
<tbody>
@for (v of group.violations; track v.documentId + v.field) {
<tr class="violation-row">
<td class="doc-cell">
<button class="doc-link" (click)="onSelectDocument(v.documentId)">
{{ v.documentId | slice:0:20 }}...
</button>
</td>
<td class="field-cell">
@if (v.field) {
<code class="field-path highlighted">{{ v.field }}</code>
} @else {
<span class="no-field">-</span>
}
</td>
<td class="expected-cell">
@if (v.expected) {
<code class="value expected">{{ v.expected }}</code>
} @else {
<span class="no-value">-</span>
}
</td>
<td class="actual-cell">
@if (v.actual) {
<code class="value actual error">{{ v.actual }}</code>
} @else {
<span class="no-value">-</span>
}
</td>
<td class="provenance-cell">
@if (v.provenance) {
<div class="provenance-info">
<span class="source-type">{{ getSourceTypeIcon(v.provenance.sourceType) }}</span>
<span class="source-id" [title]="v.provenance.sourceId">
{{ v.provenance.sourceId | slice:0:15 }}
</span>
<span class="digest" [title]="v.provenance.digest">
{{ formatDigest(v.provenance.digest) }}
</span>
</div>
} @else {
<span class="no-provenance">No provenance</span>
}
</td>
<td class="actions-cell">
<button class="btn-icon" (click)="onViewRaw(v.documentId)" title="View raw">
{ }
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
@if (filteredGroups().length === 0) {
<div class="empty-state">
@if (searchFilter()) {
<p>No violations match "{{ searchFilter() }}"</p>
} @else {
<p>No violations to display</p>
}
</div>
}
</div>
}
<!-- By Document View -->
@if (viewMode() === 'by-document') {
<div class="document-list">
@for (doc of filteredDocuments(); track doc.documentId) {
<div class="document-card">
<button
class="doc-header"
(click)="toggleDocument(doc.documentId)"
[attr.aria-expanded]="expandedDocId() === doc.documentId"
>
<span class="doc-type-badge">{{ doc.documentType }}</span>
<span class="doc-id">{{ doc.documentId }}</span>
<span class="violation-count">{{ doc.violations.length }} violation(s)</span>
<span class="expand-icon" [class.expanded]="expandedDocId() === doc.documentId">v</span>
</button>
@if (expandedDocId() === doc.documentId) {
<div class="doc-details">
<!-- Provenance Section -->
<div class="provenance-section">
<h4 class="section-title">Provenance</h4>
<dl class="provenance-grid">
<div class="prov-item">
<dt>Source</dt>
<dd>
<span class="source-type">{{ getSourceTypeIcon(doc.provenance.sourceType) }}</span>
{{ doc.provenance.sourceId }}
</dd>
</div>
<div class="prov-item">
<dt>Digest</dt>
<dd><code>{{ doc.provenance.digest }}</code></dd>
</div>
<div class="prov-item">
<dt>Ingested</dt>
<dd>{{ formatDate(doc.provenance.ingestedAt) }}</dd>
</div>
@if (doc.provenance.submitter) {
<div class="prov-item">
<dt>Submitter</dt>
<dd>{{ doc.provenance.submitter }}</dd>
</div>
}
@if (doc.provenance.sourceUrl) {
<div class="prov-item">
<dt>Source URL</dt>
<dd class="url">{{ doc.provenance.sourceUrl }}</dd>
</div>
}
</dl>
</div>
<!-- Violations Section -->
<div class="violations-section">
<h4 class="section-title">Violations</h4>
<ul class="doc-violations-list">
@for (v of doc.violations; track v.violationCode + v.field) {
<li class="doc-violation-item">
<div class="violation-header">
<code class="violation-code">{{ v.violationCode }}</code>
@if (v.field) {
<span class="at-field">at</span>
<code class="field-path highlighted">{{ v.field }}</code>
}
</div>
@if (v.expected || v.actual) {
<div class="value-diff">
<div class="expected-row">
<span class="label">Expected:</span>
<code class="value">{{ v.expected || 'N/A' }}</code>
</div>
<div class="actual-row">
<span class="label">Actual:</span>
<code class="value error">{{ v.actual || 'N/A' }}</code>
</div>
</div>
}
</li>
}
</ul>
</div>
<!-- Raw Content Preview -->
@if (doc.rawContent) {
<div class="raw-content-section">
<h4 class="section-title">
Document Fields
<button class="btn-link" (click)="onViewRaw(doc.documentId)">View Full</button>
</h4>
<div class="field-preview">
@for (field of doc.highlightedFields; track field) {
<div class="field-row" [class.error]="isFieldHighlighted(doc, field)">
<span class="field-name">{{ field }}</span>
<code class="field-value">{{ getFieldValue(doc.rawContent, field) }}</code>
</div>
}
</div>
</div>
}
</div>
}
</div>
}
@if (filteredDocuments().length === 0) {
<div class="empty-state">
@if (searchFilter()) {
<p>No documents match "{{ searchFilter() }}"</p>
} @else {
<p>No documents to display</p>
}
</div>
}
</div>
}
</div>

View File

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

View File

@@ -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<AocViolationGroup[]>();
/** Document views for by-document mode */
readonly documentViews = input<AocDocumentView[]>([]);
/** Emits when user clicks on a document */
readonly selectDocument = output<string>();
/** Emits when user wants to view raw document */
readonly viewRawDocument = output<string>();
/** Current view mode */
readonly viewMode = signal<ViewMode>('by-violation');
/** Currently expanded violation code */
readonly expandedCode = signal<string | null>(null);
/** Currently expanded document ID */
readonly expandedDocId = signal<string | null>(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<string>();
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<string, unknown> | 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<string, unknown>)[part];
}
if (current == null) return 'null';
if (typeof current === 'object') return JSON.stringify(current);
return String(current);
}
}

View File

@@ -0,0 +1,148 @@
<div class="sources-dashboard">
<header class="dashboard-header">
<h1>Sources Dashboard</h1>
<div class="actions">
<button
class="btn btn-primary"
[disabled]="verifying()"
(click)="onVerifyLast24h()"
>
{{ verifying() ? 'Verifying...' : 'Verify last 24h' }}
</button>
<button class="btn btn-secondary" (click)="loadMetrics()">
Refresh
</button>
</div>
</header>
@if (loading()) {
<div class="loading-state">
<div class="spinner"></div>
<p>Loading AOC metrics...</p>
</div>
}
@if (error()) {
<div class="error-state">
<p class="error-message">{{ error() }}</p>
<button class="btn btn-secondary" (click)="loadMetrics()">Retry</button>
</div>
}
@if (metrics(); as m) {
<div class="metrics-grid">
<!-- Pass/Fail Tile -->
<div class="tile tile-pass-fail" [class]="passRateClass()">
<h2 class="tile-title">AOC Pass/Fail</h2>
<div class="tile-content">
<div class="metric-large">
<span class="value">{{ passRate() }}%</span>
<span class="label">Pass Rate</span>
</div>
<div class="metric-details">
<div class="detail">
<span class="count pass">{{ m.passCount | number }}</span>
<span class="label">Passed</span>
</div>
<div class="detail">
<span class="count fail">{{ m.failCount | number }}</span>
<span class="label">Failed</span>
</div>
<div class="detail">
<span class="count total">{{ m.totalCount | number }}</span>
<span class="label">Total</span>
</div>
</div>
</div>
</div>
<!-- Recent Violations Tile -->
<div class="tile tile-violations">
<h2 class="tile-title">Recent Violations</h2>
<div class="tile-content">
@if (m.recentViolations.length === 0) {
<p class="empty-state">No violations in time window</p>
} @else {
<ul class="violations-list">
@for (v of m.recentViolations; track v.code) {
<li class="violation-item" [class]="getSeverityClass(v.severity)">
<div class="violation-header">
<code class="violation-code">{{ v.code }}</code>
<span class="violation-count">{{ v.count }}x</span>
</div>
<p class="violation-desc">{{ v.description }}</p>
<span class="violation-time">{{ formatRelativeTime(v.lastSeen) }}</span>
</li>
}
</ul>
}
</div>
</div>
<!-- Ingest Throughput Tile -->
<div class="tile tile-throughput" [class]="throughputStatus()">
<h2 class="tile-title">Ingest Throughput</h2>
<div class="tile-content">
<div class="throughput-grid">
<div class="throughput-item">
<span class="value">{{ m.ingestThroughput.docsPerMinute | number:'1.1-1' }}</span>
<span class="label">docs/min</span>
</div>
<div class="throughput-item">
<span class="value">{{ m.ingestThroughput.avgLatencyMs }}</span>
<span class="label">avg ms</span>
</div>
<div class="throughput-item">
<span class="value">{{ m.ingestThroughput.p95LatencyMs }}</span>
<span class="label">p95 ms</span>
</div>
<div class="throughput-item">
<span class="value">{{ m.ingestThroughput.queueDepth }}</span>
<span class="label">queue</span>
</div>
<div class="throughput-item">
<span class="value">{{ m.ingestThroughput.errorRate | number:'1.2-2' }}%</span>
<span class="label">errors</span>
</div>
</div>
</div>
</div>
</div>
<!-- Verification Result -->
@if (verificationResult(); as result) {
<div class="verification-result" [class]="'status-' + result.status">
<h3>Verification Complete</h3>
<div class="result-summary">
<span class="status-badge">{{ result.status | titlecase }}</span>
<span>Checked: {{ result.checkedCount | number }}</span>
<span>Passed: {{ result.passedCount | number }}</span>
<span>Failed: {{ result.failedCount | number }}</span>
</div>
@if (result.violations.length > 0) {
<details class="violations-details">
<summary>View {{ result.violations.length }} violation(s)</summary>
<ul class="violation-list">
@for (v of result.violations; track v.documentId) {
<li>
<strong>{{ v.violationCode }}</strong> in {{ v.documentId }}
@if (v.field) {
<br>Field: {{ v.field }} (expected: {{ v.expected }}, actual: {{ v.actual }})
}
</li>
}
</ul>
</details>
}
<p class="cli-hint">
CLI equivalent: <code>stella aoc verify --since=24h --tenant=default</code>
</p>
</div>
}
<p class="time-window">
Data from {{ m.timeWindow.start | date:'short' }} to {{ m.timeWindow.end | date:'short' }}
({{ m.timeWindow.durationMinutes / 60 | number:'1.0-0' }}h window)
</p>
}
</div>

View File

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

View File

@@ -0,0 +1,302 @@
<div class="exception-center">
<!-- Header -->
<header class="center-header">
<div class="header-left">
<h2 class="center-title">Exception Center</h2>
<div class="status-chips">
@for (col of kanbanColumns; track col.status) {
<span
class="status-chip"
[style.borderColor]="col.color"
[class.active]="filter().status?.includes(col.status)"
(click)="updateFilter('status', filter().status?.includes(col.status)
? filter().status?.filter(s => s !== col.status)
: [...(filter().status || []), col.status])"
>
{{ col.label }}
<span class="chip-count">{{ statusCounts()[col.status] || 0 }}</span>
</span>
}
</div>
</div>
<div class="header-right">
<div class="view-toggle">
<button
class="toggle-btn"
[class.active]="viewMode() === 'list'"
(click)="setViewMode('list')"
title="List view"
>
=
</button>
<button
class="toggle-btn"
[class.active]="viewMode() === 'kanban'"
(click)="setViewMode('kanban')"
title="Kanban view"
>
#
</button>
</div>
<button class="btn-filter" (click)="toggleFilters()" [class.active]="showFilters()">
Filters
</button>
<button class="btn-create" (click)="onCreate()">
+ New Exception
</button>
</div>
</header>
<!-- Filters Panel -->
@if (showFilters()) {
<div class="filters-panel">
<div class="filter-row">
<div class="filter-group">
<label class="filter-label">Search</label>
<input
type="search"
class="filter-input"
placeholder="Search exceptions..."
[value]="filter().search || ''"
(input)="updateFilter('search', $any($event.target).value)"
/>
</div>
<div class="filter-group">
<label class="filter-label">Type</label>
<div class="filter-chips">
@for (type of ['vulnerability', 'license', 'policy', 'entropy', 'determinism']; track type) {
<button
class="filter-chip"
[class.active]="filter().type?.includes($any(type))"
(click)="updateFilter('type', filter().type?.includes($any(type))
? filter().type?.filter(t => t !== type)
: [...(filter().type || []), type])"
>
{{ type | titlecase }}
</button>
}
</div>
</div>
<div class="filter-group">
<label class="filter-label">Severity</label>
<div class="filter-chips">
@for (sev of ['critical', 'high', 'medium', 'low']; track sev) {
<button
class="filter-chip"
[class]="'sev-' + sev"
[class.active]="filter().severity?.includes(sev)"
(click)="updateFilter('severity', filter().severity?.includes(sev)
? filter().severity?.filter(s => s !== sev)
: [...(filter().severity || []), sev])"
>
{{ sev | titlecase }}
</button>
}
</div>
</div>
<div class="filter-group">
<label class="filter-label">Tags</label>
<div class="filter-chips tags">
@for (tag of allTags().slice(0, 8); track tag) {
<button
class="filter-chip tag"
[class.active]="filter().tags?.includes(tag)"
(click)="updateFilter('tags', filter().tags?.includes(tag)
? filter().tags?.filter(t => t !== tag)
: [...(filter().tags || []), tag])"
>
{{ tag }}
</button>
}
</div>
</div>
<div class="filter-group">
<label class="filter-checkbox">
<input
type="checkbox"
[checked]="filter().expiringSoon"
(change)="updateFilter('expiringSoon', $any($event.target).checked)"
/>
Expiring soon
</label>
</div>
</div>
<button class="btn-clear-filters" (click)="clearFilters()">Clear filters</button>
</div>
}
<!-- List View -->
@if (viewMode() === 'list') {
<div class="list-view">
<!-- Sort Header -->
<div class="list-header">
<button class="sort-btn" (click)="setSort('title')">
Title
@if (sort().field === 'title') {
<span class="sort-icon">{{ sort().direction === 'asc' ? '^' : 'v' }}</span>
}
</button>
<button class="sort-btn" (click)="setSort('severity')">
Severity
@if (sort().field === 'severity') {
<span class="sort-icon">{{ sort().direction === 'asc' ? '^' : 'v' }}</span>
}
</button>
<span class="col-header">Status</span>
<button class="sort-btn" (click)="setSort('expiresAt')">
Expires
@if (sort().field === 'expiresAt') {
<span class="sort-icon">{{ sort().direction === 'asc' ? '^' : 'v' }}</span>
}
</button>
<button class="sort-btn" (click)="setSort('updatedAt')">
Updated
@if (sort().field === 'updatedAt') {
<span class="sort-icon">{{ sort().direction === 'asc' ? '^' : 'v' }}</span>
}
</button>
<span class="col-header">Actions</span>
</div>
<!-- Exception Rows -->
<div class="list-body">
@for (exc of filteredExceptions(); track exc.id) {
<div class="exception-row" [class]="'status-' + exc.status">
<button class="row-main" (click)="onSelect(exc)">
<div class="exc-title-cell">
<span class="type-badge">{{ getTypeIcon(exc.type) }}</span>
<div class="exc-title-info">
<span class="exc-title">{{ exc.title }}</span>
<span class="exc-id">{{ exc.id }}</span>
</div>
</div>
<div class="exc-severity-cell">
<span class="severity-badge" [class]="getSeverityClass(exc.severity)">
{{ exc.severity | titlecase }}
</span>
</div>
<div class="exc-status-cell">
<span class="status-badge" [class]="'status-' + exc.status">
{{ getStatusIcon(exc.status) }} {{ exc.status | titlecase }}
</span>
</div>
<div class="exc-expires-cell">
<span
class="expires-text"
[class.warning]="exc.timebox.isWarning"
[class.expired]="exc.timebox.isExpired"
>
{{ formatRemainingDays(exc.timebox.remainingDays) }}
</span>
</div>
<div class="exc-updated-cell">
{{ formatDate(exc.updatedAt) }}
</div>
</button>
<div class="row-actions">
@for (trans of getAvailableTransitions(exc); track trans.to) {
<button
class="action-btn"
[title]="trans.action"
(click)="onTransition(exc, trans.to)"
>
{{ trans.action }}
</button>
}
<button class="action-btn audit" (click)="onViewAudit(exc)" title="View audit log">
[A]
</button>
</div>
</div>
}
@if (filteredExceptions().length === 0) {
<div class="empty-state">
<p>No exceptions match the current filters</p>
<button class="btn-link" (click)="clearFilters()">Clear filters</button>
</div>
}
</div>
</div>
}
<!-- Kanban View -->
@if (viewMode() === 'kanban') {
<div class="kanban-view">
@for (col of kanbanColumns; track col.status) {
<div class="kanban-column">
<div class="column-header" [style.borderColor]="col.color">
<span class="column-title">{{ col.label }}</span>
<span class="column-count">{{ exceptionsByStatus().get(col.status)?.length || 0 }}</span>
</div>
<div class="column-body">
@for (exc of exceptionsByStatus().get(col.status) || []; track exc.id) {
<div class="kanban-card" [class]="getSeverityClass(exc.severity)">
<button class="card-main" (click)="onSelect(exc)">
<div class="card-header">
<span class="type-badge">{{ getTypeIcon(exc.type) }}</span>
<span class="severity-dot" [class]="getSeverityClass(exc.severity)"></span>
</div>
<h4 class="card-title">{{ exc.title }}</h4>
<p class="card-id">{{ exc.id }}</p>
<div class="card-meta">
<span
class="expires-badge"
[class.warning]="exc.timebox.isWarning"
[class.expired]="exc.timebox.isExpired"
>
{{ formatRemainingDays(exc.timebox.remainingDays) }}
</span>
</div>
@if (exc.tags.length > 0) {
<div class="card-tags">
@for (tag of exc.tags.slice(0, 3); track tag) {
<span class="tag">{{ tag }}</span>
}
</div>
}
</button>
<div class="card-actions">
@for (trans of getAvailableTransitions(exc); track trans.to) {
<button
class="card-action-btn"
(click)="onTransition(exc, trans.to)"
>
{{ trans.action }}
</button>
}
</div>
</div>
}
@if ((exceptionsByStatus().get(col.status)?.length || 0) === 0) {
<div class="column-empty">No exceptions</div>
}
</div>
</div>
}
</div>
}
<!-- Footer Stats -->
<footer class="center-footer">
<span class="total-count">
{{ filteredExceptions().length }} of {{ exceptions().length }} exceptions
</span>
</footer>
</div>

View File

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

View File

@@ -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<Exception[]>();
/** Current user role for transition permissions */
readonly userRole = input<string>('user');
/** Emits when creating new exception */
readonly create = output<void>();
/** Emits when selecting an exception */
readonly select = output<Exception>();
/** Emits when performing a workflow transition */
readonly transition = output<{ exception: Exception; to: ExceptionStatus }>();
/** Emits when viewing audit log */
readonly viewAudit = output<Exception>();
readonly viewMode = signal<ViewMode>('list');
readonly filter = signal<ExceptionFilter>({});
readonly sort = signal<ExceptionSortOption>({ field: 'updatedAt', direction: 'desc' });
readonly expandedId = signal<string | null>(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<ExceptionStatus, Exception[]>();
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<string, number> = {};
for (const exc of this.exceptions()) {
counts[exc.status] = (counts[exc.status] || 0) + 1;
}
return counts;
});
readonly allTags = computed(() => {
const tags = new Set<string>();
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';
}
}

View File

@@ -0,0 +1,406 @@
<div class="exception-wizard">
<!-- Progress Steps -->
<div class="wizard-progress">
@for (step of steps; track step; let i = $index) {
<button
class="progress-step"
[class.active]="currentStep() === step"
[class.completed]="i < currentStepIndex()"
[class.disabled]="i > currentStepIndex()"
(click)="goToStep(step)"
[disabled]="i > currentStepIndex()"
>
<span class="step-number">{{ i + 1 }}</span>
<span class="step-label">{{ step | titlecase }}</span>
</button>
@if (i < steps.length - 1) {
<div class="step-connector" [class.completed]="i < currentStepIndex()"></div>
}
}
</div>
<!-- Step Content -->
<div class="wizard-content">
<!-- Step 1: Type Selection -->
@if (currentStep() === 'type') {
<div class="step-panel">
<h3 class="step-title">What type of exception do you need?</h3>
<p class="step-desc">Select the category that best matches your exception request.</p>
<div class="type-grid">
@for (type of exceptionTypes; track type.type) {
<button
class="type-card"
[class.selected]="draft().type === type.type"
(click)="selectType(type.type)"
>
<span class="type-icon">{{ type.icon }}</span>
<div class="type-info">
<span class="type-label">{{ type.label }}</span>
<span class="type-desc">{{ type.description }}</span>
</div>
@if (draft().type === type.type) {
<span class="selected-check">[+]</span>
}
</button>
}
</div>
</div>
}
<!-- Step 2: Scope Definition -->
@if (currentStep() === 'scope') {
<div class="step-panel">
<h3 class="step-title">Define the exception scope</h3>
<p class="step-desc">Specify what this exception applies to. Be as specific as possible.</p>
<div class="scope-form">
@if (draft().type === 'vulnerability') {
<div class="scope-field">
<label class="field-label">CVEs</label>
<textarea
class="field-textarea"
placeholder="Enter CVE IDs, one per line (e.g., CVE-2024-1234)"
[value]="draft().scope.cves?.join('\n') || ''"
(input)="updateScope('cves', $any($event.target).value.split('\n').filter((v: string) => v.trim()))"
></textarea>
</div>
<div class="scope-field">
<label class="field-label">Packages (optional)</label>
<textarea
class="field-textarea"
placeholder="Package names to scope (e.g., lodash, express)"
[value]="draft().scope.packages?.join('\n') || ''"
(input)="updateScope('packages', $any($event.target).value.split('\n').filter((v: string) => v.trim()))"
></textarea>
</div>
}
@if (draft().type === 'license') {
<div class="scope-field">
<label class="field-label">Licenses</label>
<textarea
class="field-textarea"
placeholder="License identifiers (e.g., GPL-3.0, AGPL-3.0)"
[value]="draft().scope.licenses?.join('\n') || ''"
(input)="updateScope('licenses', $any($event.target).value.split('\n').filter((v: string) => v.trim()))"
></textarea>
</div>
}
@if (draft().type === 'policy') {
<div class="scope-field">
<label class="field-label">Policy Rules</label>
<textarea
class="field-textarea"
placeholder="Policy rule IDs (e.g., SEC-001, COMP-002)"
[value]="draft().scope.policyRules?.join('\n') || ''"
(input)="updateScope('policyRules', $any($event.target).value.split('\n').filter((v: string) => v.trim()))"
></textarea>
</div>
}
<div class="scope-field">
<label class="field-label">Images (optional - limits scope to specific images)</label>
<textarea
class="field-textarea"
placeholder="Image references (e.g., myregistry/myimage:*, myregistry/app:v1.0)"
[value]="draft().scope.images?.join('\n') || ''"
(input)="updateScope('images', $any($event.target).value.split('\n').filter((v: string) => v.trim()))"
></textarea>
<span class="field-hint">Use * for wildcards. Leave empty to apply to all images.</span>
</div>
<div class="scope-field">
<label class="field-label">Environments (optional)</label>
<div class="env-chips">
@for (env of ['development', 'staging', 'production']; track env) {
<button
class="env-chip"
[class.selected]="draft().scope.environments?.includes(env)"
(click)="updateScope('environments',
draft().scope.environments?.includes(env)
? draft().scope.environments?.filter(e => e !== env)
: [...(draft().scope.environments || []), env])"
>
{{ env | titlecase }}
</button>
}
</div>
</div>
@if (scopePreview().length > 0) {
<div class="scope-preview">
<span class="preview-label">Scope preview:</span>
<span class="preview-text">{{ scopePreview().join(', ') }}</span>
</div>
}
</div>
</div>
}
<!-- Step 3: Justification -->
@if (currentStep() === 'justification') {
<div class="step-panel">
<h3 class="step-title">Provide justification</h3>
<p class="step-desc">Explain why this exception is needed. Use a template or write your own.</p>
<div class="justification-form">
<div class="form-field">
<label class="field-label">Title</label>
<input
type="text"
class="field-input"
placeholder="Brief descriptive title for this exception"
[value]="draft().title"
(input)="updateDraft('title', $any($event.target).value)"
/>
</div>
<div class="form-field">
<label class="field-label">Severity</label>
<div class="severity-options">
@for (sev of ['critical', 'high', 'medium', 'low']; track sev) {
<button
class="severity-btn"
[class]="'sev-' + sev"
[class.selected]="draft().severity === sev"
(click)="updateDraft('severity', $any(sev))"
>
{{ sev | titlecase }}
</button>
}
</div>
</div>
@if (applicableTemplates().length > 0) {
<div class="form-field">
<label class="field-label">Templates</label>
<div class="template-list">
@for (tpl of applicableTemplates(); track tpl.id) {
<button
class="template-btn"
[class.selected]="selectedTemplate() === tpl.id"
(click)="selectTemplate(tpl.id)"
>
<span class="tpl-name">{{ tpl.name }}</span>
<span class="tpl-desc">{{ tpl.description }}</span>
</button>
}
</div>
</div>
}
<div class="form-field">
<label class="field-label">
Justification
<span class="char-count">{{ draft().justification.length }} chars (min 20)</span>
</label>
<textarea
class="field-textarea large"
placeholder="Provide detailed justification for this exception..."
[value]="draft().justification"
(input)="updateDraft('justification', $any($event.target).value)"
></textarea>
</div>
<div class="form-field">
<label class="field-label">Tags (optional)</label>
<div class="tags-input">
<div class="current-tags">
@for (tag of draft().tags; track tag) {
<span class="tag">
{{ tag }}
<button class="tag-remove" (click)="removeTag(tag)">x</button>
</span>
}
</div>
<input
type="text"
class="tag-input"
placeholder="Add tag..."
[value]="newTag()"
(input)="onTagInput($event)"
(keydown.enter)="addTag(); $event.preventDefault()"
/>
</div>
</div>
</div>
</div>
}
<!-- Step 4: Timebox -->
@if (currentStep() === 'timebox') {
<div class="step-panel">
<h3 class="step-title">Set exception duration</h3>
<p class="step-desc">
Exceptions must have an expiration date. Maximum duration: {{ maxDurationDays() }} days.
</p>
<div class="timebox-form">
<div class="timebox-presets">
@for (preset of timeboxPresets; track preset.days) {
<button
class="preset-btn"
[class.selected]="draft().expiresInDays === preset.days"
[disabled]="preset.days > maxDurationDays()"
(click)="selectTimebox(preset.days)"
>
<span class="preset-label">{{ preset.label }}</span>
<span class="preset-desc">{{ preset.description }}</span>
</button>
}
</div>
<div class="custom-duration">
<label class="field-label">Or set custom duration (days)</label>
<input
type="number"
class="field-input duration-input"
min="1"
[max]="maxDurationDays()"
[value]="draft().expiresInDays"
(input)="updateDraft('expiresInDays', +$any($event.target).value)"
/>
</div>
<div class="timebox-preview">
<div class="preview-row">
<span class="preview-label">Expires on:</span>
<span class="preview-value">{{ formatDate(expirationDate()) }}</span>
</div>
<div class="preview-row">
<span class="preview-label">Duration:</span>
<span class="preview-value">{{ draft().expiresInDays }} days</span>
</div>
</div>
@if (timeboxWarning()) {
<div class="timebox-warning">
<span class="warning-icon">[!]</span>
<span>{{ timeboxWarning() }}</span>
</div>
}
</div>
</div>
}
<!-- Step 5: Review -->
@if (currentStep() === 'review') {
<div class="step-panel">
<h3 class="step-title">Review and submit</h3>
<p class="step-desc">Please review your exception request before submitting.</p>
<div class="review-summary">
<div class="review-section">
<h4 class="section-title">Type & Severity</h4>
<div class="review-row">
<span class="review-label">Type:</span>
<span class="review-value">{{ draft().type | titlecase }}</span>
</div>
<div class="review-row">
<span class="review-label">Severity:</span>
<span class="review-value severity-badge" [class]="'sev-' + draft().severity">
{{ draft().severity | titlecase }}
</span>
</div>
</div>
<div class="review-section">
<h4 class="section-title">Scope</h4>
@if (draft().scope.cves?.length) {
<div class="review-row">
<span class="review-label">CVEs:</span>
<span class="review-value">{{ draft().scope.cves?.join(', ') }}</span>
</div>
}
@if (draft().scope.packages?.length) {
<div class="review-row">
<span class="review-label">Packages:</span>
<span class="review-value">{{ draft().scope.packages?.join(', ') }}</span>
</div>
}
@if (draft().scope.licenses?.length) {
<div class="review-row">
<span class="review-label">Licenses:</span>
<span class="review-value">{{ draft().scope.licenses?.join(', ') }}</span>
</div>
}
@if (draft().scope.policyRules?.length) {
<div class="review-row">
<span class="review-label">Policy Rules:</span>
<span class="review-value">{{ draft().scope.policyRules?.join(', ') }}</span>
</div>
}
@if (draft().scope.images?.length) {
<div class="review-row">
<span class="review-label">Images:</span>
<span class="review-value">{{ draft().scope.images?.join(', ') }}</span>
</div>
}
@if (draft().scope.environments?.length) {
<div class="review-row">
<span class="review-label">Environments:</span>
<span class="review-value">{{ draft().scope.environments?.join(', ') }}</span>
</div>
}
</div>
<div class="review-section">
<h4 class="section-title">Details</h4>
<div class="review-row">
<span class="review-label">Title:</span>
<span class="review-value">{{ draft().title }}</span>
</div>
<div class="review-row full">
<span class="review-label">Justification:</span>
<p class="review-justification">{{ draft().justification }}</p>
</div>
@if (draft().tags.length > 0) {
<div class="review-row">
<span class="review-label">Tags:</span>
<div class="review-tags">
@for (tag of draft().tags; track tag) {
<span class="tag">{{ tag }}</span>
}
</div>
</div>
}
</div>
<div class="review-section">
<h4 class="section-title">Timebox</h4>
<div class="review-row">
<span class="review-label">Duration:</span>
<span class="review-value">{{ draft().expiresInDays }} days</span>
</div>
<div class="review-row">
<span class="review-label">Expires:</span>
<span class="review-value">{{ formatDate(expirationDate()) }}</span>
</div>
</div>
</div>
</div>
}
</div>
<!-- Footer Actions -->
<div class="wizard-footer">
<button class="btn-cancel" (click)="onCancel()">Cancel</button>
<div class="footer-right">
@if (canGoBack()) {
<button class="btn-back" (click)="goBack()">Back</button>
}
@if (currentStep() !== 'review') {
<button class="btn-next" [disabled]="!canGoNext()" (click)="goNext()">
Next
</button>
} @else {
<button class="btn-submit" [disabled]="!canGoNext()" (click)="onSubmit()">
Submit Exception
</button>
}
</div>
</div>
</div>

View File

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

View File

@@ -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<ExceptionScope>;
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<ExceptionType>();
/** Pre-filled scope (e.g., specific CVE) */
readonly prefilledScope = input<Partial<ExceptionScope>>();
/** Available justification templates */
readonly templates = input<JustificationTemplate[]>(this.defaultTemplates);
/** Maximum allowed exception duration in days */
readonly maxDurationDays = input(90);
/** Emits when wizard is cancelled */
readonly cancel = output<void>();
/** Emits when exception is created */
readonly create = output<ExceptionDraft>();
readonly steps: WizardStep[] = ['type', 'scope', 'justification', 'timebox', 'review'];
readonly currentStep = signal<WizardStep>('type');
readonly draft = signal<ExceptionDraft>({
type: null,
severity: 'medium',
title: '',
justification: '',
scope: {},
expiresInDays: 30,
tags: [],
});
readonly scopePreview = signal<string[]>([]);
readonly selectedTemplate = signal<string | null>(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<K extends keyof ExceptionDraft>(key: K, value: ExceptionDraft[K]): void {
this.draft.update((d) => ({ ...d, [key]: value }));
}
updateScope<K extends keyof ExceptionScope>(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);
}
}

View File

@@ -0,0 +1,130 @@
<div class="determinism-badge" [class]="'status-' + status().status">
<!-- Compact Badge -->
<button
class="badge-trigger"
(click)="toggleExpanded()"
[attr.aria-expanded]="isExpanded()"
aria-controls="determinism-details"
>
<span class="badge-icon">{{ statusIcon() }}</span>
<span class="badge-label">{{ statusLabel() }}</span>
<span class="badge-stats">
{{ fragmentStats().matched }}/{{ fragmentStats().total }} fragments
</span>
<span class="badge-expand-icon" [class.expanded]="isExpanded()"></span>
</button>
<!-- Expanded Details -->
@if (isExpanded()) {
<div id="determinism-details" class="badge-details" role="region" aria-label="Determinism details">
<!-- Merkle Root Section -->
<div class="detail-section">
<h4 class="section-title">Merkle Root</h4>
<div class="merkle-info">
@if (status().merkleRoot) {
<code class="hash" [title]="status().merkleRoot">
{{ formatHash(status().merkleRoot!, 24) }}
</code>
<span class="consistency-badge" [class.consistent]="status().merkleConsistent">
{{ status().merkleConsistent ? 'Consistent' : 'Mismatch' }}
</span>
} @else {
<span class="no-data">No Merkle root available</span>
}
</div>
</div>
<!-- Composition Metadata -->
@if (status().composition; as comp) {
<div class="detail-section">
<h4 class="section-title">Composition</h4>
<dl class="composition-meta">
<div class="meta-item">
<dt>Schema</dt>
<dd>{{ comp.schemaVersion }}</dd>
</div>
<div class="meta-item">
<dt>Scanner</dt>
<dd>{{ comp.scannerVersion }}</dd>
</div>
<div class="meta-item">
<dt>Built</dt>
<dd>{{ comp.buildTimestamp | date:'short' }}</dd>
</div>
<div class="meta-item">
<dt>Hash</dt>
<dd><code [title]="comp.compositionHash">{{ formatHash(comp.compositionHash) }}</code></dd>
</div>
</dl>
<button class="btn-link" (click)="onViewComposition()">
View _composition.json →
</button>
</div>
}
<!-- Fragment Hashes -->
<div class="detail-section">
<h4 class="section-title">
Fragment Hashes
<span class="fragment-count">
({{ fragmentStats().percentage | number:'1.0-0' }}% match)
</span>
</h4>
<div class="fragments-list">
@for (fragment of status().fragments; track fragment.id) {
<div class="fragment-item" [class.mismatch]="!fragment.matches">
<span class="fragment-icon">{{ getFragmentIcon(fragment) }}</span>
<div class="fragment-info">
<span class="fragment-id" [title]="fragment.id">
{{ fragment.type }}: {{ formatHash(fragment.id, 16) }}
</span>
<span class="fragment-size">{{ formatBytes(fragment.size) }}</span>
</div>
<div class="fragment-hashes">
<code class="hash expected" [title]="fragment.expectedHash">
E: {{ formatHash(fragment.expectedHash) }}
</code>
<code class="hash computed" [title]="fragment.computedHash">
C: {{ formatHash(fragment.computedHash) }}
</code>
</div>
</div>
}
</div>
</div>
<!-- Issues -->
@if (status().issues.length > 0) {
<div class="detail-section issues-section">
<h4 class="section-title">
Issues
@if (issuesByLevel().errors.length > 0) {
<span class="issue-count error">{{ issuesByLevel().errors.length }} errors</span>
}
@if (issuesByLevel().warnings.length > 0) {
<span class="issue-count warning">{{ issuesByLevel().warnings.length }} warnings</span>
}
</h4>
<ul class="issues-list">
@for (issue of status().issues; track issue.code) {
<li class="issue-item" [class]="'severity-' + issue.severity">
<span class="issue-icon">{{ getIssueIcon(issue) }}</span>
<div class="issue-content">
<code class="issue-code">{{ issue.code }}</code>
<span class="issue-message">{{ issue.message }}</span>
@if (issue.fragmentId) {
<span class="issue-fragment">Fragment: {{ formatHash(issue.fragmentId) }}</span>
}
</div>
</li>
}
</ul>
</div>
}
<p class="verified-at">
Verified {{ status().verifiedAt | date:'medium' }}
</p>
</div>
}
</div>

View File

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

View File

@@ -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<DeterminismStatus>();
/** Whether to show expanded details by default */
readonly expanded = input(false);
/** Emits when user clicks to view full composition */
readonly viewComposition = output<void>();
/** 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 '';
}
}
}

View File

@@ -0,0 +1,160 @@
<div class="entropy-panel" [class]="riskClass()">
<!-- Header with Overall Score -->
<header class="panel-header">
<div class="score-section">
<div class="score-ring" [attr.data-score]="analysis().overallScore">
<svg viewBox="0 0 100 100" class="score-svg">
<circle class="score-bg" cx="50" cy="50" r="45" />
<circle
class="score-fill"
cx="50"
cy="50"
r="45"
[style.strokeDasharray]="(analysis().overallScore / 10 * 283) + ' 283'"
/>
</svg>
<span class="score-value">{{ analysis().overallScore | number:'1.1-1' }}</span>
</div>
<div class="score-info">
<h3 class="risk-label">{{ analysis().riskLevel | titlecase }} Risk</h3>
<p class="score-desc">{{ scoreDescription() }}</p>
</div>
</div>
<button class="btn-report" (click)="onViewReport()">
View entropy.report.json →
</button>
</header>
<div class="panel-content">
<!-- Layer Donut Chart -->
<section class="section layer-section">
<h4 class="section-title">Layer Entropy Distribution</h4>
<div class="layer-visualization">
<div class="donut-chart">
<svg viewBox="-50 -50 100 100" class="donut-svg">
@for (layer of layerDonutData(); track layer.digest) {
<path
class="donut-segment"
[attr.d]="'M 0 -40 A 40 40 0 ' + (layer.percentage > 50 ? 1 : 0) + ' 1 ' + (Math.sin(layer.percentage * 3.6 * Math.PI / 180) * 40) + ' ' + (-Math.cos(layer.percentage * 3.6 * Math.PI / 180) * 40)"
[style.stroke]="layer.color"
[style.transform]="'rotate(' + layer.startAngle + 'deg)'"
(click)="onSelectLayer(layer.digest)"
/>
}
</svg>
<span class="donut-center">
{{ analysis().layers.length }} layers
</span>
</div>
<div class="layer-legend">
@for (layer of analysis().layers; track layer.digest) {
<button
class="legend-item"
(click)="onSelectLayer(layer.digest)"
>
<span class="legend-color" [style.background]="layerDonutData()[analysis().layers.indexOf(layer)]?.color"></span>
<span class="legend-label" [title]="layer.command">
{{ formatPath(layer.command, 20) }}
</span>
<span class="legend-value">{{ layer.opaqueByteRatio | number:'1.0-0' }}% opaque</span>
</button>
}
</div>
</div>
</section>
<!-- High Entropy Files Heatmap -->
<section class="section files-section">
<h4 class="section-title">
High Entropy Files
<span class="count-badge">{{ analysis().highEntropyFiles.length }}</span>
</h4>
@if (topHighEntropyFiles().length === 0) {
<p class="empty-state">No high entropy files detected</p>
} @else {
<div class="files-heatmap">
@for (file of topHighEntropyFiles(); track file.path) {
<button
class="file-item"
[class]="getEntropyClass(file.entropy)"
(click)="onSelectFile(file.path)"
>
<span class="file-icon">{{ getClassificationIcon(file.classification) }}</span>
<div class="file-info">
<span class="file-path" [title]="file.path">{{ formatPath(file.path) }}</span>
<div class="file-meta">
<span class="file-size">{{ formatBytes(file.size) }}</span>
<span class="file-class">{{ file.classification }}</span>
</div>
</div>
<div class="entropy-bar-container">
<div class="entropy-bar" [style.width]="getEntropyBarWidth(file.entropy)"></div>
<span class="entropy-value">{{ file.entropy | number:'1.2-2' }} bits</span>
</div>
</button>
}
</div>
@if (analysis().highEntropyFiles.length > 10) {
<p class="more-files">
+ {{ analysis().highEntropyFiles.length - 10 }} more files
</p>
}
}
</section>
<!-- Why Risky? Detector Hints -->
<section class="section hints-section">
<h4 class="section-title">Why Risky?</h4>
@if (analysis().detectorHints.length === 0) {
<p class="empty-state">No specific risks detected</p>
} @else {
<div class="hint-chips">
@for (group of detectorHintsByType(); track group.type) {
<button class="hint-chip" [class]="'severity-' + group.maxSeverity">
<span class="chip-icon">{{ getHintTypeIcon(group.type) }}</span>
<span class="chip-label">{{ group.type | titlecase }}</span>
<span class="chip-count">{{ group.count }}</span>
</button>
}
</div>
<ul class="hints-list">
@for (hint of analysis().detectorHints.slice(0, 5); track hint.id) {
<li class="hint-item" [class]="'severity-' + hint.severity">
<div class="hint-header">
<span class="hint-icon">{{ getHintTypeIcon(hint.type) }}</span>
<span class="hint-type">{{ hint.type | titlecase }}</span>
<span class="hint-confidence">{{ hint.confidence }}% confidence</span>
</div>
<p class="hint-desc">{{ hint.description }}</p>
<p class="hint-remediation">
<strong>Fix:</strong> {{ hint.remediation }}
</p>
@if (hint.affectedPaths.length > 0) {
<details class="affected-paths">
<summary>{{ hint.affectedPaths.length }} affected file(s)</summary>
<ul>
@for (path of hint.affectedPaths.slice(0, 3); track path) {
<li><code>{{ formatPath(path, 50) }}</code></li>
}
@if (hint.affectedPaths.length > 3) {
<li class="more">+ {{ hint.affectedPaths.length - 3 }} more</li>
}
</ul>
</details>
}
</li>
}
</ul>
@if (analysis().detectorHints.length > 5) {
<p class="more-hints">
+ {{ analysis().detectorHints.length - 5 }} more hints in report
</p>
}
}
</section>
</div>
<footer class="panel-footer">
<span class="analyzed-at">Analyzed {{ analysis().analyzedAt | date:'medium' }}</span>
</footer>
</div>

View File

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

View File

@@ -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<EntropyAnalysis>();
/** Emits when user wants to view raw report */
readonly viewReport = output<void>();
/** Emits when user clicks on a layer */
readonly selectLayer = output<string>();
/** Emits when user clicks on a file */
readonly selectFile = output<string>();
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<string, DetectorHint[]>();
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);
}
}

View File

@@ -0,0 +1,200 @@
<div class="entropy-policy-banner" [class]="bannerClass()">
<!-- Main Banner -->
<div class="banner-main">
<span class="banner-icon">{{ bannerIcon() }}</span>
<div class="banner-content">
<h4 class="banner-title">{{ bannerTitle() }}</h4>
<p class="banner-message">{{ bannerMessage() }}</p>
</div>
<div class="banner-actions">
@if (config().reportUrl) {
<button class="btn-secondary" (click)="onDownloadReport()">
Download Report
</button>
}
<button class="btn-secondary" (click)="onViewAnalysis()">
View Analysis
</button>
@if (config().action !== 'allow') {
<button
class="btn-expand"
(click)="toggleExpanded()"
[attr.aria-expanded]="expanded()"
>
{{ expanded() ? 'Hide' : 'Show' }} Details
</button>
}
</div>
</div>
<!-- Score Visualization -->
<div class="score-visualization">
<div class="score-bar">
<div class="score-track">
<!-- Zone backgrounds -->
<div class="zone allow" [style.width]="warnPercentage() + '%'"></div>
<div
class="zone warn"
[style.left]="warnPercentage() + '%'"
[style.width]="(blockPercentage() - warnPercentage()) + '%'"
></div>
<div
class="zone block"
[style.left]="blockPercentage() + '%'"
[style.width]="(100 - blockPercentage()) + '%'"
></div>
<!-- Threshold markers -->
<div class="threshold-line warn" [style.left]="warnPercentage() + '%'">
<span class="threshold-label">Warn</span>
</div>
<div class="threshold-line block" [style.left]="blockPercentage() + '%'">
<span class="threshold-label">Block</span>
</div>
<!-- Current score marker -->
<div
class="score-marker"
[style.left]="scorePercentage() + '%'"
[class]="'action-' + config().action"
>
<span class="score-value">{{ config().currentScore | number:'1.1-1' }}</span>
</div>
</div>
<div class="scale-labels">
<span>0</span>
<span>2</span>
<span>4</span>
<span>6</span>
<span>8</span>
<span>10</span>
</div>
</div>
<div class="score-legend">
<span class="legend-item allow">
<span class="legend-dot"></span>
Allow (&lt; {{ config().warnThreshold }})
</span>
<span class="legend-item warn">
<span class="legend-dot"></span>
Warn ({{ config().warnThreshold }} - {{ config().blockThreshold }})
</span>
<span class="legend-item block">
<span class="legend-dot"></span>
Block (&gt; {{ config().blockThreshold }})
</span>
</div>
</div>
<!-- Expanded Details -->
@if (expanded()) {
<div class="banner-details">
<!-- Policy Info -->
<div class="policy-info">
<h5 class="section-title">Policy Information</h5>
<dl class="info-grid">
<div class="info-item">
<dt>Policy</dt>
<dd>{{ config().policyName }}</dd>
</div>
<div class="info-item">
<dt>Policy ID</dt>
<dd><code>{{ config().policyId }}</code></dd>
</div>
<div class="info-item">
<dt>Warn Threshold</dt>
<dd>{{ config().warnThreshold }} / 10</dd>
</div>
<div class="info-item">
<dt>Block Threshold</dt>
<dd>{{ config().blockThreshold }} / 10</dd>
</div>
<div class="info-item">
<dt>High Entropy Files</dt>
<dd>{{ config().highEntropyFileCount }} files</dd>
</div>
</dl>
</div>
<!-- Threshold Explanation -->
<div class="threshold-explanation">
<h5 class="section-title">Understanding Entropy Thresholds</h5>
<div class="explanation-content">
<p>
<strong>Entropy</strong> measures the randomness of data in files. High entropy often indicates:
</p>
<ul class="entropy-indicators">
<li><span class="indicator-icon">[E]</span> Encrypted content</li>
<li><span class="indicator-icon">[C]</span> Compressed files (zip, gz, etc.)</li>
<li><span class="indicator-icon">[B]</span> Binary executables</li>
<li><span class="indicator-icon">[S]</span> Potential secrets or credentials</li>
<li><span class="indicator-icon">[O]</span> Obfuscated or packed code</li>
</ul>
<p class="explanation-note">
While some high-entropy content is legitimate (fonts, images, compressed assets),
unexpected high entropy may indicate security concerns requiring review.
</p>
</div>
</div>
<!-- Mitigation Steps -->
@if (config().action !== 'allow') {
<div class="mitigation-section">
<h5 class="section-title">Mitigation Steps</h5>
<div class="mitigation-list">
@for (step of effectiveMitigationSteps(); track step.id) {
<div class="mitigation-card">
<div class="mitigation-header">
<span class="mitigation-title">{{ step.title }}</span>
<div class="mitigation-badges">
<span class="badge impact" [class]="'impact-' + step.impact">
{{ getImpactLabel(step.impact) }}
</span>
<span class="badge effort">{{ getEffortLabel(step.effort) }}</span>
</div>
</div>
<p class="mitigation-desc">{{ step.description }}</p>
@if (step.command) {
<div class="mitigation-command">
<code>{{ step.command }}</code>
<button class="btn-run" (click)="onRunMitigation(step)">
Run
</button>
</div>
}
@if (step.docsUrl) {
<a class="docs-link" [href]="step.docsUrl" target="_blank">
Learn more ->
</a>
}
</div>
}
</div>
</div>
}
<!-- Report Download -->
@if (config().reportUrl) {
<div class="report-section">
<h5 class="section-title">Raw Evidence</h5>
<div class="report-info">
<span class="report-label">entropy.report.json</span>
<p class="report-desc">
Download the full entropy analysis report containing per-file entropy scores,
detector findings, and detailed metrics.
</p>
<button class="btn-download" (click)="onDownloadReport()">
Download entropy.report.json
</button>
</div>
</div>
}
</div>
}
</div>

View File

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

View File

@@ -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<EntropyPolicyConfig>();
/** Custom mitigation steps */
readonly mitigationSteps = input<EntropyMitigationStep[]>([]);
/** Emits when user wants to download entropy report */
readonly downloadReport = output<string>();
/** Emits when user runs a mitigation command */
readonly runMitigation = output<EntropyMitigationStep>();
/** Emits when user wants to view detailed analysis */
readonly viewAnalysis = output<void>();
/** 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 '';
}
}
}

View File

@@ -0,0 +1,277 @@
<div class="policy-gate-indicator" [class]="statusClass()" [class.compact]="compact()">
<!-- Status Banner -->
<div class="status-banner">
<span class="status-icon">{{ statusIcon() }}</span>
<div class="status-info">
<span class="status-label">{{ statusLabel() }}</span>
<span class="gate-summary">
{{ passedGates().length }}/{{ gateStatus().gates.length }} gates passed
@if (warningGates().length > 0) {
<span class="warning-count">({{ warningGates().length }} warnings)</span>
}
</span>
</div>
<div class="status-actions">
@if (!gateStatus().canPublish && gateStatus().remediationHints.length > 0) {
<button class="btn-remediation" (click)="toggleRemediation()">
{{ showRemediation() ? 'Hide' : 'Show' }} Fixes
</button>
}
<button
class="btn-publish"
[disabled]="!gateStatus().canPublish"
(click)="onPublish()"
[title]="gateStatus().blockReason || 'Publish artifact'"
>
@if (gateStatus().canPublish) {
Publish
} @else {
Blocked
}
</button>
</div>
</div>
<!-- Block Reason Banner -->
@if (!gateStatus().canPublish && gateStatus().blockReason) {
<div class="block-banner">
<span class="block-icon">[!]</span>
<span class="block-message">{{ gateStatus().blockReason }}</span>
</div>
}
<!-- Gate List -->
@if (!compact()) {
<div class="gates-list">
@for (gate of gateStatus().gates; track gate.gateId) {
<div class="gate-item" [class]="'result-' + gate.result">
<button
class="gate-header"
(click)="toggleGate(gate.gateId)"
[attr.aria-expanded]="expandedGate() === gate.gateId"
>
<span class="gate-type-icon">{{ getGateIcon(gate.type) }}</span>
<span class="result-icon">{{ getResultIcon(gate.result) }}</span>
<div class="gate-info">
<span class="gate-name">{{ gate.name }}</span>
@if (gate.required) {
<span class="required-badge">Required</span>
}
</div>
<span class="expand-icon" [class.expanded]="expandedGate() === gate.gateId">v</span>
</button>
@if (expandedGate() === gate.gateId) {
<div class="gate-details">
<!-- Determinism Gate Details -->
@if (gate.type === 'determinism' && getDeterminismDetails(gate)) {
@let det = getDeterminismDetails(gate)!;
<div class="determinism-details">
<div class="detail-row">
<span class="detail-label">Merkle Root</span>
<span class="detail-value" [class.mismatch]="!det.merkleRootConsistent">
@if (det.merkleRootConsistent) {
<span class="match-icon">[+]</span> Consistent
} @else {
<span class="mismatch-icon">[x]</span> Mismatch
}
</span>
</div>
@if (!det.merkleRootConsistent) {
<div class="hash-comparison">
<div class="hash-row">
<span class="hash-label">Expected:</span>
<code class="hash">{{ formatHash(det.expectedMerkleRoot, 24) }}</code>
</div>
<div class="hash-row">
<span class="hash-label">Computed:</span>
<code class="hash mismatch">{{ formatHash(det.computedMerkleRoot, 24) }}</code>
</div>
</div>
}
<div class="detail-row">
<span class="detail-label">Fragments</span>
<span class="detail-value">
{{ det.matchingFragments }}/{{ det.totalFragments }} verified
</span>
</div>
<div class="detail-row">
<span class="detail-label">Composition File</span>
<span class="detail-value" [class.missing]="!det.compositionPresent">
{{ det.compositionPresent ? 'Present' : 'Missing' }}
</span>
</div>
@if (det.fragmentResults.length > 0) {
<details class="fragment-details">
<summary>Fragment Verification ({{ det.fragmentResults.length }})</summary>
<ul class="fragment-list">
@for (frag of det.fragmentResults.slice(0, 5); track frag.fragmentId) {
<li [class.mismatch]="!frag.match">
<span class="frag-id">{{ frag.fragmentId }}</span>
<span class="frag-status">{{ frag.match ? '+' : 'x' }}</span>
</li>
}
@if (det.fragmentResults.length > 5) {
<li class="more">+ {{ det.fragmentResults.length - 5 }} more</li>
}
</ul>
</details>
}
</div>
}
<!-- Entropy Gate Details -->
@if (gate.type === 'entropy' && getEntropyDetails(gate)) {
@let ent = getEntropyDetails(gate)!;
<div class="entropy-details">
<div class="detail-row">
<span class="detail-label">Entropy Score</span>
<span class="detail-value score" [class]="'action-' + ent.action">
{{ ent.entropyScore | number:'1.1-1' }} / 10
</span>
</div>
<div class="threshold-bar">
<div class="threshold-track">
<div class="threshold-marker warn" [style.left]="(ent.warnThreshold / 10 * 100) + '%'"></div>
<div class="threshold-marker block" [style.left]="(ent.blockThreshold / 10 * 100) + '%'"></div>
<div class="score-marker" [style.left]="(ent.entropyScore / 10 * 100) + '%'"></div>
</div>
<div class="threshold-labels">
<span>0</span>
<span class="warn-label">Warn ({{ ent.warnThreshold }})</span>
<span class="block-label">Block ({{ ent.blockThreshold }})</span>
<span>10</span>
</div>
</div>
<div class="detail-row">
<span class="detail-label">High Entropy Files</span>
<span class="detail-value">{{ ent.highEntropyFileCount }}</span>
</div>
@if (ent.suspiciousPatterns.length > 0) {
<div class="suspicious-patterns">
<span class="detail-label">Suspicious Patterns</span>
<ul class="pattern-list">
@for (pattern of ent.suspiciousPatterns; track pattern) {
<li>{{ pattern }}</li>
}
</ul>
</div>
}
</div>
}
<!-- Evidence Links -->
@if (gate.evidenceRefs && gate.evidenceRefs.length > 0) {
<div class="evidence-links">
<span class="evidence-label">Evidence:</span>
@for (ref of gate.evidenceRefs; track ref) {
<button class="evidence-link" (click)="onViewEvidence(ref)">
{{ ref | slice:0:20 }}...
</button>
}
</div>
}
<!-- Gate-specific Remediation -->
@if (gate.result === 'failed') {
@let hints = getHintsForGate(gate.gateId);
@if (hints.length > 0) {
<div class="gate-remediation">
<h5 class="remediation-title">How to Fix</h5>
@for (hint of hints; track hint.title) {
<div class="hint-card">
<div class="hint-header">
<span class="hint-title">{{ hint.title }}</span>
@if (hint.effort) {
<span class="effort-badge">{{ getEffortLabel(hint.effort) }}</span>
}
</div>
<ol class="hint-steps">
@for (step of hint.steps; track step) {
<li>{{ step }}</li>
}
</ol>
@if (hint.cliCommand) {
<div class="cli-command">
<code>{{ hint.cliCommand }}</code>
<button class="btn-run" (click)="onRunRemediation(hint)">Run</button>
</div>
}
@if (hint.docsUrl) {
<a class="docs-link" [href]="hint.docsUrl" target="_blank">
Documentation ->
</a>
}
</div>
}
</div>
}
}
</div>
}
</div>
}
</div>
}
<!-- Blocking Issues Summary -->
@if (gateStatus().blockingIssues.length > 0) {
<div class="blocking-issues">
<h4 class="issues-title">Blocking Issues ({{ gateStatus().blockingIssues.length }})</h4>
<ul class="issues-list">
@for (issue of gateStatus().blockingIssues; track issue.code) {
<li class="issue-item" [class]="'severity-' + issue.severity">
<span class="issue-code">{{ issue.code }}</span>
<span class="issue-message">{{ issue.message }}</span>
@if (issue.resource) {
<span class="issue-resource">{{ issue.resource }}</span>
}
</li>
}
</ul>
</div>
}
<!-- Remediation Panel -->
@if (showRemediation() && gateStatus().remediationHints.length > 0) {
<div class="remediation-panel">
<h4 class="remediation-panel-title">Remediation Steps</h4>
@for (hint of gateStatus().remediationHints; track hint.title) {
<div class="remediation-card">
<div class="remediation-header">
<span class="remediation-for">{{ hint.forGate }}</span>
<span class="remediation-title">{{ hint.title }}</span>
@if (hint.effort) {
<span class="effort-badge">{{ getEffortLabel(hint.effort) }}</span>
}
</div>
<ol class="remediation-steps">
@for (step of hint.steps; track step) {
<li>{{ step }}</li>
}
</ol>
@if (hint.cliCommand) {
<div class="cli-command">
<code>{{ hint.cliCommand }}</code>
<button class="btn-run" (click)="onRunRemediation(hint)">Run</button>
</div>
}
</div>
}
</div>
}
<!-- Footer -->
<footer class="indicator-footer">
<span class="eval-id">Evaluation: {{ gateStatus().evaluationId | slice:0:12 }}</span>
<span class="eval-time">{{ gateStatus().evaluatedAt | date:'medium' }}</span>
</footer>
</div>

View File

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

View File

@@ -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<PolicyGateStatus>();
/** Show compact view */
readonly compact = input(false);
/** Emits when user clicks publish (if allowed) */
readonly publish = output<string>();
/** Emits when user wants to view evidence */
readonly viewEvidence = output<string>();
/** Emits when user wants to run remediation */
readonly runRemediation = output<PolicyRemediationHint>();
/** Currently expanded gate */
readonly expandedGate = signal<string | null>(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);
}
}