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 a1183e7a65
53 changed files with 9481 additions and 49 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,8 +28,8 @@
## 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. |
| 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 | 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. |
@@ -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,9 @@
| 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 |

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: () =>
@@ -15,26 +22,26 @@ export const routes: Routes = [
(m) => m.TrivyDbSettingsPageComponent
),
},
{
path: 'scans/:scanId',
loadComponent: () =>
import('./features/scans/scan-detail-page.component').then(
(m) => m.ScanDetailPageComponent
),
},
{
path: 'welcome',
loadComponent: () =>
import('./features/welcome/welcome-page.component').then(
(m) => m.WelcomePageComponent
),
},
{
path: 'notify',
loadComponent: () =>
import('./features/notify/notify-panel.component').then(
(m) => m.NotifyPanelComponent
),
{
path: 'scans/:scanId',
loadComponent: () =>
import('./features/scans/scan-detail-page.component').then(
(m) => m.ScanDetailPageComponent
),
},
{
path: 'welcome',
loadComponent: () =>
import('./features/welcome/welcome-page.component').then(
(m) => m.WelcomePageComponent
),
},
{
path: 'notify',
loadComponent: () =>
import('./features/notify/notify-panel.component').then(
(m) => m.NotifyPanelComponent
),
},
{
path: 'auth/callback',

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