diff --git a/EXECPLAN.md b/EXECPLAN.md index edf5713b..048e5702 100644 --- a/EXECPLAN.md +++ b/EXECPLAN.md @@ -76,14 +76,14 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster - Team Team Excititor Connectors – Ubuntu: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Excititor.Connectors.Ubuntu.CSAF/TASKS.md`. Focus on EXCITITOR-CONN-UBUNTU-01-003 (TODO). Confirm prerequisites (internal: EXCITITOR-CONN-UBUNTU-01-002 (Wave 0); external: EXCITITOR-POLICY-01-001) before starting and report status in module TASKS.md. - Team Team Excititor Export: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Excititor.Export/TASKS.md`. Focus on EXCITITOR-EXPORT-01-006 (DONE 2025-10-21). Confirm prerequisites (internal: EXCITITOR-EXPORT-01-005 (Wave 0), POLICY-CORE-09-005 (Wave 0)) before starting and report status in module TASKS.md. - Team Team Excititor Worker: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Excititor.Worker/TASKS.md`. Focus on EXCITITOR-WORKER-01-003 (TODO). Confirm prerequisites (internal: EXCITITOR-ATTEST-01-003 (Wave 0); external: EXCITITOR-EXPORT-01-002, EXCITITOR-WORKER-01-001) before starting and report status in module TASKS.md. -- Team UI Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.UI/TASKS.md`. Focus on UI-ATTEST-11-005 (DONE 2025-10-23), UI-VEX-13-003 (TODO), UI-POLICY-13-007 (TODO), UI-ADMIN-13-004 (TODO), UI-AUTH-13-001 (DONE 2025-10-23), UI-SCANS-13-002 (TODO), UI-NOTIFY-13-006 (DOING 2025-10-19), UI-SCHED-13-005 (TODO). Confirm prerequisites (internal: ATTESTOR-API-11-201 (Wave 0), AUTH-DPOP-11-001 (Wave 0), AUTH-MTLS-11-002 (Wave 0), EXCITITOR-EXPORT-01-005 (Wave 0), NOTIFY-WEB-15-101 (Wave 0), POLICY-CORE-09-006 (Wave 0), SCHED-WEB-16-101 (Wave 0), SIGNER-API-11-101 (Wave 0); external: EXCITITOR-CORE-02-001, SCANNER-WEB-09-102, SCANNER-WEB-09-103) before starting and report status in module TASKS.md. +- Team UI Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.UI/TASKS.md`. Focus on UI-ATTEST-11-005 (DONE 2025-10-23), UI-VEX-13-003 (TODO), UI-POLICY-13-007 (TODO), UI-ADMIN-13-004 (TODO), UI-AUTH-13-001 (DONE 2025-10-23), UI-SCANS-13-002 (TODO), UI-NOTIFY-13-006 (DONE 2025-10-25), UI-SCHED-13-005 (TODO). Confirm prerequisites (internal: ATTESTOR-API-11-201 (Wave 0), AUTH-DPOP-11-001 (Wave 0), AUTH-MTLS-11-002 (Wave 0), EXCITITOR-EXPORT-01-005 (Wave 0), NOTIFY-WEB-15-101 (Wave 0), POLICY-CORE-09-006 (Wave 0), SCHED-WEB-16-101 (Wave 0), SIGNER-API-11-101 (Wave 0); external: EXCITITOR-CORE-02-001, SCANNER-WEB-09-102, SCANNER-WEB-09-103) before starting and report status in module TASKS.md. - Team Zastava Observer Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Zastava.Observer/TASKS.md`. Focus on ZASTAVA-OBS-12-001 (DONE 2025-10-24). Confirm prerequisites (internal: ZASTAVA-CORE-12-201 (Wave 0)) before starting and report status in module TASKS.md. ### Wave 2 - Team Bench Guild, Notify Team: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `bench/TASKS.md`. Focus on BENCH-NOTIFY-15-001 (TODO). Confirm prerequisites (internal: NOTIFY-ENGINE-15-301 (Wave 1)) before starting and report status in module TASKS.md. - Team Bench Guild, Scheduler Team: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `bench/TASKS.md`. Focus on BENCH-IMPACT-16-001 (TODO). Confirm prerequisites (internal: SCHED-IMPACT-16-301 (Wave 1)) before starting and report status in module TASKS.md. - Team Deployment Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `ops/deployment/TASKS.md`. Focus on DEVOPS-OPS-14-003 (TODO). Confirm prerequisites (internal: DEVOPS-REL-14-001 (Wave 1)) before starting and report status in module TASKS.md. -- Team DevOps Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `ops/devops/TASKS.md`. Focus on DEVOPS-MIRROR-08-001 (DONE 2025-10-19), DEVOPS-PERF-10-002 (TODO), DEVOPS-REL-14-004 (TODO), DEVOPS-REL-17-002 (TODO), DEVOPS-NUGET-13-001 (DOING 2025-10-24), and DEVOPS-UI-13-006 (TODO). Confirm prerequisites (internal: BENCH-SCANNER-10-002 (Wave 1), DEVOPS-REL-14-001 (Wave 1), SCANNER-EMIT-17-701 (Wave 1), UI-AUTH-13-001 (Wave 1)) before starting and report status in module TASKS.md. +- Team DevOps Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `ops/devops/TASKS.md`. Focus on DEVOPS-MIRROR-08-001 (DONE 2025-10-19), DEVOPS-PERF-10-002 (TODO), DEVOPS-REL-14-004 (TODO), DEVOPS-REL-17-002 (TODO), DEVOPS-NUGET-13-001 (DONE 2025-10-25), and DEVOPS-UI-13-006 (TODO). Confirm prerequisites (internal: BENCH-SCANNER-10-002 (Wave 1), DEVOPS-REL-14-001 (Wave 1), SCANNER-EMIT-17-701 (Wave 1), UI-AUTH-13-001 (Wave 1)) before starting and report status in module TASKS.md. - Team DevOps Guild, Notify Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `ops/devops/TASKS.md`. Focus on DEVOPS-SCANNER-09-205 (TODO). Confirm prerequisites (internal: DEVOPS-SCANNER-09-204 (Wave 1)) before starting and report status in module TASKS.md. - Team Notify Engine Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `src/StellaOps.Notify.Engine/TASKS.md`. Focus on NOTIFY-ENGINE-15-302 (TODO). Confirm prerequisites (internal: NOTIFY-ENGINE-15-301 (Wave 1)) before starting and report status in module TASKS.md. - Team Notify Queue Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `src/StellaOps.Notify.Queue/TASKS.md`. Focus on NOTIFY-QUEUE-15-403 (DONE 2025-10-23), NOTIFY-QUEUE-15-402 (DONE 2025-10-23). Confirm prerequisites (internal: NOTIFY-QUEUE-15-401 (Wave 1)) before starting and report status in module TASKS.md. @@ -108,7 +108,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster - Team Notify Worker Guild: read EXECPLAN.md Wave 3 and SPRINTS.md rows for `src/StellaOps.Notify.Worker/TASKS.md`. Focus on NOTIFY-WORKER-15-203 (TODO). Confirm prerequisites (internal: NOTIFY-ENGINE-15-302 (Wave 2)) before starting and report status in module TASKS.md. - Team Scheduler Worker Guild: read EXECPLAN.md Wave 3 and SPRINTS.md rows for `src/StellaOps.Scheduler.Worker/TASKS.md`. Focus on SCHED-WORKER-16-203 (TODO). Confirm prerequisites (internal: SCHED-WORKER-16-202 (Wave 2)) before starting and report status in module TASKS.md. - Team TBD: read EXECPLAN.md Wave 3 and SPRINTS.md rows for `src/StellaOps.Scanner.Analyzers.Lang.DotNet/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Go/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Node/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Python/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Rust/TASKS.md`. SCANNER-ANALYZERS-LANG-10-305C/304C/309N/303C/306C are all DONE (latest 2025-10-22); remaining Wave 3 attention shifts to 10-307* helper consolidation and subsequent benchmarking tickets. Confirm prerequisites (internal: SCANNER-ANALYZERS-LANG-10-303B (Wave 2), SCANNER-ANALYZERS-LANG-10-304B (Wave 2), SCANNER-ANALYZERS-LANG-10-305B (Wave 2), SCANNER-ANALYZERS-LANG-10-306B (Wave 2), SCANNER-ANALYZERS-LANG-10-308N (Wave 2)) before scheduling new work and report status in module TASKS.md. -- Team Zastava Observer Guild: read EXECPLAN.md Wave 3 and SPRINTS.md rows for `src/StellaOps.Zastava.Observer/TASKS.md`. ZASTAVA-OBS-12-003 closed (DONE 2025-10-24); ZASTAVA-OBS-12-004 (DONE 2025-10-24) delivered disk-backed batching. Remaining focus shifts to ZASTAVA-OBS-17-005 (DOING 2025-10-24). Confirm prerequisites (internal: ZASTAVA-OBS-12-002 (Wave 2)) before starting and keep TASKS.md in sync. +- Team Zastava Observer Guild: read EXECPLAN.md Wave 3 and SPRINTS.md rows for `src/StellaOps.Zastava.Observer/TASKS.md`. ZASTAVA-OBS-17-005 closed (DONE 2025-10-25); observers now emit buildIds for ELF workloads. Monitor backlog for new reachability/doc tasks and keep TASKS.md in sync. ### Wave 4 - Team DevEx/CLI: read EXECPLAN.md Wave 4 and SPRINTS.md rows for `src/StellaOps.Cli/TASKS.md`. Focus on CLI-PLUGIN-13-007 (DONE 2025-10-22). Confirm prerequisites (internal: CLI-OFFLINE-13-006 (Wave 3), CLI-RUNTIME-13-005 (Wave 0)) before starting and report status in module TASKS.md. @@ -124,7 +124,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster ### Wave 5 - Team Excititor Connectors – Stella: read EXECPLAN.md Wave 5 and SPRINTS.md rows for `src/StellaOps.Excititor.Connectors.StellaOpsMirror/TASKS.md`. Focus on EXCITITOR-CONN-STELLA-07-003 (TODO). Confirm prerequisites (internal: EXCITITOR-CONN-STELLA-07-002 (Wave 4)) before starting and report status in module TASKS.md. - Team Notify Connectors Guild: read EXECPLAN.md Wave 5 and SPRINTS.md rows for `src/StellaOps.Notify.Connectors.Email/TASKS.md`, `src/StellaOps.Notify.Connectors.Slack/TASKS.md`, `src/StellaOps.Notify.Connectors.Teams/TASKS.md`, `src/StellaOps.Notify.Connectors.Webhook/TASKS.md`. Focus on NOTIFY-CONN-SLACK-15-502 (DONE), NOTIFY-CONN-TEAMS-15-602 (DONE), NOTIFY-CONN-EMAIL-15-702 (BLOCKED 2025-10-20), NOTIFY-CONN-WEBHOOK-15-802 (BLOCKED 2025-10-20). Confirm prerequisites (internal: NOTIFY-CONN-EMAIL-15-701 (Wave 4), NOTIFY-CONN-SLACK-15-501 (Wave 4), NOTIFY-CONN-TEAMS-15-601 (Wave 4), NOTIFY-CONN-WEBHOOK-15-801 (Wave 4)) before starting and report status in module TASKS.md. -- Team Scanner WebService Guild: read EXECPLAN.md Wave 5 and SPRINTS.md rows for `src/StellaOps.Scanner.WebService/TASKS.md`. Focus on SCANNER-RUNTIME-17-401 (DOING 2025-10-24). Confirm prerequisites (internal: POLICY-RUNTIME-17-201 (Wave 4), SCANNER-EMIT-17-701 (Wave 1), SCANNER-RUNTIME-12-301 (Wave 1), ZASTAVA-OBS-17-005 (Wave 3, DOING 2025-10-24)) before starting and report status in module TASKS.md. +- Team Scanner WebService Guild: read EXECPLAN.md Wave 5 and SPRINTS.md rows for `src/StellaOps.Scanner.WebService/TASKS.md`. SCANNER-RUNTIME-17-401 closed 2025-10-25 with build-id persistence + policy/CLI exposure; monitor downstream dependencies (POLICY-RUNTIME-17-201, DEVOPS-REL-17-002) for reachability/debug-store follow-ups. - Team TBD: read EXECPLAN.md Wave 5 and SPRINTS.md rows for `src/StellaOps.Scanner.Analyzers.Lang.DotNet/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Go/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Python/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Rust/TASKS.md`. SCANNER-ANALYZERS-LANG-10-308D/G/P completed (2025-10-23/2025-10-22/2025-10-23); pending items are SCANNER-ANALYZERS-LANG-10-308R (TODO). Confirm prerequisites (internal: SCANNER-ANALYZERS-LANG-10-307D (Wave 4), SCANNER-ANALYZERS-LANG-10-307G (Wave 4), SCANNER-ANALYZERS-LANG-10-307P (Wave 4), SCANNER-ANALYZERS-LANG-10-307R (Wave 4)) before starting and report status in module TASKS.md. ### Wave 6 @@ -644,7 +644,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster 5. [TODO] UI-SCANS-13-002 — Build scans module (list/detail/SBOM/diff/attestation) with performance + accessibility targets. • Prereqs: SCANNER-WEB-09-102 (external/completed), SIGNER-API-11-101 (Wave 0) • Current: TODO - 6. [DOING] UI-NOTIFY-13-006 — Notify panel: channels/rules CRUD, deliveries view, test send integration. + 6. [DONE 2025-10-25] UI-NOTIFY-13-006 — Notify panel: channels/rules CRUD, deliveries view, test send integration. • Prereqs: NOTIFY-WEB-15-101 (Wave 0) • Current: TODO 7. [TODO] UI-SCHED-13-005 — Scheduler panel: schedules CRUD, run history, dry-run preview using API/mocks. @@ -653,7 +653,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster - **Sprint 13** · Platform Reliability - Team: DevOps Guild, Platform Leads - Path: `ops/devops/TASKS.md` - 1. [DOING 2025-10-24] DEVOPS-NUGET-13-001 — Add .NET 10 preview feeds/local mirrors so `dotnet restore` succeeds offline; document updated NuGet bootstrap. + 1. [DONE 2025-10-25] DEVOPS-NUGET-13-001 — Add .NET 10 preview feeds/local mirrors so `dotnet restore` succeeds offline; document updated NuGet bootstrap. • Prereqs: DEVOPS-REL-14-001 (Wave 1) • Current: DOING – Mirror preview packages into Offline Kit/allowlisted feeds, update NuGet.config mapping, and refresh restore documentation. 2. [TODO] DEVOPS-UI-13-006 — Add Playwright-based UI auth smoke job to CI/offline pipelines, wiring sample `/config.json` provisioning and reporting. @@ -930,9 +930,9 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster - **Sprint 17** · Symbol Intelligence & Forensics - Team: Zastava Observer Guild - Path: `src/StellaOps.Zastava.Observer/TASKS.md` - 1. [DOING (2025-10-24)] ZASTAVA-OBS-17-005 — Collect GNU build-id for ELF processes and attach it to emitted runtime events to enable symbol lookup + debug-store correlation. + 1. [DONE (2025-10-25)] ZASTAVA-OBS-17-005 — Collect GNU build-id for ELF processes and attach it to emitted runtime events to enable symbol lookup + debug-store correlation. • Prereqs: ZASTAVA-OBS-12-002 (Wave 2) - • Current: TODO + • Current: DONE — Build-id capture wired through RuntimeProcessCollector + RuntimeEventFactory; docs/runbook updated with debug-store workflow. ## Wave 4 — 15 task(s) ready after Wave 3 - **Sprint 7** · Contextual Truth Foundations @@ -945,7 +945,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster - Team: Policy Guild, Scanner WebService Guild - Path: `src/StellaOps.Policy/TASKS.md` 1. [TODO] POLICY-RUNTIME-17-201 — Define runtime reachability feed contract and alignment plan for `SCANNER-RUNTIME-17-401` once Zastava endpoints land; document policy expectations for reachability tags. - • Prereqs: ZASTAVA-OBS-17-005 (Wave 3 — DOING 2025-10-24) + • Prereqs: ZASTAVA-OBS-17-005 (Wave 3 — DONE 2025-10-25) • Current: TODO - **Sprint 10** · Backlog - Team: TBD @@ -1009,7 +1009,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster - Team: Docs Guild - Path: `docs/TASKS.md` 1. [TODO] DOCS-RUNTIME-17-004 — Document build-id workflows: SBOM exposure, runtime event payloads, debug-store layout, and operator guidance for symbol retrieval. - • Prereqs: SCANNER-EMIT-17-701 (Wave 1), ZASTAVA-OBS-17-005 (Wave 3 — DOING 2025-10-24), DEVOPS-REL-17-002 (Wave 2) + • Prereqs: SCANNER-EMIT-17-701 (Wave 1), ZASTAVA-OBS-17-005 (Wave 3 — DONE 2025-10-25), DEVOPS-REL-17-002 (Wave 2) • Current: TODO ## Wave 5 — 10 task(s) ready after Wave 4 @@ -1052,9 +1052,9 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster - **Sprint 17** · Symbol Intelligence & Forensics - Team: Scanner WebService Guild - Path: `src/StellaOps.Scanner.WebService/TASKS.md` - 1. [TODO] SCANNER-RUNTIME-17-401 — Persist runtime build-id observations and expose them via `/runtime/events` + policy joins for debug-symbol correlation. - • Prereqs: SCANNER-RUNTIME-12-301 (Wave 1), ZASTAVA-OBS-17-005 (Wave 3 — DOING 2025-10-24), SCANNER-EMIT-17-701 (Wave 1), POLICY-RUNTIME-17-201 (Wave 4) - • Current: TODO + 1. [DONE 2025-10-25] SCANNER-RUNTIME-17-401 — Persist runtime build-id observations and expose them via `/runtime/events` + policy joins for debug-symbol correlation. + • Prereqs: SCANNER-RUNTIME-12-301 (Wave 1), ZASTAVA-OBS-17-005 (Wave 3 — DONE 2025-10-25), SCANNER-EMIT-17-701 (Wave 1), POLICY-RUNTIME-17-201 (Wave 4) + • Current: DONE — runtime events normalize digests/build IDs, policy responses/CLI emit `buildIds`, docs/tests updated for debug-store workflows. ## Wave 6 — 8 task(s) ready after Wave 5 - **Sprint 10** · Backlog diff --git a/NuGet.config b/NuGet.config index 0a26c71f..0578d091 100644 --- a/NuGet.config +++ b/NuGet.config @@ -6,6 +6,7 @@ + @@ -30,6 +31,11 @@ + + + + + diff --git a/SPRINTS.md b/SPRINTS.md index eaa44f07..4d6d1328 100644 --- a/SPRINTS.md +++ b/SPRINTS.md @@ -28,9 +28,9 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation | Sprint 13 | UX & CLI Experience | src/StellaOps.UI/TASKS.md | TODO | UI Guild | UI-VEX-13-003 | Implement VEX explorer + policy editor with preview integration. | | Sprint 13 | UX & CLI Experience | src/StellaOps.UI/TASKS.md | TODO | UI Guild | UI-ADMIN-13-004 | Deliver admin area (tenants/clients/quotas/licensing) with RBAC + audit hooks. | | Sprint 13 | UX & CLI Experience | src/StellaOps.UI/TASKS.md | TODO | UI Guild | UI-SCHED-13-005 | Scheduler panel: schedules CRUD, run history, dry-run preview. | -| Sprint 13 | UX & CLI Experience | src/StellaOps.UI/TASKS.md | DOING (2025-10-19) | UI Guild | UI-NOTIFY-13-006 | Notify panel: channels/rules CRUD, deliveries view, test send. | +| Sprint 13 | UX & CLI Experience | src/StellaOps.UI/TASKS.md | DONE (2025-10-25) | UI Guild | UI-NOTIFY-13-006 | Notify panel: channels/rules CRUD, deliveries view, test send. | | Sprint 13 | UX & CLI Experience | src/StellaOps.Cli/TASKS.md | TODO | DevEx/CLI | CLI-RUNTIME-13-005 | Add runtime policy test verbs that consume `/policy/runtime` and display verdicts. | -| Sprint 13 | Platform Reliability | ops/devops/TASKS.md | DOING (2025-10-24) | DevOps Guild, Platform Leads | DEVOPS-NUGET-13-001 | Wire up .NET 10 preview feeds/local mirrors so `dotnet restore` succeeds offline; document updated NuGet bootstrap. | +| Sprint 13 | Platform Reliability | ops/devops/TASKS.md | DONE (2025-10-25) | DevOps Guild, Platform Leads | DEVOPS-NUGET-13-001 | Wire up .NET 10 preview feeds/local mirrors so `dotnet restore` succeeds offline; document updated NuGet bootstrap. | | Sprint 13 | Platform Reliability | ops/devops/TASKS.md | TODO | DevOps Guild | DEVOPS-NUGET-13-002 | Ensure all solutions/projects prioritize `local-nuget` before public feeds and add restore-order validation. | | Sprint 13 | Platform Reliability | ops/devops/TASKS.md | TODO | DevOps Guild, Platform Leads | DEVOPS-NUGET-13-003 | Upgrade `Microsoft.*` dependencies pinned to 8.* to their latest .NET 10 (or 9.x) releases and refresh guidance. | | Sprint 13 | Platform Reliability | ops/devops/TASKS.md | TODO | DevOps Guild, UI Guild | DEVOPS-UI-13-006 | Add Playwright-based UI auth smoke job to CI/offline pipelines, wiring sample `/config.json` provisioning and reporting. | @@ -86,8 +86,8 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation | Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Worker/TASKS.md | TODO | Scheduler Worker Guild | SCHED-WORKER-16-205 | Metrics/telemetry for Scheduler planners/runners. | | Sprint 16 | Benchmarks | bench/TASKS.md | TODO | Bench Guild, Scheduler Team | BENCH-IMPACT-16-001 | ImpactIndex throughput bench + RAM profile. | | Sprint 17 | Symbol Intelligence & Forensics | src/StellaOps.Scanner.Emit/TASKS.md | TODO | Emit Guild | SCANNER-EMIT-17-701 | Record GNU build-id for ELF components and surface it in SBOM/diff outputs. | -| Sprint 17 | Symbol Intelligence & Forensics | src/StellaOps.Zastava.Observer/TASKS.md | DOING (2025-10-24) | Zastava Observer Guild | ZASTAVA-OBS-17-005 | Collect GNU build-id during runtime observation and attach it to emitted events. | -| Sprint 17 | Symbol Intelligence & Forensics | src/StellaOps.Scanner.WebService/TASKS.md | DOING (2025-10-24) | Scanner WebService Guild | SCANNER-RUNTIME-17-401 | Persist runtime build-id observations and expose them for debug-symbol correlation. | +| Sprint 17 | Symbol Intelligence & Forensics | src/StellaOps.Zastava.Observer/TASKS.md | DONE (2025-10-25) | Zastava Observer Guild | ZASTAVA-OBS-17-005 | Collect GNU build-id during runtime observation and attach it to emitted events. | +| Sprint 17 | Symbol Intelligence & Forensics | src/StellaOps.Scanner.WebService/TASKS.md | DONE (2025-10-25) | Scanner WebService Guild | SCANNER-RUNTIME-17-401 | Persist runtime build-id observations and expose them for debug-symbol correlation. | | Sprint 17 | Symbol Intelligence & Forensics | ops/devops/TASKS.md | TODO | DevOps Guild | DEVOPS-REL-17-002 | Ship stripped debug artifacts organised by build-id within release/offline kits. | | Sprint 17 | Symbol Intelligence & Forensics | docs/TASKS.md | TODO | Docs Guild | DOCS-RUNTIME-17-004 | Document build-id workflows for SBOMs, runtime events, and debug-store usage. | | Sprint 18 | Launch Readiness | ops/devops/TASKS.md | TODO | DevOps Guild | DEVOPS-LAUNCH-18-001 | Production launch cutover rehearsal and runbook publication (blocked on implementation sign-off and environment setup). | diff --git a/TODOS.md b/TODOS.md index 9efaf134..3986dc99 100644 --- a/TODOS.md +++ b/TODOS.md @@ -1,12 +1,5 @@ -# Current Focus – FEEDCONN-CERTCC -| Task | Status | Notes | -|---|---|---| -|FEEDCONN-CERTCC-02-005 Deterministic fixtures/tests|DONE (2025-10-11)|Snapshot regression for summary/detail fetch landed; fixtures regenerate via `UPDATE_CERTCC_FIXTURES`.| -|FEEDCONN-CERTCC-02-008 Snapshot coverage handoff|DONE (2025-10-11)|Fixtures + README guidance shipped; QA can rerun with `UPDATE_CERTCC_FIXTURES=1` and share recorded-request diff with Merge.| -|FEEDCONN-CERTCC-02-007 Connector test harness remediation|DONE (2025-10-11)|Harness now resets time provider, wires Source.Common, and verifies VINCE canned responses across fetch→parse→map.| -|FEEDCONN-CERTCC-02-009 Detail/map reintegration plan|DONE (2025-10-11)|Plan published in `src/StellaOps.Concelier.Connector.CertCc/FEEDCONN-CERTCC-02-009_PLAN.md`; outlines staged enablement + rollback.| - -# Connector Apple Status -| Task | Status | Notes | -|---|---|---| -|FEEDCONN-APPLE-02-003 Telemetry & documentation|DONE (2025-10-11)|Apple connector meter registered with WebService OpenTelemetry metrics; README and fixtures highlight normalizedVersions coverage for conflict sprint handoff.| +1. Concelier must never merge, just aggregate. The advisories must be linked, but their severity will be considered through policies from the Web part of the scanner. +2. Same for Excititor +3. Why Web and UI? +4. Consider all of functionality there is. Is the Cli for all of it? Is there a Web for all of it? Do we have Policy editor? Does the policy support VEX application rules? Advisories application Rules? Do have SBOM graphs explorer? Do we have good SBOM vulnerability explorer? Do we have advisory using AI? +5. Do we have build docker containers that are downloadable? page describign how to download and install? \ No newline at end of file diff --git a/docs/09_API_CLI_REFERENCE.md b/docs/09_API_CLI_REFERENCE.md index d5dd3c01..d08809db 100755 --- a/docs/09_API_CLI_REFERENCE.md +++ b/docs/09_API_CLI_REFERENCE.md @@ -565,7 +565,7 @@ Content-Type: application/json "containerId": "containerd://bead5...", "imageRef": "ghcr.io/acme/api@sha256:deadbeef" }, - "process": { "pid": 12345, "entrypoint": ["/start.sh", "--serve"] }, + "process": { "pid": 12345, "entrypoint": ["/start.sh", "--serve"], "buildId": "5f0c7c3c..." }, "loadedLibs": [ { "path": "/lib/x86_64-linux-gnu/libssl.so.3", "inode": 123456, "sha256": "abc123..." } ], @@ -627,7 +627,7 @@ See `docs/dev/32_AUTH_CLIENT_GUIDE.md` for recommended profiles (online vs. air- | `stellaops-cli offline kit import` | Upload an offline kit bundle to the backend | `` (argument)
`--manifest `
`--bundle-signature `
`--manifest-signature ` | Validates digests when metadata is present, then posts multipart payloads to `POST /api/offline-kit/import`; logs the submitted import ID/status for air-gapped rollout tracking. | | `stellaops-cli offline kit status` | Display imported offline kit details | `--json` | Shows bundle id/kind, captured/imported timestamps, digests, and component versions; `--json` emits machine-readable output for scripting. | | `stellaops-cli config show` | Display resolved configuration | — | Masks secret values; helpful for air‑gapped installs | -| `stellaops-cli runtime policy test` | Ask Scanner.WebService for runtime verdicts (Webhook parity) | `--image/-i ` (repeatable, comma/space lists supported)
`--file/-f `
`--namespace/--ns `
`--label/-l key=value` (repeatable)
`--json` | Posts to `POST /api/v1/scanner/policy/runtime`, deduplicates image digests, and prints TTL/policy revision plus per-image columns for signed state, SBOM referrers, quieted-by metadata, confidence, and Rekor attestation (uuid + verified flag). Accepts newline/whitespace-delimited stdin when piped; `--json` emits the raw response without additional logging. | +| `stellaops-cli runtime policy test` | Ask Scanner.WebService for runtime verdicts (Webhook parity) | `--image/-i ` (repeatable, comma/space lists supported)
`--file/-f `
`--namespace/--ns `
`--label/-l key=value` (repeatable)
`--json` | Posts to `POST /api/v1/scanner/policy/runtime`, deduplicates image digests, and prints TTL/policy revision plus per-image columns for signed state, SBOM referrers, quieted-by metadata, confidence, Rekor attestation (uuid + verified flag), and recently observed build IDs (shortened for readability). Accepts newline/whitespace-delimited stdin when piped; `--json` emits the raw response without additional logging. | `POST /api/v1/scanner/policy/runtime` responds with one entry per digest. Each result now includes: @@ -635,6 +635,7 @@ See `docs/dev/32_AUTH_CLIENT_GUIDE.md` for recommended profiles (online vs. air- - `confidence` (0-1 double) derived from canonical `PolicyPreviewService` evaluation and `quieted`/`quietedBy` flags for muted findings. - `rekor` block carrying `uuid`, `url`, and the attestor-backed `verified` boolean when Rekor inclusion proofs have been confirmed. - `metadata` (stringified JSON) capturing runtime heuristics, policy issues, evaluated findings, and timestamps for downstream audit. +- `buildIds` (array) lists up to three distinct GNU build-id hashes recently observed for that digest so debuggers can derive `/usr/lib/debug/.build-id//.debug` paths for symbol stores. When running on an interactive terminal without explicit override flags, the CLI uses Spectre.Console prompts to let you choose per-run ORAS/offline bundle behaviour. diff --git a/docs/15_UI_GUIDE.md b/docs/15_UI_GUIDE.md index 77051f2d..fb87acfe 100755 --- a/docs/15_UI_GUIDE.md +++ b/docs/15_UI_GUIDE.md @@ -147,15 +147,27 @@ If you paste YAML but enable **Strict Mode** (toggle), backend converts to Rego Lists discovered UI plugins; each can inject routes/panels. Toggle on/off without reload. -### 3.6 Settings → **Quota & Tokens** (new) - -* View current **Client‑JWT claims** (tier, maxScansPerDay, expiry). -* **Generate Offline Token** – admin‑only button → POST `/token/offline` (UI wraps the API). -* Upload new token file for manual refresh. - ---- - -## 4 i18n & l10n +### 3.6 Settings → **Quota & Tokens** (new) + +* View current **Client‑JWT claims** (tier, maxScansPerDay, expiry). +* **Generate Offline Token** – admin‑only button → POST `/token/offline` (UI wraps the API). +* Upload new token file for manual refresh. + +### 3.7 Notifications Panel (new) + +Route: **`/notify`** (header shortcut “Notify”). The panel now exposes every Notify control-plane primitive without depending on the backend being online. + +| Area | What you can do | +| --- | --- | +| **Channels** | Create/edit Slack/Teams/Email/Webhook channels, toggle enablement, maintain labels/metadata, and execute **test send** previews. Channel health cards show mocked status + trace IDs so ops can validate wiring before Notify.WebService is reachable. | +| **Rules** | Manage routing rules (matchers, severity gates, throttles/digests, locale hints). A single-action form keeps Signal-style configuration quick while mirroring Notify schema (`match`, `actions[]`). | +| **Deliveries** | Browsable ledger with status filter (All/Sent/Failed/Throttled/…​), showing targets, kinds, and timestamps so operators confirm noise controls. | + +The component leans on the mocked Notify API service in `src/app/testing/mock-notify-api.service.ts`, meaning Offline Kit demos run instantly yet the view stays API-shaped (same DTOs + tenant header expectations). + +--- + +## 4 i18n & l10n * JSON files under `/locales`. * Russian (`ru`) ships first‑class, translated security terms align with **GOST R ISO/IEC 27002‑2020**. diff --git a/docs/TASKS.md b/docs/TASKS.md index bc482924..6db5524f 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -16,7 +16,7 @@ | PLATFORM-EVENTS-09-401 | DONE (2025-10-21) | Platform Events Guild | DOCS-EVENTS-09-003 | Embed canonical event samples into contract/integration tests and ensure CI validates payloads against published schemas. | Notify models tests now run schema validation against `docs/events/*.json`, event schemas allow optional `attributes`, and docs capture the new validation workflow. | | RUNTIME-GUILD-09-402 | DONE (2025-10-19) | Runtime Guild | SCANNER-POLICY-09-107 | Confirm Scanner WebService surfaces `quietedFindingCount` and progress hints to runtime consumers; document readiness checklist. | Runtime verification run captures enriched payload; checklist/doc updates merged; stakeholders acknowledge availability. | | DOCS-CONCELIER-07-201 | DONE (2025-10-22) | Docs Guild, Concelier WebService | FEEDWEB-DOCS-01-001 | Final editorial review and publish pass for Concelier authority toggle documentation (Quickstart + operator guide). | Review feedback resolved, publish PR merged, release notes updated with documentation pointer. | -| DOCS-RUNTIME-17-004 | TODO | Docs Guild, Runtime Guild | SCANNER-EMIT-17-701, ZASTAVA-OBS-17-005, DEVOPS-REL-17-002 | Document build-id workflows: SBOM exposure, runtime event payloads, debug-store layout, and operator guidance for symbol retrieval. | Architecture + operator docs updated with build-id sections, examples show `readelf` output + debuginfod usage, references linked from Offline Kit/Release guides. | +| DOCS-RUNTIME-17-004 | TODO | Docs Guild, Runtime Guild | SCANNER-EMIT-17-701, ZASTAVA-OBS-17-005, DEVOPS-REL-17-002 | Document build-id workflows: SBOM exposure, runtime event payloads (`process.buildId`), Scanner `/policy/runtime` response (`buildIds` list), debug-store layout, and operator guidance for symbol retrieval. | Architecture + operator docs updated with build-id sections (Observer, Scanner, CLI), examples show `readelf` output + debuginfod usage, references linked from Offline Kit/Release guides + CLI help. | > Update statuses (TODO/DOING/REVIEW/DONE/BLOCKED) as progress changes. Keep guides in sync with configuration samples under `etc/`. diff --git a/docs/ops/nuget-preview-bootstrap.md b/docs/ops/nuget-preview-bootstrap.md new file mode 100644 index 00000000..8227d545 --- /dev/null +++ b/docs/ops/nuget-preview-bootstrap.md @@ -0,0 +1,49 @@ +# NuGet Preview Bootstrap (Offline-Friendly) + +The StellaOps build relies on .NET 10 preview packages (Microsoft.Extensions.*, JwtBearer 10.0 RC). +`NuGet.config` now wires three sources: + +1. `local` → `./local-nuget` (preferred, air-gapped mirror) +2. `dotnet-public` → `https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/index.json` +3. `nuget.org` → fallback for everything else + +Follow the steps below whenever you refresh the repo or roll a new Offline Kit drop. + +## 1. Mirror the preview packages + +```bash +./ops/devops/sync-preview-nuget.sh +``` + +* Reads `ops/devops/nuget-preview-packages.csv`. Each line specifies the package, version, expected SHA-256 hash, and (optionally) the flat-container base URL (we pin to `dotnet-public`). +* Downloads the `.nupkg` straight into `./local-nuget/` and re-verifies the checksum. Existing files are skipped when hashes already match. +* Use `NUGET_V2_BASE` if you need to temporarily point at a different mirror. + +💡 The script never mutates packages in place—if a checksum changes you will see a “SHA mismatch … refreshing” message. + +## 2. Restore using the shared `NuGet.config` + +From the repo root: + +```bash +DOTNET_NOLOGO=1 dotnet restore src/StellaOps.Excititor.Connectors.Abstractions/StellaOps.Excititor.Connectors.Abstractions.csproj \ + --configfile NuGet.config +``` + +The `packageSourceMapping` section keeps `Microsoft.Extensions.*`, `Microsoft.AspNetCore.*`, and `Microsoft.Data.Sqlite` bound to `local`/`dotnet-public`, so `dotnet restore` never has to reach out to nuget.org when mirrors are populated. + +If you run fully air-gapped, remember to clear the cache between SDK upgrades: + +```bash +dotnet nuget locals all --clear +``` + +## 3. Troubleshooting + +| Symptom | Fix | +| --- | --- | +| `dotnet restore` still hits nuget.org for preview packages | Re-run `sync-preview-nuget.sh` to ensure the `.nupkg` exists locally, then delete `~/.nuget/packages/microsoft.extensions.*` so the resolver picks up the mirrored copy. | +| SHA mismatch in the manifest | Update `ops/devops/nuget-preview-packages.csv` with the new version + checksum (from the feed) and re-run the sync script. | +| Azure DevOps feed throttling | Set `DOTNET_PUBLIC_FLAT_BASE` env var and point it at your own mirrored flat-container, then add the URL to the 4th column of the manifest. | + +Keep this doc alongside Offline Kit instructions so air-gapped operators know exactly how to refresh the mirror and verify packages before restore. diff --git a/docs/ops/zastava-runtime-operations.md b/docs/ops/zastava-runtime-operations.md index 04702893..9c5c9b0d 100644 --- a/docs/ops/zastava-runtime-operations.md +++ b/docs/ops/zastava-runtime-operations.md @@ -129,3 +129,32 @@ It aligns with `Sprint 12 – Runtime Guardrails` and assumes components consume - Grafana dashboard JSON: `docs/ops/zastava-runtime-grafana-dashboard.json`. - Add both to the monitoring repo (`ops/monitoring/zastava`) and reference them in the Offline Kit manifest. + +## 7. Build-id correlation & symbol retrieval + +Runtime events emitted by Observer now include `process.buildId` (from the ELF +`NT_GNU_BUILD_ID` note) and Scanner `/policy/runtime` surfaces the most recent +`buildIds` list per digest. Operators can use these hashes to locate debug +artifacts during incident response: + +1. Capture the hash from CLI/webhook/Scanner API (example: + `5f0c7c3cb4d9f8a4f1c1d5c6b7e8f90123456789`). +2. Derive the path: `/` under the debug store, e.g. + `/var/opt/debug/.build-id/5f/0c7c3cb4d9f8a4f1c1d5c6b7e8f90123456789.debug`. +3. If the file is missing, rehydrate it from Offline Kit bundles or the + `debug-store` object bucket (mirror of release artefacts). Use: + ```sh + oras cp oci://registry.internal/debug-store:latest . --include \ + "5f/0c7c3cb4d9f8a4f1c1d5c6b7e8f90123456789.debug" + ``` +4. Attach the `.debug` file in `gdb`/`lldb` or feed it to `eu-unstrip` when + preparing symbolized traces. +5. For musl-based images, expect shorter build-id footprints. Missing hashes in + runtime events indicate stripped binaries without the GNU note—schedule a + rebuild with `-Wl,--build-id` enabled or add the binary to the debug-store + allowlist so the scanner can surface a fallback symbol package. + +Monitor `scanner.policy.runtime` responses for the `buildIds` field; absence of +data after ZASTAVA-OBS-17-005 implies containers launched before the Observer +upgrade or non-ELF entrypoints (static scripts). Re-run the workload or restart +Observer to trigger a fresh capture if symbol parity is required. diff --git a/ops/devops/README.md b/ops/devops/README.md index 1525b8c5..e8b4ffa0 100644 --- a/ops/devops/README.md +++ b/ops/devops/README.md @@ -39,3 +39,16 @@ As part of **DEVOPS-UI-13-006** the pipelines will execute the UI auth smoke tests (`npm run test:e2e`) after building the Angular bundle. See `docs/ops/ui-auth-smoke.md` for the job design, environment stubs, and offline runner considerations. + +## NuGet preview bootstrap + +`.NET 10` preview packages (Microsoft.Extensions.*, JwtBearer 10.0 RC, Sqlite 9 RC) +ship from the public `dotnet-public` Azure DevOps feed. We mirror them into +`./local-nuget` so restores succeed inside Offline Kit. + +1. Run `./ops/devops/sync-preview-nuget.sh` whenever you update the manifest. +2. The script now understands the optional `SourceBase` column (V3 flat container) + and writes packages alongside their SHA-256 checks. +3. `NuGet.config` registers the mirror (`local`), dotnet-public, and nuget.org. + +Detailed operator instructions live in `docs/ops/nuget-preview-bootstrap.md`. diff --git a/ops/devops/TASKS.md b/ops/devops/TASKS.md index 61e5db7e..ee624cb4 100644 --- a/ops/devops/TASKS.md +++ b/ops/devops/TASKS.md @@ -15,7 +15,7 @@ | DEVOPS-LAUNCH-18-100 | TODO | DevOps Guild | - | Finalise production environment footprint (clusters, secrets, network overlays) for full-platform go-live. | IaC/compose overlays committed, secrets placeholders documented, dry-run deploy succeeds in staging. | | DEVOPS-LAUNCH-18-900 | TODO | DevOps Guild, Module Leads | Wave 0 completion | Collect “full implementation” sign-off from module owners and consolidate launch readiness checklist. | Sign-off record stored under `docs/ops/launch-readiness.md`; outstanding gaps triaged; checklist approved. | | DEVOPS-LAUNCH-18-001 | TODO | DevOps Guild | DEVOPS-LAUNCH-18-100, DEVOPS-LAUNCH-18-900 | Production launch cutover rehearsal and runbook publication. | `docs/ops/launch-cutover.md` drafted, rehearsal executed with rollback drill, approvals captured. | -| DEVOPS-NUGET-13-001 | DOING (2025-10-24) | DevOps Guild, Platform Leads | DEVOPS-REL-14-001 | Add .NET 10 preview feeds / local mirrors so `Microsoft.Extensions.*` 10.0 preview packages restore offline; refresh restore docs. | NuGet.config maps preview feeds (or local mirrored packages), `dotnet restore` succeeds for Excititor/Concelier solutions without ad-hoc feed edits, docs updated for offline bootstrap. | +| DEVOPS-NUGET-13-001 | DONE (2025-10-25) | DevOps Guild, Platform Leads | DEVOPS-REL-14-001 | Add .NET 10 preview feeds / local mirrors so `Microsoft.Extensions.*` 10.0 preview packages restore offline; refresh restore docs. | NuGet.config maps preview feeds (or local mirrored packages), `dotnet restore` succeeds for Excititor/Concelier solutions without ad-hoc feed edits, docs updated for offline bootstrap. | | DEVOPS-NUGET-13-002 | TODO | DevOps Guild | DEVOPS-NUGET-13-001 | Ensure all solutions/projects prefer `local-nuget` before public sources and document restore order validation. | `NuGet.config` and solution-level configs resolve from `local-nuget` first; automated check verifies priority; docs updated for restore ordering. | | DEVOPS-NUGET-13-003 | TODO | DevOps Guild, Platform Leads | DEVOPS-NUGET-13-002 | Sweep `Microsoft.*` NuGet dependencies pinned to 8.* and upgrade to latest .NET 10 equivalents (or .NET 9 when 10 unavailable), updating restore guidance. | Dependency audit shows no 8.* `Microsoft.*` packages remaining; CI builds green; changelog/doc sections capture upgrade rationale. | | DEVOPS-UI-13-006 | TODO | DevOps Guild, UI Guild | UI-AUTH-13-001 | Add Playwright-based UI auth smoke job to CI/offline pipelines, wiring sample `/config.json` provisioning and reporting. | CI + Offline Kit run Playwright auth smoke (headless Chromium) post-build; job reuses stub config artifact, exports junit + trace on failure, docs updated under `docs/ops/ui-auth-smoke.md`. | diff --git a/ops/devops/nuget-preview-packages.csv b/ops/devops/nuget-preview-packages.csv index 274cc8b6..c84c3aca 100644 --- a/ops/devops/nuget-preview-packages.csv +++ b/ops/devops/nuget-preview-packages.csv @@ -1,16 +1,17 @@ -# Package,Version,SHA256 -Microsoft.Extensions.Caching.Memory,10.0.0-preview.7.25380.108,8721fd1420fea6e828963c8343cd83605902b663385e8c9060098374139f9b2f -Microsoft.Extensions.Configuration,10.0.0-preview.7.25380.108,5a17ba4ba47f920a04ae51d80560833da82a0926d1e462af0d11c16b5da969f4 -Microsoft.Extensions.Configuration.Binder,10.0.0-preview.7.25380.108,5a3af17729241e205fe8fbb1d458470e9603935ab2eb67cbbb06ce51265ff68f -Microsoft.Extensions.DependencyInjection.Abstractions,10.0.0-preview.7.25380.108,1e9cd330d7833a3a850a7a42bbe0c729906c60bf1c359ad30a8622b50da4399b -Microsoft.Extensions.Hosting,10.0.0-preview.7.25380.108,3123bb019bbc0182cf7ac27f30018ca620929f8027e137bd5bdfb952037c7d29 -Microsoft.Extensions.Hosting.Abstractions,10.0.0-preview.7.25380.108,b57625436c9eb53e3aa27445b680bb93285d0d2c91007bbc221b0c378ab016a3 -Microsoft.Extensions.Http,10.0.0-preview.7.25380.108,daec142b7c7bd09ec1f2a86bfc3d7fe009825f5b653d310bc9e959c0a98a0f19 -Microsoft.Extensions.Logging.Abstractions,10.0.0-preview.7.25380.108,87a495fa0b7054e134a5cf44ec8b071fe2bc3ddfb27e9aefc6375701dca2a33a -Microsoft.Extensions.Options,10.0.0-preview.7.25380.108,c0657c2be3b7b894024586cf6e46a2ebc0e710db64d2645c4655b893b8487d8a +# Package,Version,SHA256,SourceBase(optional) +# DotNetPublicFlat=https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/flat2 +Microsoft.Extensions.Caching.Memory,10.0.0-preview.7.25380.108,8721fd1420fea6e828963c8343cd83605902b663385e8c9060098374139f9b2f,https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/flat2 +Microsoft.Extensions.Configuration,10.0.0-preview.7.25380.108,5a17ba4ba47f920a04ae51d80560833da82a0926d1e462af0d11c16b5da969f4,https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/flat2 +Microsoft.Extensions.Configuration.Binder,10.0.0-preview.7.25380.108,5a3af17729241e205fe8fbb1d458470e9603935ab2eb67cbbb06ce51265ff68f,https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/flat2 +Microsoft.Extensions.DependencyInjection.Abstractions,10.0.0-preview.7.25380.108,1e9cd330d7833a3a850a7a42bbe0c729906c60bf1c359ad30a8622b50da4399b,https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/flat2 +Microsoft.Extensions.Hosting,10.0.0-preview.7.25380.108,3123bb019bbc0182cf7ac27f30018ca620929f8027e137bd5bdfb952037c7d29,https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/flat2 +Microsoft.Extensions.Hosting.Abstractions,10.0.0-preview.7.25380.108,b57625436c9eb53e3aa27445b680bb93285d0d2c91007bbc221b0c378ab016a3,https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/flat2 +Microsoft.Extensions.Http,10.0.0-preview.7.25380.108,daec142b7c7bd09ec1f2a86bfc3d7fe009825f5b653d310bc9e959c0a98a0f19,https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/flat2 +Microsoft.Extensions.Logging.Abstractions,10.0.0-preview.7.25380.108,87a495fa0b7054e134a5cf44ec8b071fe2bc3ddfb27e9aefc6375701dca2a33a,https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/flat2 +Microsoft.Extensions.Options,10.0.0-preview.7.25380.108,c0657c2be3b7b894024586cf6e46a2ebc0e710db64d2645c4655b893b8487d8a,https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/flat2 Microsoft.Extensions.DependencyInjection.Abstractions,9.0.0,0a7715c24299e42b081b63b4f8e33da97b985e1de9e941b2b9e4c748b0d52fe7 Microsoft.Extensions.Logging.Abstractions,9.0.0,8814ecf6dc2359715e111b78084ae42087282595358eb775456088f15e63eca5 Microsoft.Extensions.Options,9.0.0,0d3e5eb80418fc8b41e4b3c8f16229e839ddd254af0513f7e6f1643970baf1c9 Microsoft.Extensions.Options.ConfigurationExtensions,9.0.0,af5677b04552223787d942a3f8a323f3a85aafaf20ff3c9b4aaa128c44817280 Microsoft.Data.Sqlite,9.0.0-rc.1.24451.1,770b637317e1e924f1b13587b31af0787c8c668b1d9f53f2fccae8ee8704e167 -Microsoft.AspNetCore.Authentication.JwtBearer,10.0.0-rc.1.25451.107,05f168c2db7ba79230e3fd77e84f6912bc73721c6656494df0b227867a6c2d3c +Microsoft.AspNetCore.Authentication.JwtBearer,10.0.0-rc.1.25451.107,05f168c2db7ba79230e3fd77e84f6912bc73721c6656494df0b227867a6c2d3c,https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/flat2 diff --git a/ops/devops/sync-preview-nuget.sh b/ops/devops/sync-preview-nuget.sh index 1ea26297..f2487b96 100644 --- a/ops/devops/sync-preview-nuget.sh +++ b/ops/devops/sync-preview-nuget.sh @@ -3,12 +3,14 @@ # Sync preview NuGet packages into the local offline feed. # Reads package metadata from ops/devops/nuget-preview-packages.csv # and ensures ./local-nuget holds the expected artefacts (with SHA-256 verification). +# Optional 4th CSV column can override the download base (e.g. dotnet-public flat container). set -euo pipefail repo_root="$(git -C "${BASH_SOURCE%/*}/.." rev-parse --show-toplevel 2>/dev/null || pwd)" manifest="${repo_root}/ops/devops/nuget-preview-packages.csv" dest="${repo_root}/local-nuget" +nuget_v2_base="${NUGET_V2_BASE:-https://www.nuget.org/api/v2/package}" if [[ ! -f "$manifest" ]]; then echo "Manifest not found: $manifest" >&2 @@ -21,8 +23,17 @@ fetch_package() { local package="$1" local version="$2" local expected_sha="$3" + local source_base="$4" local target="$dest/${package}.${version}.nupkg" - local url="https://www.nuget.org/api/v2/package/${package}/${version}" + local url + + if [[ -n "$source_base" ]]; then + local package_lower + package_lower="${package,,}" + url="${source_base%/}/${package_lower}/${version}/${package_lower}.${version}.nupkg" + else + url="${nuget_v2_base%/}/${package}/${version}" + fi echo "[sync-nuget] Fetching ${package} ${version}" local tmp @@ -41,7 +52,7 @@ fetch_package() { trap - RETURN } -while IFS=',' read -r package version sha; do +while IFS=',' read -r package version sha source_base; do [[ -z "$package" || "$package" == \#* ]] && continue local_path="$dest/${package}.${version}.nupkg" @@ -56,5 +67,5 @@ while IFS=',' read -r package version sha; do echo "[sync-nuget] Missing ${package} ${version}" fi - fetch_package "$package" "$version" "$sha" + fetch_package "$package" "$version" "$sha" "${source_base:-}" done < "$manifest" diff --git a/src/StellaOps.Policy/TASKS.md b/src/StellaOps.Policy/TASKS.md index bd5a1f63..a698957b 100644 --- a/src/StellaOps.Policy/TASKS.md +++ b/src/StellaOps.Policy/TASKS.md @@ -8,7 +8,7 @@ | POLICY-CORE-09-004 | DONE (2025-10-19) | Policy Guild | POLICY-CORE-09-001 | Versioned scoring config (weights, trust table, reachability buckets) with schema validation, binder, and golden fixtures. | Config serialized with semantic version, binder loads defaults, fixtures assert deterministic hash. | | POLICY-CORE-09-005 | DONE (2025-10-19) | Policy Guild | POLICY-CORE-09-004, POLICY-CORE-09-002 | Implement scoring/quiet engine: compute score from config, enforce VEX-only quiet rules, emit inputs + `quietedBy` metadata in policy verdicts. | `/reports` policy result includes score, inputs, configVersion, quiet provenance; unit/integration tests prove reproducibility. | | POLICY-CORE-09-006 | DONE (2025-10-19) | Policy Guild | POLICY-CORE-09-005, FEEDCORE-ENGINE-07-003 | Track unknown states with deterministic confidence bands that decay over time; expose state in policy outputs and docs. | Unknown flags + confidence band persisted, decay job deterministic, preview/report APIs show state with tests covering decay math. | -| POLICY-RUNTIME-17-201 | TODO | Policy Guild, Scanner WebService Guild | ZASTAVA-OBS-17-005 | Define runtime reachability feed contract and alignment plan for `SCANNER-RUNTIME-17-401` once Zastava endpoints land; document policy expectations for reachability tags. | Contract note published, sample payload agreed with Scanner team, dependencies captured in scanner/runtime task boards. | +| POLICY-RUNTIME-17-201 | TODO | Policy Guild, Scanner WebService Guild | ZASTAVA-OBS-17-005 | Define runtime reachability feed contract and alignment plan for `SCANNER-RUNTIME-17-401` once Zastava endpoints land; roll `buildIds` + reachability hints into policy metadata so CLI/Webhook consumers know how to look up symbol/debug-store artifacts. | Contract note published (fields: `buildIds`, reachability tags, TTL guidance), sample payload agreed with Scanner team, doc cross-links captured in scanner/runtime task boards. | ## Notes - 2025-10-18: POLICY-CORE-09-001 completed. Binder + diagnostics + CLI scaffolding landed with tests; schema embedded at `src/StellaOps.Policy/Schemas/policy-schema@1.json` and referenced by docs/11_DATA_SCHEMAS.md. diff --git a/src/StellaOps.Scanner.Storage/Catalog/RuntimeEventDocument.cs b/src/StellaOps.Scanner.Storage/Catalog/RuntimeEventDocument.cs index b1cf214c..b143cdb2 100644 --- a/src/StellaOps.Scanner.Storage/Catalog/RuntimeEventDocument.cs +++ b/src/StellaOps.Scanner.Storage/Catalog/RuntimeEventDocument.cs @@ -63,6 +63,9 @@ public sealed class RuntimeEventDocument [BsonElement("imageRef")] public string? ImageRef { get; set; } + [BsonElement("imageDigest")] + public string? ImageDigest { get; set; } + [BsonElement("engine")] public string? Engine { get; set; } @@ -78,6 +81,9 @@ public sealed class RuntimeEventDocument [BsonElement("sbomReferrer")] public string? SbomReferrer { get; set; } + [BsonElement("buildId")] + public string? BuildId { get; set; } + [BsonElement("payload")] public BsonDocument Payload { get; set; } = new(); } diff --git a/src/StellaOps.Scanner.Storage/Mongo/MongoBootstrapper.cs b/src/StellaOps.Scanner.Storage/Mongo/MongoBootstrapper.cs index 1ece4501..43a839c8 100644 --- a/src/StellaOps.Scanner.Storage/Mongo/MongoBootstrapper.cs +++ b/src/StellaOps.Scanner.Storage/Mongo/MongoBootstrapper.cs @@ -195,6 +195,16 @@ public sealed class MongoBootstrapper .Ascending(x => x.Node) .Ascending(x => x.When), new CreateIndexOptions { Name = "runtime_event_tenant_node_when" }), + new( + Builders.IndexKeys + .Ascending(x => x.ImageDigest) + .Descending(x => x.When), + new CreateIndexOptions { Name = "runtime_event_imageDigest_when" }), + new( + Builders.IndexKeys + .Ascending(x => x.BuildId) + .Descending(x => x.When), + new CreateIndexOptions { Name = "runtime_event_buildId_when" }), new( Builders.IndexKeys.Ascending(x => x.ExpiresAt), new CreateIndexOptions diff --git a/src/StellaOps.Scanner.Storage/Repositories/RuntimeEventRepository.cs b/src/StellaOps.Scanner.Storage/Repositories/RuntimeEventRepository.cs index e19107ba..cdd05d37 100644 --- a/src/StellaOps.Scanner.Storage/Repositories/RuntimeEventRepository.cs +++ b/src/StellaOps.Scanner.Storage/Repositories/RuntimeEventRepository.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; +using System.Linq; using MongoDB.Driver; using StellaOps.Scanner.Storage.Catalog; using StellaOps.Scanner.Storage.Mongo; @@ -48,9 +50,83 @@ public sealed class RuntimeEventRepository return new RuntimeEventInsertResult(inserted, duplicates); } } + + public async Task> GetRecentBuildIdsAsync( + IReadOnlyCollection imageDigests, + int maxPerImage, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(imageDigests); + if (imageDigests.Count == 0 || maxPerImage <= 0) + { + return new Dictionary(StringComparer.Ordinal); + } + + var normalized = imageDigests + .Where(digest => !string.IsNullOrWhiteSpace(digest)) + .Select(digest => digest.Trim().ToLowerInvariant()) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + if (normalized.Length == 0) + { + return new Dictionary(StringComparer.Ordinal); + } + + var results = new Dictionary(StringComparer.Ordinal); + var limit = Math.Max(1, maxPerImage); + + foreach (var digest in normalized) + { + var filter = Builders.Filter.And( + Builders.Filter.Eq(doc => doc.ImageDigest, digest), + Builders.Filter.Ne(doc => doc.BuildId, null), + Builders.Filter.Ne(doc => doc.BuildId, string.Empty)); + + var documents = await _collections.RuntimeEvents + .Find(filter) + .SortByDescending(doc => doc.When) + .Limit(limit * 4) + .Project(doc => new { doc.BuildId, doc.When }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + if (documents.Count == 0) + { + continue; + } + + var buildIds = documents + .Select(doc => doc.BuildId) + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(limit) + .Select(id => id!.Trim().ToLowerInvariant()) + .ToArray(); + + if (buildIds.Length == 0) + { + continue; + } + + var observedAt = documents + .Where(doc => !string.IsNullOrWhiteSpace(doc.BuildId)) + .Select(doc => doc.When) + .FirstOrDefault(); + + results[digest] = new RuntimeBuildIdObservation(digest, buildIds, observedAt); + } + + return results; + } } public readonly record struct RuntimeEventInsertResult(int InsertedCount, int DuplicateCount) { public static RuntimeEventInsertResult Empty => new(0, 0); } + +public sealed record RuntimeBuildIdObservation( + string ImageDigest, + IReadOnlyList BuildIds, + DateTime ObservedAtUtc); diff --git a/src/StellaOps.Scanner.WebService.Tests/RuntimeEndpointsTests.cs b/src/StellaOps.Scanner.WebService.Tests/RuntimeEndpointsTests.cs index decab289..4a480d7f 100644 --- a/src/StellaOps.Scanner.WebService.Tests/RuntimeEndpointsTests.cs +++ b/src/StellaOps.Scanner.WebService.Tests/RuntimeEndpointsTests.cs @@ -28,8 +28,8 @@ public sealed class RuntimeEndpointsTests BatchId = "batch-1", Events = new[] { - CreateEnvelope("evt-001"), - CreateEnvelope("evt-002") + CreateEnvelope("evt-001", buildId: "ABCDEF1234567890ABCDEF1234567890ABCDEF12"), + CreateEnvelope("evt-002", buildId: "abcdef1234567890abcdef1234567890abcdef12") } }; @@ -50,6 +50,8 @@ public sealed class RuntimeEndpointsTests { Assert.Equal("tenant-alpha", doc.Tenant); Assert.True(doc.ExpiresAt > doc.ReceivedAt); + Assert.Equal("sha256:deadbeef", doc.ImageDigest); + Assert.Equal("abcdef1234567890abcdef1234567890abcdef12", doc.BuildId); }); } @@ -184,6 +186,17 @@ rules: }); } + var ingestRequest = new RuntimeEventsIngestRequestDto + { + Events = new[] + { + CreateEnvelope("evt-210", imageDigest: imageDigest, buildId: "1122aabbccddeeff00112233445566778899aabb"), + CreateEnvelope("evt-211", imageDigest: imageDigest, buildId: "1122AABBCCDDEEFF00112233445566778899AABB") + } + }; + var ingestResponse = await client.PostAsJsonAsync("/api/v1/runtime/events", ingestRequest); + Assert.Equal(HttpStatusCode.Accepted, ingestResponse.StatusCode); + var request = new RuntimePolicyRequestDto { Namespace = "payments", @@ -215,6 +228,8 @@ rules: Assert.InRange(decision.Confidence!.Value, 0.0, 1.0); Assert.False(decision.Quieted.GetValueOrDefault()); Assert.Null(decision.QuietedBy); + Assert.NotNull(decision.BuildIds); + Assert.Contains("1122aabbccddeeff00112233445566778899aabb", decision.BuildIds!); var metadataString = decision.Metadata; Console.WriteLine($"Runtime policy metadata: {metadataString ?? ""}"); Assert.False(string.IsNullOrWhiteSpace(metadataString)); @@ -293,8 +308,13 @@ rules: [] Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } - private static RuntimeEventEnvelope CreateEnvelope(string eventId, string? schemaVersion = null) + private static RuntimeEventEnvelope CreateEnvelope( + string eventId, + string? schemaVersion = null, + string? imageDigest = null, + string? buildId = null) { + var digest = string.IsNullOrWhiteSpace(imageDigest) ? "sha256:deadbeef" : imageDigest; var runtimeEvent = new RuntimeEvent { EventId = eventId, @@ -314,7 +334,18 @@ rules: [] Pod = "api-123", Container = "api", ContainerId = "containerd://abc", - ImageRef = "ghcr.io/example/api@sha256:deadbeef" + ImageRef = $"ghcr.io/example/api@{digest}" + }, + Delta = new RuntimeDelta + { + BaselineImageDigest = digest + }, + Process = new RuntimeProcess + { + Pid = 123, + Entrypoint = new[] { "/bin/start" }, + EntryTrace = Array.Empty(), + BuildId = buildId } }; diff --git a/src/StellaOps.Scanner.WebService/Contracts/RuntimePolicyContracts.cs b/src/StellaOps.Scanner.WebService/Contracts/RuntimePolicyContracts.cs index c5bbc39e..2a2cfe9c 100644 --- a/src/StellaOps.Scanner.WebService/Contracts/RuntimePolicyContracts.cs +++ b/src/StellaOps.Scanner.WebService/Contracts/RuntimePolicyContracts.cs @@ -69,6 +69,10 @@ public sealed record RuntimePolicyImageResponseDto [JsonPropertyName("metadata")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Metadata { get; init; } + + [JsonPropertyName("buildIds")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IReadOnlyList? BuildIds { get; init; } } public sealed record RuntimePolicyRekorDto diff --git a/src/StellaOps.Scanner.WebService/Endpoints/PolicyEndpoints.cs b/src/StellaOps.Scanner.WebService/Endpoints/PolicyEndpoints.cs index 1c6f01ad..9a137a89 100644 --- a/src/StellaOps.Scanner.WebService/Endpoints/PolicyEndpoints.cs +++ b/src/StellaOps.Scanner.WebService/Endpoints/PolicyEndpoints.cs @@ -311,7 +311,8 @@ internal static class PolicyEndpoints Confidence = Math.Round(decision.Confidence, 6, MidpointRounding.AwayFromZero), Quieted = decision.Quieted, QuietedBy = decision.QuietedBy, - Metadata = metadata + Metadata = metadata, + BuildIds = decision.BuildIds is { Count: > 0 } ? decision.BuildIds.ToArray() : null }; } diff --git a/src/StellaOps.Scanner.WebService/Services/RuntimeEventIngestionService.cs b/src/StellaOps.Scanner.WebService/Services/RuntimeEventIngestionService.cs index 5c46baab..17f938ed 100644 --- a/src/StellaOps.Scanner.WebService/Services/RuntimeEventIngestionService.cs +++ b/src/StellaOps.Scanner.WebService/Services/RuntimeEventIngestionService.cs @@ -87,6 +87,8 @@ internal sealed class RuntimeEventIngestionService : IRuntimeEventIngestionServi var payloadDocument = BsonDocument.Parse(Encoding.UTF8.GetString(payloadBytes)); var runtimeEvent = envelope.Event; + var normalizedDigest = ExtractImageDigest(runtimeEvent); + var normalizedBuildId = NormalizeBuildId(runtimeEvent.Process?.BuildId); var document = new RuntimeEventDocument { @@ -104,11 +106,13 @@ internal sealed class RuntimeEventIngestionService : IRuntimeEventIngestionServi Container = runtimeEvent.Workload.Container, ContainerId = runtimeEvent.Workload.ContainerId, ImageRef = runtimeEvent.Workload.ImageRef, + ImageDigest = normalizedDigest, Engine = runtimeEvent.Runtime.Engine, EngineVersion = runtimeEvent.Runtime.Version, BaselineDigest = runtimeEvent.Delta?.BaselineImageDigest, ImageSigned = runtimeEvent.Posture?.ImageSigned, SbomReferrer = runtimeEvent.Posture?.SbomReferrer, + BuildId = normalizedBuildId, Payload = payloadDocument }; @@ -125,6 +129,66 @@ internal sealed class RuntimeEventIngestionService : IRuntimeEventIngestionServi return RuntimeEventIngestionResult.Success(insertResult.InsertedCount, insertResult.DuplicateCount, totalPayloadBytes); } + + private static string? ExtractImageDigest(RuntimeEvent runtimeEvent) + { + var digest = NormalizeDigest(runtimeEvent.Delta?.BaselineImageDigest); + if (!string.IsNullOrWhiteSpace(digest)) + { + return digest; + } + + var imageRef = runtimeEvent.Workload.ImageRef; + if (string.IsNullOrWhiteSpace(imageRef)) + { + return null; + } + + var trimmed = imageRef.Trim(); + var atIndex = trimmed.LastIndexOf('@'); + if (atIndex >= 0 && atIndex < trimmed.Length - 1) + { + var candidate = trimmed[(atIndex + 1)..]; + var parsed = NormalizeDigest(candidate); + if (!string.IsNullOrWhiteSpace(parsed)) + { + return parsed; + } + } + + if (trimmed.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)) + { + return NormalizeDigest(trimmed); + } + + return null; + } + + private static string? NormalizeDigest(string? candidate) + { + if (string.IsNullOrWhiteSpace(candidate)) + { + return null; + } + + var trimmed = candidate.Trim(); + if (!trimmed.Contains(':', StringComparison.Ordinal)) + { + return null; + } + + return trimmed.ToLowerInvariant(); + } + + private static string? NormalizeBuildId(string? buildId) + { + if (string.IsNullOrWhiteSpace(buildId)) + { + return null; + } + + return buildId.Trim().ToLowerInvariant(); + } } internal readonly record struct RuntimeEventIngestionResult( diff --git a/src/StellaOps.Scanner.WebService/Services/RuntimePolicyService.cs b/src/StellaOps.Scanner.WebService/Services/RuntimePolicyService.cs index 56421594..f3a19c49 100644 --- a/src/StellaOps.Scanner.WebService/Services/RuntimePolicyService.cs +++ b/src/StellaOps.Scanner.WebService/Services/RuntimePolicyService.cs @@ -26,12 +26,15 @@ internal interface IRuntimePolicyService internal sealed class RuntimePolicyService : IRuntimePolicyService { + private const int MaxBuildIdsPerImage = 3; + private static readonly Meter PolicyMeter = new("StellaOps.Scanner.RuntimePolicy", "1.0.0"); private static readonly Counter PolicyEvaluations = PolicyMeter.CreateCounter("scanner.runtime.policy.requests", unit: "1", description: "Total runtime policy evaluation requests processed."); private static readonly Histogram PolicyEvaluationLatencyMs = PolicyMeter.CreateHistogram("scanner.runtime.policy.latency.ms", unit: "ms", description: "Latency for runtime policy evaluations."); private readonly LinkRepository _linkRepository; private readonly ArtifactRepository _artifactRepository; + private readonly RuntimeEventRepository _runtimeEventRepository; private readonly PolicySnapshotStore _policySnapshotStore; private readonly PolicyPreviewService _policyPreviewService; private readonly IOptionsMonitor _optionsMonitor; @@ -42,6 +45,7 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService public RuntimePolicyService( LinkRepository linkRepository, ArtifactRepository artifactRepository, + RuntimeEventRepository runtimeEventRepository, PolicySnapshotStore policySnapshotStore, PolicyPreviewService policyPreviewService, IOptionsMonitor optionsMonitor, @@ -51,6 +55,7 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService { _linkRepository = linkRepository ?? throw new ArgumentNullException(nameof(linkRepository)); _artifactRepository = artifactRepository ?? throw new ArgumentNullException(nameof(artifactRepository)); + _runtimeEventRepository = runtimeEventRepository ?? throw new ArgumentNullException(nameof(runtimeEventRepository)); _policySnapshotStore = policySnapshotStore ?? throw new ArgumentNullException(nameof(policySnapshotStore)); _policyPreviewService = policyPreviewService ?? throw new ArgumentNullException(nameof(policyPreviewService)); _optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor)); @@ -82,6 +87,10 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService new("namespace", request.Namespace ?? "unspecified") }; + var buildIdObservations = await _runtimeEventRepository + .GetRecentBuildIdsAsync(request.Images, MaxBuildIdsPerImage, cancellationToken) + .ConfigureAwait(false); + try { var evaluated = new HashSet(StringComparer.Ordinal); @@ -126,6 +135,9 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService _logger.LogWarning(ex, "Runtime policy preview failed for image {ImageDigest}; falling back to heuristic evaluation.", image); } + var normalizedImage = image.Trim().ToLowerInvariant(); + buildIdObservations.TryGetValue(normalizedImage, out var buildIdObservation); + var decision = await BuildDecisionAsync( image, metadata, @@ -133,6 +145,7 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService projectedVerdicts, issues, policyDigest, + buildIdObservation?.BuildIds, cancellationToken).ConfigureAwait(false); results[image] = decision; @@ -260,6 +273,7 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService ImmutableArray projectedVerdicts, ImmutableArray issues, string? policyDigest, + IReadOnlyList? buildIds, CancellationToken cancellationToken) { var reasons = new List(heuristicReasons); @@ -315,7 +329,8 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService metadataPayload, confidence, quieted, - quietedBy); + quietedBy, + buildIds); } private RuntimePolicyVerdict MapVerdict(ImmutableArray projectedVerdicts, IReadOnlyList heuristicReasons) @@ -485,7 +500,8 @@ internal sealed record RuntimePolicyImageDecision( IDictionary? Metadata, double Confidence, bool Quieted, - string? QuietedBy); + string? QuietedBy, + IReadOnlyList? BuildIds); internal sealed record RuntimePolicyRekorReference(string? Uuid, string? Url, bool? Verified); diff --git a/src/StellaOps.Scanner.WebService/TASKS.md b/src/StellaOps.Scanner.WebService/TASKS.md index 695df795..67f87e78 100644 --- a/src/StellaOps.Scanner.WebService/TASKS.md +++ b/src/StellaOps.Scanner.WebService/TASKS.md @@ -17,7 +17,7 @@ | SCANNER-RUNTIME-12-305 | DONE (2025-10-24) | Scanner WebService Guild | SCANNER-RUNTIME-12-301, SCANNER-RUNTIME-12-302 | Promote shared fixtures with Zastava/CLI and add end-to-end automation for `/runtime/events` + `/policy/runtime`. | Runtime policy integration test + CLI-aligned fixture assert confidence, metadata JSON, and Rekor verification; docs note shared contract. | | SCANNER-EVENTS-15-201 | DONE (2025-10-20) | Scanner WebService Guild | NOTIFY-QUEUE-15-401 | Emit `scanner.report.ready` and `scanner.scan.completed` events (bus adapters + tests). | Event envelopes published to queue with schemas; fixtures committed; Notify consumption test passes. | | SCANNER-EVENTS-16-301 | BLOCKED (2025-10-20) | Scanner WebService Guild | NOTIFY-QUEUE-15-401 | Integrate Redis publisher end-to-end once Notify queue abstraction ships; replace in-memory recorder with real stream assertions. | Notify Queue adapter available; integration test exercises Redis stream length/fields via test harness; docs updated with ops validation checklist. | -| SCANNER-RUNTIME-17-401 | DOING (2025-10-24) | Scanner WebService Guild | SCANNER-RUNTIME-12-301, ZASTAVA-OBS-17-005, SCANNER-EMIT-17-701, POLICY-RUNTIME-17-201 | Persist runtime build-id observations and expose them via `/runtime/events` + policy joins for debug-symbol correlation. | Mongo schema stores optional `buildId`, API/SDK responses document field, integration test resolves debug-store path using stored build-id, docs updated accordingly. | +| SCANNER-RUNTIME-17-401 | DONE (2025-10-25) | Scanner WebService Guild | SCANNER-RUNTIME-12-301, ZASTAVA-OBS-17-005, SCANNER-EMIT-17-701, POLICY-RUNTIME-17-201 | Persist runtime build-id observations and expose them via `/runtime/events` + policy joins for debug-symbol correlation. | Runtime events store normalized digests + build IDs with supporting indexes, runtime policy responses surface `buildIds`, tests/docs updated, and CLI/API consumers can derive debug-store paths deterministically. | ## Notes - 2025-10-19: Sprint 9 streaming + policy endpoints (SCANNER-WEB-09-103, SCANNER-POLICY-09-105/106/107) landed with SSE/JSONL, OpenAPI, signed report coverage documented in `docs/09_API_CLI_REFERENCE.md`. diff --git a/src/StellaOps.UI/TASKS.md b/src/StellaOps.UI/TASKS.md index 9650b1a9..3e4718d7 100644 --- a/src/StellaOps.UI/TASKS.md +++ b/src/StellaOps.UI/TASKS.md @@ -8,5 +8,5 @@ | UI-ADMIN-13-004 | TODO | UI Guild | AUTH-MTLS-11-002 | Deliver admin area (tenants/clients/quotas/licensing) with RBAC + audit hooks. | Admin e2e tests pass; unauthorized access blocked; telemetry wired. | | UI-ATTEST-11-005 | DONE (2025-10-23) | UI Guild | SIGNER-API-11-101, ATTESTOR-API-11-201 | Attestation visibility (Rekor id, status) on Scan Detail. | UI shows Rekor UUID/status; mock attestation fixtures displayed; tests cover success/failure. | | UI-SCHED-13-005 | TODO | UI Guild | SCHED-WEB-16-101 | Scheduler panel: schedules CRUD, run history, dry-run preview using API/mocks. | Panel functional with mocked endpoints; UX signoff; integration tests added. | -| UI-NOTIFY-13-006 | DOING (2025-10-19) | UI Guild | NOTIFY-WEB-15-101 | Notify panel: channels/rules CRUD, deliveries view, test send integration. | Panel interacts with mocked Notify API; tests cover rule lifecycle; docs updated. | +| UI-NOTIFY-13-006 | DONE (2025-10-25) | UI Guild | NOTIFY-WEB-15-101 | Notify panel: channels/rules CRUD, deliveries view, test send integration. | Panel interacts with mocked Notify API; tests cover rule lifecycle; docs updated. | | UI-POLICY-13-007 | TODO | UI Guild | POLICY-CORE-09-006, SCANNER-WEB-09-103 | Surface policy confidence metadata (band, age, quiet provenance) on preview and report views. | UI renders new columns/tooltips, accessibility and responsive checks pass, Cypress regression updated with confidence fixtures. | diff --git a/src/StellaOps.Web/src/app/app.component.html b/src/StellaOps.Web/src/app/app.component.html index 8e67e1dc..edb81c9e 100644 --- a/src/StellaOps.Web/src/app/app.component.html +++ b/src/StellaOps.Web/src/app/app.component.html @@ -8,6 +8,9 @@ Scan Detail + + Notify +
diff --git a/src/StellaOps.Web/src/app/app.config.ts b/src/StellaOps.Web/src/app/app.config.ts index 495db8b9..9e11692b 100644 --- a/src/StellaOps.Web/src/app/app.config.ts +++ b/src/StellaOps.Web/src/app/app.config.ts @@ -4,8 +4,14 @@ import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; import { CONCELIER_EXPORTER_API_BASE_URL } from './core/api/concelier-exporter.client'; +import { + NOTIFY_API, + NOTIFY_API_BASE_URL, + NOTIFY_TENANT_ID, +} from './core/api/notify.client'; import { AppConfigService } from './core/config/app-config.service'; import { AuthHttpInterceptor } from './core/auth/auth-http.interceptor'; +import { MockNotifyApiService } from './testing/mock-notify-api.service'; export const appConfig: ApplicationConfig = { providers: [ @@ -27,5 +33,18 @@ export const appConfig: ApplicationConfig = { provide: CONCELIER_EXPORTER_API_BASE_URL, useValue: '/api/v1/concelier/exporters/trivy-db', }, + { + provide: NOTIFY_API_BASE_URL, + useValue: '/api/v1/notify', + }, + { + provide: NOTIFY_TENANT_ID, + useValue: 'tenant-dev', + }, + MockNotifyApiService, + { + provide: NOTIFY_API, + useExisting: MockNotifyApiService, + }, ], }; diff --git a/src/StellaOps.Web/src/app/app.routes.ts b/src/StellaOps.Web/src/app/app.routes.ts index 1bc2570c..2aee653b 100644 --- a/src/StellaOps.Web/src/app/app.routes.ts +++ b/src/StellaOps.Web/src/app/app.routes.ts @@ -15,6 +15,13 @@ export const routes: Routes = [ (m) => m.ScanDetailPageComponent ), }, + { + path: 'notify', + loadComponent: () => + import('./features/notify/notify-panel.component').then( + (m) => m.NotifyPanelComponent + ), + }, { path: 'auth/callback', loadComponent: () => diff --git a/src/StellaOps.Web/src/app/core/api/notify.client.ts b/src/StellaOps.Web/src/app/core/api/notify.client.ts new file mode 100644 index 00000000..d3820017 --- /dev/null +++ b/src/StellaOps.Web/src/app/core/api/notify.client.ts @@ -0,0 +1,142 @@ +import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; +import { + Inject, + Injectable, + InjectionToken, + Optional, +} from '@angular/core'; +import { Observable } from 'rxjs'; + +import { + ChannelHealthResponse, + ChannelTestSendRequest, + ChannelTestSendResponse, + NotifyChannel, + NotifyDeliveriesQueryOptions, + NotifyDeliveriesResponse, + NotifyRule, +} from './notify.models'; + +export interface NotifyApi { + listChannels(): Observable; + saveChannel(channel: NotifyChannel): Observable; + deleteChannel(channelId: string): Observable; + getChannelHealth(channelId: string): Observable; + testChannel( + channelId: string, + payload: ChannelTestSendRequest + ): Observable; + listRules(): Observable; + saveRule(rule: NotifyRule): Observable; + deleteRule(ruleId: string): Observable; + listDeliveries( + options?: NotifyDeliveriesQueryOptions + ): Observable; +} + +export const NOTIFY_API = new InjectionToken('NOTIFY_API'); + +export const NOTIFY_API_BASE_URL = new InjectionToken( + 'NOTIFY_API_BASE_URL' +); + +export const NOTIFY_TENANT_ID = new InjectionToken('NOTIFY_TENANT_ID'); + +@Injectable({ providedIn: 'root' }) +export class NotifyApiHttpClient implements NotifyApi { + constructor( + private readonly http: HttpClient, + @Inject(NOTIFY_API_BASE_URL) private readonly baseUrl: string, + @Optional() @Inject(NOTIFY_TENANT_ID) private readonly tenantId: string | null + ) {} + + listChannels(): Observable { + return this.http.get(`${this.baseUrl}/channels`, { + headers: this.buildHeaders(), + }); + } + + saveChannel(channel: NotifyChannel): Observable { + return this.http.post(`${this.baseUrl}/channels`, channel, { + headers: this.buildHeaders(), + }); + } + + deleteChannel(channelId: string): Observable { + return this.http.delete(`${this.baseUrl}/channels/${channelId}`, { + headers: this.buildHeaders(), + }); + } + + getChannelHealth(channelId: string): Observable { + return this.http.get( + `${this.baseUrl}/channels/${channelId}/health`, + { + headers: this.buildHeaders(), + } + ); + } + + testChannel( + channelId: string, + payload: ChannelTestSendRequest + ): Observable { + return this.http.post( + `${this.baseUrl}/channels/${channelId}/test`, + payload, + { + headers: this.buildHeaders(), + } + ); + } + + listRules(): Observable { + return this.http.get(`${this.baseUrl}/rules`, { + headers: this.buildHeaders(), + }); + } + + saveRule(rule: NotifyRule): Observable { + return this.http.post(`${this.baseUrl}/rules`, rule, { + headers: this.buildHeaders(), + }); + } + + deleteRule(ruleId: string): Observable { + return this.http.delete(`${this.baseUrl}/rules/${ruleId}`, { + headers: this.buildHeaders(), + }); + } + + listDeliveries( + options?: NotifyDeliveriesQueryOptions + ): Observable { + let params = new HttpParams(); + if (options?.status) { + params = params.set('status', options.status); + } + if (options?.since) { + params = params.set('since', options.since); + } + if (options?.limit) { + params = params.set('limit', options.limit); + } + if (options?.continuationToken) { + params = params.set('continuationToken', options.continuationToken); + } + + return this.http.get(`${this.baseUrl}/deliveries`, { + headers: this.buildHeaders(), + params, + }); + } + + private buildHeaders(): HttpHeaders { + if (!this.tenantId) { + return new HttpHeaders(); + } + + return new HttpHeaders({ 'X-StellaOps-Tenant': this.tenantId }); + } +} + diff --git a/src/StellaOps.Web/src/app/core/api/notify.models.ts b/src/StellaOps.Web/src/app/core/api/notify.models.ts new file mode 100644 index 00000000..711dcc82 --- /dev/null +++ b/src/StellaOps.Web/src/app/core/api/notify.models.ts @@ -0,0 +1,194 @@ +export type NotifyChannelType = + | 'Slack' + | 'Teams' + | 'Email' + | 'Webhook' + | 'Custom'; + +export type ChannelHealthStatus = 'Healthy' | 'Degraded' | 'Unhealthy'; + +export type NotifyDeliveryStatus = + | 'Pending' + | 'Sent' + | 'Failed' + | 'Throttled' + | 'Digested' + | 'Dropped'; + +export type NotifyDeliveryAttemptStatus = + | 'Enqueued' + | 'Sending' + | 'Succeeded' + | 'Failed' + | 'Throttled' + | 'Skipped'; + +export type NotifyDeliveryFormat = + | 'Slack' + | 'Teams' + | 'Email' + | 'Webhook' + | 'Json'; + +export interface NotifyChannelLimits { + readonly concurrency?: number | null; + readonly requestsPerMinute?: number | null; + readonly timeout?: string | null; + readonly maxBatchSize?: number | null; +} + +export interface NotifyChannelConfig { + readonly secretRef: string; + readonly target?: string; + readonly endpoint?: string; + readonly properties?: Record; + readonly limits?: NotifyChannelLimits | null; +} + +export interface NotifyChannel { + readonly schemaVersion?: string; + readonly channelId: string; + readonly tenantId: string; + readonly name: string; + readonly displayName?: string; + readonly description?: string; + readonly type: NotifyChannelType; + readonly enabled: boolean; + readonly config: NotifyChannelConfig; + readonly labels?: Record; + readonly metadata?: Record; + readonly createdBy?: string; + readonly createdAt?: string; + readonly updatedBy?: string; + readonly updatedAt?: string; +} + +export interface NotifyRuleMatchVex { + readonly includeAcceptedJustifications?: boolean; + readonly includeRejectedJustifications?: boolean; + readonly includeUnknownJustifications?: boolean; + readonly justificationKinds?: readonly string[]; +} + +export interface NotifyRuleMatch { + readonly eventKinds?: readonly string[]; + readonly namespaces?: readonly string[]; + readonly repositories?: readonly string[]; + readonly digests?: readonly string[]; + readonly labels?: readonly string[]; + readonly componentPurls?: readonly string[]; + readonly minSeverity?: string | null; + readonly verdicts?: readonly string[]; + readonly kevOnly?: boolean | null; + readonly vex?: NotifyRuleMatchVex | null; +} + +export interface NotifyRuleAction { + readonly actionId: string; + readonly channel: string; + readonly template?: string; + readonly digest?: string; + readonly throttle?: string | null; + readonly locale?: string; + readonly enabled: boolean; + readonly metadata?: Record; +} + +export interface NotifyRule { + readonly schemaVersion?: string; + readonly ruleId: string; + readonly tenantId: string; + readonly name: string; + readonly description?: string; + readonly enabled: boolean; + readonly match: NotifyRuleMatch; + readonly actions: readonly NotifyRuleAction[]; + readonly labels?: Record; + readonly metadata?: Record; + readonly createdBy?: string; + readonly createdAt?: string; + readonly updatedBy?: string; + readonly updatedAt?: string; +} + +export interface NotifyDeliveryAttempt { + readonly timestamp: string; + readonly status: NotifyDeliveryAttemptStatus; + readonly statusCode?: number; + readonly reason?: string; +} + +export interface NotifyDeliveryRendered { + readonly channelType: NotifyChannelType; + readonly format: NotifyDeliveryFormat; + readonly target: string; + readonly title: string; + readonly body: string; + readonly summary?: string; + readonly textBody?: string; + readonly locale?: string; + readonly bodyHash?: string; + readonly attachments?: readonly string[]; +} + +export interface NotifyDelivery { + readonly deliveryId: string; + readonly tenantId: string; + readonly ruleId: string; + readonly actionId: string; + readonly eventId: string; + readonly kind: string; + readonly status: NotifyDeliveryStatus; + readonly statusReason?: string; + readonly rendered?: NotifyDeliveryRendered; + readonly attempts?: readonly NotifyDeliveryAttempt[]; + readonly metadata?: Record; + readonly createdAt: string; + readonly sentAt?: string; + readonly completedAt?: string; +} + +export interface NotifyDeliveriesQueryOptions { + readonly status?: NotifyDeliveryStatus; + readonly since?: string; + readonly limit?: number; + readonly continuationToken?: string; +} + +export interface NotifyDeliveriesResponse { + readonly items: readonly NotifyDelivery[]; + readonly continuationToken?: string | null; + readonly count: number; +} + +export interface ChannelHealthResponse { + readonly tenantId: string; + readonly channelId: string; + readonly status: ChannelHealthStatus; + readonly message?: string | null; + readonly checkedAt: string; + readonly traceId: string; + readonly metadata?: Record; +} + +export interface ChannelTestSendRequest { + readonly target?: string; + readonly templateId?: string; + readonly title?: string; + readonly summary?: string; + readonly body?: string; + readonly textBody?: string; + readonly locale?: string; + readonly metadata?: Record; + readonly attachments?: readonly string[]; +} + +export interface ChannelTestSendResponse { + readonly tenantId: string; + readonly channelId: string; + readonly preview: NotifyDeliveryRendered; + readonly queuedAt: string; + readonly traceId: string; + readonly metadata?: Record; +} + diff --git a/src/StellaOps.Web/src/app/features/notify/notify-panel.component.html b/src/StellaOps.Web/src/app/features/notify/notify-panel.component.html new file mode 100644 index 00000000..fd0259e1 --- /dev/null +++ b/src/StellaOps.Web/src/app/features/notify/notify-panel.component.html @@ -0,0 +1,344 @@ +
+
+
+

Notifications

+

Notify control plane

+

Manage channels, routing rules, deliveries, and preview payloads offline.

+
+ +
+ +
+
+
+
+

Channels

+

Destinations for Slack, Teams, Email, or Webhook notifications.

+
+ +
+ +

+ {{ channelMessage() }} +

+ +
    +
  • + +
  • +
+ +
+
+ + + + + + + + +
+ +
+ + +
+ +
+ + + +
+
+ +
+
+ {{ health.status }} +
+
+

{{ health.message }}

+ Last checked {{ health.checkedAt | date: 'medium' }} • Trace {{ health.traceId }} +
+
+ +
+

Test send

+
+ + + +
+ +
+ +
+
+ +
+
+ Preview queued + {{ preview.queuedAt | date: 'short' }} +
+

Target: {{ preview.preview.target }}

+

Title: {{ preview.preview.title }}

+

Summary: {{ preview.preview.summary || 'n/a' }}

+

{{ preview.preview.body }}

+
+
+ +
+
+
+

Rules

+

Define routing logic and throttles per channel.

+
+ +
+ +

+ {{ ruleMessage() }} +

+ +
    +
  • + +
  • +
+ +
+
+ + + + + + + + +
+ + + + + +
+ + + +
+
+
+ +
+
+
+

Deliveries

+

Recent delivery attempts, statuses, and preview traces.

+
+ +
+ +
+ +
+ +

+ {{ deliveriesMessage() }} +

+ +
+ + + + + + + + + + + + + + + + + + + + +
StatusTargetKindCreated
+ + {{ delivery.status }} + + + {{ delivery.rendered?.target || 'n/a' }} + + {{ delivery.kind }} + + {{ delivery.createdAt | date: 'short' }} +
No deliveries match this filter.
+
+
+
+
diff --git a/src/StellaOps.Web/src/app/features/notify/notify-panel.component.scss b/src/StellaOps.Web/src/app/features/notify/notify-panel.component.scss new file mode 100644 index 00000000..a99d65a9 --- /dev/null +++ b/src/StellaOps.Web/src/app/features/notify/notify-panel.component.scss @@ -0,0 +1,386 @@ +:host { + display: block; + color: #e2e8f0; +} + +.notify-panel { + background: #0f172a; + border-radius: 16px; + padding: 2rem; + box-shadow: 0 20px 45px rgba(15, 23, 42, 0.45); +} + +.notify-panel__header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 2rem; + + h1 { + margin: 0.25rem 0; + font-size: 1.75rem; + } + + p { + margin: 0; + color: #cbd5f5; + } +} + +.eyebrow { + text-transform: uppercase; + font-size: 0.75rem; + letter-spacing: 0.1em; + color: #94a3b8; + margin: 0; +} + +.notify-grid { + display: grid; + gap: 1.5rem; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); +} + +.notify-card { + background: #111827; + border: 1px solid #1f2937; + border-radius: 16px; + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1rem; + min-height: 100%; +} + +.notify-card__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + + h2 { + margin: 0; + font-size: 1.25rem; + } + + p { + margin: 0; + color: #94a3b8; + font-size: 0.9rem; + } +} + +.ghost-button { + border: 1px solid rgba(148, 163, 184, 0.4); + background: transparent; + color: #e2e8f0; + border-radius: 999px; + padding: 0.35rem 1rem; + font-size: 0.85rem; + cursor: pointer; + transition: background-color 0.2s ease, border-color 0.2s ease; + + &:hover, + &:focus-visible { + border-color: #38bdf8; + background: rgba(56, 189, 248, 0.15); + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } +} + +.notify-message { + margin: 0; + padding: 0.5rem 0.75rem; + border-radius: 8px; + background: rgba(59, 130, 246, 0.15); + color: #e0f2fe; + font-size: 0.9rem; +} + +.channel-list, +.rule-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.channel-item, +.rule-item { + width: 100%; + border: 1px solid #1f2937; + background: #0f172a; + color: inherit; + border-radius: 12px; + padding: 0.75rem 1rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + cursor: pointer; + transition: border-color 0.2s ease, transform 0.2s ease; + + &.active { + border-color: #38bdf8; + background: rgba(56, 189, 248, 0.1); + transform: translateY(-1px); + } +} + +.channel-meta, +.rule-meta { + font-size: 0.8rem; + color: #94a3b8; +} + +.channel-status { + font-size: 0.75rem; + text-transform: uppercase; + padding: 0.15rem 0.5rem; + border-radius: 999px; + border: 1px solid rgba(248, 250, 252, 0.2); +} + +.channel-status--enabled { + border-color: #34d399; + color: #34d399; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + width: 100%; +} + +label { + display: flex; + flex-direction: column; + gap: 0.35rem; + font-size: 0.85rem; +} + +label span { + color: #cbd5f5; + font-weight: 500; +} + +input, +textarea, +select { + background: #0f172a; + border: 1px solid #1f2937; + border-radius: 10px; + color: inherit; + padding: 0.6rem; + font-size: 0.95rem; + font-family: inherit; + + &:focus-visible { + outline: 2px solid #38bdf8; + outline-offset: 2px; + } +} + +.checkbox { + flex-direction: row; + align-items: center; + gap: 0.5rem; + font-weight: 500; + + input { + width: auto; + } +} + +.full-width { + grid-column: 1 / -1; +} + +.notify-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; +} + +.notify-actions button { + border: none; + border-radius: 999px; + padding: 0.45rem 1.25rem; + font-weight: 600; + cursor: pointer; + background: linear-gradient(120deg, #38bdf8, #8b5cf6); + color: #0f172a; + transition: opacity 0.2s ease; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.notify-actions .ghost-button { + background: transparent; + color: #e2e8f0; +} + +.channel-health { + display: flex; + gap: 0.75rem; + align-items: center; + padding: 0.75rem 1rem; + border-radius: 12px; + background: #0b1220; + border: 1px solid #1d2a44; +} + +.status-pill { + padding: 0.25rem 0.75rem; + border-radius: 999px; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.08em; + border: 1px solid rgba(248, 250, 252, 0.3); +} + +.status-pill--healthy { + border-color: #34d399; + color: #34d399; +} + +.status-pill--warning { + border-color: #facc15; + color: #facc15; +} + +.status-pill--error { + border-color: #f87171; + color: #f87171; +} + +.channel-health__details p { + margin: 0; + font-size: 0.9rem; +} + +.channel-health__details small { + color: #94a3b8; +} + +.test-form h3 { + margin: 0; + font-size: 1rem; + color: #cbd5f5; +} + +.test-preview { + border: 1px solid #1f2937; + border-radius: 12px; + padding: 1rem; + background: #0b1220; + + header { + display: flex; + justify-content: space-between; + font-size: 0.9rem; + } + + p { + margin: 0.25rem 0; + font-size: 0.9rem; + } + + span { + font-weight: 600; + color: #cbd5f5; + } + + .preview-body { + font-family: 'JetBrains Mono', 'Fira Code', monospace; + background: #0f172a; + border-radius: 8px; + padding: 0.75rem; + } +} + +.deliveries-controls { + display: flex; + justify-content: flex-start; + gap: 1rem; +} + +.deliveries-controls label { + min-width: 140px; +} + +.deliveries-table { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +thead th { + text-align: left; + font-weight: 600; + padding-bottom: 0.5rem; + color: #cbd5f5; +} + +tbody td { + padding: 0.6rem 0.25rem; + border-top: 1px solid #1f2937; +} + +.empty-row { + text-align: center; + color: #94a3b8; + padding: 1rem 0; +} + +.status-badge { + display: inline-block; + padding: 0.2rem 0.6rem; + border-radius: 8px; + font-size: 0.75rem; + text-transform: uppercase; + border: 1px solid rgba(148, 163, 184, 0.5); +} + +.status-badge--sent { + border-color: #34d399; + color: #34d399; +} + +.status-badge--failed { + border-color: #f87171; + color: #f87171; +} + +.status-badge--throttled { + border-color: #facc15; + color: #facc15; +} + +@media (max-width: 720px) { + .notify-panel { + padding: 1.25rem; + } + + .notify-panel__header { + flex-direction: column; + align-items: flex-start; + } +} + diff --git a/src/StellaOps.Web/src/app/features/notify/notify-panel.component.spec.ts b/src/StellaOps.Web/src/app/features/notify/notify-panel.component.spec.ts new file mode 100644 index 00000000..9e5eb0f7 --- /dev/null +++ b/src/StellaOps.Web/src/app/features/notify/notify-panel.component.spec.ts @@ -0,0 +1,66 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NOTIFY_API } from '../../core/api/notify.client'; +import { MockNotifyApiService } from '../../testing/mock-notify-api.service'; +import { NotifyPanelComponent } from './notify-panel.component'; + +describe('NotifyPanelComponent', () => { + let fixture: ComponentFixture; + let component: NotifyPanelComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [NotifyPanelComponent], + providers: [ + MockNotifyApiService, + { provide: NOTIFY_API, useExisting: MockNotifyApiService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(NotifyPanelComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('renders channels from the mocked API', async () => { + await component.refreshAll(); + fixture.detectChanges(); + const items: NodeListOf = + fixture.nativeElement.querySelectorAll('[data-testid="channel-item"]'); + expect(items.length).toBeGreaterThan(0); + }); + + it('persists a new rule via the mocked API', async () => { + await component.refreshAll(); + fixture.detectChanges(); + + component.createRuleDraft(); + component.ruleForm.patchValue({ + name: 'Notify preview rule', + channel: component.channels()[0]?.channelId ?? '', + eventKindsText: 'scanner.report.ready', + labelsText: 'kev', + }); + + await component.saveRule(); + fixture.detectChanges(); + + const ruleButtons: HTMLElement[] = Array.from( + fixture.nativeElement.querySelectorAll('[data-testid="rule-item"]') + ); + expect( + ruleButtons.some((el) => el.textContent?.includes('Notify preview rule')) + ).toBeTrue(); + }); + + it('shows a test preview after sending', async () => { + await component.refreshAll(); + fixture.detectChanges(); + + await component.sendTestPreview(); + fixture.detectChanges(); + + const preview = fixture.nativeElement.querySelector('[data-testid="test-preview"]'); + expect(preview).toBeTruthy(); + }); +}); diff --git a/src/StellaOps.Web/src/app/features/notify/notify-panel.component.ts b/src/StellaOps.Web/src/app/features/notify/notify-panel.component.ts new file mode 100644 index 00000000..ef5329cb --- /dev/null +++ b/src/StellaOps.Web/src/app/features/notify/notify-panel.component.ts @@ -0,0 +1,642 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + OnInit, + computed, + inject, + signal, +} from '@angular/core'; +import { + NonNullableFormBuilder, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { firstValueFrom } from 'rxjs'; + +import { + NOTIFY_API, + NotifyApi, +} from '../../core/api/notify.client'; +import { + ChannelHealthResponse, + ChannelTestSendResponse, + NotifyChannel, + NotifyDelivery, + NotifyDeliveriesQueryOptions, + NotifyDeliveryStatus, + NotifyRule, + NotifyRuleAction, +} from '../../core/api/notify.models'; + +type DeliveryFilter = + | 'all' + | 'pending' + | 'sent' + | 'failed' + | 'throttled' + | 'digested' + | 'dropped'; + +@Component({ + selector: 'app-notify-panel', + standalone: true, + imports: [CommonModule, ReactiveFormsModule], + templateUrl: './notify-panel.component.html', + styleUrls: ['./notify-panel.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NotifyPanelComponent implements OnInit { + private readonly api = inject(NOTIFY_API); + private readonly formBuilder = inject(NonNullableFormBuilder); + + private readonly tenantId = signal('tenant-dev'); + + readonly channelTypes: readonly NotifyChannel['type'][] = [ + 'Slack', + 'Teams', + 'Email', + 'Webhook', + 'Custom', + ]; + + readonly severityOptions = ['critical', 'high', 'medium', 'low']; + + readonly channels = signal([]); + readonly selectedChannelId = signal(null); + readonly channelLoading = signal(false); + readonly channelMessage = signal(null); + readonly channelHealth = signal(null); + readonly testPreview = signal(null); + readonly testSending = signal(false); + + readonly rules = signal([]); + readonly selectedRuleId = signal(null); + readonly ruleLoading = signal(false); + readonly ruleMessage = signal(null); + + readonly deliveries = signal([]); + readonly deliveriesLoading = signal(false); + readonly deliveriesMessage = signal(null); + readonly deliveryFilter = signal('all'); + + readonly filteredDeliveries = computed(() => { + const filter = this.deliveryFilter(); + const items = this.deliveries(); + if (filter === 'all') { + return items; + } + return items.filter((item) => + item.status.toLowerCase() === filter + ); + }); + + readonly channelForm = this.formBuilder.group({ + channelId: this.formBuilder.control(''), + name: this.formBuilder.control('', { + validators: [Validators.required], + }), + displayName: this.formBuilder.control(''), + description: this.formBuilder.control(''), + type: this.formBuilder.control('Slack'), + target: this.formBuilder.control(''), + endpoint: this.formBuilder.control(''), + secretRef: this.formBuilder.control('', { + validators: [Validators.required], + }), + enabled: this.formBuilder.control(true), + labelsText: this.formBuilder.control(''), + metadataText: this.formBuilder.control(''), + }); + + readonly ruleForm = this.formBuilder.group({ + ruleId: this.formBuilder.control(''), + name: this.formBuilder.control('', { + validators: [Validators.required], + }), + description: this.formBuilder.control(''), + enabled: this.formBuilder.control(true), + minSeverity: this.formBuilder.control('critical'), + eventKindsText: this.formBuilder.control('scanner.report.ready'), + labelsText: this.formBuilder.control('kev,critical'), + channel: this.formBuilder.control('', { + validators: [Validators.required], + }), + digest: this.formBuilder.control('instant'), + template: this.formBuilder.control('tmpl-critical'), + locale: this.formBuilder.control('en-US'), + throttleSeconds: this.formBuilder.control(300), + }); + + readonly testForm = this.formBuilder.group({ + title: this.formBuilder.control('Policy verdict update'), + summary: this.formBuilder.control('Mock preview of Notify payload.'), + body: this.formBuilder.control( + 'Sample preview body rendered by the mocked Notify API service.' + ), + textBody: this.formBuilder.control(''), + target: this.formBuilder.control(''), + }); + + async ngOnInit(): Promise { + await this.refreshAll(); + } + + async refreshAll(): Promise { + await Promise.all([ + this.loadChannels(), + this.loadRules(), + this.loadDeliveries(), + ]); + } + + async loadChannels(): Promise { + this.channelLoading.set(true); + this.channelMessage.set(null); + try { + const channels = await firstValueFrom(this.api.listChannels()); + this.channels.set(channels); + if (channels.length) { + this.tenantId.set(channels[0].tenantId); + } + if (!this.selectedChannelId() && channels.length) { + this.selectChannel(channels[0].channelId); + } + } catch (error) { + this.channelMessage.set(this.toErrorMessage(error)); + } finally { + this.channelLoading.set(false); + } + } + + async loadRules(): Promise { + this.ruleLoading.set(true); + this.ruleMessage.set(null); + try { + const rules = await firstValueFrom(this.api.listRules()); + this.rules.set(rules); + if (!this.selectedRuleId() && rules.length) { + this.selectRule(rules[0].ruleId); + } + if (!this.ruleForm.controls.channel.value && this.channels().length) { + this.ruleForm.patchValue({ channel: this.channels()[0].channelId }); + } + } catch (error) { + this.ruleMessage.set(this.toErrorMessage(error)); + } finally { + this.ruleLoading.set(false); + } + } + + async loadDeliveries(): Promise { + this.deliveriesLoading.set(true); + this.deliveriesMessage.set(null); + try { + const options: NotifyDeliveriesQueryOptions = { + status: this.mapFilterToStatus(this.deliveryFilter()), + limit: 15, + }; + const response = await firstValueFrom( + this.api.listDeliveries(options) + ); + this.deliveries.set([...(response.items ?? [])]); + } catch (error) { + this.deliveriesMessage.set(this.toErrorMessage(error)); + } finally { + this.deliveriesLoading.set(false); + } + } + + selectChannel(channelId: string): void { + const channel = this.channels().find((c) => c.channelId === channelId); + if (!channel) { + return; + } + this.selectedChannelId.set(channelId); + this.channelForm.patchValue({ + channelId: channel.channelId, + name: channel.name, + displayName: channel.displayName ?? '', + description: channel.description ?? '', + type: channel.type, + target: channel.config.target ?? '', + endpoint: channel.config.endpoint ?? '', + secretRef: channel.config.secretRef, + enabled: channel.enabled, + labelsText: this.formatKeyValueMap(channel.labels), + metadataText: this.formatKeyValueMap(channel.metadata), + }); + this.testPreview.set(null); + void this.loadChannelHealth(channelId); + } + + selectRule(ruleId: string): void { + const rule = this.rules().find((r) => r.ruleId === ruleId); + if (!rule) { + return; + } + this.selectedRuleId.set(ruleId); + const action = rule.actions?.[0]; + this.ruleForm.patchValue({ + ruleId: rule.ruleId, + name: rule.name, + description: rule.description ?? '', + enabled: rule.enabled, + minSeverity: rule.match?.minSeverity ?? '', + eventKindsText: this.formatList(rule.match?.eventKinds ?? []), + labelsText: this.formatList(rule.match?.labels ?? []), + channel: action?.channel ?? this.channels()[0]?.channelId ?? '', + digest: action?.digest ?? '', + template: action?.template ?? '', + locale: action?.locale ?? '', + throttleSeconds: this.parseDuration(action?.throttle), + }); + } + + createChannelDraft(): void { + this.selectedChannelId.set(null); + this.channelForm.reset({ + channelId: '', + name: '', + displayName: '', + description: '', + type: 'Slack', + target: '', + endpoint: '', + secretRef: '', + enabled: true, + labelsText: '', + metadataText: '', + }); + this.channelHealth.set(null); + this.testPreview.set(null); + } + + createRuleDraft(): void { + this.selectedRuleId.set(null); + this.ruleForm.reset({ + ruleId: '', + name: '', + description: '', + enabled: true, + minSeverity: 'high', + eventKindsText: 'scanner.report.ready', + labelsText: '', + channel: this.channels()[0]?.channelId ?? '', + digest: 'instant', + template: '', + locale: 'en-US', + throttleSeconds: 0, + }); + } + + async saveChannel(): Promise { + if (this.channelForm.invalid) { + this.channelForm.markAllAsTouched(); + return; + } + + this.channelLoading.set(true); + this.channelMessage.set(null); + + try { + const payload = this.buildChannelPayload(); + const saved = await firstValueFrom(this.api.saveChannel(payload)); + await this.loadChannels(); + this.selectChannel(saved.channelId); + this.channelMessage.set('Channel saved successfully.'); + } catch (error) { + this.channelMessage.set(this.toErrorMessage(error)); + } finally { + this.channelLoading.set(false); + } + } + + async deleteChannel(): Promise { + const channelId = this.selectedChannelId(); + if (!channelId) { + return; + } + this.channelLoading.set(true); + this.channelMessage.set(null); + try { + await firstValueFrom(this.api.deleteChannel(channelId)); + await this.loadChannels(); + if (this.channels().length) { + this.selectChannel(this.channels()[0].channelId); + } else { + this.createChannelDraft(); + } + this.channelMessage.set('Channel deleted.'); + } catch (error) { + this.channelMessage.set(this.toErrorMessage(error)); + } finally { + this.channelLoading.set(false); + } + } + + async saveRule(): Promise { + if (this.ruleForm.invalid) { + this.ruleForm.markAllAsTouched(); + return; + } + this.ruleLoading.set(true); + this.ruleMessage.set(null); + try { + const payload = this.buildRulePayload(); + const saved = await firstValueFrom(this.api.saveRule(payload)); + await this.loadRules(); + this.selectRule(saved.ruleId); + this.ruleMessage.set('Rule saved successfully.'); + } catch (error) { + this.ruleMessage.set(this.toErrorMessage(error)); + } finally { + this.ruleLoading.set(false); + } + } + + async deleteRule(): Promise { + const ruleId = this.selectedRuleId(); + if (!ruleId) { + return; + } + this.ruleLoading.set(true); + this.ruleMessage.set(null); + try { + await firstValueFrom(this.api.deleteRule(ruleId)); + await this.loadRules(); + if (this.rules().length) { + this.selectRule(this.rules()[0].ruleId); + } else { + this.createRuleDraft(); + } + this.ruleMessage.set('Rule deleted.'); + } catch (error) { + this.ruleMessage.set(this.toErrorMessage(error)); + } finally { + this.ruleLoading.set(false); + } + } + + async sendTestPreview(): Promise { + const channelId = this.selectedChannelId(); + if (!channelId) { + this.channelMessage.set('Select a channel before running a test send.'); + return; + } + this.testSending.set(true); + this.channelMessage.set(null); + try { + const payload = this.testForm.getRawValue(); + const response = await firstValueFrom( + this.api.testChannel(channelId, { + target: payload.target || undefined, + title: payload.title || undefined, + summary: payload.summary || undefined, + body: payload.body || undefined, + textBody: payload.textBody || undefined, + }) + ); + this.testPreview.set(response); + this.channelMessage.set('Test send queued successfully.'); + await this.loadDeliveries(); + } catch (error) { + this.channelMessage.set(this.toErrorMessage(error)); + } finally { + this.testSending.set(false); + } + } + + async refreshDeliveries(): Promise { + await this.loadDeliveries(); + } + + onDeliveryFilterChange(rawValue: string): void { + const filter = this.isDeliveryFilter(rawValue) ? rawValue : 'all'; + this.deliveryFilter.set(filter); + void this.loadDeliveries(); + } + + trackByChannel = (_: number, item: NotifyChannel) => item.channelId; + trackByRule = (_: number, item: NotifyRule) => item.ruleId; + trackByDelivery = (_: number, item: NotifyDelivery) => item.deliveryId; + + private async loadChannelHealth(channelId: string): Promise { + try { + const response = await firstValueFrom( + this.api.getChannelHealth(channelId) + ); + this.channelHealth.set(response); + } catch { + this.channelHealth.set(null); + } + } + + private buildChannelPayload(): NotifyChannel { + const raw = this.channelForm.getRawValue(); + const existing = this.channels().find((c) => c.channelId === raw.channelId); + const now = new Date().toISOString(); + const channelId = raw.channelId?.trim() || this.generateId('chn'); + const tenantId = existing?.tenantId ?? this.tenantId(); + + return { + schemaVersion: existing?.schemaVersion ?? '1.0', + channelId, + tenantId, + name: raw.name.trim(), + displayName: raw.displayName?.trim() || undefined, + description: raw.description?.trim() || undefined, + type: raw.type, + enabled: raw.enabled, + config: { + secretRef: raw.secretRef.trim(), + target: raw.target?.trim() || undefined, + endpoint: raw.endpoint?.trim() || undefined, + properties: existing?.config.properties ?? {}, + limits: existing?.config.limits, + }, + labels: this.parseKeyValueText(raw.labelsText), + metadata: this.parseKeyValueText(raw.metadataText), + createdBy: existing?.createdBy ?? 'ui@stella-ops.local', + createdAt: existing?.createdAt ?? now, + updatedBy: 'ui@stella-ops.local', + updatedAt: now, + }; + } + + private buildRulePayload(): NotifyRule { + const raw = this.ruleForm.getRawValue(); + const existing = this.rules().find((r) => r.ruleId === raw.ruleId); + const now = new Date().toISOString(); + const ruleId = raw.ruleId?.trim() || this.generateId('rule'); + + const action: NotifyRuleAction = { + actionId: existing?.actions?.[0]?.actionId ?? this.generateId('act'), + channel: raw.channel ?? this.channels()[0]?.channelId ?? '', + template: raw.template?.trim() || undefined, + digest: raw.digest?.trim() || undefined, + locale: raw.locale?.trim() || undefined, + throttle: + raw.throttleSeconds && raw.throttleSeconds > 0 + ? this.formatDuration(raw.throttleSeconds) + : null, + enabled: true, + metadata: existing?.actions?.[0]?.metadata ?? {}, + }; + + return { + schemaVersion: existing?.schemaVersion ?? '1.0', + ruleId, + tenantId: existing?.tenantId ?? this.tenantId(), + name: raw.name.trim(), + description: raw.description?.trim() || undefined, + enabled: raw.enabled, + match: { + eventKinds: this.parseList(raw.eventKindsText), + labels: this.parseList(raw.labelsText), + minSeverity: raw.minSeverity?.trim() || null, + }, + actions: [action], + labels: existing?.labels ?? {}, + metadata: existing?.metadata ?? {}, + createdBy: existing?.createdBy ?? 'ui@stella-ops.local', + createdAt: existing?.createdAt ?? now, + updatedBy: 'ui@stella-ops.local', + updatedAt: now, + }; + } + + private parseKeyValueText(value?: string | null): Record { + const result: Record = {}; + if (!value) { + return result; + } + value + .split(/\r?\n|,/) + .map((entry) => entry.trim()) + .filter(Boolean) + .forEach((entry) => { + const [key, ...rest] = entry.split('='); + if (!key) { + return; + } + result[key.trim()] = rest.join('=').trim(); + }); + return result; + } + + private formatKeyValueMap( + map?: Record | null + ): string { + if (!map) { + return ''; + } + return Object.entries(map) + .map(([key, value]) => `${key}=${value}`) + .join('\n'); + } + + private parseList(value?: string | null): string[] { + if (!value) { + return []; + } + return value + .split(/\r?\n|,/) + .map((item) => item.trim()) + .filter(Boolean); + } + + private formatList(items: readonly string[]): string { + if (!items?.length) { + return ''; + } + return items.join('\n'); + } + + private parseDuration(duration?: string | null): number { + if (!duration) { + return 0; + } + if (duration.startsWith('PT')) { + const hours = extractNumber(duration, /([0-9]+)H/); + const minutes = extractNumber(duration, /([0-9]+)M/); + const seconds = extractNumber(duration, /([0-9]+)S/); + return hours * 3600 + minutes * 60 + seconds; + } + const parts = duration.split(':').map((p) => Number.parseInt(p, 10)); + if (parts.length === 3) { + return parts[0] * 3600 + parts[1] * 60 + parts[2]; + } + return Number.parseInt(duration, 10) || 0; + } + + private formatDuration(seconds: number): string { + const clamped = Math.max(0, Math.floor(seconds)); + const hrs = Math.floor(clamped / 3600); + const mins = Math.floor((clamped % 3600) / 60); + const secs = clamped % 60; + let result = 'PT'; + if (hrs) { + result += `${hrs}H`; + } + if (mins) { + result += `${mins}M`; + } + if (secs || result === 'PT') { + result += `${secs}S`; + } + return result; + } + + private mapFilterToStatus( + filter: DeliveryFilter + ): NotifyDeliveryStatus | undefined { + switch (filter) { + case 'pending': + return 'Pending'; + case 'sent': + return 'Sent'; + case 'failed': + return 'Failed'; + case 'throttled': + return 'Throttled'; + case 'digested': + return 'Digested'; + case 'dropped': + return 'Dropped'; + default: + return undefined; + } + } + + private isDeliveryFilter(value: string): value is DeliveryFilter { + return ( + value === 'all' || + value === 'pending' || + value === 'sent' || + value === 'failed' || + value === 'throttled' || + value === 'digested' || + value === 'dropped' + ); + } + + private toErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === 'string') { + return error; + } + return 'Operation failed. Please retry.'; + } + + private generateId(prefix: string): string { + return `${prefix}-${Math.random().toString(36).slice(2, 10)}`; + } +} + +function extractNumber(source: string, pattern: RegExp): number { + const match = source.match(pattern); + return match ? Number.parseInt(match[1], 10) : 0; +} diff --git a/src/StellaOps.Web/src/app/testing/mock-notify-api.service.ts b/src/StellaOps.Web/src/app/testing/mock-notify-api.service.ts new file mode 100644 index 00000000..d99ff837 --- /dev/null +++ b/src/StellaOps.Web/src/app/testing/mock-notify-api.service.ts @@ -0,0 +1,290 @@ +import { Injectable, signal } from '@angular/core'; +import { defer, Observable, of } from 'rxjs'; +import { delay } from 'rxjs/operators'; + +import { NotifyApi } from '../core/api/notify.client'; +import { + ChannelHealthResponse, + ChannelTestSendRequest, + ChannelTestSendResponse, + ChannelHealthStatus, + NotifyChannel, + NotifyDeliveriesQueryOptions, + NotifyDeliveriesResponse, + NotifyDelivery, + NotifyDeliveryRendered, + NotifyRule, +} from '../core/api/notify.models'; +import { + inferHealthStatus, + mockNotifyChannels, + mockNotifyDeliveries, + mockNotifyRules, + mockNotifyTenant, +} from './notify-fixtures'; + +const LATENCY_MS = 140; + +@Injectable({ providedIn: 'root' }) +export class MockNotifyApiService implements NotifyApi { + private readonly channels = signal( + clone(mockNotifyChannels) + ); + private readonly rules = signal(clone(mockNotifyRules)); + private readonly deliveries = signal( + clone(mockNotifyDeliveries) + ); + + listChannels(): Observable { + return this.simulate(() => this.channels()); + } + + saveChannel(channel: NotifyChannel): Observable { + const next = this.enrichChannel(channel); + this.channels.update((items) => upsertById(items, next, (c) => c.channelId)); + return this.simulate(() => next); + } + + deleteChannel(channelId: string): Observable { + this.channels.update((items) => items.filter((c) => c.channelId !== channelId)); + return this.simulate(() => undefined); + } + + getChannelHealth(channelId: string): Observable { + const channel = this.channels().find((c) => c.channelId === channelId); + const now = new Date().toISOString(); + const status: ChannelHealthStatus = channel + ? inferHealthStatus(channel.enabled, !!channel.config.target) + : 'Unhealthy'; + + const response: ChannelHealthResponse = { + tenantId: mockNotifyTenant, + channelId, + status, + message: + status === 'Healthy' + ? 'Channel configuration validated.' + : status === 'Degraded' + ? 'Channel disabled. Enable to resume deliveries.' + : 'Channel is missing a destination target or endpoint.', + checkedAt: now, + traceId: this.traceId(), + metadata: channel?.metadata ?? {}, + }; + + return this.simulate(() => response, 90); + } + + testChannel( + channelId: string, + payload: ChannelTestSendRequest + ): Observable { + const channel = this.channels().find((c) => c.channelId === channelId); + const preview: NotifyDeliveryRendered = { + channelType: channel?.type ?? 'Slack', + format: channel?.type === 'Email' ? 'Email' : 'Slack', + target: + payload.target ?? channel?.config.target ?? channel?.config.endpoint ?? 'demo@stella-ops.org', + title: payload.title ?? 'Notify preview — policy verdict change', + body: + payload.body ?? + 'Sample preview payload emitted by the mocked Notify API integration.', + summary: payload.summary ?? 'Mock delivery queued.', + textBody: payload.textBody, + locale: payload.locale ?? 'en-US', + attachments: payload.attachments ?? [], + }; + + const response: ChannelTestSendResponse = { + tenantId: mockNotifyTenant, + channelId, + preview, + queuedAt: new Date().toISOString(), + traceId: this.traceId(), + metadata: { + source: 'mock-service', + }, + }; + + this.appendDeliveryFromPreview(channelId, preview); + + return this.simulate(() => response, 180); + } + + listRules(): Observable { + return this.simulate(() => this.rules()); + } + + saveRule(rule: NotifyRule): Observable { + const next = this.enrichRule(rule); + this.rules.update((items) => upsertById(items, next, (r) => r.ruleId)); + return this.simulate(() => next); + } + + deleteRule(ruleId: string): Observable { + this.rules.update((items) => items.filter((rule) => rule.ruleId !== ruleId)); + return this.simulate(() => undefined); + } + + listDeliveries( + options?: NotifyDeliveriesQueryOptions + ): Observable { + const filtered = this.filterDeliveries(options); + const payload: NotifyDeliveriesResponse = { + items: filtered, + continuationToken: null, + count: filtered.length, + }; + return this.simulate(() => payload); + } + + private enrichChannel(channel: NotifyChannel): NotifyChannel { + const now = new Date().toISOString(); + const current = this.channels().find((c) => c.channelId === channel.channelId); + return { + schemaVersion: channel.schemaVersion ?? current?.schemaVersion ?? '1.0', + channelId: channel.channelId || this.randomId('chn'), + tenantId: channel.tenantId || mockNotifyTenant, + name: channel.name, + displayName: channel.displayName, + description: channel.description, + type: channel.type, + enabled: channel.enabled, + config: { + ...channel.config, + properties: channel.config.properties ?? current?.config.properties ?? {}, + }, + labels: channel.labels ?? current?.labels ?? {}, + metadata: channel.metadata ?? current?.metadata ?? {}, + createdBy: current?.createdBy ?? 'ui@stella-ops.org', + createdAt: current?.createdAt ?? now, + updatedBy: 'ui@stella-ops.org', + updatedAt: now, + }; + } + + private enrichRule(rule: NotifyRule): NotifyRule { + const now = new Date().toISOString(); + const current = this.rules().find((r) => r.ruleId === rule.ruleId); + return { + schemaVersion: rule.schemaVersion ?? current?.schemaVersion ?? '1.0', + ruleId: rule.ruleId || this.randomId('rule'), + tenantId: rule.tenantId || mockNotifyTenant, + name: rule.name, + description: rule.description, + enabled: rule.enabled, + match: rule.match, + actions: rule.actions?.length + ? rule.actions + : current?.actions ?? [], + labels: rule.labels ?? current?.labels ?? {}, + metadata: rule.metadata ?? current?.metadata ?? {}, + createdBy: current?.createdBy ?? 'ui@stella-ops.org', + createdAt: current?.createdAt ?? now, + updatedBy: 'ui@stella-ops.org', + updatedAt: now, + }; + } + + private appendDeliveryFromPreview( + channelId: string, + preview: NotifyDeliveryRendered + ): void { + const now = new Date().toISOString(); + const delivery: NotifyDelivery = { + deliveryId: this.randomId('dlv'), + tenantId: mockNotifyTenant, + ruleId: 'rule-critical-soc', + actionId: 'act-slack-critical', + eventId: cryptoRandomUuid(), + kind: 'notify.preview', + status: 'Sent', + statusReason: 'Preview enqueued (mock)', + rendered: preview, + attempts: [ + { + timestamp: now, + status: 'Enqueued', + statusCode: 202, + }, + { + timestamp: now, + status: 'Succeeded', + statusCode: 200, + }, + ], + metadata: { + previewChannel: channelId, + }, + createdAt: now, + sentAt: now, + completedAt: now, + }; + + this.deliveries.update((items) => [delivery, ...items].slice(0, 20)); + } + + private filterDeliveries( + options?: NotifyDeliveriesQueryOptions + ): NotifyDelivery[] { + const source = this.deliveries(); + const since = options?.since ? Date.parse(options.since) : null; + const status = options?.status; + + return source + .filter((item) => { + const matchStatus = status ? item.status === status : true; + const matchSince = since ? Date.parse(item.createdAt) >= since : true; + return matchStatus && matchSince; + }) + .slice(0, options?.limit ?? 15); + } + + private simulate(factory: () => T, ms: number = LATENCY_MS): Observable { + return defer(() => of(clone(factory()))).pipe(delay(ms)); + } + + private randomId(prefix: string): string { + const raw = cryptoRandomUuid().replace(/-/g, '').slice(0, 12); + return `${prefix}-${raw}`; + } + + private traceId(): string { + return `trace-${cryptoRandomUuid()}`; + } +} + +function upsertById( + collection: readonly T[], + entity: T, + selector: (item: T) => string +): T[] { + const id = selector(entity); + const next = [...collection]; + const index = next.findIndex((item) => selector(item) === id); + if (index >= 0) { + next[index] = entity; + } else { + next.unshift(entity); + } + return next; +} + +function clone(value: T): T { + if (typeof structuredClone === 'function') { + return structuredClone(value); + } + return JSON.parse(JSON.stringify(value)) as T; +} + +function cryptoRandomUuid(): string { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID(); + } + const template = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'; + return template.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} diff --git a/src/StellaOps.Web/src/app/testing/notify-fixtures.ts b/src/StellaOps.Web/src/app/testing/notify-fixtures.ts new file mode 100644 index 00000000..6383ffdf --- /dev/null +++ b/src/StellaOps.Web/src/app/testing/notify-fixtures.ts @@ -0,0 +1,257 @@ +import { + ChannelHealthStatus, + NotifyChannel, + NotifyDelivery, + NotifyDeliveryAttemptStatus, + NotifyDeliveryStatus, + NotifyRule, +} from '../core/api/notify.models'; + +export const mockNotifyTenant = 'tenant-dev'; + +export const mockNotifyChannels: NotifyChannel[] = [ + { + channelId: 'chn-slack-soc', + tenantId: mockNotifyTenant, + name: 'slack-soc', + displayName: 'Slack · SOC', + description: 'Critical scanner verdicts routed to the SOC war room.', + type: 'Slack', + enabled: true, + config: { + secretRef: 'ref://notify/slack/soc-token', + target: '#stellaops-soc', + properties: { + emoji: ':rotating_light:', + unfurl: 'false', + }, + }, + labels: { + tier: 'critical', + region: 'global', + }, + metadata: { + workspace: 'stellaops', + }, + createdBy: 'ops@stella-ops.org', + createdAt: '2025-10-10T08:12:00Z', + updatedBy: 'ops@stella-ops.org', + updatedAt: '2025-10-23T11:05:00Z', + }, + { + channelId: 'chn-email-comms', + tenantId: mockNotifyTenant, + name: 'email-compliance', + displayName: 'Email · Compliance Digest', + description: 'Hourly compliance digest for licensing/secrets alerts.', + type: 'Email', + enabled: true, + config: { + secretRef: 'ref://notify/smtp/compliance', + target: 'compliance@stella-ops.org', + }, + labels: { + cadence: 'hourly', + }, + metadata: { + smtpProfile: 'smtp.internal', + }, + createdBy: 'legal@stella-ops.org', + createdAt: '2025-10-08T14:31:00Z', + updatedBy: 'legal@stella-ops.org', + updatedAt: '2025-10-20T09:44:00Z', + }, + { + channelId: 'chn-webhook-intake', + tenantId: mockNotifyTenant, + name: 'webhook-opsbridge', + displayName: 'Webhook · OpsBridge', + description: 'Bridges Notify events into OpsBridge for automation.', + type: 'Webhook', + enabled: false, + config: { + secretRef: 'ref://notify/webhook/signing', + endpoint: 'https://opsbridge.internal/hooks/notify', + }, + labels: { + env: 'staging', + }, + metadata: { + signature: 'ed25519', + }, + createdBy: 'platform@stella-ops.org', + createdAt: '2025-10-05T12:01:00Z', + updatedBy: 'platform@stella-ops.org', + updatedAt: '2025-10-18T17:22:00Z', + }, +]; + +export const mockNotifyRules: NotifyRule[] = [ + { + ruleId: 'rule-critical-soc', + tenantId: mockNotifyTenant, + name: 'Critical scanner verdicts', + description: + 'Route KEV-tagged critical findings to SOC Slack with zero delay.', + enabled: true, + match: { + eventKinds: ['scanner.report.ready'], + labels: ['kev', 'critical'], + minSeverity: 'critical', + verdicts: ['block', 'escalate'], + kevOnly: true, + }, + actions: [ + { + actionId: 'act-slack-critical', + channel: 'chn-slack-soc', + template: 'tmpl-critical', + digest: 'instant', + throttle: 'PT300S', + locale: 'en-US', + enabled: true, + metadata: { + priority: 'p1', + }, + }, + ], + labels: { + owner: 'soc', + }, + metadata: { + revision: '12', + }, + createdBy: 'soc@stella-ops.org', + createdAt: '2025-10-12T10:02:00Z', + updatedBy: 'soc@stella-ops.org', + updatedAt: '2025-10-23T15:44:00Z', + }, + { + ruleId: 'rule-digest-compliance', + tenantId: mockNotifyTenant, + name: 'Compliance hourly digest', + description: 'Summarise licensing + secret alerts once per hour.', + enabled: true, + match: { + eventKinds: ['scanner.scan.completed', 'scanner.report.ready'], + labels: ['compliance'], + minSeverity: 'medium', + kevOnly: false, + vex: { + includeAcceptedJustifications: true, + includeRejectedJustifications: false, + includeUnknownJustifications: true, + justificationKinds: ['exploitable', 'component_not_present'], + }, + }, + actions: [ + { + actionId: 'act-email-compliance', + channel: 'chn-email-comms', + digest: '1h', + throttle: 'PT1H', + enabled: true, + metadata: { + layout: 'digest', + }, + }, + ], + labels: { + owner: 'compliance', + }, + metadata: { + frequency: 'hourly', + }, + createdBy: 'compliance@stella-ops.org', + createdAt: '2025-10-09T06:15:00Z', + updatedBy: 'compliance@stella-ops.org', + updatedAt: '2025-10-21T19:45:00Z', + }, +]; + +const deliveryStatuses: NotifyDeliveryStatus[] = [ + 'Sent', + 'Failed', + 'Throttled', +]; + +export const mockNotifyDeliveries: NotifyDelivery[] = deliveryStatuses.map( + (status, index) => { + const now = new Date('2025-10-24T12:00:00Z').getTime(); + const created = new Date(now - index * 20 * 60 * 1000).toISOString(); + const attemptsStatus: NotifyDeliveryAttemptStatus = + status === 'Sent' ? 'Succeeded' : status === 'Failed' ? 'Failed' : 'Throttled'; + + return { + deliveryId: `dlv-${index + 1}`, + tenantId: mockNotifyTenant, + ruleId: index === 0 ? 'rule-critical-soc' : 'rule-digest-compliance', + actionId: index === 0 ? 'act-slack-critical' : 'act-email-compliance', + eventId: `00000000-0000-0000-0000-${(index + 1) + .toString() + .padStart(12, '0')}`, + kind: index === 0 ? 'scanner.report.ready' : 'scanner.scan.completed', + status, + statusReason: + status === 'Sent' + ? 'Delivered' + : status === 'Failed' + ? 'Channel timeout (Slack API)' + : 'Rule throttled (digest window).', + rendered: { + channelType: index === 0 ? 'Slack' : 'Email', + format: index === 0 ? 'Slack' : 'Email', + target: index === 0 ? '#stellaops-soc' : 'compliance@stella-ops.org', + title: + index === 0 + ? 'Critical CVE flagged for registry.git.stella-ops.org' + : 'Hourly compliance digest (#23)', + body: + index === 0 + ? 'KEV CVE-2025-1234 detected in ubuntu:24.04. Rescan triggered.' + : '3 findings require compliance review. See attached report.', + summary: index === 0 ? 'Immediate attention required.' : 'Digest only.', + locale: 'en-US', + attachments: index === 0 ? [] : ['https://scanner.local/reports/digest-23'], + }, + attempts: [ + { + timestamp: created, + status: 'Sending', + statusCode: 202, + }, + { + timestamp: created, + status: attemptsStatus, + statusCode: status === 'Sent' ? 200 : 429, + reason: + status === 'Failed' + ? 'Slack API returned 504' + : status === 'Throttled' + ? 'Digest window open' + : undefined, + }, + ], + metadata: { + batch: `window-${index + 1}`, + }, + createdAt: created, + sentAt: created, + completedAt: created, + } satisfies NotifyDelivery; + } +); + +export function inferHealthStatus( + enabled: boolean, + hasTarget: boolean +): ChannelHealthStatus { + if (!hasTarget) { + return 'Unhealthy'; + } + if (!enabled) { + return 'Degraded'; + } + return 'Healthy'; +} + diff --git a/src/StellaOps.Zastava.Observer.Tests/Worker/RuntimeEventFactoryTests.cs b/src/StellaOps.Zastava.Observer.Tests/Worker/RuntimeEventFactoryTests.cs new file mode 100644 index 00000000..4fde6065 --- /dev/null +++ b/src/StellaOps.Zastava.Observer.Tests/Worker/RuntimeEventFactoryTests.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using StellaOps.Zastava.Core.Contracts; +using StellaOps.Zastava.Observer.Configuration; +using StellaOps.Zastava.Observer.ContainerRuntime; +using StellaOps.Zastava.Observer.ContainerRuntime.Cri; +using StellaOps.Zastava.Observer.Runtime; +using StellaOps.Zastava.Observer.Worker; +using Xunit; + +namespace StellaOps.Zastava.Observer.Tests.Worker; + +public sealed class RuntimeEventFactoryTests +{ + [Fact] + public void Create_AttachesBuildIdFromProcessCapture() + { + var timestamp = DateTimeOffset.UtcNow; + var snapshot = new CriContainerInfo( + Id: "container-a", + PodSandboxId: "sandbox-a", + Name: "api", + Attempt: 1, + Image: "ghcr.io/example/api:1.0", + ImageRef: "ghcr.io/example/api@sha256:deadbeef", + Labels: new Dictionary + { + [CriLabelKeys.PodName] = "api-abc", + [CriLabelKeys.PodNamespace] = "payments", + [CriLabelKeys.ContainerName] = "api" + }, + Annotations: new Dictionary(), + CreatedAt: timestamp, + StartedAt: timestamp, + FinishedAt: null, + ExitCode: null, + Reason: null, + Message: null, + Pid: 4321); + + var lifecycleEvent = new ContainerLifecycleEvent(ContainerLifecycleEventKind.Start, timestamp, snapshot); + var endpoint = new ContainerRuntimeEndpointOptions + { + Engine = ContainerRuntimeEngine.Containerd, + Endpoint = "unix:///run/containerd/containerd.sock", + Name = "containerd" + }; + var identity = new CriRuntimeIdentity("containerd", "1.7.19", "v1"); + var process = new RuntimeProcess + { + Pid = 4321, + Entrypoint = new[] { "/entrypoint.sh" }, + EntryTrace = Array.Empty(), + BuildId = "5f0c7c3cb4d9f8a4" + }; + var capture = new RuntimeProcessCapture( + process, + Array.Empty(), + new List()); + + var envelope = RuntimeEventFactory.Create( + lifecycleEvent, + endpoint, + identity, + tenant: "tenant-alpha", + nodeName: "node-1", + capture: capture, + posture: null, + additionalEvidence: null); + + Assert.NotNull(envelope.Event.Process); + Assert.Equal("5f0c7c3cb4d9f8a4", envelope.Event.Process!.BuildId); + } +} diff --git a/src/StellaOps.Zastava.Observer/TASKS.md b/src/StellaOps.Zastava.Observer/TASKS.md index 6254b21c..68dc1821 100644 --- a/src/StellaOps.Zastava.Observer/TASKS.md +++ b/src/StellaOps.Zastava.Observer/TASKS.md @@ -6,6 +6,6 @@ | ZASTAVA-OBS-12-002 | DONE (2025-10-24) | Zastava Observer Guild | ZASTAVA-OBS-12-001 | Capture entrypoint traces and loaded libraries, hashing binaries and correlating to SBOM baseline per architecture sections 2.1 and 10. | EntryTrace parser covers shell/python/node launchers, loaded library hashes recorded, fixtures assert linkage to SBOM usage view. | | ZASTAVA-OBS-12-003 | DONE (2025-10-24) | Zastava Observer Guild | ZASTAVA-OBS-12-002 | Implement runtime posture checks (signature/SBOM/attestation presence) with offline caching and warning surfaces. | Observer marks posture status, caches refresh across restarts, integration tests prove offline tolerance. | | ZASTAVA-OBS-12-004 | DONE (2025-10-24) | Zastava Observer Guild | ZASTAVA-OBS-12-002 | Batch `/runtime/events` submissions with disk-backed buffer, rate limits, and deterministic envelopes. | Buffered submissions survive restart, rate-limits enforced in tests, JSON envelopes match schema in docs/events. | -| ZASTAVA-OBS-17-005 | DOING (2025-10-24) | Zastava Observer Guild | ZASTAVA-OBS-12-002 | Collect GNU build-id for ELF processes and attach it to emitted runtime events to enable symbol lookup + debug-store correlation. | Observer reads build-id via `/proc//exe`/notes without pausing workloads, runtime events include `buildId` field, fixtures cover glibc/musl images, docs updated with retrieval notes. | +| ZASTAVA-OBS-17-005 | DONE (2025-10-25) | Zastava Observer Guild | ZASTAVA-OBS-12-002 | Collect GNU build-id for ELF processes and attach it to emitted runtime events to enable symbol lookup + debug-store correlation. | Build-id extraction feeds RuntimeEvent envelopes plus Scanner policy downstream; unit tests cover capture + envelope wiring, and ops runbook documents retrieval + debug-store mapping. | > 2025-10-24: Observer unit tests pending; `dotnet restore` requires offline copies of `Google.Protobuf`, `Grpc.Net.Client`, `Grpc.Tools` in `local-nuget` before execution can be verified.