diff --git a/.gitea/workflows/docs.yml b/.gitea/workflows/docs.yml index 3812c428..612a0668 100755 --- a/.gitea/workflows/docs.yml +++ b/.gitea/workflows/docs.yml @@ -37,7 +37,12 @@ jobs: - name: Install documentation toolchain run: | npm install --no-save markdown-link-check remark-cli remark-preset-lint-recommended ajv ajv-cli ajv-formats - + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.100-rc.2.25502.107' + - name: Link check run: | find docs -name '*.md' -print0 | \ @@ -62,9 +67,13 @@ jobs: fi npx ajv validate -c ajv-formats -s "$schema_path" -d "$sample" done - - - name: Setup Python - uses: actions/setup-python@v5 + + - name: Run Notify schema validation tests + run: | + dotnet test src/StellaOps.Notify.Models.Tests/StellaOps.Notify.Models.Tests.csproj --configuration Release --nologo + + - name: Setup Python + uses: actions/setup-python@v5 with: python-version: ${{ env.PYTHON_VERSION }} diff --git a/EXECPLAN.md b/EXECPLAN.md index 87538f1c..18487340 100644 --- a/EXECPLAN.md +++ b/EXECPLAN.md @@ -38,7 +38,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster - Team Team Excititor Connectors – Oracle: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Excititor.Connectors.Oracle.CSAF/TASKS.md`. Focus on EXCITITOR-CONN-ORACLE-01-001 (DOING). Confirm prerequisites (external: EXCITITOR-CONN-ABS-01-001) before starting and report status in module TASKS.md. - Team Team Excititor Connectors – SUSE: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Excititor.Connectors.SUSE.RancherVEXHub/TASKS.md`. Focus on EXCITITOR-CONN-SUSE-01-002 (TODO). Confirm prerequisites (external: EXCITITOR-CONN-SUSE-01-001, EXCITITOR-STORAGE-01-003) before starting and report status in module TASKS.md. - Team Team Excititor Connectors – Ubuntu: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Excititor.Connectors.Ubuntu.CSAF/TASKS.md`. Focus on EXCITITOR-CONN-UBUNTU-01-002 (TODO). Confirm prerequisites (external: EXCITITOR-CONN-UBUNTU-01-001, EXCITITOR-STORAGE-01-003) before starting and report status in module TASKS.md. -- Team Team Excititor Export: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Excititor.Export/TASKS.md`. Focus on EXCITITOR-EXPORT-01-005 (TODO). Confirm prerequisites (external: EXCITITOR-CORE-02-001, EXCITITOR-EXPORT-01-004) before starting and report status in module TASKS.md. +- Team Team Excititor Export: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Excititor.Export/TASKS.md`. Focus on EXCITITOR-EXPORT-01-005 (DONE 2025-10-21). Confirm prerequisites (external: EXCITITOR-CORE-02-001, EXCITITOR-EXPORT-01-004) before starting and report status in module TASKS.md. - Team Team Excititor Formats: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Excititor.Formats.CSAF/TASKS.md`, `src/StellaOps.Excititor.Formats.CycloneDX/TASKS.md`, `src/StellaOps.Excititor.Formats.OpenVEX/TASKS.md`. Focus on EXCITITOR-FMT-CSAF-01-002 (TODO), EXCITITOR-FMT-CSAF-01-003 (TODO), EXCITITOR-FMT-CYCLONE-01-002 (TODO), EXCITITOR-FMT-CYCLONE-01-003 (TODO), EXCITITOR-FMT-OPENVEX-01-002 (TODO), EXCITITOR-FMT-OPENVEX-01-003 (TODO). Confirm prerequisites (external: EXCITITOR-EXPORT-01-001, EXCITITOR-FMT-CSAF-01-001, EXCITITOR-FMT-CYCLONE-01-001, EXCITITOR-FMT-OPENVEX-01-001, EXCITITOR-POLICY-01-001) before starting and report status in module TASKS.md. - Team Team Excititor Storage: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Excititor.Storage.Mongo/TASKS.md`. Focus on EXCITITOR-STORAGE-MONGO-08-001 (DONE 2025-10-19), EXCITITOR-STORAGE-03-001 (TODO). Confirm prerequisites (external: EXCITITOR-STORAGE-01-003, EXCITITOR-STORAGE-02-001) before starting and report status in module TASKS.md. - Team Team Excititor WebService: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Excititor.WebService/TASKS.md`. Focus on EXCITITOR-WEB-01-002 (DONE 2025-10-20), EXCITITOR-WEB-01-003 (TODO), EXCITITOR-WEB-01-004 (DONE 2025-10-20). Confirm prerequisites (external: EXCITITOR-ATTEST-01-001, EXCITITOR-EXPORT-01-001, EXCITITOR-WEB-01-001) before starting and report status in module TASKS.md. @@ -47,7 +47,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster - Team Team Normalization & Storage Backbone: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Concelier.Storage.Mongo/TASKS.md`. Focus on FEEDSTORAGE-MONGO-08-001 (DONE 2025-10-19). Confirm prerequisites (none) before starting and report status in module TASKS.md. - Team Team WebService & Authority: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/TASKS.md`, `src/StellaOps.Concelier.WebService/TASKS.md`. Focus on SEC2.PLG (DOING), SEC3.PLG (DOING), SEC5.PLG (DOING), PLG4-6.CAPABILITIES (BLOCKED), PLG6.DIAGRAM (TODO), PLG7.RFC (REVIEW), FEEDWEB-DOCS-01-001 (DOING), FEEDWEB-OPS-01-006 (TODO), FEEDWEB-OPS-01-007 (BLOCKED). Confirm prerequisites (none) before starting and report status in module TASKS.md. - Team Tools Guild, BE-Conn-MSRC: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Concelier.Connector.Common/TASKS.md`. Focus on FEEDCONN-SHARED-STATE-003 (**TODO). Confirm prerequisites (none) before starting and report status in module TASKS.md. -- Team UX Specialist, Angular Eng: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Web/TASKS.md`. Focus on WEB1.TRIVY-SETTINGS (DONE 2025-10-21) and WEB1.TRIVY-SETTINGS-TESTS (BLOCKED 2025-10-21). Confirm prerequisites (none) before starting and report status in module TASKS.md. +- Team UX Specialist, Angular Eng: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Web/TASKS.md`. Focus on WEB1.TRIVY-SETTINGS (DONE 2025-10-21), WEB1.TRIVY-SETTINGS-TESTS (DONE 2025-10-21), and WEB1.DEPS-13-001 (TODO). Confirm prerequisites (none) before starting and report status in module TASKS.md. - Team Zastava Core Guild: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Zastava.Core/TASKS.md`. Focus on ZASTAVA-CORE-12-201 (TODO), ZASTAVA-CORE-12-202 (TODO), ZASTAVA-CORE-12-203 (TODO), ZASTAVA-OPS-12-204 (TODO). Confirm prerequisites (none) before starting and report status in module TASKS.md. - Team Zastava Webhook Guild: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Zastava.Webhook/TASKS.md`. Focus on ZASTAVA-WEBHOOK-12-101 (TODO), ZASTAVA-WEBHOOK-12-102 (TODO), ZASTAVA-WEBHOOK-12-103 (TODO). Confirm prerequisites (none) before starting and report status in module TASKS.md. @@ -73,7 +73,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster - Team Team Excititor Connectors – Oracle: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Excititor.Connectors.Oracle.CSAF/TASKS.md`. Focus on EXCITITOR-CONN-ORACLE-01-002 (TODO). Confirm prerequisites (internal: EXCITITOR-CONN-ORACLE-01-001 (Wave 0); external: EXCITITOR-STORAGE-01-003) before starting and report status in module TASKS.md. - Team Team Excititor Connectors – SUSE: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Excititor.Connectors.SUSE.RancherVEXHub/TASKS.md`. Focus on EXCITITOR-CONN-SUSE-01-003 (TODO). Confirm prerequisites (internal: EXCITITOR-CONN-SUSE-01-002 (Wave 0); external: EXCITITOR-POLICY-01-001) before starting and report status in module TASKS.md. - 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 (TODO). 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 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 (TODO), UI-VEX-13-003 (TODO), UI-POLICY-13-007 (TODO), UI-ADMIN-13-004 (TODO), UI-AUTH-13-001 (TODO), UI-SCANS-13-002 (TODO), UI-NOTIFY-13-006 (DOING), 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 (TODO). Confirm prerequisites (internal: ZASTAVA-CORE-12-201 (Wave 0)) before starting and report status in module TASKS.md. @@ -82,7 +82,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster - 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-17-002 (TODO). Confirm prerequisites (internal: BENCH-SCANNER-10-002 (Wave 1), DEVOPS-REL-14-001 (Wave 1), SCANNER-EMIT-17-701 (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-17-002 (TODO), and DEVOPS-NUGET-13-001 (TODO). Confirm prerequisites (internal: BENCH-SCANNER-10-002 (Wave 1), DEVOPS-REL-14-001 (Wave 1), SCANNER-EMIT-17-701 (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 (TODO), NOTIFY-QUEUE-15-402 (TODO). Confirm prerequisites (internal: NOTIFY-QUEUE-15-401 (Wave 1)) before starting and report status in module TASKS.md. @@ -96,13 +96,13 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster - Team Scheduler Worker Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `src/StellaOps.Scheduler.Worker/TASKS.md`. Focus on SCHED-WORKER-16-202 (TODO), SCHED-WORKER-16-205 (TODO). Confirm prerequisites (internal: SCHED-IMPACT-16-301 (Wave 1), SCHED-WORKER-16-201 (Wave 1)) before starting and report status in module TASKS.md. - Team TBD: read EXECPLAN.md Wave 2 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`. Focus on SCANNER-ANALYZERS-LANG-10-305B (TODO), SCANNER-ANALYZERS-LANG-10-304B (TODO), SCANNER-ANALYZERS-LANG-10-308N (TODO), SCANNER-ANALYZERS-LANG-10-303B (TODO), SCANNER-ANALYZERS-LANG-10-306B (TODO). Confirm prerequisites (internal: SCANNER-ANALYZERS-LANG-10-303A (Wave 1), SCANNER-ANALYZERS-LANG-10-304A (Wave 1), SCANNER-ANALYZERS-LANG-10-305A (Wave 1), SCANNER-ANALYZERS-LANG-10-306A (Wave 1), SCANNER-ANALYZERS-LANG-10-307N (Wave 1)) before starting and report status in module TASKS.md. - Team Team Excititor Connectors – Oracle: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `src/StellaOps.Excititor.Connectors.Oracle.CSAF/TASKS.md`. Focus on EXCITITOR-CONN-ORACLE-01-003 (TODO). Confirm prerequisites (internal: EXCITITOR-CONN-ORACLE-01-002 (Wave 1); external: EXCITITOR-POLICY-01-001) before starting and report status in module TASKS.md. -- Team Team Excititor Export: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `src/StellaOps.Excititor.Export/TASKS.md`. Focus on EXCITITOR-EXPORT-01-007 (TODO). Confirm prerequisites (internal: EXCITITOR-EXPORT-01-006 (Wave 1)) before starting and report status in module TASKS.md. +- Team Team Excititor Export: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `src/StellaOps.Excititor.Export/TASKS.md`. Focus on EXCITITOR-EXPORT-01-007 (DONE 2025-10-21). Confirm prerequisites (internal: EXCITITOR-EXPORT-01-006 (Wave 1)) before starting and report status in module TASKS.md. - Team Zastava Observer Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `src/StellaOps.Zastava.Observer/TASKS.md`. Focus on ZASTAVA-OBS-12-002 (TODO). Confirm prerequisites (internal: ZASTAVA-OBS-12-001 (Wave 1)) before starting and report status in module TASKS.md. ### Wave 3 - Team DevEx/CLI: read EXECPLAN.md Wave 3 and SPRINTS.md rows for `src/StellaOps.Cli/TASKS.md`. Focus on CLI-OFFLINE-13-006 (DONE 2025-10-21). Confirm prerequisites (internal: DEVOPS-OFFLINE-14-002 (Wave 2)) before starting and report status in module TASKS.md. - Team DevEx/CLI, Scanner WebService Guild: read EXECPLAN.md Wave 3 and SPRINTS.md rows for `src/StellaOps.Cli/TASKS.md`. Focus on CLI-RUNTIME-13-008 (TODO). Confirm prerequisites (internal: SCANNER-RUNTIME-12-302 (Wave 2)) before starting and report status in module TASKS.md. -- Team Excititor Connectors – Stella: read EXECPLAN.md Wave 3 and SPRINTS.md rows for `src/StellaOps.Excititor.Connectors.StellaOpsMirror/TASKS.md`. Focus on EXCITITOR-CONN-STELLA-07-001 (TODO). Confirm prerequisites (internal: EXCITITOR-EXPORT-01-007 (Wave 2)) before starting and report status in module TASKS.md. +- Team Excititor Connectors – Stella: read EXECPLAN.md Wave 3 and SPRINTS.md rows for `src/StellaOps.Excititor.Connectors.StellaOpsMirror/TASKS.md`. Focus on EXCITITOR-CONN-STELLA-07-001 (DONE 2025-10-21). Confirm prerequisites (internal: EXCITITOR-EXPORT-01-007 (Wave 2)) before starting and report status in module TASKS.md. - Team Notify Engine Guild: read EXECPLAN.md Wave 3 and SPRINTS.md rows for `src/StellaOps.Notify.Engine/TASKS.md`. Focus on NOTIFY-ENGINE-15-303 (TODO). Confirm prerequisites (internal: NOTIFY-ENGINE-15-302 (Wave 2)) before starting and report status in module TASKS.md. - 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. @@ -137,10 +137,10 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster - Team Team Core Engine & Data Science: read EXECPLAN.md Wave 8 and SPRINTS.md rows for `src/StellaOps.Concelier.Core/TASKS.md`. Focus on FEEDCORE-ENGINE-07-002 (DONE 2025-10-21). Confirm prerequisites (internal: FEEDCORE-ENGINE-07-001 (Wave 7)) before starting and report status in module TASKS.md. ### Wave 9 -- Team Team Core Engine & Storage Analytics: read EXECPLAN.md Wave 9 and SPRINTS.md rows for `src/StellaOps.Concelier.Core/TASKS.md`. Focus on FEEDCORE-ENGINE-07-003 (TODO). Confirm prerequisites (internal: FEEDCORE-ENGINE-07-001 (Wave 7)) before starting and report status in module TASKS.md. +- Team Team Core Engine & Storage Analytics: read EXECPLAN.md Wave 9 and SPRINTS.md rows for `src/StellaOps.Concelier.Core/TASKS.md`. FEEDCORE-ENGINE-07-003 marked DONE (2025-10-21); share ledger heuristics with Policy when integrating confidence decay. ### Wave 10 -- Team Team Normalization & Storage Backbone: read EXECPLAN.md Wave 10 and SPRINTS.md rows for `src/StellaOps.Concelier.Storage.Mongo/TASKS.md`. Focus on FEEDSTORAGE-DATA-07-001 (TODO). Confirm prerequisites (internal: FEEDMERGE-ENGINE-07-001 (Wave 11)) before starting and report status in module TASKS.md. +- Team Team Normalization & Storage Backbone: read EXECPLAN.md Wave 10 and SPRINTS.md rows for `src/StellaOps.Concelier.Storage.Mongo/TASKS.md`. Focus on FEEDSTORAGE-DATA-07-001 (DONE 2025-10-19). Confirm prerequisites (internal: FEEDMERGE-ENGINE-07-001 (Wave 11)) before starting and report status in module TASKS.md. ### Wave 11 - Team BE-Merge: read EXECPLAN.md Wave 11 and SPRINTS.md rows for `src/StellaOps.Concelier.Merge/TASKS.md`. FEEDMERGE-ENGINE-07-001 marked DONE (2025-10-20); share conflict explainer rollout notes with Storage before Wave 10 resumes. @@ -167,12 +167,12 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster - **Sprint 1** · Backlog - Team: UX Specialist, Angular Eng - Path: `src/StellaOps.Web/TASKS.md` - 1. [DONE] WEB1.TRIVY-SETTINGS — Implement Trivy DB exporter settings panel with `publishFull`, `publishDelta`, `includeFull`, `includeDelta` toggles and “Run export now” action using future `/exporters/trivy-db/settings` API. - • Prereqs: — - • Current: DONE (2025-10-21) – Angular route `/concelier/trivy-db-settings` with reactive form, API client, and run-now workflow built; see `TrivyDbSettingsPageComponent`. - 2. [BLOCKED] WEB1.TRIVY-SETTINGS-TESTS — Add headless UI test run (`ng test --watch=false`) and document prerequisites once Angular tooling is chained up. + 2. [DONE 2025-10-21] WEB1.TRIVY-SETTINGS-TESTS — Add headless UI test run (`ng test --watch=false`) and document prerequisites once Angular tooling is chained up. • Prereqs: WEB1.TRIVY-SETTINGS - • Current: BLOCKED (2025-10-21) – Awaiting Angular CLI/toolchain availability in CI/local dev environments before wiring Karma tests for the new screen. + • Current: DONE (2025-10-21) – ChromeHeadless launcher + README updates merged; awaiting dependency hardening follow-up (WEB1.DEPS-13-001). + 3. [TODO] WEB1.DEPS-13-001 — Stabilise Angular workspace dependencies for headless CI installs (`npm install`, Chromium handling, docs). + • Prereqs: WEB1.TRIVY-SETTINGS-TESTS + • Current: TODO – Capture deterministic lockfile flow, cache Puppeteer downloads, and validate `npm test` from clean checkout in air-gapped mode. - **Sprint 1** · Developer Tooling - Team: DevEx/CLI - Path: `src/StellaOps.Cli/TASKS.md` @@ -214,9 +214,6 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster 1. [DOING] FEEDWEB-DOCS-01-001 — Document authority toggle & scope requirements — Quickstart updates are staged; awaiting Docs guild review before publishing operator guide refresh. • Prereqs: — • Current: DOING (2025-10-10) - 2. [DONE] FEEDWEB-OPS-01-006 — Rename plugin drop directory to namespaced path — Build outputs now target `StellaOps.Concelier.PluginBinaries`/`StellaOps.Authority.PluginBinaries`, plugin host defaults updated, and docs/tests refreshed (see `dotnet test src/StellaOps.Concelier.WebService.Tests/StellaOps.Concelier.WebService.Tests.csproj --no-restore`). - • Prereqs: — - • Current: TODO 3. [BLOCKED] FEEDWEB-OPS-01-007 — Authority resilience adoption — Roll out retry/offline knobs to deployment docs and align CLI parity once LIB5 resilience options land; unblock when library release is available and docs review completes. • Prereqs: — • Current: BLOCKED (2025-10-10) @@ -245,9 +242,6 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster • Current: TODO – Add verification helpers for Worker/WebService, metrics/logging hooks, and negative-path regression tests. - Team: Team Excititor WebService - Path: `src/StellaOps.Excititor.WebService/TASKS.md` - 1. [DONE] EXCITITOR-WEB-01-002 — EXCITITOR-WEB-01-002 – Ingest & reconcile endpoints - • Prereqs: EXCITITOR-WEB-01-001 (external/completed) - • Current: DONE (2025-10-20) – `/excititor/init`, `/excititor/ingest/run`, `/excititor/ingest/resume`, `/excititor/reconcile` enforce `vex.admin`, normalize provider inputs, and emit deterministic summaries; verified via `dotnet test src/StellaOps.Excititor.WebService.Tests/StellaOps.Excititor.WebService.Tests.csproj --filter FullyQualifiedName~IngestEndpointsTests`. 2. [TODO] EXCITITOR-WEB-01-003 — EXCITITOR-WEB-01-003 – Export & verify endpoints • Prereqs: EXCITITOR-WEB-01-001 (external/completed), EXCITITOR-EXPORT-01-001 (external/completed), EXCITITOR-ATTEST-01-001 (external/completed) • Current: TODO – Add `/excititor/export`, `/excititor/export/{id}`, `/excititor/export/{id}/download`, `/excititor/verify`, returning artifact + attestation metadata with cache awareness. @@ -299,100 +293,27 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster 2. [TODO] EXCITITOR-FMT-OPENVEX-01-003 — EXCITITOR-FMT-OPENVEX-01-003 – OpenVEX export writer • Prereqs: EXCITITOR-EXPORT-01-001 (external/completed), EXCITITOR-FMT-OPENVEX-01-001 (external/completed) • Current: TODO – Provide export serializer generating canonical OpenVEX documents with optional SBOM references and hash-stable ordering. - - Team: Team Excititor Worker - - Path: `src/StellaOps.Excititor.Worker/TASKS.md` - 1. [DONE 2025-10-21] EXCITITOR-WORKER-01-002 — EXCITITOR-WORKER-01-002 – Resume tokens & retry policy - • Prereqs: EXCITITOR-WORKER-01-001 (external/completed) - • Current: DONE – Worker updates connector state with resume tokens + success/failure metadata and applies jittered exponential backoff with quarantine scheduling; unit coverage added for skip/backoff/resume flows. + - **Sprint 7** · Contextual Truth Foundations - Team: Team Excititor Export - Path: `src/StellaOps.Excititor.Export/TASKS.md` - 1. [TODO] EXCITITOR-EXPORT-01-005 — EXCITITOR-EXPORT-01-005 – Score & resolve envelope surfaces + 1. [DONE 2025-10-21] EXCITITOR-EXPORT-01-005 — EXCITITOR-EXPORT-01-005 – Score & resolve envelope surfaces • Prereqs: EXCITITOR-EXPORT-01-004 (external/completed), EXCITITOR-CORE-02-001 (external/completed) • Current: TODO – Emit consensus+score envelopes in export manifests, include policy/scoring digests, and update offline bundle/ORAS layouts to carry signed VEX responses. - - Team: Team Excititor WebService - - Path: `src/StellaOps.Excititor.WebService/TASKS.md` - 1. [DONE 2025-10-20] EXCITITOR-WEB-01-004 — Resolve API & signed responses – expose `/excititor/resolve`, return signed consensus/score envelopes, document auth. - • Prereqs: — - • Current: TODO - - Team: Team Excititor Worker - - Path: `src/StellaOps.Excititor.Worker/TASKS.md` - 1. [DONE 2025-10-21] EXCITITOR-WORKER-01-004 — EXCITITOR-WORKER-01-004 – TTL refresh & stability damper - • Prereqs: EXCITITOR-WORKER-01-001 (external/completed), EXCITITOR-CORE-02-001 (external/completed) - • Current: TODO – Monitor consensus/VEX TTLs, apply 24–48h dampers before flipping published status/score, and trigger re-resolve when base image or kernel fingerprints change. -- **Sprint 8** · Mongo strengthening - - Team: Authority Core & Storage Guild - - Path: `src/StellaOps.Authority/TASKS.md` - 1. [DONE] AUTHSTORAGE-MONGO-08-001 — Harden Authority Mongo usage — Scoped Mongo sessions with majority read/write concerns wired through stores and GraphQL/HTTP pipelines; replica-set election regression validated. - • Prereqs: — - • Current: BLOCKED (2025-10-19) - - Team: Team Excititor Storage - - Path: `src/StellaOps.Excititor.Storage.Mongo/TASKS.md` - 1. [DONE 2025-10-19] EXCITITOR-STORAGE-MONGO-08-001 — Session + causal consistency hardening shipped with scoped session provider, repository updates, and replica-set consistency tests (`dotnet test src/StellaOps.Excititor.Storage.Mongo.Tests/StellaOps.Excititor.Storage.Mongo.Tests.csproj`) - • Prereqs: EXCITITOR-STORAGE-01-003 (external/completed) - • Current: DONE – Scoped sessions with causal consistency in place; repositories/tests updated for deterministic read-your-write semantics. - - Team: Team Normalization & Storage Backbone - - Path: `src/StellaOps.Concelier.Storage.Mongo/TASKS.md` - 1. [DONE] FEEDSTORAGE-MONGO-08-001 — Causal-consistent Concelier storage sessions — Scoped session facilitator registered, repositories accept optional session handles, and replica-set failover tests verify read-your-write + monotonic reads. - • Prereqs: — - • Current: TODO -- **Sprint 8** · Platform Maintenance - - Team: Team Excititor Storage - - Path: `src/StellaOps.Excititor.Storage.Mongo/TASKS.md` - 1. [DONE 2025-10-19] EXCITITOR-STORAGE-03-001 — Statement backfill tooling - • Prereqs: EXCITITOR-STORAGE-02-001 (external/completed) - • Current: DONE – Admin backfill endpoint, CLI command (`stellaops excititor backfill-statements`), integration coverage, and operator runbook published; further automation tracked separately if needed. - - Team: Team Excititor Worker - - Path: `src/StellaOps.Excititor.Worker/TASKS.md` - 1. [DONE 2025-10-21] EXCITITOR-WORKER-02-001 — EXCITITOR-WORKER-02-001 – Resolve Microsoft.Extensions.Caching.Memory advisory - • Prereqs: EXCITITOR-WORKER-01-001 (external/completed) - • Current: DONE (2025-10-21) – Upgraded Excititor workers/connectors to `Microsoft.Extensions.*` 10.0.0-preview.7.25380.108, restored attestation diagnostics, and re-ran worker + webservice test suites with no NU1903 vulnerabilities. -- **Sprint 8** · Plugin Infrastructure - - Team: Plugin Platform Guild - - Path: `src/StellaOps.Plugin/TASKS.md` - 1. [TODO] PLUGIN-DI-08-001 — Scoped service support in plugin bootstrap — Teach the plugin loader/registrar to surface services with scoped lifetimes, honour `StellaOps.DependencyInjection` metadata, and document the new contract. - • Prereqs: — - • Current: TODO - - Team: Plugin Platform Guild, Authority Core - - Path: `src/StellaOps.Plugin/TASKS.md` - 1. [DONE] PLUGIN-DI-08-002 — Update Authority plugin integration — Flow scoped services through identity-provider registrars, bootstrap flows, and background jobs; add regression coverage around scoped lifetimes. (Implemented 2025-10-20 with scoped Standard plugin registrations and registry handles.) - • Prereqs: — - • Current: DONE (2025-10-20) – Standard registrar registers scoped credential/provisioning stores and identity-provider plugins, registry Acquire returns scoped handles, and tests `dotnet test src/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StellaOps.Authority.Plugin.Standard.Tests.csproj` + `dotnet test src/StellaOps.Authority/StellaOps.Authority.Tests/StellaOps.Authority.Tests.csproj` validate behaviour. + - **Sprint 9** · Docs & Governance - - Team: Platform Events Guild - - Path: `docs/TASKS.md` - 1. [TODO] PLATFORM-EVENTS-09-401 — Embed canonical event samples into contract/integration tests and ensure CI validates payloads against published schemas. - • Prereqs: DOCS-EVENTS-09-003 (external/completed) - • Current: TODO + - Team: Runtime Guild - Path: `docs/TASKS.md` 1. [TODO] RUNTIME-GUILD-09-402 — Confirm Scanner WebService surfaces `quietedFindingCount` and progress hints to runtime consumers; document readiness checklist. • Prereqs: SCANNER-POLICY-09-107 (external/completed) • Current: TODO -- **Sprint 9** · Policy Foundations - - Team: Policy Guild - - Path: `src/StellaOps.Policy/TASKS.md` - 1. [DONE] POLICY-CORE-09-004 — Versioned scoring config with schema validation, trust table, and golden fixtures. (2025-10-19) - • Prereqs: — - • Current: DONE (2025-10-19) - 2. [DONE] POLICY-CORE-09-005 — Scoring/quiet engine – compute score, enforce VEX-only quiet rules, emit inputs and provenance. (2025-10-19) - • Prereqs: — - • Current: DONE (2025-10-19) - 3. [DONE] POLICY-CORE-09-006 — Unknown state & confidence decay – deterministic bands surfaced in policy outputs. (2025-10-19) - • Prereqs: — - • Current: DONE (2025-10-19) - **Sprint 10** · Backlog - Team: TBD - Path: `src/StellaOps.Scanner.Analyzers.Lang.Node/TASKS.md` 1. [TODO] SCANNER-ANALYZERS-LANG-10-302C — Surface script metadata (postinstall/preinstall) and policy hints; emit telemetry counters and evidence records. • Prereqs: SCANNER-ANALYZERS-LANG-10-302B (external/completed) • Current: TODO -- **Sprint 10** · DevOps Perf - - Team: DevOps Guild - - Path: `ops/devops/TASKS.md` - 1. [DONE] DEVOPS-SEC-10-301 — Address NU1902/NU1903 advisories for `MongoDB.Driver` 2.12.0 and `SharpCompress` 0.23.0 surfaced during scanner cache and worker test runs (2025-10-20) – local Mongo2Go feed repacked to require MongoDB.Driver 3.5.0 and SharpCompress 0.41.0; targeted cache tests green. - • Prereqs: — - • Current: TODO - **Sprint 10** · Scanner Analyzers & SBOM - Team: Diff Guild - Path: `src/StellaOps.Scanner.Diff/TASKS.md` @@ -477,23 +398,10 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster • Current: TODO - Team: Authority Core & Security Guild - Path: `src/StellaOps.Authority/TASKS.md` - 1. [DONE] AUTH-DPOP-11-001 — Implement DPoP proof validation + nonce handling for high-value audiences per architecture. (Redis-configurable nonce store + docs landed 2025-10-20) - • Prereqs: — - • Current: DOING (2025-10-19) 2. [DOING] AUTH-MTLS-11-002 — Add OAuth mTLS client credential support with certificate-bound tokens and introspection updates. • Prereqs: — • Current: DOING (2025-10-19) - - Team: Signer Guild - - Path: `src/StellaOps.Signer/TASKS.md` - 1. [DONE] SIGNER-API-11-101 — `/sign/dsse` pipeline with Authority auth, PoE introspection, release verification, DSSE signing. - • Prereqs: — - • Current: DONE (2025-10-21) – Minimal API host now issues DSSE bundles with PoE validation, release verification, and quota enforcement; integration tests cover success/error paths via `dotnet test src/StellaOps.Signer/StellaOps.Signer.Tests/StellaOps.Signer.Tests.csproj`. - 2. [DONE] SIGNER-REF-11-102 — `/verify/referrers` endpoint with OCI lookup, caching, and policy enforcement. - • Prereqs: — - • Current: DONE (2025-10-21) – Added `/api/v1/signer/verify/referrers` returning deterministic JSON responses for trusted/untrusted digests with regression coverage. - 3. [DONE] SIGNER-QUOTA-11-103 — Enforce plan quotas, concurrency/QPS limits, artifact size caps with metrics/audit logs. - • Prereqs: — - • Current: DONE (2025-10-21) – In-memory quota service applies payload caps and per-tenant QPS throttles; tests cover oversize and throttled cases. + - **Sprint 12** · Runtime Guardrails - Team: Zastava Core Guild - Path: `src/StellaOps.Zastava.Core/TASKS.md` @@ -559,28 +467,11 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster • Current: TODO - Team: Scanner WebService Guild - Path: `src/StellaOps.Scanner.WebService/TASKS.md` - 1. [DONE] SCANNER-EVENTS-15-201 — Emit `scanner.report.ready` + `scanner.scan.completed` events. - • Prereqs: — - • Current: TODO 2. [BLOCKED] SCANNER-EVENTS-16-301 — Redis publisher integration tests once Notify queue adapter ships. • Prereqs: NOTIFY-QUEUE-15-401 (Wave 1) • Current: BLOCKED – waiting on Notify queue abstraction and Redis adapter deliverables for end-to-end validation. - **Sprint 16** · Scheduler Intelligence - - Team: Scheduler ImpactIndex Guild - - Path: `src/StellaOps.Scheduler.ImpactIndex/TASKS.md` - 1. [DONE (2025-10-20)] SCHED-IMPACT-16-300 — **STUB** ingest/query using fixtures to unblock Scheduler planning (remove by SP16 end). - • Prereqs: SAMPLES-10-001 (external/completed) - • Current: DOING - - Team: Scheduler Models Guild - - Path: `src/StellaOps.Scheduler.Models/TASKS.md` - 1. [DONE (2025-10-20)] SCHED-MODELS-16-103 - Versioning/migration helpers (schedule evolution, run state transitions). - • Prereqs: SCHED-MODELS-16-101 (external/completed) - • Current: DONE - - Team: Scheduler Queue Guild - - Path: `src/StellaOps.Scheduler.Queue/TASKS.md` - 1. [DONE (2025-10-20)] SCHED-QUEUE-16-401 - Implement queue abstraction + Redis Streams adapter (planner inputs, runner segments) with ack/lease semantics. - • Prereqs: SCHED-MODELS-16-101 (external/completed) - • Current: DONE + - Team: Scheduler Storage Guild - Path: `src/StellaOps.Scheduler.Storage.Mongo/TASKS.md` 1. [TODO] SCHED-STORAGE-16-201 — Create Mongo collections (schedules, runs, impact_cursors, locks, audit) with indexes/migrations per architecture. @@ -634,18 +525,9 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster - **Sprint 7** · Contextual Truth Foundations - Team: Team Excititor Export - Path: `src/StellaOps.Excititor.Export/TASKS.md` - 1. [TODO] EXCITITOR-EXPORT-01-006 — EXCITITOR-EXPORT-01-006 – Quiet provenance packaging + 1. [DONE 2025-10-21] EXCITITOR-EXPORT-01-006 — EXCITITOR-EXPORT-01-006 – Quiet provenance packaging • Prereqs: EXCITITOR-EXPORT-01-005 (Wave 0), POLICY-CORE-09-005 (Wave 0) • Current: TODO – Attach `quietedBy` statement IDs, signers, and justification codes to exports/offline bundles, mirror metadata into attested manifest, and add regression fixtures. -- **Sprint 9** · DevOps Foundations - - Team: DevOps Guild, Scanner WebService Guild - - Path: `ops/devops/TASKS.md` - 1. [DONE] DEVOPS-SCANNER-09-204 — Surface `SCANNER__EVENTS__*` environment variables across docker-compose (dev/stage/airgap) and Helm values, defaulting to share the Redis queue DSN. (2025-10-21) - • Prereqs: SCANNER-EVENTS-15-201 (Wave 0) - • Current: DONE (2025-10-21) – Compose dev/stage/airgap profiles and Helm values now expose the SCANNER__EVENTS__* toggles; docs (deploy/compose/README.md, docs/ARCHITECTURE_SCANNER.md) call out the new configuration knobs. - 2. [DONE] DEVOPS-SCANNER-09-205 — Add Notify smoke stage that tails the Redis stream and asserts `scanner.report.ready`/`scanner.scan.completed` reach Notify WebService in staging. (2025-10-21) - • Prereqs: DEVOPS-SCANNER-09-204 (Wave 0) - • Current: DONE (2025-10-21) – `notify-smoke` CI job runs the NotifySmokeCheck tool against staging Redis/Notify using configured secrets; deploy docs enumerate required configuration. - **Sprint 10** · Backlog - Team: TBD - Path: `src/StellaOps.Scanner.Analyzers.Lang.DotNet/TASKS.md` @@ -668,12 +550,6 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster 1. [TODO] SCANNER-ANALYZERS-LANG-10-306A — Parse Cargo metadata (`Cargo.lock`, `.fingerprint`, `.metadata`) and map crates to components with evidence. • Prereqs: SCANNER-ANALYZERS-LANG-10-307 (Wave 0) • Current: TODO -- **Sprint 10** · Benchmarks - - Team: Bench Guild, Language Analyzer Guild - - Path: `bench/TASKS.md` - 1. [DONE] BENCH-SCANNER-10-002 — Wire real language analyzers into bench harness & refresh baselines post-implementation. (2025-10-21) - • Prereqs: SCANNER-ANALYZERS-LANG-10-301 (Wave 0) - • Current: DONE (2025-10-21) – Harness now invokes language analyzers via `StellaOps.Bench.ScannerAnalyzers`, baseline refreshed against samples/runtime fixtures, and README/config updated for the new flow. - **Sprint 10** · Scanner Analyzers & SBOM - Team: Emit Guild - Path: `src/StellaOps.Scanner.Emit/TASKS.md` @@ -709,9 +585,6 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster - **Sprint 12** · Runtime Guardrails - Team: Scanner WebService Guild - Path: `src/StellaOps.Scanner.WebService/TASKS.md` - 1. [DONE] SCANNER-RUNTIME-12-301 — Implement `/runtime/events` ingestion endpoint with validation, batching, and storage hooks per Zastava contract. (2025-10-20) - • Prereqs: ZASTAVA-CORE-12-201 (Wave 0) - • Current: DONE (2025-10-20) — Mongo persistence + rate limiting shipped; observer fixtures can replay batches end-to-end. 2. [DOING] SCANNER-RUNTIME-12-302 — Implement `/policy/runtime` endpoint joining SBOM baseline + policy verdict, returning admission guidance. • Prereqs: SCANNER-RUNTIME-12-301 (Wave 1), ZASTAVA-CORE-12-201 (Wave 0) • Current: DOING (2025-10-20) — Locking response schema with Policy/CLI guilds, wiring determinism tests. @@ -729,6 +602,11 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster 1. [TODO] CLI-RUNTIME-13-009 — CLI-RUNTIME-13-009 – Runtime policy smoke fixture • Prereqs: CLI-RUNTIME-13-005 (Wave 0) • Current: TODO – Build Spectre test harness exercising `runtime policy test` against a stubbed backend to lock output shape (table + `--json`) and guard regressions. Integrate into `dotnet test` suite. + - Team: UX Specialist, Angular Eng, DevEx + - Path: `src/StellaOps.Web/TASKS.md` + 1. [TODO] WEB1.DEPS-13-001 — Stabilise Angular workspace dependencies for headless CI installs (`npm install`, Chromium handling, docs). + • Prereqs: WEB1.TRIVY-SETTINGS-TESTS (Wave 0) + • Current: TODO – Capture deterministic lockfile flow, cache Puppeteer downloads, validate `npm test` from clean checkout offline, and update README. - Team: UI Guild - Path: `src/StellaOps.UI/TASKS.md` 1. [TODO] UI-VEX-13-003 — Implement VEX explorer + policy editor with preview integration. @@ -752,6 +630,12 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster 7. [TODO] UI-SCHED-13-005 — Scheduler panel: schedules CRUD, run history, dry-run preview using API/mocks. • Prereqs: SCHED-WEB-16-101 (Wave 0) • Current: TODO +- **Sprint 13** · Platform Reliability + - Team: DevOps Guild, Platform Leads + - Path: `ops/devops/TASKS.md` + 1. [TODO] 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: TODO – Mirror preview packages into Offline Kit/allowlisted feeds, update NuGet.config mapping, and refresh restore documentation. - **Sprint 14** · Release & Offline Ops - Team: DevOps Guild - Path: `ops/devops/TASKS.md` @@ -774,25 +658,14 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster 1. [TODO] NOTIFY-QUEUE-15-401 — Build queue abstraction + Redis Streams adapter with ack/claim APIs, idempotency tokens, serialization contracts. • Prereqs: NOTIFY-MODELS-15-101 (Wave 0) • Current: TODO - - Team: Notify WebService Guild - - Path: `src/StellaOps.Notify.WebService/TASKS.md` - 1. [DONE] NOTIFY-WEB-15-103 — Delivery history + test-send endpoints with rate limits. - • Prereqs: NOTIFY-WEB-15-102 (Wave 0) - • Current: TODO + - **Sprint 16** · Scheduler Intelligence - Team: Scheduler ImpactIndex Guild - Path: `src/StellaOps.Scheduler.ImpactIndex/TASKS.md` 1. [TODO] SCHED-IMPACT-16-301 — Implement ingestion of per-image BOM-Index sidecars into roaring bitmap store (contains/usedBy). • Prereqs: SCANNER-EMIT-10-605 (Wave 0) • Current: TODO - - Team: Scheduler Queue Guild - - Path: `src/StellaOps.Scheduler.Queue/TASKS.md` - 1. [DONE (2025-10-20)] SCHED-QUEUE-16-402 - Add NATS JetStream adapter with configuration binding, health probes, failover. - • Prereqs: SCHED-QUEUE-16-401 (Wave 0) - • Current: DONE - 2. [DONE (2025-10-20)] SCHED-QUEUE-16-403 - Dead-letter handling + metrics (queue depth, retry counts), configuration toggles. - • Prereqs: SCHED-QUEUE-16-401 (Wave 0) - • Current: DONE + - Team: Scheduler Storage Guild - Path: `src/StellaOps.Scheduler.Storage.Mongo/TASKS.md` 1. [TODO] SCHED-STORAGE-16-203 — Audit/logging pipeline + run stats materialized views for UI. @@ -831,15 +704,9 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster - **Sprint 7** · Contextual Truth Foundations - Team: Team Excititor Export - Path: `src/StellaOps.Excititor.Export/TASKS.md` - 1. [TODO] EXCITITOR-EXPORT-01-007 — EXCITITOR-EXPORT-01-007 – Mirror bundle + domain manifest + 1. [DONE 2025-10-21] EXCITITOR-EXPORT-01-007 — EXCITITOR-EXPORT-01-007 – Mirror bundle + domain manifest • Prereqs: EXCITITOR-EXPORT-01-006 (Wave 1) • Current: TODO – Create per-domain mirror bundles with consensus/score artifacts, publish signed index for downstream Excititor sync, and ensure deterministic digests + fixtures. -- **Sprint 8** · Mirror Distribution - - Team: DevOps Guild - - Path: `ops/devops/TASKS.md` - 1. [DONE] DEVOPS-MIRROR-08-001 — Stand up managed mirror profiles for `*.stella-ops.org` (Concelier/Excititor), including Helm/Compose overlays, multi-tenant secrets, CDN caching, and sync documentation. - • Prereqs: DEVOPS-REL-14-001 (Wave 1) - • Current: DONE (2025-10-19) - **Sprint 9** · DevOps Foundations - Team: DevOps Guild, Notify Guild - Path: `ops/devops/TASKS.md` @@ -974,7 +841,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster - **Sprint 7** · Contextual Truth Foundations - Team: Excititor Connectors – Stella - Path: `src/StellaOps.Excititor.Connectors.StellaOpsMirror/TASKS.md` - 1. [TODO] EXCITITOR-CONN-STELLA-07-001 — Implement mirror fetch client consuming `https://.stella-ops.org/excititor/exports/index.json`, validating signatures/digests, storing raw consensus bundles with provenance. + 1. [DONE 2025-10-21] EXCITITOR-CONN-STELLA-07-001 — Implement mirror fetch client consuming `https://.stella-ops.org/excititor/exports/index.json`, validating signatures/digests, storing raw consensus bundles with provenance. • Prereqs: EXCITITOR-EXPORT-01-007 (Wave 2) • Current: TODO - **Sprint 10** · Backlog @@ -1009,11 +876,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster • Prereqs: ZASTAVA-OBS-12-002 (Wave 2) • Current: TODO - **Sprint 13** · UX & CLI Experience - - Team: DevEx/CLI - - Path: `src/StellaOps.Cli/TASKS.md` - 1. [DONE] CLI-OFFLINE-13-006 — CLI-OFFLINE-13-006 – Offline kit workflows - • Prereqs: DEVOPS-OFFLINE-14-002 (Wave 2) - • Current: DONE (2025-10-21) – Delivered `offline kit pull/import/status` commands with resumable downloads, digest/metadata validation, CLI metrics + docs, and regression coverage (`dotnet test src/StellaOps.Cli.Tests`). + - Team: DevEx/CLI, Scanner WebService Guild - Path: `src/StellaOps.Cli/TASKS.md` 1. [TODO] CLI-RUNTIME-13-008 — CLI-RUNTIME-13-008 – Runtime policy contract sync @@ -1153,16 +1016,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster • Prereqs: NOTIFY-CONN-EMAIL-15-701 (Wave 4) • Current: BLOCKED – waiting on base SMTP connector implementation (NOTIFY-CONN-EMAIL-15-701). - Path: `src/StellaOps.Notify.Connectors.Slack/TASKS.md` - 1. [DONE] NOTIFY-CONN-SLACK-15-502 — Health check & test-send support with minimal scopes and redacted tokens. - • Prereqs: NOTIFY-CONN-SLACK-15-501 (Wave 4) - • Current: TODO - Path: `src/StellaOps.Notify.Connectors.Teams/TASKS.md` - 1. [DONE] NOTIFY-CONN-TEAMS-15-602 — Provide health/test-send support with fallback text for legacy clients. - • Prereqs: NOTIFY-CONN-TEAMS-15-601 (Wave 4) - • Current: TODO - 2. [DONE] NOTIFY-CONN-TEAMS-15-604 — Align Teams health endpoint output with preview metadata redaction. - • Prereqs: NOTIFY-CONN-TEAMS-15-602 (Wave 5) - • Current: DONE - Path: `src/StellaOps.Notify.Connectors.Webhook/TASKS.md` 1. [DOING] NOTIFY-CONN-WEBHOOK-15-802 — Health/test-send support with signature validation hints and secret management. • Prereqs: NOTIFY-CONN-WEBHOOK-15-801 (Wave 4) @@ -1193,109 +1047,11 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster 1. [TODO] SCANNER-ANALYZERS-LANG-10-309R — Package plug-in manifest + Offline Kit documentation; ensure Worker integration. • Prereqs: SCANNER-ANALYZERS-LANG-10-308R (Wave 5) • Current: TODO -- **Sprint 15** · Notify Foundations - - Team: Notify Connectors Guild - - Path: `src/StellaOps.Notify.Connectors.Email/TASKS.md` - 1. [DONE] NOTIFY-CONN-EMAIL-15-703 — Package Email connector as restart-time plug-in (manifest + host registration). - • Prereqs: NOTIFY-CONN-EMAIL-15-702 (Wave 5) - • Current: TODO - - Path: `src/StellaOps.Notify.Connectors.Slack/TASKS.md` - 1. [DONE] NOTIFY-CONN-SLACK-15-503 — Package Slack connector as restart-time plug-in (manifest + host registration). - • Prereqs: NOTIFY-CONN-SLACK-15-502 (Wave 5) - • Current: TODO - - Path: `src/StellaOps.Notify.Connectors.Teams/TASKS.md` - 1. [DONE] NOTIFY-CONN-TEAMS-15-603 — Package Teams connector as restart-time plug-in (manifest + host registration). - • Prereqs: NOTIFY-CONN-TEAMS-15-602 (Wave 5) - • Current: TODO - - Path: `src/StellaOps.Notify.Connectors.Webhook/TASKS.md` - 1. [DONE] NOTIFY-CONN-WEBHOOK-15-803 — Package Webhook connector as restart-time plug-in (manifest + host registration). - • Prereqs: NOTIFY-CONN-WEBHOOK-15-802 (Wave 5) - • Current: TODO - -## Wave 7 — 1 task(s) ready after Wave 6 -- **Sprint 7** · Contextual Truth Foundations - - Team: Team Core Engine & Storage Analytics - - Path: `src/StellaOps.Concelier.Core/TASKS.md` - 1. [DONE] FEEDCORE-ENGINE-07-001 — FEEDCORE-ENGINE-07-001 – Advisory event log & asOf queries - • Prereqs: FEEDSTORAGE-DATA-07-001 (Wave 10) - • Current: DONE (2025-10-19) – `AdvisoryEventLog` service and repository abstractions landed with canonical hashing, lower-cased keys, replay API, and doc updates. Tests: `dotnet test src/StellaOps.Concelier.Core.Tests/StellaOps.Concelier.Core.Tests.csproj`. - -## Wave 8 — 1 task(s) ready after Wave 7 -- **Sprint 7** · Contextual Truth Foundations - - Team: Team Core Engine & Data Science - - Path: `src/StellaOps.Concelier.Core/TASKS.md` - 1. [DONE] FEEDCORE-ENGINE-07-002 — FEEDCORE-ENGINE-07-002 – Noise prior computation service - • Prereqs: FEEDCORE-ENGINE-07-001 (Wave 7) - • Current: DONE (2025-10-21) – Added NoisePriorService with rule-based aggregation of advisory statements, repository contracts for deterministic summaries, DI helper, and unit tests covering heuristics and persistence. - -## Wave 9 — 1 task(s) ready after Wave 8 -- **Sprint 7** · Contextual Truth Foundations - - Team: Team Core Engine & Storage Analytics - - Path: `src/StellaOps.Concelier.Core/TASKS.md` - 1. [TODO] FEEDCORE-ENGINE-07-003 — FEEDCORE-ENGINE-07-003 – Unknown state ledger & confidence seeding - • Prereqs: FEEDCORE-ENGINE-07-001 (Wave 7) - • Current: TODO – Persist `unknown_vuln_range/unknown_origin/ambiguous_fix` markers with initial confidence bands, expose query surface for Policy, and add fixtures validating canonical serialization. - -## Wave 10 — 1 task(s) ready after Wave 9 - **Sprint 7** · Contextual Truth Foundations - Team: Team Normalization & Storage Backbone - Path: `src/StellaOps.Concelier.Storage.Mongo/TASKS.md` - 1. [TODO] FEEDSTORAGE-DATA-07-001 — FEEDSTORAGE-DATA-07-001 Advisory statement & conflict collections + 1. [DONE 2025-10-19] FEEDSTORAGE-DATA-07-001 — FEEDSTORAGE-DATA-07-001 Advisory statement & conflict collections • Prereqs: FEEDMERGE-ENGINE-07-001 (Wave 11) • Current: TODO – Create `advisory_statements` (immutable) and `advisory_conflicts` collections, define `asOf`/`vulnerabilityKey` indexes, and document migration/rollback steps for event-sourced merge. ## Wave 11 — 1 task(s) ready after Wave 10 -- **Sprint 7** · Contextual Truth Foundations - - Team: BE-Merge - - Path: `src/StellaOps.Concelier.Merge/TASKS.md` - 1. [DONE] FEEDMERGE-ENGINE-07-001 — Conflict sets & explainers (2025-10-20) – Merge now returns conflict summaries with hashes and WebService exposes structured explainers. - • Prereqs: FEEDSTORAGE-DATA-07-001 (Wave 10) - • Current: TODO – Persist conflict sets referencing advisory statements, output rule/explainer payloads with replay hashes, and add integration tests covering deterministic `asOf` evaluations. - -## Wave 12 — 1 task(s) ready after Wave 11 -- **Sprint 8** · Mirror Distribution - - Team: Concelier Export Guild - - Path: `src/StellaOps.Concelier.Exporter.Json/TASKS.md` - 1. [DONE] CONCELIER-EXPORT-08-201 — CONCELIER-EXPORT-08-201 – Mirror bundle + domain manifest - • Prereqs: FEEDCORE-ENGINE-07-001 (Wave 7) - • Current: DONE (2025-10-19) – Mirror bundles + manifests + signed index shipped; regression coverage via `dotnet test src/StellaOps.Concelier.Exporter.Json.Tests/StellaOps.Concelier.Exporter.Json.Tests.csproj` (2025-10-19). - -## Wave 13 — 1 task(s) ready after Wave 12 -- **Sprint 8** · Mirror Distribution - - Team: Concelier Export Guild - - Path: `src/StellaOps.Concelier.Exporter.TrivyDb/TASKS.md` - 1. [DONE] CONCELIER-EXPORT-08-202 — CONCELIER-EXPORT-08-202 – Mirror-ready Trivy DB bundles - • Prereqs: CONCELIER-EXPORT-08-201 (Wave 12) - • Current: DONE (2025-10-19) – Trivy exporter mirror options produce `mirror/index.json` plus per-domain manifest/metadata/db files with reproducible SHA-256 digests; validated via `dotnet test src/StellaOps.Concelier.Exporter.TrivyDb.Tests/StellaOps.Concelier.Exporter.TrivyDb.Tests.csproj`. - -## Wave 14 — 1 task(s) ready after Wave 13 -- **Sprint 8** · Mirror Distribution - - Team: Concelier WebService Guild - - Path: `src/StellaOps.Concelier.WebService/TASKS.md` - 1. [DONE] CONCELIER-WEB-08-201 — Mirror distribution endpoints (2025-10-20) – Service enforces Authority/bypass rules, issues cache headers, rate limits per domain, and ops docs list smoke tests. - • Prereqs: CONCELIER-EXPORT-08-201 (Wave 12), DEVOPS-MIRROR-08-001 (Wave 2) - • Current: DONE (2025-10-20) – See `docs/ops/concelier-mirror-operations.md` for updated auth + rate-limit guidance; tests `WebServiceEndpointsTests` cover 401/Retry-After. - -## Wave 15 — 1 task(s) ready after Wave 14 -- **Sprint 8** · Mirror Distribution - - Team: BE-Conn-Stella - - Path: `src/StellaOps.Concelier.Connector.StellaOpsMirror/TASKS.md` - 1. [DONE] FEEDCONN-STELLA-08-001 — Implement Concelier mirror fetcher hitting `https://.stella-ops.org/concelier/exports/index.json`, verify signatures/digests, and persist raw documents with provenance. - • Prereqs: CONCELIER-EXPORT-08-201 (Wave 12) - • Current: DONE (2025-10-20) – Fetch job persists manifest/bundle metadata, enforces digest and detached JWS verification (fallback PEM support), and regression coverage captured via `dotnet test src/StellaOps.Concelier.Connector.StellaOpsMirror.Tests/StellaOps.Concelier.Connector.StellaOpsMirror.Tests.csproj`. - -## Wave 16 — 1 task(s) ready after Wave 15 -- **Sprint 8** · Mirror Distribution - - Team: BE-Conn-Stella - - Path: `src/StellaOps.Concelier.Connector.StellaOpsMirror/TASKS.md` - 1. [DONE] FEEDCONN-STELLA-08-002 — Map mirror payloads into canonical advisory DTOs with provenance referencing mirror domain + original source metadata. (2025-10-20) - • Prereqs: FEEDCONN-STELLA-08-001 (Wave 15) - • Current: DONE (2025-10-20) – `MirrorAdvisoryMapper` emits canonical advisories and fixtures assert parity with exporter outputs. - -## Wave 17 — 1 task(s) ready after Wave 16 -- **Sprint 8** · Mirror Distribution - - Team: BE-Conn-Stella - - Path: `src/StellaOps.Concelier.Connector.StellaOpsMirror/TASKS.md` - 1. [DONE] FEEDCONN-STELLA-08-003 — Add incremental cursor + resume support (per-export fingerprint) and document configuration for downstream Concelier instances. (2025-10-20) - • Prereqs: FEEDCONN-STELLA-08-002 (Wave 16) - • Current: DONE (2025-10-20) – Connector records per-export fingerprints, resumes pending documents, and ops guide documents offline configuration knobs. diff --git a/SPRINTS.md b/SPRINTS.md index 30a9b4db..40b4431e 100644 --- a/SPRINTS.md +++ b/SPRINTS.md @@ -2,33 +2,6 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation | Sprint | Theme | Tasks File Path | Status | Type of Specialist | Task ID | Task Description | | --- | --- | --- | --- | --- | --- | --- | -| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.WebService/TASKS.md | DONE (2025-10-20) | Team Excititor WebService | EXCITITOR-WEB-01-002 | Ingest & reconcile endpoints – scope-enforced `/excititor/init`, `/excititor/ingest/run`, `/excititor/ingest/resume`, `/excititor/reconcile`; regression via `dotnet test … --filter FullyQualifiedName~IngestEndpointsTests`. | -| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.WebService/TASKS.md | DONE (2025-10-20) | Team Excititor WebService | EXCITITOR-WEB-01-004 | Resolve API & signed responses – expose `/excititor/resolve`, return signed consensus/score envelopes, document auth. | -| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.WebService/TASKS.md | TODO | Team Excititor WebService | EXCITITOR-WEB-01-005 | Mirror distribution endpoints – expose download APIs for downstream Excititor instances. | -| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.Worker/TASKS.md | DONE (2025-10-21) | Team Excititor Worker | EXCITITOR-WORKER-01-004 | TTL refresh & stability damper – schedule re-resolve loops and guard against status flapping. | -| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.Export/TASKS.md | TODO | Team Excititor Export | EXCITITOR-EXPORT-01-005 | Score & resolve envelope surfaces – include signed consensus/score artifacts in exports. | -| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.Export/TASKS.md | TODO | Team Excititor Export | EXCITITOR-EXPORT-01-006 | Quiet provenance packaging – attach quieted-by statement IDs, signers, justification codes to exports and attestations. | -| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.Export/TASKS.md | TODO | Team Excititor Export | EXCITITOR-EXPORT-01-007 | Mirror bundle + domain manifest – publish signed consensus bundles for mirrors. | -| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.Connectors.StellaOpsMirror/TASKS.md | TODO | Excititor Connectors – Stella | EXCITITOR-CONN-STELLA-07-001 | Excititor mirror connector – ingest signed mirror bundles and map to VexClaims with resume handling. | -| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Concelier.Core/TASKS.md | DONE (2025-10-21) | Team Core Engine & Data Science | FEEDCORE-ENGINE-07-002 | Noise prior computation service – learn false-positive priors and expose deterministic summaries. | -| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Concelier.Core/TASKS.md | TODO | Team Core Engine & Storage Analytics | FEEDCORE-ENGINE-07-003 | Unknown state ledger & confidence seeding – persist unknown flags, seed confidence bands, expose query surface. | -| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Concelier.Storage.Mongo/TASKS.md | TODO | Team Normalization & Storage Backbone | FEEDSTORAGE-DATA-07-001 | Advisory statement & conflict collections – provision Mongo schema/indexes for event-sourced merge. | -| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Web/TASKS.md | BLOCKED (2025-10-21) | UX Specialist, Angular Eng | WEB1.TRIVY-SETTINGS-TESTS | Add headless UI test run (`ng test --watch=false`) and document prerequisites once Angular tooling is chained up. | -| Sprint 8 | Mirror Distribution | src/StellaOps.Concelier.Connector.StellaOpsMirror/TASKS.md | DONE (2025-10-20) | BE-Conn-Stella | FEEDCONN-STELLA-08-001 | Concelier mirror connector – fetch mirror manifest, verify signatures, and hydrate canonical DTOs with resume support. | -| Sprint 8 | Mirror Distribution | src/StellaOps.Concelier.Connector.StellaOpsMirror/TASKS.md | DONE (2025-10-20) | BE-Conn-Stella | FEEDCONN-STELLA-08-002 | Map mirror payloads into canonical advisory DTOs with provenance referencing mirror domain + original source metadata. | -| Sprint 8 | Mirror Distribution | src/StellaOps.Concelier.Connector.StellaOpsMirror/TASKS.md | DONE (2025-10-20) | BE-Conn-Stella | FEEDCONN-STELLA-08-003 | Add incremental cursor + resume support (per-export fingerprint) and document configuration for downstream Concelier instances. | -| Sprint 8 | Plugin Infrastructure | src/StellaOps.Plugin/TASKS.md | DONE (2025-10-20) | Plugin Platform Guild, Authority Core | PLUGIN-DI-08-002.COORD | Authority scoped-service integration handshake
Workshop concluded 2025-10-20 15:00–16:05 UTC; decisions + follow-ups recorded in `docs/dev/authority-plugin-di-coordination.md`. | -| Sprint 8 | Plugin Infrastructure | src/StellaOps.Plugin/TASKS.md | DONE (2025-10-20) | Plugin Platform Guild, Authority Core | PLUGIN-DI-08-002 | Authority plugin integration updates – scoped identity-provider services with registry handles; regression coverage via scoped registrar/unit tests. | -| Sprint 8 | Plugin Infrastructure | src/StellaOps.Authority/TASKS.md | DONE (2025-10-20) | Authority Core, Plugin Platform Guild | AUTH-PLUGIN-COORD-08-002 | Coordinate scoped-service adoption for Authority plug-in registrars
Workshop notes and follow-up backlog captured 2025-10-20 in `docs/dev/authority-plugin-di-coordination.md`. | -| Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.WebService/TASKS.md | DONE (2025-10-19) | Team Scanner WebService | SCANNER-WEB-09-103 | Progress streaming (SSE/JSONL) with correlation IDs and ISO-8601 UTC timestamps, documented in API reference. | -| Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.WebService/TASKS.md | DONE (2025-10-19) | Team Scanner WebService | SCANNER-POLICY-09-105 | Policy snapshot loader + schema + OpenAPI (YAML ignore rules, VEX include/exclude, vendor precedence). | -| Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.WebService/TASKS.md | DONE (2025-10-19) | Team Scanner WebService | SCANNER-POLICY-09-106 | `/reports` verdict assembly (Feedser+Vexer+Policy) + signed response envelope. | -| Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.WebService/TASKS.md | DONE (2025-10-19) | Team Scanner WebService | SCANNER-POLICY-09-107 | Expose score inputs, config version, and quiet provenance in `/reports` JSON and signed payload. | -| Sprint 9 | DevOps Foundations | ops/devops/TASKS.md | DONE (2025-10-21) | DevOps Guild, Scanner WebService Guild | DEVOPS-SCANNER-09-204 | Surface `SCANNER__EVENTS__*` env config across Compose/Helm and document overrides. | -| Sprint 9 | DevOps Foundations | ops/devops/TASKS.md | DONE (2025-10-21) | DevOps Guild, Notify Guild | DEVOPS-SCANNER-09-205 | Notify smoke job validates Redis stream + Notify deliveries after staging deploys. | -| Sprint 9 | Policy Foundations | src/StellaOps.Policy/TASKS.md | DONE (2025-10-19) | Policy Guild | POLICY-CORE-09-004 | Versioned scoring config with schema validation, trust table, and golden fixtures. | -| Sprint 9 | Policy Foundations | src/StellaOps.Policy/TASKS.md | DONE (2025-10-19) | Policy Guild | POLICY-CORE-09-005 | Scoring/quiet engine – compute score, enforce VEX-only quiet rules, emit inputs and provenance. | -| Sprint 9 | Policy Foundations | src/StellaOps.Policy/TASKS.md | DONE (2025-10-19) | Policy Guild | POLICY-CORE-09-006 | Unknown state & confidence decay – deterministic bands surfaced in policy outputs. | | Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Cache/TASKS.md | TODO | Scanner Cache Guild | SCANNER-CACHE-10-101 | Implement layer cache store keyed by layer digest with metadata retention per architecture §3.3. | | Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Cache/TASKS.md | TODO | Scanner Cache Guild | SCANNER-CACHE-10-102 | Build file CAS with dedupe, TTL enforcement, and offline import/export hooks. | | Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Cache/TASKS.md | TODO | Scanner Cache Guild | SCANNER-CACHE-10-103 | Expose cache metrics/logging and configuration toggles for warm/cold thresholds. | @@ -66,13 +39,9 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation | Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Emit/TASKS.md | TODO | Emit Guild | SCANNER-EMIT-10-605 | Emit BOM-Index sidecar schema/fixtures (CRITICAL PATH for SP16). | | Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Emit/TASKS.md | TODO | Emit Guild | SCANNER-EMIT-10-606 | Usage view bit flags integrated with EntryTrace. | | Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Emit/TASKS.md | TODO | Emit Guild | SCANNER-EMIT-10-607 | Embed scoring inputs, confidence band, and quiet provenance in CycloneDX/DSSE artifacts. | -| Sprint 10 | Benchmarks | bench/TASKS.md | DONE (2025-10-21) | Bench Guild, Language Analyzer Guild | BENCH-SCANNER-10-002 | Wire real language analyzers into bench harness & refresh baselines post-implementation. | | Sprint 10 | Samples | samples/TASKS.md | TODO | Samples Guild, Scanner Team | SAMPLES-10-001 | Sample images with SBOM/BOM-Index sidecars. | | Sprint 10 | DevOps Perf | ops/devops/TASKS.md | TODO | DevOps Guild | DEVOPS-PERF-10-001 | Perf smoke job ensuring <5 s SBOM compose. | | Sprint 11 | Signing Chain Bring-up | src/StellaOps.Authority/TASKS.md | DOING (2025-10-19) | Authority Core & Security Guild | AUTH-MTLS-11-002 | Add OAuth mTLS client credential support with certificate-bound tokens and introspection updates. | -| Sprint 11 | Signing Chain Bring-up | src/StellaOps.Signer/TASKS.md | DONE (2025-10-21) | Signer Guild | SIGNER-API-11-101 | `/sign/dsse` pipeline with Authority auth, PoE introspection, release verification, DSSE signing. | -| Sprint 11 | Signing Chain Bring-up | src/StellaOps.Signer/TASKS.md | DONE (2025-10-21) | Signer Guild | SIGNER-REF-11-102 | `/verify/referrers` endpoint with OCI lookup, caching, and policy enforcement. | -| Sprint 11 | Signing Chain Bring-up | src/StellaOps.Signer/TASKS.md | DONE (2025-10-21) | Signer Guild | SIGNER-QUOTA-11-103 | Enforce plan quotas, concurrency/QPS limits, artifact size caps with metrics/audit logs. | | Sprint 11 | Signing Chain Bring-up | src/StellaOps.Attestor/TASKS.md | TODO | Attestor Guild | ATTESTOR-API-11-201 | `/rekor/entries` submission pipeline with dedupe, proof acquisition, and persistence. | | Sprint 11 | Signing Chain Bring-up | src/StellaOps.Attestor/TASKS.md | TODO | Attestor Guild | ATTESTOR-VERIFY-11-202 | `/rekor/verify` + retrieval endpoints validating signatures and Merkle proofs. | | Sprint 11 | Signing Chain Bring-up | src/StellaOps.Attestor/TASKS.md | TODO | Attestor Guild | ATTESTOR-OBS-11-203 | Telemetry, alerting, mTLS hardening, and archive workflow for Attestor. | @@ -88,7 +57,6 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation | Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Webhook/TASKS.md | TODO | Zastava Webhook Guild | ZASTAVA-WEBHOOK-12-101 | Admission controller host with TLS bootstrap and Authority auth. | | Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Webhook/TASKS.md | TODO | Zastava Webhook Guild | ZASTAVA-WEBHOOK-12-102 | Query Scanner `/policy/runtime`, resolve digests, enforce verdicts. | | Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Webhook/TASKS.md | TODO | Zastava Webhook Guild | ZASTAVA-WEBHOOK-12-103 | Caching, fail-open/closed toggles, metrics/logging for admission decisions. | -| Sprint 12 | Runtime Guardrails | src/StellaOps.Scanner.WebService/TASKS.md | DONE (2025-10-20) | Scanner WebService Guild | SCANNER-RUNTIME-12-301 | `/runtime/events` ingestion endpoint with validation, batching, storage hooks. | | Sprint 12 | Runtime Guardrails | src/StellaOps.Scanner.WebService/TASKS.md | DOING (2025-10-20) | Scanner WebService Guild | SCANNER-RUNTIME-12-302 | `/policy/runtime` endpoint joining SBOM baseline + policy verdict, returning admission guidance. | | Sprint 12 | Runtime Guardrails | src/StellaOps.Scanner.WebService/TASKS.md | TODO | Scanner WebService Guild | SCANNER-RUNTIME-12-303 | Align `/policy/runtime` verdicts with canonical policy evaluation (Feedser/Vexer). | | Sprint 12 | Runtime Guardrails | src/StellaOps.Scanner.WebService/TASKS.md | TODO | Scanner WebService Guild | SCANNER-RUNTIME-12-304 | Integrate attestation verification into runtime policy metadata. | @@ -100,8 +68,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-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.Cli/TASKS.md | TODO | DevEx/CLI | CLI-RUNTIME-13-005 | Add runtime policy test verbs that consume `/policy/runtime` and display verdicts. | -| Sprint 13 | UX & CLI Experience | src/StellaOps.Cli/TASKS.md | DONE (2025-10-21) | DevEx/CLI | CLI-OFFLINE-13-006 | Implement offline kit pull/import/status commands with integrity checks. | | Sprint 13 | UX & CLI Experience | src/StellaOps.Cli/TASKS.md | TODO | DevEx/CLI | CLI-PLUGIN-13-007 | Package non-core CLI verbs as restart-time plug-ins (manifest + loader tests). | +| Sprint 13 | UX & CLI Experience | src/StellaOps.Web/TASKS.md | TODO | UX Specialist, Angular Eng, DevEx | WEB1.DEPS-13-001 | Stabilise Angular workspace dependencies for headless CI installs (`npm install`, Chromium handling, docs). | +| Sprint 13 | Platform Reliability | ops/devops/TASKS.md | TODO | 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 14 | Release & Offline Ops | ops/devops/TASKS.md | TODO | DevOps Guild | DEVOPS-REL-14-001 | Deterministic build/release pipeline with SBOM/provenance, signing, and manifest generation. | | Sprint 14 | Release & Offline Ops | ops/offline-kit/TASKS.md | TODO | Offline Kit Guild | DEVOPS-OFFLINE-14-002 | Offline kit packaging workflow with integrity verification and documentation. | | Sprint 14 | Release & Offline Ops | ops/deployment/TASKS.md | TODO | Deployment Guild | DEVOPS-OPS-14-003 | Deployment/update/rollback automation and channel management documentation. | @@ -139,7 +108,6 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation | Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Storage.Mongo/TASKS.md | TODO | Scheduler Storage Guild | SCHED-STORAGE-16-201 | Mongo schemas/indexes for Scheduler state. | | Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Storage.Mongo/TASKS.md | TODO | Scheduler Storage Guild | SCHED-STORAGE-16-202 | Repositories with tenant scoping, TTL, causal consistency. | | Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Storage.Mongo/TASKS.md | TODO | Scheduler Storage Guild | SCHED-STORAGE-16-203 | Audit + stats materialization for UI. | -| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Queue/TASKS.md | DONE (2025-10-20) | Scheduler Queue Guild | SCHED-QUEUE-16-403 | Dead-letter handling + metrics. | | Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.ImpactIndex/TASKS.md | TODO | Scheduler ImpactIndex Guild | SCHED-IMPACT-16-301 | Ingest BOM-Index into roaring bitmap store. | | Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.ImpactIndex/TASKS.md | TODO | Scheduler ImpactIndex Guild | SCHED-IMPACT-16-302 | Query APIs for ResolveByPurls/ResolveByVulns/ResolveAll. | | Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.ImpactIndex/TASKS.md | TODO | Scheduler ImpactIndex Guild | SCHED-IMPACT-16-303 | Snapshot/compaction/invalidation workflow. | diff --git a/SPRINTS_PRIOR_20251021.md b/SPRINTS_PRIOR_20251021.md new file mode 100644 index 00000000..d3c8a75e --- /dev/null +++ b/SPRINTS_PRIOR_20251021.md @@ -0,0 +1,40 @@ +This file describe implementation of Stella Ops (docs/README.md). Implementation must respect rules from AGENTS.md (read if you have not). + +| Sprint | Theme | Tasks File Path | Status | Type of Specialist | Task ID | Task Description | +| --- | --- | --- | --- | --- | --- | --- | +| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.WebService/TASKS.md | DONE (2025-10-20) | Team Excititor WebService | EXCITITOR-WEB-01-002 | Ingest & reconcile endpoints – scope-enforced `/excititor/init`, `/excititor/ingest/run`, `/excititor/ingest/resume`, `/excititor/reconcile`; regression via `dotnet test … --filter FullyQualifiedName~IngestEndpointsTests`. | +| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.WebService/TASKS.md | DONE (2025-10-20) | Team Excititor WebService | EXCITITOR-WEB-01-004 | Resolve API & signed responses – expose `/excititor/resolve`, return signed consensus/score envelopes, document auth. | +| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.Worker/TASKS.md | DONE (2025-10-21) | Team Excititor Worker | EXCITITOR-WORKER-01-004 | TTL refresh & stability damper – schedule re-resolve loops and guard against status flapping. | +| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Concelier.Core/TASKS.md | DONE (2025-10-21) | Team Core Engine & Data Science | FEEDCORE-ENGINE-07-002 | Noise prior computation service – learn false-positive priors and expose deterministic summaries. | +| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Concelier.Core/TASKS.md | DONE (2025-10-21) | Team Core Engine & Storage Analytics | FEEDCORE-ENGINE-07-003 | Unknown state ledger & confidence seeding – persist unknown flags, seed confidence bands, expose query surface. | +| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.WebService/TASKS.md | DONE (2025-10-19) | Team Excititor WebService | EXCITITOR-WEB-01-005 | Mirror distribution endpoints – expose download APIs for downstream Excititor instances. | +| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.Export/TASKS.md | DONE (2025-10-21) | Team Excititor Export | EXCITITOR-EXPORT-01-005 | Score & resolve envelope surfaces – include signed consensus/score artifacts in exports. | +| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.Export/TASKS.md | DONE (2025-10-21) | Team Excititor Export | EXCITITOR-EXPORT-01-006 | Quiet provenance packaging – attach quieted-by statement IDs, signers, justification codes to exports and attestations. | +| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.Export/TASKS.md | DONE (2025-10-21) | Team Excititor Export | EXCITITOR-EXPORT-01-007 | Mirror bundle + domain manifest – publish signed consensus bundles for mirrors. | +| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.Connectors.StellaOpsMirror/TASKS.md | DONE (2025-10-21) | Excititor Connectors – Stella | EXCITITOR-CONN-STELLA-07-001 | Excititor mirror connector – ingest signed mirror bundles and map to VexClaims with resume handling. | +| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Concelier.Storage.Mongo/TASKS.md | DONE (2025-10-19) | Team Normalization & Storage Backbone | FEEDSTORAGE-DATA-07-001 | Advisory statement & conflict collections – provision Mongo schema/indexes for event-sourced merge. | +| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Web/TASKS.md | DONE (2025-10-21) | UX Specialist, Angular Eng | WEB1.TRIVY-SETTINGS-TESTS | Add headless UI test run (`ng test --watch=false`) and document prerequisites once Angular tooling is chained up. | +| Sprint 8 | Mirror Distribution | src/StellaOps.Concelier.Connector.StellaOpsMirror/TASKS.md | DONE (2025-10-20) | BE-Conn-Stella | FEEDCONN-STELLA-08-001 | Concelier mirror connector – fetch mirror manifest, verify signatures, and hydrate canonical DTOs with resume support. | +| Sprint 8 | Mirror Distribution | src/StellaOps.Concelier.Connector.StellaOpsMirror/TASKS.md | DONE (2025-10-20) | BE-Conn-Stella | FEEDCONN-STELLA-08-002 | Map mirror payloads into canonical advisory DTOs with provenance referencing mirror domain + original source metadata. | +| Sprint 8 | Mirror Distribution | src/StellaOps.Concelier.Connector.StellaOpsMirror/TASKS.md | DONE (2025-10-20) | BE-Conn-Stella | FEEDCONN-STELLA-08-003 | Add incremental cursor + resume support (per-export fingerprint) and document configuration for downstream Concelier instances. | +| Sprint 8 | Plugin Infrastructure | src/StellaOps.Plugin/TASKS.md | DONE (2025-10-21) | Plugin Platform Guild | PLUGIN-DI-08-001 | Scoped service support in plugin bootstrap – added dynamic plugin tests ensuring `[ServiceBinding]` metadata flows through plugin hosts and remains idempotent. | +| Sprint 8 | Plugin Infrastructure | src/StellaOps.Plugin/TASKS.md | DONE (2025-10-20) | Plugin Platform Guild, Authority Core | PLUGIN-DI-08-002.COORD | Authority scoped-service integration handshake
Workshop concluded 2025-10-20 15:00–16:05 UTC; decisions + follow-ups recorded in `docs/dev/authority-plugin-di-coordination.md`. | +| Sprint 8 | Plugin Infrastructure | src/StellaOps.Plugin/TASKS.md | DONE (2025-10-20) | Plugin Platform Guild, Authority Core | PLUGIN-DI-08-002 | Authority plugin integration updates – scoped identity-provider services with registry handles; regression coverage via scoped registrar/unit tests. | +| Sprint 8 | Plugin Infrastructure | src/StellaOps.Authority/TASKS.md | DONE (2025-10-20) | Authority Core, Plugin Platform Guild | AUTH-PLUGIN-COORD-08-002 | Coordinate scoped-service adoption for Authority plug-in registrars
Workshop notes and follow-up backlog captured 2025-10-20 in `docs/dev/authority-plugin-di-coordination.md`. | +| Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.WebService/TASKS.md | DONE (2025-10-19) | Team Scanner WebService | SCANNER-WEB-09-103 | Progress streaming (SSE/JSONL) with correlation IDs and ISO-8601 UTC timestamps, documented in API reference. | +| Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.WebService/TASKS.md | DONE (2025-10-19) | Team Scanner WebService | SCANNER-POLICY-09-105 | Policy snapshot loader + schema + OpenAPI (YAML ignore rules, VEX include/exclude, vendor precedence). | +| Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.WebService/TASKS.md | DONE (2025-10-19) | Team Scanner WebService | SCANNER-POLICY-09-106 | `/reports` verdict assembly (Feedser+Vexer+Policy) + signed response envelope. | +| Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.WebService/TASKS.md | DONE (2025-10-19) | Team Scanner WebService | SCANNER-POLICY-09-107 | Expose score inputs, config version, and quiet provenance in `/reports` JSON and signed payload. | +| Sprint 9 | DevOps Foundations | ops/devops/TASKS.md | DONE (2025-10-21) | DevOps Guild, Scanner WebService Guild | DEVOPS-SCANNER-09-204 | Surface `SCANNER__EVENTS__*` env config across Compose/Helm and document overrides. | +| Sprint 9 | DevOps Foundations | ops/devops/TASKS.md | DONE (2025-10-21) | DevOps Guild, Notify Guild | DEVOPS-SCANNER-09-205 | Notify smoke job validates Redis stream + Notify deliveries after staging deploys. | +| Sprint 9 | Policy Foundations | src/StellaOps.Policy/TASKS.md | DONE (2025-10-19) | Policy Guild | POLICY-CORE-09-004 | Versioned scoring config with schema validation, trust table, and golden fixtures. | +| Sprint 9 | Policy Foundations | src/StellaOps.Policy/TASKS.md | DONE (2025-10-19) | Policy Guild | POLICY-CORE-09-005 | Scoring/quiet engine – compute score, enforce VEX-only quiet rules, emit inputs and provenance. | +| Sprint 9 | Policy Foundations | src/StellaOps.Policy/TASKS.md | DONE (2025-10-19) | Policy Guild | POLICY-CORE-09-006 | Unknown state & confidence decay – deterministic bands surfaced in policy outputs. | +| Sprint 9 | Docs & Governance | docs/TASKS.md | DONE (2025-10-21) | Platform Events Guild | PLATFORM-EVENTS-09-401 | Embed canonical event samples into contract/integration tests and ensure CI validates payloads against published schemas. | +| Sprint 10 | Benchmarks | bench/TASKS.md | DONE (2025-10-21) | Bench Guild, Language Analyzer Guild | BENCH-SCANNER-10-002 | Wire real language analyzers into bench harness & refresh baselines post-implementation. | +| Sprint 11 | Signing Chain Bring-up | src/StellaOps.Signer/TASKS.md | DONE (2025-10-21) | Signer Guild | SIGNER-API-11-101 | `/sign/dsse` pipeline with Authority auth, PoE introspection, release verification, DSSE signing. | +| Sprint 11 | Signing Chain Bring-up | src/StellaOps.Signer/TASKS.md | DONE (2025-10-21) | Signer Guild | SIGNER-REF-11-102 | `/verify/referrers` endpoint with OCI lookup, caching, and policy enforcement. | +| Sprint 11 | Signing Chain Bring-up | src/StellaOps.Signer/TASKS.md | DONE (2025-10-21) | Signer Guild | SIGNER-QUOTA-11-103 | Enforce plan quotas, concurrency/QPS limits, artifact size caps with metrics/audit logs. | +| Sprint 12 | Runtime Guardrails | src/StellaOps.Scanner.WebService/TASKS.md | DONE (2025-10-20) | Scanner WebService Guild | SCANNER-RUNTIME-12-301 | `/runtime/events` ingestion endpoint with validation, batching, storage hooks. | +| Sprint 13 | UX & CLI Experience | src/StellaOps.Cli/TASKS.md | DONE (2025-10-21) | DevEx/CLI | CLI-OFFLINE-13-006 | Implement offline kit pull/import/status commands with integrity checks. | +| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Queue/TASKS.md | DONE (2025-10-20) | Scheduler Queue Guild | SCHED-QUEUE-16-403 | Dead-letter handling + metrics. | diff --git a/docs/ARCHITECTURE_EXCITITOR_MIRRORS.md b/docs/ARCHITECTURE_EXCITITOR_MIRRORS.md index 37fb948f..06fb1e19 100644 --- a/docs/ARCHITECTURE_EXCITITOR_MIRRORS.md +++ b/docs/ARCHITECTURE_EXCITITOR_MIRRORS.md @@ -44,7 +44,26 @@ Excititor: vulnId: CVE-2025-0001 ``` -### Field reference +### Root settings + +| Field | Required | Description | +| --- | --- | --- | +| `outputRoot` | – | Filesystem root where mirror artefacts are written. Defaults to the Excititor file-system artifact store root when omitted. | +| `directoryName` | – | Optional subdirectory created under `outputRoot`; defaults to `mirror`. | +| `targetRepository` | – | Hint propagated to manifests/index files indicating the operator-visible location (for example `s3://mirror/excititor`). | +| `signing` | – | Bundle signing configuration. When enabled, the exporter emits a detached JWS (`bundle.json.jws`) alongside each domain bundle. | + +`signing` supports the following fields: + +| Field | Required | Description | +| --- | --- | --- | +| `enabled` | – | Toggles detached signing for domain bundles. | +| `algorithm` | – | Signing algorithm identifier (default `ES256`). | +| `keyId` | ✅ (when `enabled`) | Signing key identifier resolved via the configured crypto provider registry. | +| `provider` | – | Optional provider hint when multiple registries are available. | +| `keyPath` | – | Optional PEM path used to seed the provider when the key is not already loaded. | + +### Domain field reference | Field | Required | Description | | --- | --- | --- | @@ -53,13 +72,13 @@ Excititor: | `requireAuthentication` | – | When `true` the service enforces that the caller is authenticated (Authority token). | | `maxIndexRequestsPerHour` | – | Per-domain quota for index endpoints. `0`/negative disables the guard. | | `maxDownloadRequestsPerHour` | – | Per-domain quota for artifact downloads. | -| `exports` | ✅ | Collection of export projections. | +| `exports` | ✅ | Collection of export projections. | Export-level fields: | Field | Required | Description | | --- | --- | --- | -| `key` | ✅ | Unique key within the domain. Used in URLs (`/exports/{key}`) and filenames. | +| `key` | ✅ | Unique key within the domain. Used in URLs (`/exports/{key}`) and filenames/bundle entries. | | `format` | ✅ | One of `json`, `jsonl`, `openvex`, `csaf`. Maps to `VexExportFormat`. | | `filters` | – | Key/value pairs executed via `VexQueryFilter`. Keys must match export data source columns (e.g., `vulnId`, `productKey`). | | `sort` | – | Key/boolean map (false = descending). | @@ -117,7 +136,14 @@ Recommended workflow: * `GET /download` when new * Verify digest + attestation -When the export team lands deterministic mirror bundles (Sprint 7 tasks 01-005/006/007), these configurations can be generated automatically. +When the export engine runs, it materializes the following artefacts under `outputRoot/`: + +- `index.json` – canonical index listing each configured domain, manifest/bundle descriptors (with SHA-256 digests), and available export keys. +- `/manifest.json` – per-domain summary with export metadata (query signature, consensus/score digests, source providers) and a descriptor pointing at the bundle. +- `/bundle.json` – canonical payload containing serialized consensus, score envelopes, and normalized VEX claims for the matching export definitions. +- `/bundle.json.jws` – optional detached JWS when signing is enabled. + +Downstream automation reads `manifest.json`/`bundle.json` directly, while `/excititor/mirror` endpoints stream the same artefacts through authenticated HTTP. --- diff --git a/docs/TASKS.md b/docs/TASKS.md index 9b26b9ed..084c9692 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -13,7 +13,7 @@ | DOCS-EVENTS-09-002 | DONE (2025-10-19) | Docs Guild, Platform Events | SCANNER-EVENTS-15-201 | Publish event schema catalog (`docs/events/`) for `scanner.report.ready@1`, `scheduler.rescan.delta@1`, `attestor.logged@1`. | Schemas validated (Ajv CI hooked); docs/events/README summarises usage; Platform Events notified via `docs/updates/2025-10-18-docs-guild.md`. | | DOCS-EVENTS-09-003 | DONE (2025-10-19) | Docs Guild | DOCS-EVENTS-09-002 | Add human-readable envelope field references and canonical payload samples for published events, including offline validation workflow. | Tables explain common headers/payload segments; versioned sample payloads committed; README links to validation instructions and samples. | | DOCS-EVENTS-09-004 | DONE (2025-10-19) | Docs Guild, Scanner WebService | SCANNER-EVENTS-15-201 | Refresh scanner event docs to mirror DSSE-backed report fields, document `scanner.scan.completed`, and capture canonical sample validation. | Schemas updated for new payload shape; README references DSSE reuse and validation test; samples align with emitted events. | -| PLATFORM-EVENTS-09-401 | DONE (2025-10-19) | Platform Events Guild | DOCS-EVENTS-09-003 | Embed canonical event samples into contract/integration tests and ensure CI validates payloads against published schemas. | Notify/Scheduler contract suites exercise samples; CI job validates samples with `ajv-cli`; Platform Events changelog notes coverage. | +| 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 | TODO | 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. | diff --git a/docs/events/README.md b/docs/events/README.md index 7e97d049..df0c7625 100644 --- a/docs/events/README.md +++ b/docs/events/README.md @@ -20,12 +20,13 @@ All event envelopes share the same deterministic header. Use the following table | `tenant` | `string` | Multi‑tenant isolation key; mirror the value recorded in queue/Mongo metadata. | | `ts` | `date-time` | RFC 3339 UTC timestamp. Use monotonic clocks or atomic offsets so ordering survives retries. | | `scope` | `object` | Optional block used when the event concerns a specific image or repository. See schema for required fields (e.g., `repo`, `digest`). | -| `payload` | `object` | Event-specific body. Schemas allow additional properties so producers can add optional hints (e.g., `reportId`, `quietedFindingCount`) without breaking consumers. For scanner events, payloads embed both the canonical report document and the DSSE envelope so consumers can reuse signatures without recomputing them. See `docs/runtime/SCANNER_RUNTIME_READINESS.md` for the runtime consumer checklist covering these hints. | +| `payload` | `object` | Event-specific body. Schemas allow additional properties so producers can add optional hints (e.g., `reportId`, `quietedFindingCount`) without breaking consumers. For scanner events, payloads embed both the canonical report document and the DSSE envelope so consumers can reuse signatures without recomputing them. See `docs/runtime/SCANNER_RUNTIME_READINESS.md` for the runtime consumer checklist covering these hints. | +| `attributes` | `object` | Optional metadata bag (`string` keys/values) for downstream correlation (e.g., pipeline identifiers). Omit when unused to keep payloads concise. | When adding new optional fields, document the behaviour in the schema’s `description` block and update the consumer checklist in the next sprint sync. ## Canonical samples & validation -Reference payloads live under `docs/events/samples/`, mirroring the schema version (`@.sample.json`). They illustrate common field combinations, including the optional attributes that downstream teams rely on for UI affordances and audit trails. Scanner samples reuse the exact DSSE envelope checked into `samples/api/reports/report-sample.dsse.json`, and a unit test (`ReportSamplesTests`) guards that the payload/base64 remain canonical. +Reference payloads live under `docs/events/samples/`, mirroring the schema version (`@.sample.json`). They illustrate common field combinations, including the optional attributes that downstream teams rely on for UI affordances and audit trails. Scanner samples reuse the exact DSSE envelope checked into `samples/api/reports/report-sample.dsse.json`, and unit tests (`ReportSamplesTests`, `PlatformEventSchemaValidationTests`) guard that payloads stay canonical and continue to satisfy the published schemas. Run the following loop offline to validate both schemas and samples: diff --git a/docs/events/attestor.logged@1.json b/docs/events/attestor.logged@1.json index 54631e86..514f13d1 100644 --- a/docs/events/attestor.logged@1.json +++ b/docs/events/attestor.logged@1.json @@ -8,8 +8,8 @@ "kind": {"const": "attestor.logged"}, "tenant": {"type": "string"}, "ts": {"type": "string", "format": "date-time"}, - "payload": { - "type": "object", + "payload": { + "type": "object", "required": ["artifactSha256", "rekor", "subject"], "properties": { "artifactSha256": {"type": "string"}, @@ -30,9 +30,14 @@ "name": {"type": "string"} } } - }, - "additionalProperties": true - } - }, - "additionalProperties": false -} + }, + "additionalProperties": true + }, + "attributes": { + "type": "object", + "description": "Optional event attributes for downstream correlation.", + "additionalProperties": true + } + }, + "additionalProperties": false +} diff --git a/docs/events/scanner.report.ready@1.json b/docs/events/scanner.report.ready@1.json index 31523de6..4e5d89e7 100644 --- a/docs/events/scanner.report.ready@1.json +++ b/docs/events/scanner.report.ready@1.json @@ -17,10 +17,10 @@ "digest": {"type": "string"} } }, - "payload": { - "type": "object", - "required": ["verdict", "delta", "links"], - "properties": { + "payload": { + "type": "object", + "required": ["verdict", "delta", "links"], + "properties": { "reportId": {"type": "string"}, "generatedAt": {"type": "string", "format": "date-time"}, "verdict": {"enum": ["pass", "warn", "fail"]}, @@ -76,9 +76,14 @@ }, "additionalProperties": false } - }, - "additionalProperties": true - } - }, - "additionalProperties": false -} + }, + "additionalProperties": true + }, + "attributes": { + "type": "object", + "description": "Optional event attributes for downstream correlation.", + "additionalProperties": true + } + }, + "additionalProperties": false +} diff --git a/docs/events/scanner.scan.completed@1.json b/docs/events/scanner.scan.completed@1.json index 7e6e51a8..2b425595 100644 --- a/docs/events/scanner.scan.completed@1.json +++ b/docs/events/scanner.scan.completed@1.json @@ -17,8 +17,8 @@ "digest": {"type": "string"} } }, - "payload": { - "type": "object", + "payload": { + "type": "object", "required": ["reportId", "digest", "verdict", "summary"], "properties": { "reportId": {"type": "string"}, @@ -90,8 +90,13 @@ "additionalProperties": false } }, - "additionalProperties": true - } - }, - "additionalProperties": false -} + "additionalProperties": true + }, + "attributes": { + "type": "object", + "description": "Optional event attributes for downstream correlation.", + "additionalProperties": true + } + }, + "additionalProperties": false +} diff --git a/docs/events/scheduler.rescan.delta@1.json b/docs/events/scheduler.rescan.delta@1.json index f95db12b..19b72414 100644 --- a/docs/events/scheduler.rescan.delta@1.json +++ b/docs/events/scheduler.rescan.delta@1.json @@ -8,8 +8,8 @@ "kind": {"const": "scheduler.rescan.delta"}, "tenant": {"type": "string"}, "ts": {"type": "string", "format": "date-time"}, - "payload": { - "type": "object", + "payload": { + "type": "object", "required": ["scheduleId", "impactedDigests", "summary"], "properties": { "scheduleId": {"type": "string"}, @@ -26,8 +26,13 @@ } } }, - "additionalProperties": true - } - }, - "additionalProperties": false -} + "additionalProperties": true + }, + "attributes": { + "type": "object", + "description": "Optional event attributes for downstream correlation.", + "additionalProperties": true + } + }, + "additionalProperties": false +} diff --git a/ops/devops/TASKS.md b/ops/devops/TASKS.md index 1f8ea7a9..7818fbb8 100644 --- a/ops/devops/TASKS.md +++ b/ops/devops/TASKS.md @@ -14,5 +14,6 @@ | 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 | TODO | 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. | > Remark (2025-10-20): Repacked `Mongo2Go` local feed to require MongoDB.Driver 3.5.0 + SharpCompress 0.41.0; cache regression tests green and NU1902/NU1903 suppressed. > Remark (2025-10-21): Compose/Helm profiles now surface `SCANNER__EVENTS__*` toggles with docs pointing at new `.env` placeholders. diff --git a/src/StellaOps.Concelier.Core.Tests/Unknown/UnknownStateLedgerTests.cs b/src/StellaOps.Concelier.Core.Tests/Unknown/UnknownStateLedgerTests.cs new file mode 100644 index 00000000..e0c7a536 --- /dev/null +++ b/src/StellaOps.Concelier.Core.Tests/Unknown/UnknownStateLedgerTests.cs @@ -0,0 +1,207 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using StellaOps.Concelier.Core.Unknown; +using StellaOps.Concelier.Models; +using Xunit; + +namespace StellaOps.Concelier.Core.Tests.Unknown; + +public sealed class UnknownStateLedgerTests +{ + [Fact] + public async Task RecordAsync_DetectsMarkersAndPersistsLedger() + { + var repository = new FakeUnknownStateRepository(); + var observedAt = DateTimeOffset.Parse("2025-10-20T08:00:00Z"); + var recordedAt = DateTimeOffset.Parse("2025-10-21T00:00:00Z"); + var ledger = new UnknownStateLedger(repository, new FixedTimeProvider(recordedAt)); + var advisory = BuildAdvisory( + provenance: Array.Empty(), + packages: new[] + { + BuildPackage( + statuses: new[] { BuildStatus(AffectedPackageStatusCatalog.KnownAffected, source: "Vendor") }, + versionRanges: Array.Empty()), + BuildPackage( + statuses: new[] { BuildStatus(AffectedPackageStatusCatalog.Fixed) }, + versionRanges: new[] + { + new AffectedVersionRange( + rangeKind: "semver", + introducedVersion: null, + fixedVersion: null, + lastAffectedVersion: null, + rangeExpression: null, + provenance: new AdvisoryProvenance("unknown", "range", string.Empty, recordedAt, fieldMask: null)), + }), + }); + + var request = new UnknownStateLedgerRequest("CVE-2025-1111", advisory, observedAt); + var result = await ledger.RecordAsync(request, CancellationToken.None); + + Assert.True(advisory.Provenance.IsDefaultOrEmpty); + Assert.Equal("cve-2025-1111", result.VulnerabilityKey); + Assert.Equal(observedAt.ToUniversalTime(), result.AsOf); + var markerNames = result.Markers.Select(marker => marker.Marker).OrderBy(name => name, StringComparer.Ordinal).ToArray(); + Assert.True(markerNames.Length == 3, "Markers: " + string.Join(",", markerNames)); + Assert.Single(repository.Upserts); + Assert.Equal("cve-2025-1111", repository.Upserts.Single().VulnerabilityKey); + Assert.Equal(3, repository.Upserts.Single().Snapshots.Count); + + var markers = result.Markers.ToDictionary(marker => marker.Marker, marker => marker, StringComparer.Ordinal); + Assert.True(markers.ContainsKey(UnknownStateMarkerKinds.UnknownVulnerabilityRange)); + Assert.True(markers.ContainsKey(UnknownStateMarkerKinds.UnknownOrigin)); + Assert.True(markers.ContainsKey(UnknownStateMarkerKinds.AmbiguousFix)); + + var fixMarker = markers[UnknownStateMarkerKinds.AmbiguousFix]; + Assert.Equal(0.45, fixMarker.Confidence, 3); + Assert.Equal("medium", fixMarker.ConfidenceBand); + Assert.Contains("explicit fixed version", fixMarker.Evidence, StringComparison.OrdinalIgnoreCase); + + var repositoryMarkers = await repository.GetByVulnerabilityAsync("cve-2025-1111", CancellationToken.None); + Assert.Equal(3, repositoryMarkers.Count); + } + + [Fact] + public async Task RecordAsync_NoUnknownSignals_ClearsLedger() + { + var repository = new FakeUnknownStateRepository(); + var observedAt = DateTimeOffset.Parse("2025-10-19T09:00:00Z"); + var recordedAt = DateTimeOffset.Parse("2025-10-21T03:00:00Z"); + var ledger = new UnknownStateLedger(repository, new FixedTimeProvider(recordedAt)); + var advisory = BuildAdvisory( + provenance: new[] { new AdvisoryProvenance("NVD", "merge", "nvd-source", recordedAt, fieldMask: null) }, + packages: new[] + { + BuildPackage( + statuses: new[] { BuildStatus(AffectedPackageStatusCatalog.KnownAffected, source: "Vendor") }, + versionRanges: new[] + { + new AffectedVersionRange("semver", "1.0.0", "1.0.5", null, ">=1.0.0,<1.0.5", new AdvisoryProvenance("NVD", "range", string.Empty, recordedAt, fieldMask: null)), + }, + provenance: new[] + { + new AdvisoryProvenance("Vendor", "advisory", "vendor", recordedAt, fieldMask: null), + }), + }); + + var result = await ledger.RecordAsync(new UnknownStateLedgerRequest("GHSA-1234", advisory, observedAt), CancellationToken.None); + + Assert.Equal("ghsa-1234", result.VulnerabilityKey); + Assert.Empty(result.Markers); + Assert.Single(repository.Upserts); + var stored = repository.Upserts.Single(); + Assert.Empty(stored.Snapshots); + } + + [Fact] + public async Task GetByVulnerabilityAsync_NormalizesKey() + { + var repository = new FakeUnknownStateRepository(); + var snapshot = new UnknownStateSnapshot( + UnknownStateMarkerKinds.UnknownOrigin, + 0.6, + "medium", + DateTimeOffset.Parse("2025-10-19T00:00:00Z"), + DateTimeOffset.Parse("2025-10-19T01:00:00Z"), + "evidence"); + + repository.Stored["cve-2025-0001"] = new List { snapshot }; + + var ledger = new UnknownStateLedger(repository); + var markers = await ledger.GetByVulnerabilityAsync("CVE-2025-0001", CancellationToken.None); + + Assert.Single(markers); + Assert.Equal(snapshot, markers[0]); + } + + [Fact] + public void CanonicalSerialization_IsDeterministic() + { + var snapshot = new UnknownStateSnapshot( + UnknownStateMarkerKinds.UnknownOrigin, + 0.6, + "medium", + DateTimeOffset.Parse("2025-10-19T00:00:00Z"), + DateTimeOffset.Parse("2025-10-21T12:00:00Z"), + "Provenance missing"); + + var json = CanonicalJsonSerializer.Serialize(snapshot); + + const string expected = "{\"confidence\":0.6,\"confidenceBand\":\"medium\",\"evidence\":\"Provenance missing\",\"marker\":\"unknown_origin\",\"observedAt\":\"2025-10-19T00:00:00+00:00\",\"recordedAt\":\"2025-10-21T12:00:00+00:00\"}"; + Assert.Equal(expected, json); + } + + private static Advisory BuildAdvisory( + IEnumerable provenance, + IEnumerable packages) + => new( + advisoryKey: "ADV-1", + title: "Sample advisory", + summary: null, + language: "en", + published: null, + modified: null, + severity: "High", + exploitKnown: false, + aliases: new[] { "CVE-2025-1111" }, + references: Array.Empty(), + affectedPackages: packages, + cvssMetrics: Array.Empty(), + provenance: provenance, + description: null, + cwes: Array.Empty(), + canonicalMetricId: null); + + private static AffectedPackage BuildPackage( + IEnumerable statuses, + IEnumerable versionRanges, + IEnumerable? provenance = null) + => new( + type: AffectedPackageTypes.SemVer, + identifier: "pkg/example", + platform: null, + versionRanges: versionRanges, + statuses: statuses, + provenance: provenance, + normalizedVersions: Array.Empty()); + + private static AffectedPackageStatus BuildStatus(string status, string source = "unknown") + => new(status, new AdvisoryProvenance(source, "status", string.Empty, DateTimeOffset.Parse("2025-10-15T00:00:00Z"), fieldMask: null)); + + private sealed class FakeUnknownStateRepository : IUnknownStateRepository + { + public List<(string VulnerabilityKey, IReadOnlyCollection Snapshots)> Upserts { get; } = new(); + + public Dictionary> Stored { get; } = new(StringComparer.Ordinal); + + public ValueTask UpsertAsync(string vulnerabilityKey, IReadOnlyCollection snapshots, CancellationToken cancellationToken) + { + Upserts.Add((vulnerabilityKey, snapshots)); + Stored[vulnerabilityKey] = snapshots?.ToList() ?? new List(); + return ValueTask.CompletedTask; + } + + public ValueTask> GetByVulnerabilityAsync(string vulnerabilityKey, CancellationToken cancellationToken) + { + Stored.TryGetValue(vulnerabilityKey, out var snapshots); + return ValueTask.FromResult>(snapshots ?? new List()); + } + } + + private sealed class FixedTimeProvider : TimeProvider + { + private readonly DateTimeOffset _now; + + public FixedTimeProvider(DateTimeOffset now) + { + _now = now.ToUniversalTime(); + } + + public override DateTimeOffset GetUtcNow() => _now; + } +} diff --git a/src/StellaOps.Concelier.Core/TASKS.md b/src/StellaOps.Concelier.Core/TASKS.md index 96cf6bb9..ec3ca0ac 100644 --- a/src/StellaOps.Concelier.Core/TASKS.md +++ b/src/StellaOps.Concelier.Core/TASKS.md @@ -18,4 +18,4 @@ |Reference normalization & freshness instrumentation cleanup|BE-Core, QA|Models|DONE (2025-10-15) – reference keys normalized, freshness overrides applied to union fields, and new tests assert decision logging.| |FEEDCORE-ENGINE-07-001 – Advisory event log & asOf queries|Team Core Engine & Storage Analytics|FEEDSTORAGE-DATA-07-001|**DONE (2025-10-19)** – Implemented `AdvisoryEventLog` service plus repository contracts, canonical hashing, and lower-cased key normalization with replay support; documented determinism guarantees. Tests: `dotnet test src/StellaOps.Concelier.Core.Tests/StellaOps.Concelier.Core.Tests.csproj`.| |FEEDCORE-ENGINE-07-002 – Noise prior computation service|Team Core Engine & Data Science|FEEDCORE-ENGINE-07-001|**DONE (2025-10-21)** – Build rule-based learner capturing false-positive priors per package/env, persist summaries, and expose APIs for Excititor/scan suppressors with reproducible statistics.| -|FEEDCORE-ENGINE-07-003 – Unknown state ledger & confidence seeding|Team Core Engine & Storage Analytics|FEEDCORE-ENGINE-07-001|TODO – Persist `unknown_vuln_range/unknown_origin/ambiguous_fix` markers with initial confidence bands, expose query surface for Policy, and add fixtures validating canonical serialization.| +|FEEDCORE-ENGINE-07-003 – Unknown state ledger & confidence seeding|Team Core Engine & Storage Analytics|FEEDCORE-ENGINE-07-001|DONE (2025-10-21) – Persisted `unknown_vuln_range/unknown_origin/ambiguous_fix` markers with seeded confidence bands, exposed query surface for Policy, and added canonical serialization fixtures + regression tests.| diff --git a/src/StellaOps.Concelier.Core/Unknown/IUnknownStateLedger.cs b/src/StellaOps.Concelier.Core/Unknown/IUnknownStateLedger.cs new file mode 100644 index 00000000..f585c4ff --- /dev/null +++ b/src/StellaOps.Concelier.Core/Unknown/IUnknownStateLedger.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace StellaOps.Concelier.Core.Unknown; + +/// +/// Surface for recording and querying unknown-state markers. +/// +public interface IUnknownStateLedger +{ + ValueTask RecordAsync( + UnknownStateLedgerRequest request, + CancellationToken cancellationToken); + + ValueTask> GetByVulnerabilityAsync( + string vulnerabilityKey, + CancellationToken cancellationToken); +} diff --git a/src/StellaOps.Concelier.Core/Unknown/IUnknownStateRepository.cs b/src/StellaOps.Concelier.Core/Unknown/IUnknownStateRepository.cs new file mode 100644 index 00000000..cf0ff878 --- /dev/null +++ b/src/StellaOps.Concelier.Core/Unknown/IUnknownStateRepository.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace StellaOps.Concelier.Core.Unknown; + +/// +/// Persistence abstraction for unknown-state ledger entries. +/// +public interface IUnknownStateRepository +{ + ValueTask UpsertAsync( + string vulnerabilityKey, + IReadOnlyCollection snapshots, + CancellationToken cancellationToken); + + ValueTask> GetByVulnerabilityAsync( + string vulnerabilityKey, + CancellationToken cancellationToken); +} diff --git a/src/StellaOps.Concelier.Core/Unknown/UnknownStateLedger.cs b/src/StellaOps.Concelier.Core/Unknown/UnknownStateLedger.cs new file mode 100644 index 00000000..09a5f709 --- /dev/null +++ b/src/StellaOps.Concelier.Core/Unknown/UnknownStateLedger.cs @@ -0,0 +1,313 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using StellaOps.Concelier.Models; + +namespace StellaOps.Concelier.Core.Unknown; + +/// +/// Default implementation that derives unknown-state markers from canonical advisories. +/// +public sealed class UnknownStateLedger : IUnknownStateLedger +{ + private static readonly ImmutableHashSet ImpactStatuses = ImmutableHashSet.Create( + StringComparer.Ordinal, + AffectedPackageStatusCatalog.KnownAffected, + AffectedPackageStatusCatalog.Affected, + AffectedPackageStatusCatalog.UnderInvestigation, + AffectedPackageStatusCatalog.Pending, + AffectedPackageStatusCatalog.Unknown); + + private static readonly ImmutableHashSet FixStatuses = ImmutableHashSet.Create( + StringComparer.Ordinal, + AffectedPackageStatusCatalog.Fixed, + AffectedPackageStatusCatalog.FirstFixed, + AffectedPackageStatusCatalog.Mitigated); + + private static readonly ImmutableDictionary MarkerSeeds = new Dictionary(StringComparer.Ordinal) + { + [UnknownStateMarkerKinds.UnknownVulnerabilityRange] = new UnknownMarkerSeed(0.8, "high"), + [UnknownStateMarkerKinds.UnknownOrigin] = new UnknownMarkerSeed(0.6, "medium"), + [UnknownStateMarkerKinds.AmbiguousFix] = new UnknownMarkerSeed(0.45, "medium"), + }.ToImmutableDictionary(StringComparer.Ordinal); + + private readonly IUnknownStateRepository _repository; + private readonly TimeProvider _timeProvider; + + public UnknownStateLedger(IUnknownStateRepository repository, TimeProvider? timeProvider = null) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _timeProvider = timeProvider ?? TimeProvider.System; + } + + public async ValueTask RecordAsync( + UnknownStateLedgerRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var recordedAt = _timeProvider.GetUtcNow(); + var markers = EvaluateMarkers(request.Advisory, request.AsOf, recordedAt); + + await _repository.UpsertAsync(request.VulnerabilityKey, markers, cancellationToken).ConfigureAwait(false); + + return new UnknownStateLedgerResult(request.VulnerabilityKey, request.AsOf, markers); + } + + public ValueTask> GetByVulnerabilityAsync( + string vulnerabilityKey, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityKey); + var normalizedKey = vulnerabilityKey.Trim().ToLowerInvariant(); + return _repository.GetByVulnerabilityAsync(normalizedKey, cancellationToken); + } + + private static ImmutableArray EvaluateMarkers( + Advisory advisory, + DateTimeOffset observedAt, + DateTimeOffset recordedAt) + { + var builder = ImmutableArray.CreateBuilder(initialCapacity: 3); + + if (advisory is not null) + { + if (TryCreateUnknownVulnerabilityRangeMarker(advisory, observedAt, recordedAt, out var unknownRange)) + { + builder.Add(unknownRange); + } + + if (TryCreateUnknownOriginMarker(advisory, observedAt, recordedAt, out var unknownOrigin)) + { + builder.Add(unknownOrigin); + } + + if (TryCreateAmbiguousFixMarker(advisory, observedAt, recordedAt, out var ambiguousFix)) + { + builder.Add(ambiguousFix); + } + } + + if (builder.Count == 0) + { + return ImmutableArray.Empty; + } + + builder.Sort(static (left, right) => StringComparer.Ordinal.Compare(left.Marker, right.Marker)); + return builder.ToImmutable(); + } + + private static bool TryCreateUnknownVulnerabilityRangeMarker( + Advisory advisory, + DateTimeOffset observedAt, + DateTimeOffset recordedAt, + out UnknownStateSnapshot snapshot) + { + snapshot = null!; + + if (advisory.AffectedPackages.IsDefaultOrEmpty) + { + return false; + } + + var lackingPackages = 0; + + foreach (var package in advisory.AffectedPackages) + { + if (package is null) + { + continue; + } + + if (!HasImpactStatus(package)) + { + continue; + } + + if (!HasConcreteRange(package)) + { + lackingPackages++; + } + } + + if (lackingPackages == 0) + { + return false; + } + + var seed = MarkerSeeds[UnknownStateMarkerKinds.UnknownVulnerabilityRange]; + var evidence = lackingPackages == 1 + ? "1 affected package lacks explicit version ranges." + : $"{lackingPackages} affected packages lack explicit version ranges."; + + snapshot = new UnknownStateSnapshot( + UnknownStateMarkerKinds.UnknownVulnerabilityRange, + seed.Confidence, + seed.Band, + observedAt, + recordedAt, + evidence); + return true; + } + + private static bool TryCreateUnknownOriginMarker( + Advisory advisory, + DateTimeOffset observedAt, + DateTimeOffset recordedAt, + out UnknownStateSnapshot snapshot) + { + snapshot = null!; + + if (ContainsKnownProvenance(advisory.Provenance)) + { + return false; + } + + var seed = MarkerSeeds[UnknownStateMarkerKinds.UnknownOrigin]; + var evidence = advisory.Provenance.IsDefaultOrEmpty + ? "Advisory provenance is missing; falling back to inferred sources." + : "All advisory provenance sources resolve to 'unknown'."; + + snapshot = new UnknownStateSnapshot( + UnknownStateMarkerKinds.UnknownOrigin, + seed.Confidence, + seed.Band, + observedAt, + recordedAt, + evidence); + return true; + } + + private static bool TryCreateAmbiguousFixMarker( + Advisory advisory, + DateTimeOffset observedAt, + DateTimeOffset recordedAt, + out UnknownStateSnapshot snapshot) + { + snapshot = null!; + + if (advisory.AffectedPackages.IsDefaultOrEmpty) + { + return false; + } + + var ambiguousPackages = 0; + + foreach (var package in advisory.AffectedPackages) + { + if (package is null) + { + continue; + } + + if (!package.Statuses.IsDefaultOrEmpty && package.Statuses.Any(status => FixStatuses.Contains(status.Status))) + { + var hasFixedRange = package.VersionRanges.Any(static range => !string.IsNullOrWhiteSpace(range.FixedVersion)); + if (!hasFixedRange) + { + ambiguousPackages++; + } + } + } + + if (ambiguousPackages == 0) + { + return false; + } + + var seed = MarkerSeeds[UnknownStateMarkerKinds.AmbiguousFix]; + var evidence = ambiguousPackages == 1 + ? "Fix status published without explicit fixed version details." + : $"Fix status published without explicit fixed versions for {ambiguousPackages} packages."; + + snapshot = new UnknownStateSnapshot( + UnknownStateMarkerKinds.AmbiguousFix, + seed.Confidence, + seed.Band, + observedAt, + recordedAt, + evidence); + return true; + } + + private static bool HasImpactStatus(AffectedPackage package) + { + if (package.Statuses.IsDefaultOrEmpty) + { + return false; + } + + foreach (var status in package.Statuses) + { + if (status is null) + { + continue; + } + + if (ImpactStatuses.Contains(status.Status)) + { + return true; + } + } + + return false; + } + + private static bool HasConcreteRange(AffectedPackage package) + { + if (package.VersionRanges.IsDefaultOrEmpty) + { + return false; + } + + foreach (var range in package.VersionRanges) + { + if (range is null) + { + continue; + } + + if (!string.IsNullOrWhiteSpace(range.IntroducedVersion) || + !string.IsNullOrWhiteSpace(range.FixedVersion) || + !string.IsNullOrWhiteSpace(range.LastAffectedVersion) || + !string.IsNullOrWhiteSpace(range.RangeExpression)) + { + return true; + } + } + + return false; + } + + private static bool ContainsKnownProvenance(ImmutableArray provenance) + { + if (provenance.IsDefaultOrEmpty) + { + return false; + } + + foreach (var entry in provenance) + { + if (entry is null) + { + continue; + } + + if (IsKnownSource(entry.Source)) + { + return true; + } + } + + return false; + } + + private static bool IsKnownSource(string? source) + => !string.IsNullOrWhiteSpace(source) && + !string.Equals(source, "unknown", StringComparison.OrdinalIgnoreCase); + + private readonly record struct UnknownMarkerSeed(double Confidence, string Band); +} diff --git a/src/StellaOps.Concelier.Core/Unknown/UnknownStateLedgerRequest.cs b/src/StellaOps.Concelier.Core/Unknown/UnknownStateLedgerRequest.cs new file mode 100644 index 00000000..d4d21595 --- /dev/null +++ b/src/StellaOps.Concelier.Core/Unknown/UnknownStateLedgerRequest.cs @@ -0,0 +1,29 @@ +using System; +using StellaOps.Concelier.Models; + +namespace StellaOps.Concelier.Core.Unknown; + +/// +/// Input payload describing the advisory snapshot used to derive unknown-state markers. +/// +public sealed record UnknownStateLedgerRequest +{ + public UnknownStateLedgerRequest(string vulnerabilityKey, Advisory advisory, DateTimeOffset asOf) + { + VulnerabilityKey = NormalizeKey(vulnerabilityKey); + Advisory = advisory ?? throw new ArgumentNullException(nameof(advisory)); + AsOf = asOf.ToUniversalTime(); + } + + public string VulnerabilityKey { get; init; } + + public Advisory Advisory { get; init; } + + public DateTimeOffset AsOf { get; init; } + + private static string NormalizeKey(string key) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + return key.Trim().ToLowerInvariant(); + } +} diff --git a/src/StellaOps.Concelier.Core/Unknown/UnknownStateLedgerResult.cs b/src/StellaOps.Concelier.Core/Unknown/UnknownStateLedgerResult.cs new file mode 100644 index 00000000..3642c68b --- /dev/null +++ b/src/StellaOps.Concelier.Core/Unknown/UnknownStateLedgerResult.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Immutable; + +namespace StellaOps.Concelier.Core.Unknown; + +/// +/// Result emitted after unknown-state markers are derived and persisted. +/// +public sealed record UnknownStateLedgerResult +{ + public UnknownStateLedgerResult(string vulnerabilityKey, DateTimeOffset asOf, ImmutableArray markers) + { + if (string.IsNullOrWhiteSpace(vulnerabilityKey)) + { + throw new ArgumentException("Vulnerability key must be provided.", nameof(vulnerabilityKey)); + } + + VulnerabilityKey = vulnerabilityKey.Trim().ToLowerInvariant(); + AsOf = asOf.ToUniversalTime(); + Markers = markers.IsDefault ? ImmutableArray.Empty : markers; + } + + public string VulnerabilityKey { get; init; } + + public DateTimeOffset AsOf { get; init; } + + public ImmutableArray Markers { get; init; } +} diff --git a/src/StellaOps.Concelier.Core/Unknown/UnknownStateMarkerKinds.cs b/src/StellaOps.Concelier.Core/Unknown/UnknownStateMarkerKinds.cs new file mode 100644 index 00000000..9b529237 --- /dev/null +++ b/src/StellaOps.Concelier.Core/Unknown/UnknownStateMarkerKinds.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; + +namespace StellaOps.Concelier.Core.Unknown; + +/// +/// Known unknown-state markers emitted from advisory analysis. +/// +public static class UnknownStateMarkerKinds +{ + public const string UnknownVulnerabilityRange = "unknown_vuln_range"; + + public const string UnknownOrigin = "unknown_origin"; + + public const string AmbiguousFix = "ambiguous_fix"; + + public static IReadOnlyList All { get; } = new[] + { + UnknownVulnerabilityRange, + UnknownOrigin, + AmbiguousFix, + }; +} diff --git a/src/StellaOps.Concelier.Core/Unknown/UnknownStateSnapshot.cs b/src/StellaOps.Concelier.Core/Unknown/UnknownStateSnapshot.cs new file mode 100644 index 00000000..a0a2210b --- /dev/null +++ b/src/StellaOps.Concelier.Core/Unknown/UnknownStateSnapshot.cs @@ -0,0 +1,73 @@ +using System; +using System.Globalization; + +namespace StellaOps.Concelier.Core.Unknown; + +/// +/// Describes a persisted unknown-state marker for a vulnerability. +/// +public sealed record UnknownStateSnapshot +{ + public UnknownStateSnapshot( + string marker, + double confidence, + string confidenceBand, + DateTimeOffset observedAt, + DateTimeOffset recordedAt, + string evidence) + { + Marker = NormalizeMarker(marker); + Confidence = NormalizeConfidence(confidence); + ConfidenceBand = NormalizeBand(confidenceBand); + ObservedAt = observedAt.ToUniversalTime(); + RecordedAt = recordedAt.ToUniversalTime(); + Evidence = NormalizeEvidence(evidence); + } + + public string Marker { get; init; } + + public double Confidence { get; init; } + + public string ConfidenceBand { get; init; } + + public DateTimeOffset ObservedAt { get; init; } + + public DateTimeOffset RecordedAt { get; init; } + + public string Evidence { get; init; } + + public override string ToString() + => string.Create(CultureInfo.InvariantCulture, $"{Marker}:{Confidence:0.###}@{ObservedAt:O}"); + + private static string NormalizeMarker(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + return value.Trim().ToLowerInvariant(); + } + + private static double NormalizeConfidence(double value) + { + if (double.IsNaN(value) || double.IsInfinity(value)) + { + return 0d; + } + + return Math.Clamp(value, 0d, 1d); + } + + private static string NormalizeBand(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + return value.Trim().ToLowerInvariant(); + } + + private static string NormalizeEvidence(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + return value.Trim(); + } +} diff --git a/src/StellaOps.Excititor.Connectors.StellaOpsMirror/TASKS.md b/src/StellaOps.Excititor.Connectors.StellaOpsMirror/TASKS.md index 43064222..3830dd17 100644 --- a/src/StellaOps.Excititor.Connectors.StellaOpsMirror/TASKS.md +++ b/src/StellaOps.Excititor.Connectors.StellaOpsMirror/TASKS.md @@ -2,6 +2,6 @@ | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | |----|--------|----------|------------|-------------|---------------| -| EXCITITOR-CONN-STELLA-07-001 | TODO | Excititor Connectors – Stella | EXCITITOR-EXPORT-01-007 | Implement mirror fetch client consuming `https://.stella-ops.org/excititor/exports/index.json`, validating signatures/digests, storing raw consensus bundles with provenance. | Fetch job downloads mirror manifest, verifies DSSE/signature, stores raw documents + provenance; unit tests cover happy path and tampered manifest failure. | +| EXCITITOR-CONN-STELLA-07-001 | DONE (2025-10-21) | Excititor Connectors – Stella | EXCITITOR-EXPORT-01-007 | **DONE (2025-10-21)** – Implemented `StellaOpsMirrorConnector` with `MirrorManifestClient` + `MirrorSignatureVerifier`, digest validation, signature enforcement, raw document + DTO persistence, and resume cursor updates. Added fixture-backed tests covering happy path and tampered manifest rejection. | Fetch job downloads mirror manifest, verifies DSSE/signature, stores raw documents + provenance; unit tests cover happy path and tampered manifest failure. | | EXCITITOR-CONN-STELLA-07-002 | TODO | Excititor Connectors – Stella | EXCITITOR-CONN-STELLA-07-001 | Normalize mirror bundles into VexClaim sets referencing original provider metadata and mirror provenance. | Normalizer emits VexClaims with mirror provenance + policy metadata, fixtures assert deterministic output parity vs local exports. | | EXCITITOR-CONN-STELLA-07-003 | TODO | Excititor Connectors – Stella | EXCITITOR-CONN-STELLA-07-002 | Implement incremental cursor handling per-export digest, support resume, and document configuration for downstream Excititor mirrors. | Connector resumes from last export digest, handles delta/export rotation, docs show configuration; integration test covers resume + new export ingest. | diff --git a/src/StellaOps.Excititor.Core/MirrorDistributionOptions.cs b/src/StellaOps.Excititor.Core/MirrorDistributionOptions.cs new file mode 100644 index 00000000..00c6eb71 --- /dev/null +++ b/src/StellaOps.Excititor.Core/MirrorDistributionOptions.cs @@ -0,0 +1,110 @@ +using System.Collections.Generic; + +namespace StellaOps.Excititor.Core; + +public sealed class MirrorDistributionOptions +{ + public const string SectionName = "Excititor:Mirror"; + + /// + /// Global enable flag for mirror distribution surfaces and bundle generation. + /// + public bool Enabled { get; set; } = true; + + /// + /// Optional absolute or relative path for mirror artifacts. When unset, publishers + /// may fall back to artifact-store specific defaults. + /// + public string? OutputRoot { get; set; } + + /// + /// Directory name created under that holds mirror artifacts. + /// Defaults to mirror to align with offline kit layouts. + /// + public string DirectoryName { get; set; } = "mirror"; + + /// + /// Optional human-readable hint describing where downstream mirrors should publish + /// bundles (e.g., s3://mirror/excititor). Propagated to manifests and index payloads. + /// + public string? TargetRepository { get; set; } + + /// + /// Signing configuration applied to generated bundle payloads. + /// + public MirrorSigningOptions Signing { get; } = new(); + + /// + /// Domains exposed for mirror consumption. Each domain groups a set of export plans. + /// + public List Domains { get; } = new(); +} + +public sealed class MirrorDomainOptions +{ + public string Id { get; set; } = string.Empty; + + public string DisplayName { get; set; } = string.Empty; + + public bool RequireAuthentication { get; set; } = false; + + /// + /// Maximum index requests allowed per rolling window. + /// + public int MaxIndexRequestsPerHour { get; set; } = 120; + + /// + /// Maximum export downloads allowed per rolling window. + /// + public int MaxDownloadRequestsPerHour { get; set; } = 600; + + public List Exports { get; } = new(); +} + +public sealed class MirrorExportOptions +{ + public string Key { get; set; } = string.Empty; + + public string Format { get; set; } = string.Empty; + + public Dictionary Filters { get; } = new(); + + public Dictionary Sort { get; } = new(); + + public int? Limit { get; set; } = null; + + public int? Offset { get; set; } = null; + + public string? View { get; set; } = null; +} + +public sealed class MirrorSigningOptions +{ + /// + /// Enables signing of mirror bundle payloads when true. When false the publisher + /// omits detached JWS artifacts. + /// + public bool Enabled { get; set; } = false; + + /// + /// Signing algorithm requested (for example, ES256). The publisher validates that + /// the selected provider can satisfy the requested algorithm. + /// + public string? Algorithm { get; set; } + + /// + /// Optional key identifier resolved against the configured crypto provider registry. + /// + public string? KeyId { get; set; } + + /// + /// Optional provider hint used to resolve signing providers when multiple are registered. + /// + public string? Provider { get; set; } + + /// + /// Optional file path to a signing key (PEM). Used when the requested provider does + /// not already have the key loaded into its key store. + /// + public string? KeyPath { get; set; } +} diff --git a/src/StellaOps.Excititor.Core/MirrorExportPlanner.cs b/src/StellaOps.Excititor.Core/MirrorExportPlanner.cs new file mode 100644 index 00000000..5bf65943 --- /dev/null +++ b/src/StellaOps.Excititor.Core/MirrorExportPlanner.cs @@ -0,0 +1,47 @@ +using System; +using System.Linq; + +namespace StellaOps.Excititor.Core; + +public sealed record MirrorExportPlan( + string Key, + VexExportFormat Format, + VexQuery Query, + VexQuerySignature Signature); + +public static class MirrorExportPlanner +{ + public static bool TryBuild(MirrorExportOptions exportOptions, out MirrorExportPlan plan, out string? error) + { + if (exportOptions is null) + { + plan = null!; + error = "invalid_export_configuration"; + return false; + } + + if (string.IsNullOrWhiteSpace(exportOptions.Key)) + { + plan = null!; + error = "missing_export_key"; + return false; + } + + if (string.IsNullOrWhiteSpace(exportOptions.Format) || + !Enum.TryParse(exportOptions.Format, ignoreCase: true, out VexExportFormat format)) + { + plan = null!; + error = "unsupported_export_format"; + return false; + } + + var filters = exportOptions.Filters.Select(pair => new VexQueryFilter(pair.Key, pair.Value)); + var sorts = exportOptions.Sort.Select(pair => new VexQuerySort(pair.Key, pair.Value)); + var query = VexQuery.Create(filters, sorts, exportOptions.Limit, exportOptions.Offset, exportOptions.View); + var signature = VexQuerySignature.FromQuery(query); + + plan = new MirrorExportPlan(exportOptions.Key.Trim(), format, query, signature); + error = null; + return true; + } +} diff --git a/src/StellaOps.Excititor.Core/VexCanonicalJsonSerializer.cs b/src/StellaOps.Excititor.Core/VexCanonicalJsonSerializer.cs index 7e040743..7e7e52b0 100644 --- a/src/StellaOps.Excititor.Core/VexCanonicalJsonSerializer.cs +++ b/src/StellaOps.Excititor.Core/VexCanonicalJsonSerializer.cs @@ -230,13 +230,33 @@ public static class VexCanonicalJsonSerializer "sourceProviders", "consensusRevision", "policyRevisionId", - "policyDigest", - "consensusDigest", - "scoreDigest", - "attestation", - "sizeBytes", - } - }, + "policyDigest", + "consensusDigest", + "scoreDigest", + "quietProvenance", + "attestation", + "sizeBytes", + } + }, + { + typeof(VexQuietProvenance), + new[] + { + "vulnerabilityId", + "productKey", + "statements", + } + }, + { + typeof(VexQuietStatement), + new[] + { + "providerId", + "statementId", + "justification", + "signature", + } + }, { typeof(VexScoreEnvelope), new[] diff --git a/src/StellaOps.Excititor.Core/VexExportManifest.cs b/src/StellaOps.Excititor.Core/VexExportManifest.cs index 120dc6cb..22598cae 100644 --- a/src/StellaOps.Excititor.Core/VexExportManifest.cs +++ b/src/StellaOps.Excititor.Core/VexExportManifest.cs @@ -1,6 +1,7 @@ -using System.Collections.Immutable; -using System.Runtime.Serialization; -using System.Text; +using System.Collections.Immutable; +using System.Linq; +using System.Runtime.Serialization; +using System.Text; namespace StellaOps.Excititor.Core; @@ -19,9 +20,10 @@ public sealed record VexExportManifest string? policyRevisionId = null, string? policyDigest = null, VexContentAddress? consensusDigest = null, - VexContentAddress? scoreDigest = null, - VexAttestationMetadata? attestation = null, - long sizeBytes = 0) + VexContentAddress? scoreDigest = null, + IEnumerable? quietProvenance = null, + VexAttestationMetadata? attestation = null, + long sizeBytes = 0) { if (string.IsNullOrWhiteSpace(exportId)) { @@ -48,11 +50,12 @@ public sealed record VexExportManifest SourceProviders = NormalizeProviders(sourceProviders); ConsensusRevision = string.IsNullOrWhiteSpace(consensusRevision) ? null : consensusRevision.Trim(); PolicyRevisionId = string.IsNullOrWhiteSpace(policyRevisionId) ? null : policyRevisionId.Trim(); - PolicyDigest = string.IsNullOrWhiteSpace(policyDigest) ? null : policyDigest.Trim(); - ConsensusDigest = consensusDigest; - ScoreDigest = scoreDigest; - Attestation = attestation; - SizeBytes = sizeBytes; + PolicyDigest = string.IsNullOrWhiteSpace(policyDigest) ? null : policyDigest.Trim(); + ConsensusDigest = consensusDigest; + ScoreDigest = scoreDigest; + QuietProvenance = NormalizeQuietProvenance(quietProvenance); + Attestation = attestation; + SizeBytes = sizeBytes; } public string ExportId { get; } @@ -79,13 +82,15 @@ public sealed record VexExportManifest public VexContentAddress? ConsensusDigest { get; } - public VexContentAddress? ScoreDigest { get; } - - public VexAttestationMetadata? Attestation { get; } + public VexContentAddress? ScoreDigest { get; } + + public ImmutableArray QuietProvenance { get; } + + public VexAttestationMetadata? Attestation { get; } public long SizeBytes { get; } - private static ImmutableArray NormalizeProviders(IEnumerable providers) + private static ImmutableArray NormalizeProviders(IEnumerable providers) { if (providers is null) { @@ -103,11 +108,24 @@ public sealed record VexExportManifest set.Add(provider.Trim()); } - return set.Count == 0 - ? ImmutableArray.Empty - : set.ToImmutableArray(); - } -} + return set.Count == 0 + ? ImmutableArray.Empty + : set.ToImmutableArray(); + } + + private static ImmutableArray NormalizeQuietProvenance(IEnumerable? quietProvenance) + { + if (quietProvenance is null) + { + return ImmutableArray.Empty; + } + + return quietProvenance + .OrderBy(static entry => entry.VulnerabilityId, StringComparer.Ordinal) + .ThenBy(static entry => entry.ProductKey, StringComparer.Ordinal) + .ToImmutableArray(); + } +} public sealed record VexContentAddress { diff --git a/src/StellaOps.Excititor.Core/VexQuietProvenance.cs b/src/StellaOps.Excititor.Core/VexQuietProvenance.cs new file mode 100644 index 00000000..19a2bad7 --- /dev/null +++ b/src/StellaOps.Excititor.Core/VexQuietProvenance.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace StellaOps.Excititor.Core; + +public sealed record VexQuietProvenance +{ + public VexQuietProvenance(string vulnerabilityId, string productKey, IEnumerable statements) + { + if (string.IsNullOrWhiteSpace(vulnerabilityId)) + { + throw new ArgumentException("Vulnerability id must be provided.", nameof(vulnerabilityId)); + } + + if (string.IsNullOrWhiteSpace(productKey)) + { + throw new ArgumentException("Product key must be provided.", nameof(productKey)); + } + + VulnerabilityId = vulnerabilityId.Trim(); + ProductKey = productKey.Trim(); + Statements = NormalizeStatements(statements); + } + + public string VulnerabilityId { get; } + + public string ProductKey { get; } + + public ImmutableArray Statements { get; } + + private static ImmutableArray NormalizeStatements(IEnumerable statements) + { + if (statements is null) + { + throw new ArgumentNullException(nameof(statements)); + } + + return statements + .OrderBy(static s => s.ProviderId, StringComparer.Ordinal) + .ThenBy(static s => s.StatementId, StringComparer.Ordinal) + .ToImmutableArray(); + } +} + +public sealed record VexQuietStatement +{ + public VexQuietStatement( + string providerId, + string statementId, + VexJustification? justification, + VexSignatureMetadata? signature) + { + if (string.IsNullOrWhiteSpace(providerId)) + { + throw new ArgumentException("Provider id must be provided.", nameof(providerId)); + } + + if (string.IsNullOrWhiteSpace(statementId)) + { + throw new ArgumentException("Statement id must be provided.", nameof(statementId)); + } + + ProviderId = providerId.Trim(); + StatementId = statementId.Trim(); + Justification = justification; + Signature = signature; + } + + public string ProviderId { get; } + + public string StatementId { get; } + + public VexJustification? Justification { get; } + + public VexSignatureMetadata? Signature { get; } +} diff --git a/src/StellaOps.Excititor.Export.Tests/ExportEngineTests.cs b/src/StellaOps.Excititor.Export.Tests/ExportEngineTests.cs index 0d488a80..f5c6618c 100644 --- a/src/StellaOps.Excititor.Export.Tests/ExportEngineTests.cs +++ b/src/StellaOps.Excititor.Export.Tests/ExportEngineTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Immutable; using System.IO; using System.Text; +using System.Globalization; using Microsoft.Extensions.Logging.Abstractions; using MongoDB.Driver; using StellaOps.Excititor.Core; @@ -32,6 +33,18 @@ public sealed class ExportEngineTests Assert.Equal(VexExportFormat.Json, manifest.Format); Assert.Equal("baseline/v1", manifest.ConsensusRevision); Assert.Equal(1, manifest.ClaimCount); + Assert.NotNull(dataSource.LastDataSet); + var expectedEnvelopes = VexExportEnvelopeBuilder.Build( + dataSource.LastDataSet!, + VexPolicySnapshot.Default, + context.RequestedAt); + Assert.NotNull(manifest.ConsensusDigest); + Assert.Equal(expectedEnvelopes.ConsensusDigest.Algorithm, manifest.ConsensusDigest!.Algorithm); + Assert.Equal(expectedEnvelopes.ConsensusDigest.Digest, manifest.ConsensusDigest.Digest); + Assert.NotNull(manifest.ScoreDigest); + Assert.Equal(expectedEnvelopes.ScoreDigest.Algorithm, manifest.ScoreDigest!.Algorithm); + Assert.Equal(expectedEnvelopes.ScoreDigest.Digest, manifest.ScoreDigest.Digest); + Assert.Empty(manifest.QuietProvenance); // second call hits cache var cached = await engine.ExportAsync(context, CancellationToken.None); @@ -114,13 +127,82 @@ public sealed class ExportEngineTests var manifest = await engine.ExportAsync(context, CancellationToken.None); Assert.NotNull(attestation.LastRequest); + Assert.NotNull(dataSource.LastDataSet); + var expectedEnvelopes = VexExportEnvelopeBuilder.Build( + dataSource.LastDataSet!, + VexPolicySnapshot.Default, + requestedAt); Assert.Equal(manifest.ExportId, attestation.LastRequest!.ExportId); + var metadata = attestation.LastRequest.Metadata; + Assert.True(metadata.ContainsKey("consensusDigest"), "Consensus digest metadata missing"); + Assert.Equal(expectedEnvelopes.ConsensusDigest.ToUri(), metadata["consensusDigest"]); + Assert.True(metadata.ContainsKey("scoreDigest"), "Score digest metadata missing"); + Assert.Equal(expectedEnvelopes.ScoreDigest.ToUri(), metadata["scoreDigest"]); + Assert.Equal(expectedEnvelopes.Consensus.Length.ToString(CultureInfo.InvariantCulture), metadata["consensusEntryCount"]); + Assert.Equal(expectedEnvelopes.ScoreEnvelope.Entries.Length.ToString(CultureInfo.InvariantCulture), metadata["scoreEntryCount"]); + Assert.Equal(VexPolicySnapshot.Default.RevisionId, metadata["policyRevisionId"]); + Assert.Equal(VexPolicySnapshot.Default.Version, metadata["policyVersion"]); + Assert.Equal(VexPolicySnapshot.Default.ConsensusOptions.Alpha.ToString("G17", CultureInfo.InvariantCulture), metadata["scoreAlpha"]); + Assert.Equal(VexPolicySnapshot.Default.ConsensusOptions.Beta.ToString("G17", CultureInfo.InvariantCulture), metadata["scoreBeta"]); + Assert.Equal(VexPolicySnapshot.Default.ConsensusOptions.WeightCeiling.ToString("G17", CultureInfo.InvariantCulture), metadata["scoreWeightCeiling"]); Assert.NotNull(manifest.Attestation); Assert.Equal(attestation.Response.Attestation.EnvelopeDigest, manifest.Attestation!.EnvelopeDigest); Assert.Equal(attestation.Response.Attestation.PredicateType, manifest.Attestation.PredicateType); + Assert.NotNull(manifest.ConsensusDigest); + Assert.Equal(expectedEnvelopes.ConsensusDigest.Digest, manifest.ConsensusDigest!.Digest); + Assert.NotNull(manifest.ScoreDigest); + Assert.Equal(expectedEnvelopes.ScoreDigest.Digest, manifest.ScoreDigest!.Digest); + Assert.Empty(manifest.QuietProvenance); Assert.NotNull(store.LastSavedManifest); Assert.Equal(manifest.Attestation, store.LastSavedManifest!.Attestation); + Assert.Equal(manifest.QuietProvenance, store.LastSavedManifest!.QuietProvenance); + } + + [Fact] + public async Task ExportAsync_IncludesQuietProvenanceMetadata() + { + var store = new InMemoryExportStore(); + var evaluator = new StaticPolicyEvaluator("baseline/v1"); + var dataSource = new QuietExportDataSource(); + var exporter = new DummyExporter(VexExportFormat.Json); + var attestation = new RecordingAttestationClient(); + var engine = new VexExportEngine( + store, + evaluator, + dataSource, + new[] { exporter }, + NullLogger.Instance, + cacheIndex: null, + artifactStores: null, + attestationClient: attestation); + + var query = VexQuery.Create(new[] { new VexQueryFilter("vulnId", "CVE-2025-0002") }); + var requestedAt = DateTimeOffset.UtcNow; + var context = new VexExportRequestContext(query, VexExportFormat.Json, requestedAt); + + var manifest = await engine.ExportAsync(context, CancellationToken.None); + + var quiet = Assert.Single(manifest.QuietProvenance); + Assert.Equal("CVE-2025-0002", quiet.VulnerabilityId); + Assert.Equal("pkg:demo/app", quiet.ProductKey); + var statement = Assert.Single(quiet.Statements); + Assert.Equal("vendor", statement.ProviderId); + Assert.Equal("sha256:quiet", statement.StatementId); + Assert.Equal(VexJustification.ComponentNotPresent, statement.Justification); + Assert.NotNull(statement.Signature); + Assert.Equal("quiet-signer", statement.Signature!.Subject); + Assert.Equal("quiet-key", statement.Signature.KeyId); + + var expectedQuietJson = VexCanonicalJsonSerializer.Serialize(manifest.QuietProvenance); + Assert.NotNull(attestation.LastRequest); + Assert.True(attestation.LastRequest!.Metadata.TryGetValue("quietedBy", out var quietJson)); + Assert.Equal(expectedQuietJson, quietJson); + Assert.True(attestation.LastRequest.Metadata.TryGetValue("quietedByStatementCount", out var quietCount)); + Assert.Equal("1", quietCount); + + Assert.NotNull(store.LastSavedManifest); + Assert.Equal(manifest.QuietProvenance, store.LastSavedManifest!.QuietProvenance); } private sealed class InMemoryExportStore : IVexExportStore @@ -148,6 +230,48 @@ public sealed class ExportEngineTests => FormattableString.Invariant($"{signature}|{format}"); } + private sealed class QuietExportDataSource : IVexExportDataSource + { + public ValueTask FetchAsync(VexQuery query, CancellationToken cancellationToken) + { + var signature = new VexSignatureMetadata( + type: "pgp", + subject: "quiet-signer", + issuer: "quiet-ca", + keyId: "quiet-key", + verifiedAt: DateTimeOffset.UnixEpoch, + transparencyLogReference: "rekor://quiet"); + + var claim = new VexClaim( + "CVE-2025-0002", + "vendor", + new VexProduct("pkg:demo/app", "Demo"), + VexClaimStatus.NotAffected, + new VexClaimDocument(VexDocumentFormat.OpenVex, "sha256:quiet", new Uri("https://example.org/quiet"), signature: signature), + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + justification: VexJustification.ComponentNotPresent); + + var consensus = new VexConsensus( + "CVE-2025-0002", + claim.Product, + VexConsensusStatus.NotAffected, + DateTimeOffset.UtcNow, + new[] + { + new VexConsensusSource("vendor", VexClaimStatus.NotAffected, "sha256:quiet", 1.0, claim.Justification), + }, + conflicts: null, + policyVersion: "baseline/v1", + summary: "not_affected"); + + return ValueTask.FromResult(new VexExportDataSet( + ImmutableArray.Create(consensus), + ImmutableArray.Create(claim), + ImmutableArray.Create("vendor"))); + } + } + private sealed class RecordingAttestationClient : IVexAttestationClient { public VexAttestationRequest? LastRequest { get; private set; } @@ -226,6 +350,8 @@ public sealed class ExportEngineTests private sealed class InMemoryExportDataSource : IVexExportDataSource { + public VexExportDataSet? LastDataSet { get; private set; } + public ValueTask FetchAsync(VexQuery query, CancellationToken cancellationToken) { var claim = new VexClaim( @@ -247,10 +373,13 @@ public sealed class ExportEngineTests policyVersion: "baseline/v1", summary: "affected"); - return ValueTask.FromResult(new VexExportDataSet( + var dataSet = new VexExportDataSet( ImmutableArray.Create(consensus), ImmutableArray.Create(claim), - ImmutableArray.Create("vendor"))); + ImmutableArray.Create("vendor")); + + LastDataSet = dataSet; + return ValueTask.FromResult(dataSet); } } diff --git a/src/StellaOps.Excititor.Export.Tests/MirrorBundlePublisherTests.cs b/src/StellaOps.Excititor.Export.Tests/MirrorBundlePublisherTests.cs new file mode 100644 index 00000000..cb27427f --- /dev/null +++ b/src/StellaOps.Excititor.Export.Tests/MirrorBundlePublisherTests.cs @@ -0,0 +1,324 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using StellaOps.Excititor.Core; +using StellaOps.Excititor.Export; +using StellaOps.Excititor.Policy; +using System.Collections.Immutable; +using System.IO.Abstractions.TestingHelpers; +using Xunit; + +namespace StellaOps.Excititor.Export.Tests; + +public sealed class MirrorBundlePublisherTests +{ + [Fact] + public async Task PublishAsync_WritesMirrorArtifacts() + { + var generatedAt = DateTimeOffset.Parse("2025-10-21T12:00:00Z"); + var timeProvider = new FixedTimeProvider(generatedAt); + var fileSystem = new MockFileSystem(); + + var options = new MirrorDistributionOptions + { + OutputRoot = @"C:\exports", + DirectoryName = "mirror", + TargetRepository = "s3://mirror/excititor", + }; + + var domain = new MirrorDomainOptions + { + Id = "primary", + DisplayName = "Primary Mirror", + }; + + var exportOptions = new MirrorExportOptions + { + Key = "consensus-json", + Format = "json", + }; + exportOptions.Filters["vulnId"] = "CVE-2025-0001"; + domain.Exports.Add(exportOptions); + options.Domains.Add(domain); + + var publisher = new VexMirrorBundlePublisher( + new StaticOptionsMonitor(options), + NullLogger.Instance, + timeProvider, + fileSystem, + cryptoRegistry: null, + Options.Create(new FileSystemArtifactStoreOptions { RootPath = @"C:\exports" })); + + var sample = CreateSampleExport(generatedAt); + var manifest = sample.Manifest; + var envelope = sample.Envelope; + var dataSet = sample.DataSet; + + await publisher.PublishAsync(manifest, envelope, dataSet, CancellationToken.None); + await publisher.PublishAsync(manifest, envelope, dataSet, CancellationToken.None); + + var mirrorRoot = @"C:\exports\mirror"; + var domainRoot = Path.Combine(mirrorRoot, "primary"); + var bundlePath = Path.Combine(domainRoot, "bundle.json"); + var manifestPath = Path.Combine(domainRoot, "manifest.json"); + var indexPath = Path.Combine(mirrorRoot, "index.json"); + var signaturePath = Path.Combine(domainRoot, "bundle.json.jws"); + + Assert.True(fileSystem.File.Exists(bundlePath)); + Assert.True(fileSystem.File.Exists(manifestPath)); + Assert.True(fileSystem.File.Exists(indexPath)); + Assert.False(fileSystem.File.Exists(signaturePath)); + + var bundleBytes = fileSystem.File.ReadAllBytes(bundlePath); + var manifestBytes = fileSystem.File.ReadAllBytes(manifestPath); + var indexBytes = fileSystem.File.ReadAllBytes(indexPath); + + var expectedBundleDigest = ComputeSha256(bundleBytes); + var expectedManifestDigest = ComputeSha256(manifestBytes); + var expectedConsensusJson = envelope.ConsensusCanonicalJson; + var expectedScoreJson = envelope.ScoreCanonicalJson; + var expectedClaimsJson = SerializeClaims(dataSet.Claims); + var expectedQuietJson = VexCanonicalJsonSerializer.Serialize(envelope.QuietProvenance); + + using (var bundleDocument = JsonDocument.Parse(bundleBytes)) + { + var root = bundleDocument.RootElement; + Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32()); + Assert.Equal("primary", root.GetProperty("domainId").GetString()); + Assert.Equal("Primary Mirror", root.GetProperty("displayName").GetString()); + Assert.Equal("s3://mirror/excititor", root.GetProperty("targetRepository").GetString()); + + var exports = root.GetProperty("exports").EnumerateArray().ToArray(); + Assert.Single(exports); + var export = exports[0]; + Assert.Equal("consensus-json", export.GetProperty("key").GetString()); + Assert.Equal("json", export.GetProperty("format").GetString()); + Assert.Equal(manifest.ExportId, export.GetProperty("exportId").GetString()); + Assert.Equal(manifest.QuerySignature.Value, export.GetProperty("querySignature").GetString()); + Assert.Equal(manifest.Artifact.ToUri(), export.GetProperty("artifactDigest").GetString()); + Assert.Equal(manifest.SizeBytes, export.GetProperty("artifactSizeBytes").GetInt64()); + Assert.Equal(manifest.ConsensusRevision, export.GetProperty("consensusRevision").GetString()); + Assert.Equal(manifest.PolicyRevisionId, export.GetProperty("policyRevisionId").GetString()); + Assert.Equal(manifest.PolicyDigest, export.GetProperty("policyDigest").GetString()); + Assert.Equal(expectedConsensusJson, export.GetProperty("consensusDocument").GetString()); + Assert.Equal(expectedScoreJson, export.GetProperty("scoreDocument").GetString()); + Assert.Equal(expectedClaimsJson, export.GetProperty("claimsDocument").GetString()); + Assert.Equal(expectedQuietJson, export.GetProperty("quietDocument").GetString()); + var providers = export.GetProperty("sourceProviders").EnumerateArray().Select(p => p.GetString()).ToArray(); + Assert.Single(providers); + Assert.Equal("vendor", providers[0]); + } + + using (var manifestDocument = JsonDocument.Parse(manifestBytes)) + { + var root = manifestDocument.RootElement; + Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32()); + Assert.Equal("primary", root.GetProperty("domainId").GetString()); + Assert.Equal("Primary Mirror", root.GetProperty("displayName").GetString()); + Assert.Equal("s3://mirror/excititor", root.GetProperty("targetRepository").GetString()); + + var bundleDescriptor = root.GetProperty("bundle"); + Assert.Equal("primary/bundle.json", bundleDescriptor.GetProperty("path").GetString()); + Assert.Equal(expectedBundleDigest, bundleDescriptor.GetProperty("digest").GetString()); + Assert.Equal(bundleBytes.LongLength, bundleDescriptor.GetProperty("sizeBytes").GetInt64()); + Assert.False(bundleDescriptor.TryGetProperty("signature", out _)); + + var exports = root.GetProperty("exports").EnumerateArray().ToArray(); + Assert.Single(exports); + var export = exports[0]; + Assert.Equal("consensus-json", export.GetProperty("key").GetString()); + Assert.Equal("json", export.GetProperty("format").GetString()); + Assert.Equal(manifest.ExportId, export.GetProperty("exportId").GetString()); + Assert.Equal(manifest.QuerySignature.Value, export.GetProperty("querySignature").GetString()); + Assert.Equal(manifest.Artifact.ToUri(), export.GetProperty("artifactDigest").GetString()); + Assert.Equal(manifest.SizeBytes, export.GetProperty("artifactSizeBytes").GetInt64()); + Assert.Equal(manifest.ConsensusRevision, export.GetProperty("consensusRevision").GetString()); + Assert.Equal(manifest.PolicyRevisionId, export.GetProperty("policyRevisionId").GetString()); + Assert.Equal(manifest.PolicyDigest, export.GetProperty("policyDigest").GetString()); + Assert.False(export.TryGetProperty("attestation", out _)); + } + + using (var indexDocument = JsonDocument.Parse(indexBytes)) + { + var root = indexDocument.RootElement; + Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32()); + Assert.Equal("s3://mirror/excititor", root.GetProperty("targetRepository").GetString()); + + var domains = root.GetProperty("domains").EnumerateArray().ToArray(); + Assert.Single(domains); + var entry = domains[0]; + Assert.Equal("primary", entry.GetProperty("domainId").GetString()); + Assert.Equal("Primary Mirror", entry.GetProperty("displayName").GetString()); + Assert.Equal(generatedAt, entry.GetProperty("generatedAt").GetDateTimeOffset()); + Assert.Equal(1, entry.GetProperty("exportCount").GetInt32()); + + var manifestDescriptor = entry.GetProperty("manifest"); + Assert.Equal("primary/manifest.json", manifestDescriptor.GetProperty("path").GetString()); + Assert.Equal(expectedManifestDigest, manifestDescriptor.GetProperty("digest").GetString()); + Assert.Equal(manifestBytes.LongLength, manifestDescriptor.GetProperty("sizeBytes").GetInt64()); + + var bundleDescriptor = entry.GetProperty("bundle"); + Assert.Equal("primary/bundle.json", bundleDescriptor.GetProperty("path").GetString()); + Assert.Equal(expectedBundleDigest, bundleDescriptor.GetProperty("digest").GetString()); + Assert.Equal(bundleBytes.LongLength, bundleDescriptor.GetProperty("sizeBytes").GetInt64()); + + var exportKeys = entry.GetProperty("exportKeys").EnumerateArray().Select(x => x.GetString()).ToArray(); + Assert.Single(exportKeys); + Assert.Equal("consensus-json", exportKeys[0]); + } + } + + [Fact] + public async Task PublishAsync_NoMatchingDomain_DoesNotWriteArtifacts() + { + var generatedAt = DateTimeOffset.Parse("2025-10-21T12:00:00Z"); + var timeProvider = new FixedTimeProvider(generatedAt); + var fileSystem = new MockFileSystem(); + + var options = new MirrorDistributionOptions + { + OutputRoot = @"C:\exports", + DirectoryName = "mirror", + }; + + var domain = new MirrorDomainOptions + { + Id = "primary", + DisplayName = "Primary Mirror", + }; + + var exportOptions = new MirrorExportOptions + { + Key = "consensus-json", + Format = "json", + }; + exportOptions.Filters["vulnId"] = "CVE-2099-9999"; + domain.Exports.Add(exportOptions); + options.Domains.Add(domain); + + var publisher = new VexMirrorBundlePublisher( + new StaticOptionsMonitor(options), + NullLogger.Instance, + timeProvider, + fileSystem, + cryptoRegistry: null, + Options.Create(new FileSystemArtifactStoreOptions { RootPath = @"C:\exports" })); + + var sample = CreateSampleExport(generatedAt); + await publisher.PublishAsync(sample.Manifest, sample.Envelope, sample.DataSet, CancellationToken.None); + + Assert.False(fileSystem.Directory.Exists(@"C:\exports\mirror")); + } + + private static SampleExport CreateSampleExport(DateTimeOffset generatedAt) + { + var query = VexQuery.Create(new[] { new VexQueryFilter("vulnId", "CVE-2025-0001") }); + var signature = VexQuerySignature.FromQuery(query); + + var product = new VexProduct("pkg:demo/app", "Demo"); + var document = new VexClaimDocument(VexDocumentFormat.OpenVex, "sha256:quiet", new Uri("https://example.org/vex.json")); + var claim = new VexClaim( + "CVE-2025-0001", + "vendor", + product, + VexClaimStatus.NotAffected, + document, + generatedAt.AddDays(-1), + generatedAt, + justification: VexJustification.ComponentNotPresent); + + var consensus = new VexConsensus( + "CVE-2025-0001", + product, + VexConsensusStatus.NotAffected, + generatedAt, + new[] { new VexConsensusSource("vendor", VexClaimStatus.NotAffected, document.Digest, 1.0, claim.Justification) }, + conflicts: null, + signals: null, + policyVersion: "baseline/v1", + summary: "not_affected", + policyRevisionId: "policy/v1", + policyDigest: "sha256:policy"); + + var dataSet = new VexExportDataSet( + ImmutableArray.Create(consensus), + ImmutableArray.Create(claim), + ImmutableArray.Create("vendor")); + + var envelope = VexExportEnvelopeBuilder.Build(dataSet, VexPolicySnapshot.Default, generatedAt); + + var manifest = new VexExportManifest( + "exports/20251021T120000000Z/abcdef", + signature, + VexExportFormat.Json, + generatedAt, + new VexContentAddress("sha256", "deadbeef"), + dataSet.Claims.Length, + dataSet.SourceProviders, + consensusRevision: "baseline/v1", + policyRevisionId: "policy/v1", + policyDigest: "sha256:policy", + consensusDigest: envelope.ConsensusDigest, + scoreDigest: envelope.ScoreDigest, + quietProvenance: envelope.QuietProvenance, + attestation: null, + sizeBytes: 1024); + + return new SampleExport(manifest, envelope, dataSet); + } + + private static string SerializeClaims(ImmutableArray claims) + => VexCanonicalJsonSerializer.Serialize( + claims + .OrderBy(claim => claim.VulnerabilityId, StringComparer.Ordinal) + .ThenBy(claim => claim.Product.Key, StringComparer.Ordinal) + .ThenBy(claim => claim.ProviderId, StringComparer.Ordinal) + .ToImmutableArray()); + + private static string ComputeSha256(byte[] bytes) + { + using var sha = SHA256.Create(); + var digest = sha.ComputeHash(bytes); + return "sha256:" + Convert.ToHexString(digest).ToLowerInvariant(); + } + + private sealed record SampleExport( + VexExportManifest Manifest, + VexExportEnvelopeContext Envelope, + VexExportDataSet DataSet); + + private sealed class FixedTimeProvider : TimeProvider + { + private readonly DateTimeOffset _value; + + public FixedTimeProvider(DateTimeOffset value) => _value = value; + + public override DateTimeOffset GetUtcNow() => _value; + } + + private sealed class StaticOptionsMonitor : IOptionsMonitor + { + public StaticOptionsMonitor(T value) => CurrentValue = value; + + public T CurrentValue { get; private set; } + + public T Get(string? name) => CurrentValue; + + public IDisposable OnChange(Action listener) => NullDisposable.Instance; + + private sealed class NullDisposable : IDisposable + { + public static readonly NullDisposable Instance = new(); + public void Dispose() + { + } + } + } +} diff --git a/src/StellaOps.Excititor.Export/ExportEngine.cs b/src/StellaOps.Excititor.Export/ExportEngine.cs index 8c5f6b32..5f37d8b4 100644 --- a/src/StellaOps.Excititor.Export/ExportEngine.cs +++ b/src/StellaOps.Excititor.Export/ExportEngine.cs @@ -1,8 +1,9 @@ using System; -using System.Collections.Immutable; using System.Collections.Generic; +using System.Collections.Immutable; using System.IO; using System.Linq; +using System.Globalization; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using StellaOps.Excititor.Core; @@ -42,6 +43,7 @@ public sealed class VexExportEngine : IExportEngine private readonly IVexCacheIndex? _cacheIndex; private readonly IReadOnlyList _artifactStores; private readonly IVexAttestationClient? _attestationClient; + private readonly IVexMirrorBundlePublisher? _mirrorPublisher; public VexExportEngine( IVexExportStore exportStore, @@ -51,7 +53,8 @@ public sealed class VexExportEngine : IExportEngine ILogger logger, IVexCacheIndex? cacheIndex = null, IEnumerable? artifactStores = null, - IVexAttestationClient? attestationClient = null) + IVexAttestationClient? attestationClient = null, + IVexMirrorBundlePublisher? mirrorPublisher = null) { _exportStore = exportStore ?? throw new ArgumentNullException(nameof(exportStore)); _policyEvaluator = policyEvaluator ?? throw new ArgumentNullException(nameof(policyEvaluator)); @@ -60,6 +63,7 @@ public sealed class VexExportEngine : IExportEngine _cacheIndex = cacheIndex; _artifactStores = artifactStores?.ToArray() ?? Array.Empty(); _attestationClient = attestationClient; + _mirrorPublisher = mirrorPublisher; if (exporters is null) { @@ -105,12 +109,13 @@ public sealed class VexExportEngine : IExportEngine } var dataset = await _dataSource.FetchAsync(context.Query, cancellationToken).ConfigureAwait(false); - var exporter = ResolveExporter(context.Format); var policySnapshot = _policyEvaluator.Snapshot; + var envelopeContext = VexExportEnvelopeBuilder.Build(dataset, policySnapshot, context.RequestedAt); + var exporter = ResolveExporter(context.Format); var exportRequest = new VexExportRequest( context.Query, - dataset.Consensus, + envelopeContext.Consensus, dataset.Claims, context.RequestedAt); @@ -120,6 +125,37 @@ public sealed class VexExportEngine : IExportEngine await using var buffer = new MemoryStream(); var result = await exporter.SerializeAsync(exportRequest, buffer, cancellationToken).ConfigureAwait(false); + var metadataBuilder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); + foreach (var kvp in result.Metadata) + { + metadataBuilder[kvp.Key] = kvp.Value; + } + + metadataBuilder["consensusDigest"] = envelopeContext.ConsensusDigest.ToUri(); + metadataBuilder["consensusEntryCount"] = envelopeContext.Consensus.Length.ToString(CultureInfo.InvariantCulture); + metadataBuilder["scoreDigest"] = envelopeContext.ScoreDigest.ToUri(); + metadataBuilder["scoreEntryCount"] = envelopeContext.ScoreEnvelope.Entries.Length.ToString(CultureInfo.InvariantCulture); + metadataBuilder["policyRevisionId"] = policySnapshot.RevisionId; + metadataBuilder["policyVersion"] = policySnapshot.Version; + + if (!string.IsNullOrWhiteSpace(policySnapshot.Digest)) + { + metadataBuilder["policyDigest"] = policySnapshot.Digest; + } + + metadataBuilder["scoreAlpha"] = policySnapshot.ConsensusOptions.Alpha.ToString("G17", CultureInfo.InvariantCulture); + metadataBuilder["scoreBeta"] = policySnapshot.ConsensusOptions.Beta.ToString("G17", CultureInfo.InvariantCulture); + metadataBuilder["scoreWeightCeiling"] = policySnapshot.ConsensusOptions.WeightCeiling.ToString("G17", CultureInfo.InvariantCulture); + + if (!envelopeContext.QuietProvenance.IsDefaultOrEmpty && envelopeContext.QuietProvenance.Length > 0) + { + metadataBuilder["quietedBy"] = VexCanonicalJsonSerializer.Serialize(envelopeContext.QuietProvenance); + var quietStatementCount = envelopeContext.QuietProvenance.Sum(static entry => entry.Statements.Length); + metadataBuilder["quietedByStatementCount"] = quietStatementCount.ToString(CultureInfo.InvariantCulture); + } + + var exportMetadata = metadataBuilder.ToImmutable(); + if (_artifactStores.Count > 0) { var writtenBytes = buffer.ToArray(); @@ -129,7 +165,7 @@ public sealed class VexExportEngine : IExportEngine result.Digest, context.Format, writtenBytes, - result.Metadata); + exportMetadata); foreach (var store in _artifactStores) { @@ -155,7 +191,7 @@ public sealed class VexExportEngine : IExportEngine context.Format, context.RequestedAt, dataset.SourceProviders, - result.Metadata); + exportMetadata); var response = await _attestationClient.SignAsync(attestationRequest, cancellationToken).ConfigureAwait(false); attestationMetadata = response.Attestation; @@ -175,8 +211,8 @@ public sealed class VexExportEngine : IExportEngine _logger.LogInformation("Attestation generated for export {ExportId}", exportId); } - var consensusDigestAddress = TryGetContentAddress(result.Metadata, "consensusDigest"); - var scoreDigestAddress = TryGetContentAddress(result.Metadata, "scoreDigest"); + var consensusDigestAddress = TryGetContentAddress(exportMetadata, "consensusDigest"); + var scoreDigestAddress = TryGetContentAddress(exportMetadata, "scoreDigest"); var manifest = new VexExportManifest( exportId, @@ -192,11 +228,24 @@ public sealed class VexExportEngine : IExportEngine policyDigest: policySnapshot.Digest, consensusDigest: consensusDigestAddress, scoreDigest: scoreDigestAddress, + quietProvenance: envelopeContext.QuietProvenance, attestation: attestationMetadata, sizeBytes: result.BytesWritten); await _exportStore.SaveAsync(manifest, cancellationToken).ConfigureAwait(false); + if (_mirrorPublisher is not null) + { + try + { + await _mirrorPublisher.PublishAsync(manifest, envelopeContext, dataset, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Mirror bundle publishing failed for export {ExportId}", manifest.ExportId); + } + } + _logger.LogInformation( "Export generated for {Signature} ({Format}) size={SizeBytes} bytes", signature.Value, @@ -237,6 +286,7 @@ public static class VexExportServiceCollectionExtensions { public static IServiceCollection AddVexExportEngine(this IServiceCollection services) { + services.AddSingleton(); services.AddSingleton(); services.AddVexExportCacheServices(); return services; diff --git a/src/StellaOps.Excititor.Export/StellaOps.Excititor.Export.csproj b/src/StellaOps.Excititor.Export/StellaOps.Excititor.Export.csproj index 64e918ce..983f00d5 100644 --- a/src/StellaOps.Excititor.Export/StellaOps.Excititor.Export.csproj +++ b/src/StellaOps.Excititor.Export/StellaOps.Excititor.Export.csproj @@ -15,5 +15,6 @@ + diff --git a/src/StellaOps.Excititor.Export/TASKS.md b/src/StellaOps.Excititor.Export/TASKS.md index 1cbe4965..2b3b123b 100644 --- a/src/StellaOps.Excititor.Export/TASKS.md +++ b/src/StellaOps.Excititor.Export/TASKS.md @@ -6,6 +6,6 @@ If you are working on this file you need to read docs/ARCHITECTURE_EXCITITOR.md |EXCITITOR-EXPORT-01-002 – Cache index & eviction hooks|Team Excititor Export|EXCITITOR-EXPORT-01-001, EXCITITOR-STORAGE-01-003|**DONE (2025-10-16)** – Export engine now invalidates cache entries on force refresh, cache services expose prune/invalidate APIs, and storage maintenance trims expired/dangling records with Mongo2Go coverage.| |EXCITITOR-EXPORT-01-003 – Artifact store adapters|Team Excititor Export|EXCITITOR-EXPORT-01-001|**DONE (2025-10-16)** – Implemented multi-store pipeline with filesystem, S3-compatible, and offline bundle adapters (hash verification + manifest/zip output) plus unit coverage and DI hooks.| |EXCITITOR-EXPORT-01-004 – Attestation handoff integration|Team Excititor Export|EXCITITOR-EXPORT-01-001, EXCITITOR-ATTEST-01-001|**DONE (2025-10-17)** – Export engine now invokes attestation client, logs diagnostics, and persists Rekor/envelope metadata on manifests; regression coverage added in `ExportEngineTests.ExportAsync_AttachesAttestationMetadata`.| -|EXCITITOR-EXPORT-01-005 – Score & resolve envelope surfaces|Team Excititor Export|EXCITITOR-EXPORT-01-004, EXCITITOR-CORE-02-001|**DOING (2025-10-19)** – Prereqs EXCITITOR-EXPORT-01-004 and EXCITITOR-CORE-02-001 confirmed DONE; planning export updates to emit consensus+score envelopes, include policy/scoring digests, and extend offline bundle/ORAS layouts for signed VEX responses.| -|EXCITITOR-EXPORT-01-006 – Quiet provenance packaging|Team Excititor Export|EXCITITOR-EXPORT-01-005, POLICY-CORE-09-005|TODO – Attach `quietedBy` statement IDs, signers, and justification codes to exports/offline bundles, mirror metadata into attested manifest, and add regression fixtures.| -|EXCITITOR-EXPORT-01-007 – Mirror bundle + domain manifest|Team Excititor Export|EXCITITOR-EXPORT-01-006|TODO – Create per-domain mirror bundles with consensus/score artifacts, publish signed index for downstream Excititor sync, and ensure deterministic digests + fixtures.| +|EXCITITOR-EXPORT-01-005 – Score & resolve envelope surfaces|Team Excititor Export|EXCITITOR-EXPORT-01-004, EXCITITOR-CORE-02-001|**DONE (2025-10-21)** – Export engine now canonicalizes consensus/score envelopes, persists their SHA-256 digests into manifests/attestation metadata, and regression tests validate metadata wiring via `ExportEngineTests`.| +|EXCITITOR-EXPORT-01-006 – Quiet provenance packaging|Team Excititor Export|EXCITITOR-EXPORT-01-005, POLICY-CORE-09-005|**DONE (2025-10-21)** – Export manifests now carry quiet-provenance entries (statement digests, signers, justification codes); metadata flows into offline bundles & attestations with regression coverage in `ExportEngineTests`.| +|EXCITITOR-EXPORT-01-007 – Mirror bundle + domain manifest|Team Excititor Export|EXCITITOR-EXPORT-01-006|**DONE (2025-10-21)** – Created per-domain mirror bundles with consensus/score artefacts, published signed-ready manifests/index for downstream Excititor sync, and added regression coverage.| diff --git a/src/StellaOps.Excititor.Export/VexExportEnvelopeBuilder.cs b/src/StellaOps.Excititor.Export/VexExportEnvelopeBuilder.cs new file mode 100644 index 00000000..5ff91ab9 --- /dev/null +++ b/src/StellaOps.Excititor.Export/VexExportEnvelopeBuilder.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Security.Cryptography; +using System.Text; +using System.Linq; +using StellaOps.Excititor.Core; +using StellaOps.Excititor.Policy; + +namespace StellaOps.Excititor.Export; + +internal static class VexExportEnvelopeBuilder +{ + public static VexExportEnvelopeContext Build( + VexExportDataSet dataSet, + VexPolicySnapshot policySnapshot, + DateTimeOffset generatedAt) + { + ArgumentNullException.ThrowIfNull(dataSet); + ArgumentNullException.ThrowIfNull(policySnapshot); + + var orderedConsensus = dataSet.Consensus + .OrderBy(static consensus => consensus.VulnerabilityId, StringComparer.Ordinal) + .ThenBy(static consensus => consensus.Product.Key, StringComparer.Ordinal) + .ToImmutableArray(); + + var claimsByKey = dataSet.Claims + .GroupBy(static claim => (claim.VulnerabilityId, claim.Product.Key)) + .ToDictionary( + static group => group.Key, + static group => group.ToImmutableArray()); + + var quietEntries = ImmutableArray.CreateBuilder(); + + foreach (var consensus in orderedConsensus) + { + if (consensus.Status != VexConsensusStatus.NotAffected) + { + continue; + } + + if (!claimsByKey.TryGetValue((consensus.VulnerabilityId, consensus.Product.Key), out var claimsForKey) || + claimsForKey.IsDefaultOrEmpty) + { + continue; + } + + var statementsBuilder = ImmutableArray.CreateBuilder(); + foreach (var source in consensus.Sources) + { + if (source.Status != VexClaimStatus.NotAffected) + { + continue; + } + + var matchingClaim = claimsForKey.FirstOrDefault(claim => + string.Equals(claim.ProviderId, source.ProviderId, StringComparison.Ordinal) && + string.Equals(claim.Document.Digest, source.DocumentDigest, StringComparison.Ordinal)); + + if (matchingClaim is null) + { + continue; + } + + var justification = matchingClaim.Justification ?? source.Justification; + statementsBuilder.Add(new VexQuietStatement( + matchingClaim.ProviderId, + matchingClaim.Document.Digest, + justification, + matchingClaim.Document.Signature)); + } + + if (statementsBuilder.Count == 0) + { + continue; + } + + quietEntries.Add(new VexQuietProvenance( + consensus.VulnerabilityId, + consensus.Product.Key, + statementsBuilder.ToImmutable())); + } + + var consensusJson = VexCanonicalJsonSerializer.Serialize(orderedConsensus); + var consensusDigest = ComputeAddress(consensusJson); + + var scoreEntries = orderedConsensus + .Select(static consensus => new VexScoreEntry( + consensus.VulnerabilityId, + consensus.Product.Key, + consensus.Status, + consensus.CalculatedAt, + consensus.Signals, + score: null)) + .ToImmutableArray(); + + var options = policySnapshot.ConsensusOptions; + var scoreEnvelope = new VexScoreEnvelope( + generatedAt.ToUniversalTime(), + policySnapshot.RevisionId, + NormalizeDigest(policySnapshot.Digest), + options.Alpha, + options.Beta, + options.WeightCeiling, + scoreEntries); + + var scoreJson = VexCanonicalJsonSerializer.Serialize(scoreEnvelope); + var scoreDigest = ComputeAddress(scoreJson); + + return new VexExportEnvelopeContext( + orderedConsensus, + consensusJson, + consensusDigest, + scoreEnvelope, + scoreJson, + scoreDigest, + quietEntries.ToImmutable()); + } + + private static string? NormalizeDigest(string? digest) + => string.IsNullOrWhiteSpace(digest) ? null : digest.Trim(); + + private static VexContentAddress ComputeAddress(string canonicalJson) + { + var bytes = Encoding.UTF8.GetBytes(canonicalJson); + Span hash = stackalloc byte[SHA256.HashSizeInBytes]; + SHA256.HashData(bytes, hash); + var digest = Convert.ToHexString(hash).ToLowerInvariant(); + return new VexContentAddress("sha256", digest); + } +} + +internal sealed record VexExportEnvelopeContext( + ImmutableArray Consensus, + string ConsensusCanonicalJson, + VexContentAddress ConsensusDigest, + VexScoreEnvelope ScoreEnvelope, + string ScoreCanonicalJson, + VexContentAddress ScoreDigest, + ImmutableArray QuietProvenance); diff --git a/src/StellaOps.Excititor.Export/VexMirrorBundlePublisher.cs b/src/StellaOps.Excititor.Export/VexMirrorBundlePublisher.cs new file mode 100644 index 00000000..24276e9f --- /dev/null +++ b/src/StellaOps.Excititor.Export/VexMirrorBundlePublisher.cs @@ -0,0 +1,716 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.IO; +using System.IO.Abstractions; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.Cryptography; +using StellaOps.Excititor.Core; + +namespace StellaOps.Excititor.Export; + +public interface IVexMirrorBundlePublisher +{ + ValueTask PublishAsync( + VexExportManifest manifest, + VexExportEnvelopeContext envelope, + VexExportDataSet dataSet, + CancellationToken cancellationToken); +} + +public sealed class VexMirrorBundlePublisher : IVexMirrorBundlePublisher +{ + private const int SchemaVersion = 1; + private const string BundleFileName = "bundle.json"; + private const string BundleSignatureFileName = "bundle.json.jws"; + private const string ManifestFileName = "manifest.json"; + private const string IndexFileName = "index.json"; + private const string SignatureMediaType = "application/vnd.stellaops.excititor.mirror-bundle+jws"; + + private static readonly Encoding Utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false, + }; + + private readonly IOptionsMonitor _optionsMonitor; + private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; + private readonly IFileSystem _fileSystem; + private readonly ICryptoProviderRegistry? _cryptoRegistry; + private readonly IOptions? _fileSystemOptions; + private readonly SemaphoreSlim _mutex = new(1, 1); + + public VexMirrorBundlePublisher( + IOptionsMonitor optionsMonitor, + ILogger logger, + TimeProvider timeProvider, + IFileSystem? fileSystem = null, + ICryptoProviderRegistry? cryptoRegistry = null, + IOptions? fileSystemOptions = null) + { + _optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _fileSystem = fileSystem ?? new FileSystem(); + _cryptoRegistry = cryptoRegistry; + _fileSystemOptions = fileSystemOptions; + } + + public async ValueTask PublishAsync( + VexExportManifest manifest, + VexExportEnvelopeContext envelope, + VexExportDataSet dataSet, + CancellationToken cancellationToken) + { + if (manifest is null) + { + throw new ArgumentNullException(nameof(manifest)); + } + + if (envelope is null) + { + throw new ArgumentNullException(nameof(envelope)); + } + + if (dataSet is null) + { + throw new ArgumentNullException(nameof(dataSet)); + } + + var options = _optionsMonitor.CurrentValue; + if (!options.Enabled || options.Domains.Count == 0) + { + return; + } + + var matches = ResolveDomainMatches(options, manifest); + if (matches.Count == 0) + { + return; + } + + await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var outputRoot = ResolveOutputRoot(options); + var mirrorRoot = _fileSystem.Path.Combine(outputRoot, options.DirectoryName ?? "mirror"); + _fileSystem.Directory.CreateDirectory(mirrorRoot); + + foreach (var match in matches) + { + await UpdateDomainAsync( + options, + match.Domain, + match.Plan, + manifest, + envelope, + dataSet, + mirrorRoot, + cancellationToken).ConfigureAwait(false); + } + + await WriteIndexAsync(options, mirrorRoot, cancellationToken).ConfigureAwait(false); + } + finally + { + _mutex.Release(); + } + } + + private string ResolveOutputRoot(MirrorDistributionOptions options) + { + if (!string.IsNullOrWhiteSpace(options.OutputRoot)) + { + return _fileSystem.Path.GetFullPath(options.OutputRoot); + } + + if (_fileSystemOptions?.Value is { RootPath: { Length: > 0 } root }) + { + return _fileSystem.Path.GetFullPath(root); + } + + return _fileSystem.Path.GetFullPath(_fileSystem.Path.Combine(AppContext.BaseDirectory, "mirror")); + } + + private static List ResolveDomainMatches(MirrorDistributionOptions options, VexExportManifest manifest) + { + var matches = new List(); + + foreach (var domain in options.Domains) + { + foreach (var export in domain.Exports) + { + if (!MirrorExportPlanner.TryBuild(export, out var plan, out _)) + { + continue; + } + + if (!string.Equals(plan.Signature.Value, manifest.QuerySignature.Value, StringComparison.Ordinal)) + { + continue; + } + + if (plan.Format != manifest.Format) + { + continue; + } + + matches.Add(new DomainMatch(domain, plan)); + } + } + + return matches; + } + + private async Task UpdateDomainAsync( + MirrorDistributionOptions options, + MirrorDomainOptions domain, + MirrorExportPlan plan, + VexExportManifest manifest, + VexExportEnvelopeContext envelope, + VexExportDataSet dataSet, + string mirrorRoot, + CancellationToken cancellationToken) + { + var domainDirectory = _fileSystem.Path.Combine(mirrorRoot, domain.Id); + _fileSystem.Directory.CreateDirectory(domainDirectory); + + var bundlePath = _fileSystem.Path.Combine(domainDirectory, BundleFileName); + var existingBundle = await ReadDocumentAsync(bundlePath, cancellationToken).ConfigureAwait(false); + var exports = existingBundle?.Exports.ToDictionary(entry => entry.Key, StringComparer.Ordinal) + ?? new Dictionary(StringComparer.Ordinal); + + var exportEntry = CreateExportEntry(plan, manifest, envelope, dataSet); + exports[exportEntry.Key] = exportEntry; + var orderedExports = exports.Values.OrderBy(entry => entry.Key, StringComparer.Ordinal).ToArray(); + + var generatedAt = _timeProvider.GetUtcNow(); + var bundleDocument = new MirrorBundleDocument( + SchemaVersion, + generatedAt, + options.TargetRepository, + domain.Id, + string.IsNullOrWhiteSpace(domain.DisplayName) ? domain.Id : domain.DisplayName, + orderedExports); + + var bundleBytes = Serialize(bundleDocument); + var bundleDigest = ComputeDigest(bundleBytes); + await WriteFileAsync(bundlePath, bundleBytes, cancellationToken).ConfigureAwait(false); + + MirrorSignatureDescriptor? signatureDescriptor = null; + if (options.Signing.Enabled) + { + signatureDescriptor = await WriteSignatureAsync( + options.Signing, + mirrorRoot, + domainDirectory, + bundleBytes, + cancellationToken).ConfigureAwait(false); + } + else + { + var signaturePath = _fileSystem.Path.Combine(domainDirectory, BundleSignatureFileName); + if (_fileSystem.File.Exists(signaturePath)) + { + _fileSystem.File.Delete(signaturePath); + } + } + + var manifestDocument = new MirrorDomainManifestDocument( + SchemaVersion, + generatedAt, + domain.Id, + string.IsNullOrWhiteSpace(domain.DisplayName) ? domain.Id : domain.DisplayName, + options.TargetRepository, + new MirrorFileDescriptor( + ToRelativePath(mirrorRoot, bundlePath), + bundleBytes.LongLength, + bundleDigest, + signatureDescriptor), + orderedExports.Select(CreateManifestExportEntry).ToArray()); + + var manifestBytes = Serialize(manifestDocument); + await WriteFileAsync(_fileSystem.Path.Combine(domainDirectory, ManifestFileName), manifestBytes, cancellationToken).ConfigureAwait(false); + + _logger.LogInformation( + "Updated mirror bundle for domain {DomainId} export {ExportKey} (digest {Digest}).", + domain.Id, + plan.Key, + bundleDigest); + } + + private async Task WriteIndexAsync(MirrorDistributionOptions options, string mirrorRoot, CancellationToken cancellationToken) + { + var entries = new List(); + + foreach (var domain in options.Domains.OrderBy(d => d.Id, StringComparer.Ordinal)) + { + var domainDirectory = _fileSystem.Path.Combine(mirrorRoot, domain.Id); + var manifestPath = _fileSystem.Path.Combine(domainDirectory, ManifestFileName); + var bundlePath = _fileSystem.Path.Combine(domainDirectory, BundleFileName); + + var manifestBytes = await ReadAllBytesAsync(manifestPath, cancellationToken).ConfigureAwait(false); + var bundleBytes = await ReadAllBytesAsync(bundlePath, cancellationToken).ConfigureAwait(false); + + if (manifestBytes is null || bundleBytes is null) + { + continue; + } + + var manifestDocument = JsonSerializer.Deserialize(manifestBytes, SerializerOptions); + if (manifestDocument is null) + { + continue; + } + + var manifestDescriptor = new MirrorFileDescriptor( + ToRelativePath(mirrorRoot, manifestPath), + manifestBytes.LongLength, + ComputeDigest(manifestBytes), + signature: null); + + var bundleDescriptor = manifestDocument.Bundle with + { + Path = ToRelativePath(mirrorRoot, bundlePath), + SizeBytes = bundleBytes.LongLength, + Digest = ComputeDigest(bundleBytes), + }; + + var exportKeys = manifestDocument.Exports + .Select(export => export.Key) + .OrderBy(key => key, StringComparer.Ordinal) + .ToArray(); + + entries.Add(new MirrorIndexDomainEntry( + manifestDocument.DomainId, + manifestDocument.DisplayName, + manifestDocument.GeneratedAt, + manifestDocument.Exports.Length, + manifestDescriptor, + bundleDescriptor, + exportKeys)); + } + + var indexDocument = new MirrorIndexDocument( + SchemaVersion, + _timeProvider.GetUtcNow(), + options.TargetRepository, + entries.OrderBy(entry => entry.DomainId, StringComparer.Ordinal).ToArray()); + + var indexBytes = Serialize(indexDocument); + var indexPath = _fileSystem.Path.Combine(mirrorRoot, IndexFileName); + await WriteFileAsync(indexPath, indexBytes, cancellationToken).ConfigureAwait(false); + } + + private MirrorBundleExportEntry CreateExportEntry( + MirrorExportPlan plan, + VexExportManifest manifest, + VexExportEnvelopeContext envelope, + VexExportDataSet dataSet) + { + var consensusJson = envelope.ConsensusCanonicalJson; + var scoreJson = envelope.ScoreCanonicalJson; + var claimsJson = SerializeClaims(dataSet.Claims); + var quietJson = SerializeQuiet(envelope.QuietProvenance); + + return new MirrorBundleExportEntry( + plan.Key, + plan.Format.ToString().ToLowerInvariant(), + manifest.ExportId, + manifest.QuerySignature.Value, + manifest.CreatedAt, + manifest.SizeBytes, + manifest.Artifact.ToUri(), + manifest.ConsensusRevision, + manifest.PolicyRevisionId, + manifest.PolicyDigest, + manifest.ConsensusDigest?.ToUri(), + manifest.ScoreDigest?.ToUri(), + manifest.SourceProviders.ToArray(), + consensusJson, + scoreJson, + claimsJson, + quietJson, + manifest.Attestation is null + ? null + : new MirrorExportAttestationDescriptor( + manifest.Attestation.PredicateType, + manifest.Attestation.Rekor?.Location, + manifest.Attestation.EnvelopeDigest, + manifest.Attestation.SignedAt)); + } + + private static MirrorManifestExportEntry CreateManifestExportEntry(MirrorBundleExportEntry entry) + => new( + entry.Key, + entry.Format, + entry.ExportId, + entry.QuerySignature, + entry.CreatedAt, + entry.ArtifactDigest, + entry.ArtifactSizeBytes, + entry.ConsensusRevision, + entry.PolicyRevisionId, + entry.PolicyDigest, + entry.ConsensusDigest, + entry.ScoreDigest, + entry.SourceProviders, + entry.Attestation); + + private static string? SerializeClaims(ImmutableArray claims) + { + if (claims.IsDefaultOrEmpty || claims.Length == 0) + { + return null; + } + + var ordered = claims + .OrderBy(c => c.VulnerabilityId, StringComparer.Ordinal) + .ThenBy(c => c.Product.Key, StringComparer.Ordinal) + .ThenBy(c => c.ProviderId, StringComparer.Ordinal) + .ToImmutableArray(); + + return VexCanonicalJsonSerializer.Serialize(ordered); + } + + private static string? SerializeQuiet(ImmutableArray quiet) + { + if (quiet.IsDefaultOrEmpty || quiet.Length == 0) + { + return null; + } + + return VexCanonicalJsonSerializer.Serialize(quiet); + } + + private static byte[] Serialize(T document) + => JsonSerializer.SerializeToUtf8Bytes(document, SerializerOptions); + + private async Task ReadDocumentAsync(string path, CancellationToken cancellationToken) + { + if (!_fileSystem.File.Exists(path)) + { + return default; + } + + await using var stream = _fileSystem.File.OpenRead(path); + return await JsonSerializer.DeserializeAsync(stream, SerializerOptions, cancellationToken).ConfigureAwait(false); + } + + private async Task ReadAllBytesAsync(string path, CancellationToken cancellationToken) + { + if (!_fileSystem.File.Exists(path)) + { + return null; + } + + await using var stream = _fileSystem.File.OpenRead(path); + using var buffer = new MemoryStream(); + await stream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false); + return buffer.ToArray(); + } + + private async Task WriteFileAsync(string path, byte[] content, CancellationToken cancellationToken) + { + var directory = _fileSystem.Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + { + _fileSystem.Directory.CreateDirectory(directory); + } + + await using var stream = _fileSystem.File.Create(path); + await stream.WriteAsync(content, 0, content.Length, cancellationToken).ConfigureAwait(false); + } + + private static string ComputeDigest(ReadOnlySpan content) + { + Span hash = stackalloc byte[SHA256.HashSizeInBytes]; + SHA256.HashData(content, hash); + return FormattableString.Invariant($"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"); + } + + private async Task WriteSignatureAsync( + MirrorSigningOptions signingOptions, + string mirrorRoot, + string domainDirectory, + ReadOnlyMemory payload, + CancellationToken cancellationToken) + { + if (!signingOptions.Enabled) + { + return null; + } + + if (_cryptoRegistry is null) + { + throw new InvalidOperationException("Mirror signing requires ICryptoProviderRegistry to be registered."); + } + + var context = PrepareSigningContext(signingOptions); + var (signature, signedAt) = await CreateSignatureAsync(context, payload, cancellationToken).ConfigureAwait(false); + var signaturePath = _fileSystem.Path.Combine(domainDirectory, BundleSignatureFileName); + await WriteFileAsync(signaturePath, Utf8NoBom.GetBytes(signature), cancellationToken).ConfigureAwait(false); + + return new MirrorSignatureDescriptor( + ToRelativePath(mirrorRoot, signaturePath), + context.Algorithm, + context.Signer.KeyId, + context.Provider, + signedAt); + } + + private JsonMirrorSigningContext PrepareSigningContext(MirrorSigningOptions signingOptions) + { + var algorithm = string.IsNullOrWhiteSpace(signingOptions.Algorithm) + ? SignatureAlgorithms.Es256 + : signingOptions.Algorithm.Trim(); + + var keyId = signingOptions.KeyId?.Trim(); + if (string.IsNullOrEmpty(keyId)) + { + throw new InvalidOperationException("Mirror signing requires Excititor:Mirror:Signing:KeyId to be configured."); + } + + var providerHint = signingOptions.Provider?.Trim(); + CryptoSignerResolution resolved; + + try + { + resolved = _cryptoRegistry!.ResolveSigner(CryptoCapability.Signing, algorithm, new CryptoKeyReference(keyId, providerHint), providerHint); + } + catch (KeyNotFoundException) + { + var provider = ResolveProvider(algorithm, providerHint); + var signingKey = LoadSigningKey(signingOptions, provider, algorithm); + provider.UpsertSigningKey(signingKey); + resolved = _cryptoRegistry.ResolveSigner(CryptoCapability.Signing, algorithm, new CryptoKeyReference(keyId, provider.Name), provider.Name); + } + + return new JsonMirrorSigningContext(resolved.Signer, algorithm, resolved.ProviderName, _timeProvider); + } + + private ICryptoProvider ResolveProvider(string algorithm, string? providerHint) + { + if (!string.IsNullOrWhiteSpace(providerHint) && _cryptoRegistry!.TryResolve(providerHint, out var hinted)) + { + if (!hinted.Supports(CryptoCapability.Signing, algorithm)) + { + throw new InvalidOperationException(FormattableString.Invariant( + $"Crypto provider '{providerHint}' does not support signing algorithm '{algorithm}'.")); + } + + return hinted; + } + + return _cryptoRegistry!.ResolveOrThrow(CryptoCapability.Signing, algorithm); + } + + private CryptoSigningKey LoadSigningKey(MirrorSigningOptions signingOptions, ICryptoProvider provider, string algorithm) + { + var keyPath = signingOptions.KeyPath?.Trim(); + if (string.IsNullOrEmpty(keyPath)) + { + throw new InvalidOperationException("Mirror signing requires Excititor:Mirror:Signing:KeyPath when the key is not already loaded."); + } + + var resolvedPath = _fileSystem.Path.IsPathRooted(keyPath) + ? keyPath + : _fileSystem.Path.GetFullPath(_fileSystem.Path.Combine(AppContext.BaseDirectory, keyPath)); + + if (!_fileSystem.File.Exists(resolvedPath)) + { + throw new FileNotFoundException($"Mirror signing key '{resolvedPath}' not found.", resolvedPath); + } + + var pem = _fileSystem.File.ReadAllText(resolvedPath); + using var ecdsa = ECDsa.Create(); + try + { + ecdsa.ImportFromPem(pem); + } + catch (CryptographicException ex) + { + throw new InvalidOperationException("Failed to import mirror signing key. Ensure the PEM contains an EC private key.", ex); + } + + var parameters = ecdsa.ExportParameters(includePrivateParameters: true); + return new CryptoSigningKey( + new CryptoKeyReference(signingOptions.KeyId!, provider.Name), + algorithm, + in parameters, + _timeProvider.GetUtcNow()); + } + + private static async Task<(string Signature, DateTimeOffset SignedAt)> CreateSignatureAsync( + JsonMirrorSigningContext context, + ReadOnlyMemory payload, + CancellationToken cancellationToken) + { + var header = new Dictionary + { + ["alg"] = context.Algorithm, + ["kid"] = context.Signer.KeyId, + ["typ"] = SignatureMediaType, + ["b64"] = false, + ["crit"] = new[] { "b64" }, + }; + + if (!string.IsNullOrWhiteSpace(context.Provider)) + { + header["provider"] = context.Provider; + } + + var headerJson = JsonSerializer.Serialize(header, SerializerOptions); + var protectedHeader = Base64UrlEncode(Utf8NoBom.GetBytes(headerJson)); + + var signingInputLength = protectedHeader.Length + 1 + payload.Length; + var buffer = ArrayPool.Shared.Rent(signingInputLength); + try + { + var headerBytes = Encoding.ASCII.GetBytes(protectedHeader); + Buffer.BlockCopy(headerBytes, 0, buffer, 0, headerBytes.Length); + buffer[headerBytes.Length] = (byte)'.'; + payload.Span.CopyTo(new Span(buffer, headerBytes.Length + 1, payload.Length)); + + var signingInput = new ReadOnlyMemory(buffer, 0, signingInputLength); + var signatureBytes = await context.Signer.SignAsync(signingInput, cancellationToken).ConfigureAwait(false); + var encodedSignature = Base64UrlEncode(signatureBytes); + var signedAt = context.TimeProvider.GetUtcNow(); + return (string.Concat(protectedHeader, "..", encodedSignature), signedAt); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private static string Base64UrlEncode(ReadOnlySpan value) + => Convert.ToBase64String(value) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + + private string ToRelativePath(string root, string path) + { + if (string.IsNullOrEmpty(path)) + { + return string.Empty; + } + + var fullRoot = _fileSystem.Path.GetFullPath(root); + var fullPath = _fileSystem.Path.GetFullPath(path); + + if (!fullPath.StartsWith(fullRoot, StringComparison.OrdinalIgnoreCase)) + { + return fullPath.Replace('\\', '/'); + } + + var relative = fullPath[fullRoot.Length..] + .TrimStart(_fileSystem.Path.DirectorySeparatorChar, _fileSystem.Path.AltDirectorySeparatorChar); + return relative.Replace('\\', '/'); + } + + private readonly record struct DomainMatch(MirrorDomainOptions Domain, MirrorExportPlan Plan); + + private sealed record JsonMirrorSigningContext(ICryptoSigner Signer, string Algorithm, string Provider, TimeProvider TimeProvider); + + private sealed record MirrorBundleDocument( + int SchemaVersion, + DateTimeOffset GeneratedAt, + string? TargetRepository, + string DomainId, + string DisplayName, + IReadOnlyList Exports); + + private sealed record MirrorBundleExportEntry( + string Key, + string Format, + string ExportId, + string QuerySignature, + DateTimeOffset CreatedAt, + long ArtifactSizeBytes, + string ArtifactDigest, + string? ConsensusRevision, + string? PolicyRevisionId, + string? PolicyDigest, + string? ConsensusDigest, + string? ScoreDigest, + IReadOnlyList SourceProviders, + string? ConsensusDocument, + string? ScoreDocument, + string? ClaimsDocument, + string? QuietDocument, + MirrorExportAttestationDescriptor? Attestation); + + private sealed record MirrorExportAttestationDescriptor( + string PredicateType, + string? RekorLocation, + string? EnvelopeDigest, + DateTimeOffset? SignedAt); + + private sealed record MirrorDomainManifestDocument( + int SchemaVersion, + DateTimeOffset GeneratedAt, + string DomainId, + string DisplayName, + string? TargetRepository, + MirrorFileDescriptor Bundle, + IReadOnlyList Exports); + + private sealed record MirrorManifestExportEntry( + string Key, + string Format, + string ExportId, + string QuerySignature, + DateTimeOffset CreatedAt, + string ArtifactDigest, + long ArtifactSizeBytes, + string? ConsensusRevision, + string? PolicyRevisionId, + string? PolicyDigest, + string? ConsensusDigest, + string? ScoreDigest, + IReadOnlyList SourceProviders, + MirrorExportAttestationDescriptor? Attestation); + + private sealed record MirrorFileDescriptor( + string Path, + long SizeBytes, + string Digest, + MirrorSignatureDescriptor? Signature); + + private sealed record MirrorSignatureDescriptor( + string Path, + string Algorithm, + string KeyId, + string? Provider, + DateTimeOffset SignedAt); + + private sealed record MirrorIndexDocument( + int SchemaVersion, + DateTimeOffset GeneratedAt, + string? TargetRepository, + IReadOnlyList Domains); + + private sealed record MirrorIndexDomainEntry( + string DomainId, + string DisplayName, + DateTimeOffset GeneratedAt, + int ExportCount, + MirrorFileDescriptor Manifest, + MirrorFileDescriptor Bundle, + IReadOnlyList ExportKeys); +} diff --git a/src/StellaOps.Excititor.Storage.Mongo/VexMongoModels.cs b/src/StellaOps.Excititor.Storage.Mongo/VexMongoModels.cs index c3e12d48..4c96c2a8 100644 --- a/src/StellaOps.Excititor.Storage.Mongo/VexMongoModels.cs +++ b/src/StellaOps.Excititor.Storage.Mongo/VexMongoModels.cs @@ -105,11 +105,13 @@ internal sealed class VexExportManifestRecord public string? ScoreDigestAlgorithm { get; set; } = null; - public string? ScoreDigestValue { get; set; } - = null; - - public string? PredicateType { get; set; } - = null; + public string? ScoreDigestValue { get; set; } + = null; + + public List QuietProvenance { get; set; } = new(); + + public string? PredicateType { get; set; } + = null; public string? RekorApiVersion { get; set; } = null; @@ -150,10 +152,11 @@ internal sealed class VexExportManifestRecord ConsensusDigestAlgorithm = manifest.ConsensusDigest?.Algorithm, ConsensusDigestValue = manifest.ConsensusDigest?.Digest, ScoreDigestAlgorithm = manifest.ScoreDigest?.Algorithm, - ScoreDigestValue = manifest.ScoreDigest?.Digest, - PredicateType = manifest.Attestation?.PredicateType, - RekorApiVersion = manifest.Attestation?.Rekor?.ApiVersion, - RekorLocation = manifest.Attestation?.Rekor?.Location, + ScoreDigestValue = manifest.ScoreDigest?.Digest, + QuietProvenance = manifest.QuietProvenance.Select(VexQuietProvenanceRecord.FromDomain).ToList(), + PredicateType = manifest.Attestation?.PredicateType, + RekorApiVersion = manifest.Attestation?.Rekor?.ApiVersion, + RekorLocation = manifest.Attestation?.Rekor?.Location, RekorLogIndex = manifest.Attestation?.Rekor?.LogIndex, RekorInclusionProofUri = manifest.Attestation?.Rekor?.InclusionProofUri?.ToString(), EnvelopeDigest = manifest.Attestation?.EnvelopeDigest, @@ -201,19 +204,81 @@ internal sealed class VexExportManifestRecord ConsensusRevision, PolicyRevisionId, PolicyDigest, - consensusDigest, - scoreDigest, - attestation, - SizeBytes); + consensusDigest, + scoreDigest, + quietProvenance: QuietProvenance.Count == 0 + ? ImmutableArray.Empty + : QuietProvenance + .Select(static record => record.ToDomain()) + .ToImmutableArray(), + attestation, + SizeBytes); } public static string CreateId(VexQuerySignature signature, VexExportFormat format) => string.Format(CultureInfo.InvariantCulture, "{0}|{1}", signature.Value, format.ToString().ToLowerInvariant()); -} - -[BsonIgnoreExtraElements] -internal sealed class VexProviderRecord -{ +} + +[BsonIgnoreExtraElements] +internal sealed class VexQuietProvenanceRecord +{ + public string VulnerabilityId { get; set; } = default!; + + public string ProductKey { get; set; } = default!; + + public List Statements { get; set; } = new(); + + public static VexQuietProvenanceRecord FromDomain(VexQuietProvenance provenance) + => new() + { + VulnerabilityId = provenance.VulnerabilityId, + ProductKey = provenance.ProductKey, + Statements = provenance.Statements.Select(VexQuietStatementRecord.FromDomain).ToList(), + }; + + public VexQuietProvenance ToDomain() + => new(VulnerabilityId, ProductKey, Statements.Select(static statement => statement.ToDomain())); +} + +[BsonIgnoreExtraElements] +internal sealed class VexQuietStatementRecord +{ + public string ProviderId { get; set; } = default!; + + public string StatementId { get; set; } = default!; + + public string? Justification { get; set; } + = null; + + public VexSignatureMetadataDocument? Signature { get; set; } + = null; + + public static VexQuietStatementRecord FromDomain(VexQuietStatement statement) + => new() + { + ProviderId = statement.ProviderId, + StatementId = statement.StatementId, + Justification = statement.Justification?.ToString().ToLowerInvariant(), + Signature = VexSignatureMetadataDocument.FromDomain(statement.Signature), + }; + + public VexQuietStatement ToDomain() + { + var justification = string.IsNullOrWhiteSpace(Justification) + ? (VexJustification?)null + : Enum.Parse(Justification, ignoreCase: true); + + return new VexQuietStatement( + ProviderId, + StatementId, + justification, + Signature?.ToDomain()); + } +} + +[BsonIgnoreExtraElements] +internal sealed class VexProviderRecord +{ [BsonId] public string Id { get; set; } = default!; diff --git a/src/StellaOps.Excititor.WebService.Tests/MirrorEndpointsTests.cs b/src/StellaOps.Excititor.WebService.Tests/MirrorEndpointsTests.cs index 367e75be..e8f31e3d 100644 --- a/src/StellaOps.Excititor.WebService.Tests/MirrorEndpointsTests.cs +++ b/src/StellaOps.Excititor.WebService.Tests/MirrorEndpointsTests.cs @@ -15,7 +15,6 @@ using StellaOps.Excititor.Connectors.Abstractions; using StellaOps.Excititor.Export; using StellaOps.Excititor.Policy; using StellaOps.Excititor.Storage.Mongo; -using StellaOps.Excititor.WebService.Options; namespace StellaOps.Excititor.WebService.Tests; diff --git a/src/StellaOps.Excititor.WebService/Endpoints/MirrorEndpoints.cs b/src/StellaOps.Excititor.WebService/Endpoints/MirrorEndpoints.cs index 4d0dc1e3..85b51693 100644 --- a/src/StellaOps.Excititor.WebService/Endpoints/MirrorEndpoints.cs +++ b/src/StellaOps.Excititor.WebService/Endpoints/MirrorEndpoints.cs @@ -8,9 +8,8 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Excititor.Core; using StellaOps.Excititor.Export; -using StellaOps.Excititor.Storage.Mongo; -using StellaOps.Excititor.WebService.Options; -using StellaOps.Excititor.WebService.Services; +using StellaOps.Excititor.Storage.Mongo; +using StellaOps.Excititor.WebService.Services; namespace StellaOps.Excititor.WebService.Endpoints; @@ -99,13 +98,13 @@ internal static class MirrorEndpoints } var resolvedExports = new List(); - foreach (var exportOption in domain.Exports) - { - if (!TryBuildExportPlan(exportOption, out var plan, out var error)) - { - resolvedExports.Add(new MirrorExportIndexEntry( - exportOption.Key, - null, + foreach (var exportOption in domain.Exports) + { + if (!MirrorExportPlanner.TryBuild(exportOption, out var plan, out var error)) + { + resolvedExports.Add(new MirrorExportIndexEntry( + exportOption.Key, + null, null, exportOption.Format, null, @@ -117,7 +116,7 @@ internal static class MirrorEndpoints continue; } - var manifest = await exportStore.FindAsync(plan.Signature, plan.Format, cancellationToken).ConfigureAwait(false); + var manifest = await exportStore.FindAsync(plan.Signature, plan.Format, cancellationToken).ConfigureAwait(false); if (manifest is null) { @@ -178,16 +177,16 @@ internal static class MirrorEndpoints return Results.Unauthorized(); } - if (!TryFindExport(domain, exportKey, out var exportOptions)) - { - return Results.NotFound(); - } - - if (!TryBuildExportPlan(exportOptions, out var plan, out var error)) - { - await WritePlainTextAsync(httpContext, error ?? "invalid_export_configuration", StatusCodes.Status503ServiceUnavailable, cancellationToken).ConfigureAwait(false); - return Results.Empty; - } + if (!TryFindExport(domain, exportKey, out var exportOptions)) + { + return Results.NotFound(); + } + + if (!MirrorExportPlanner.TryBuild(exportOptions, out var plan, out var error)) + { + await WritePlainTextAsync(httpContext, error ?? "invalid_export_configuration", StatusCodes.Status503ServiceUnavailable, cancellationToken).ConfigureAwait(false); + return Results.Empty; + } var manifest = await exportStore.FindAsync(plan.Signature, plan.Format, cancellationToken).ConfigureAwait(false); if (manifest is null) @@ -242,10 +241,10 @@ internal static class MirrorEndpoints return Results.Empty; } - if (!TryFindExport(domain, exportKey, out var exportOptions) || !TryBuildExportPlan(exportOptions, out var plan, out _)) - { - return Results.NotFound(); - } + if (!TryFindExport(domain, exportKey, out var exportOptions) || !MirrorExportPlanner.TryBuild(exportOptions, out var plan, out _)) + { + return Results.NotFound(); + } var manifest = await exportStore.FindAsync(plan.Signature, plan.Format, cancellationToken).ConfigureAwait(false); if (manifest is null) @@ -287,37 +286,11 @@ internal static class MirrorEndpoints return domain is not null; } - private static bool TryFindExport(MirrorDomainOptions domain, string exportKey, out MirrorExportOptions export) - { - export = domain.Exports.FirstOrDefault(e => string.Equals(e.Key, exportKey, StringComparison.OrdinalIgnoreCase))!; - return export is not null; - } - - private static bool TryBuildExportPlan(MirrorExportOptions exportOptions, out MirrorExportPlan plan, out string? error) - { - plan = null!; - error = null; - - if (string.IsNullOrWhiteSpace(exportOptions.Key)) - { - error = "missing_export_key"; - return false; - } - - if (string.IsNullOrWhiteSpace(exportOptions.Format) || !Enum.TryParse(exportOptions.Format, ignoreCase: true, out var format)) - { - error = "unsupported_export_format"; - return false; - } - - var filters = exportOptions.Filters.Select(pair => new KeyValuePair(pair.Key, pair.Value)).ToArray(); - var sorts = exportOptions.Sort.Select(pair => new VexQuerySort(pair.Key, pair.Value)).ToArray(); - var query = VexQuery.Create(filters.Select(kv => new VexQueryFilter(kv.Key, kv.Value)), sorts, exportOptions.Limit, exportOptions.Offset, exportOptions.View); - var signature = VexQuerySignature.FromQuery(query); - - plan = new MirrorExportPlan(format, query, signature); - return true; - } + private static bool TryFindExport(MirrorDomainOptions domain, string exportKey, out MirrorExportOptions export) + { + export = domain.Exports.FirstOrDefault(e => string.Equals(e.Key, exportKey, StringComparison.OrdinalIgnoreCase))!; + return export is not null; + } private static string ResolveContentType(VexExportFormat format) => format switch @@ -351,19 +324,15 @@ internal static class MirrorEndpoints await context.Response.WriteAsync(message, cancellationToken); } - private static async Task WriteJsonAsync(HttpContext context, T payload, int statusCode, CancellationToken cancellationToken) - { - context.Response.StatusCode = statusCode; - context.Response.ContentType = "application/json"; - var json = VexCanonicalJsonSerializer.Serialize(payload); - await context.Response.WriteAsync(json, cancellationToken); + private static async Task WriteJsonAsync(HttpContext context, T payload, int statusCode, CancellationToken cancellationToken) + { + context.Response.StatusCode = statusCode; + context.Response.ContentType = "application/json"; + var json = VexCanonicalJsonSerializer.Serialize(payload); + await context.Response.WriteAsync(json, cancellationToken); } - private sealed record MirrorExportPlan( - VexExportFormat Format, - VexQuery Query, - VexQuerySignature Signature); -} +} internal sealed record MirrorDomainListResponse(IReadOnlyList Domains); diff --git a/src/StellaOps.Excititor.WebService/Options/MirrorDistributionOptions.cs b/src/StellaOps.Excititor.WebService/Options/MirrorDistributionOptions.cs deleted file mode 100644 index b583d222..00000000 --- a/src/StellaOps.Excititor.WebService/Options/MirrorDistributionOptions.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System.Collections.Generic; - -namespace StellaOps.Excititor.WebService.Options; - -public sealed class MirrorDistributionOptions -{ - public const string SectionName = "Excititor:Mirror"; - - public List Domains { get; } = new(); -} - -public sealed class MirrorDomainOptions -{ - public string Id { get; set; } = string.Empty; - - public string DisplayName { get; set; } = string.Empty; - - public bool RequireAuthentication { get; set; } - = false; - - /// - /// Maximum index requests allowed per rolling window. - /// - public int MaxIndexRequestsPerHour { get; set; } = 120; - - /// - /// Maximum export downloads allowed per rolling window. - /// - public int MaxDownloadRequestsPerHour { get; set; } = 600; - - public List Exports { get; } = new(); -} - -public sealed class MirrorExportOptions -{ - public string Key { get; set; } = string.Empty; - - public string Format { get; set; } = string.Empty; - - public Dictionary Filters { get; } = new(); - - public Dictionary Sort { get; } = new(); - - public int? Limit { get; set; } - = null; - - public int? Offset { get; set; } - = null; - - public string? View { get; set; } - = null; -} diff --git a/src/StellaOps.Excititor.WebService/Program.cs b/src/StellaOps.Excititor.WebService/Program.cs index 39d86834..bbe9b158 100644 --- a/src/StellaOps.Excititor.WebService/Program.cs +++ b/src/StellaOps.Excititor.WebService/Program.cs @@ -13,11 +13,11 @@ using StellaOps.Excititor.Export; using StellaOps.Excititor.Formats.CSAF; using StellaOps.Excititor.Formats.CycloneDX; using StellaOps.Excititor.Formats.OpenVEX; -using StellaOps.Excititor.Policy; -using StellaOps.Excititor.Storage.Mongo; -using StellaOps.Excititor.WebService.Endpoints; -using StellaOps.Excititor.WebService.Options; -using StellaOps.Excititor.WebService.Services; +using StellaOps.Excititor.Policy; +using StellaOps.Excititor.Storage.Mongo; +using StellaOps.Excititor.WebService.Endpoints; +using StellaOps.Excititor.WebService.Services; +using StellaOps.Excititor.Core; var builder = WebApplication.CreateBuilder(args); var configuration = builder.Configuration; diff --git a/src/StellaOps.Notify.Models.Tests/PlatformEventSchemaValidationTests.cs b/src/StellaOps.Notify.Models.Tests/PlatformEventSchemaValidationTests.cs new file mode 100644 index 00000000..3acf554a --- /dev/null +++ b/src/StellaOps.Notify.Models.Tests/PlatformEventSchemaValidationTests.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NJsonSchema; +using Xunit; +using System.Threading.Tasks; + +namespace StellaOps.Notify.Models.Tests; + +public sealed class PlatformEventSchemaValidationTests +{ + public static IEnumerable SampleFiles() => new[] + { + new object[] { "scanner.report.ready@1.sample.json", "scanner.report.ready@1.json" }, + new object[] { "scanner.scan.completed@1.sample.json", "scanner.scan.completed@1.json" }, + new object[] { "scheduler.rescan.delta@1.sample.json", "scheduler.rescan.delta@1.json" }, + new object[] { "attestor.logged@1.sample.json", "attestor.logged@1.json" } + }; + + [Theory] + [MemberData(nameof(SampleFiles))] + public async Task EventSamplesConformToPublishedSchemas(string sampleFile, string schemaFile) + { + var baseDirectory = AppContext.BaseDirectory; + var samplePath = Path.Combine(baseDirectory, sampleFile); + var schemaPath = Path.Combine(baseDirectory, schemaFile); + + Assert.True(File.Exists(samplePath), $"Sample '{sampleFile}' not found at '{samplePath}'."); + Assert.True(File.Exists(schemaPath), $"Schema '{schemaFile}' not found at '{schemaPath}'."); + + var schema = await JsonSchema.FromJsonAsync(File.ReadAllText(schemaPath)); + var errors = schema.Validate(File.ReadAllText(samplePath)); + + if (errors.Count > 0) + { + var formatted = string.Join( + Environment.NewLine, + errors.Select(error => $"{error.Path}: {error.Kind} ({error})")); + + Assert.True(errors.Count == 0, $"Schema validation failed for '{sampleFile}':{Environment.NewLine}{formatted}"); + } + } +} diff --git a/src/StellaOps.Notify.Models.Tests/StellaOps.Notify.Models.Tests.csproj b/src/StellaOps.Notify.Models.Tests/StellaOps.Notify.Models.Tests.csproj index 5db9f630..25ef5cb4 100644 --- a/src/StellaOps.Notify.Models.Tests/StellaOps.Notify.Models.Tests.csproj +++ b/src/StellaOps.Notify.Models.Tests/StellaOps.Notify.Models.Tests.csproj @@ -5,16 +5,20 @@ enable - - - - - - - Always - - - Always - - + + + + + + + + Always + + + Always + + + Always + + diff --git a/src/StellaOps.Plugin.Tests/DependencyInjection/PluginDependencyInjectionExtensionsTests.cs b/src/StellaOps.Plugin.Tests/DependencyInjection/PluginDependencyInjectionExtensionsTests.cs new file mode 100644 index 00000000..188d2b30 --- /dev/null +++ b/src/StellaOps.Plugin.Tests/DependencyInjection/PluginDependencyInjectionExtensionsTests.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using StellaOps.DependencyInjection; +using StellaOps.Plugin.DependencyInjection; +using StellaOps.Plugin.Hosting; +using Xunit; + +namespace StellaOps.Plugin.Tests.DependencyInjection; + +public sealed class PluginDependencyInjectionExtensionsTests +{ + [Fact] + public void RegisterPluginRoutines_RegistersServiceBindingsAndHonoursLifetimes() + { + const string source = """ +using Microsoft.Extensions.DependencyInjection; +using StellaOps.DependencyInjection; + +namespace SamplePlugin; + +public interface IScopedExample {} +public interface ISingletonExample {} + +[ServiceBinding(typeof(IScopedExample), ServiceLifetime.Scoped, RegisterAsSelf = true)] +public sealed class ScopedExample : IScopedExample {} + +[ServiceBinding(typeof(ISingletonExample), ServiceLifetime.Singleton)] +public sealed class SingletonExample : ISingletonExample {} +"""; + + using var plugin = TestPluginAssembly.Create(source); + + var configuration = new ConfigurationBuilder().Build(); + var services = new ServiceCollection(); + + services.RegisterPluginRoutines(configuration, plugin.Options, NullLogger.Instance); + + var scopedDescriptor = Assert.Single( + services, + static d => d.ServiceType.FullName == "SamplePlugin.IScopedExample"); + Assert.Equal(ServiceLifetime.Scoped, scopedDescriptor.Lifetime); + Assert.Equal("SamplePlugin.ScopedExample", scopedDescriptor.ImplementationType?.FullName); + + var scopedSelfDescriptor = Assert.Single( + services, + static d => d.ServiceType.FullName == "SamplePlugin.ScopedExample"); + Assert.Equal(ServiceLifetime.Scoped, scopedSelfDescriptor.Lifetime); + + var singletonDescriptor = Assert.Single( + services, + static d => d.ServiceType.FullName == "SamplePlugin.ISingletonExample"); + Assert.Equal(ServiceLifetime.Singleton, singletonDescriptor.Lifetime); + + using var provider = services.BuildServiceProvider(); + + object firstScopeInstance; + using (var scope = provider.CreateScope()) + { + var resolvedFirst = ServiceProviderServiceExtensions.GetRequiredService(scope.ServiceProvider, scopedDescriptor.ServiceType); + var resolvedSecond = ServiceProviderServiceExtensions.GetRequiredService(scope.ServiceProvider, scopedDescriptor.ServiceType); + Assert.Same(resolvedFirst, resolvedSecond); + firstScopeInstance = resolvedFirst; + } + + using (var scope = provider.CreateScope()) + { + var resolved = ServiceProviderServiceExtensions.GetRequiredService(scope.ServiceProvider, scopedDescriptor.ServiceType); + Assert.NotSame(firstScopeInstance, resolved); + } + + var singletonFirst = ServiceProviderServiceExtensions.GetRequiredService(provider, singletonDescriptor.ServiceType); + var singletonSecond = ServiceProviderServiceExtensions.GetRequiredService(provider, singletonDescriptor.ServiceType); + Assert.Same(singletonFirst, singletonSecond); + + services.RegisterPluginRoutines(configuration, plugin.Options, NullLogger.Instance); + + var scopedRegistrations = services.Count(d => + d.ServiceType.FullName == "SamplePlugin.IScopedExample" && + d.ImplementationType?.FullName == "SamplePlugin.ScopedExample"); + Assert.Equal(1, scopedRegistrations); + } + + private sealed class TestPluginAssembly : IDisposable + { + private TestPluginAssembly(string directoryPath, string assemblyPath) + { + DirectoryPath = directoryPath; + AssemblyPath = assemblyPath; + + Options = new PluginHostOptions + { + PluginsDirectory = directoryPath, + EnsureDirectoryExists = false, + RecursiveSearch = false, + }; + Options.SearchPatterns.Add(Path.GetFileName(assemblyPath)); + } + + public string DirectoryPath { get; } + + public string AssemblyPath { get; } + + public PluginHostOptions Options { get; } + + public static TestPluginAssembly Create(string source) + { + var directoryPath = Path.Combine(Path.GetTempPath(), "stellaops-plugin-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directoryPath); + + var assemblyName = "SamplePlugin" + Guid.NewGuid().ToString("N"); + var assemblyPath = Path.Combine(directoryPath, assemblyName + ".dll"); + + var syntaxTree = CSharpSyntaxTree.ParseText(source); + var references = CollectMetadataReferences(); + + var compilation = CSharpCompilation.Create( + assemblyName, + new[] { syntaxTree }, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, optimizationLevel: OptimizationLevel.Release)); + + var emitResult = compilation.Emit(assemblyPath); + if (!emitResult.Success) + { + var diagnostics = string.Join(Environment.NewLine, emitResult.Diagnostics); + throw new InvalidOperationException("Failed to compile plugin assembly:" + Environment.NewLine + diagnostics); + } + + return new TestPluginAssembly(directoryPath, assemblyPath); + } + + public void Dispose() + { + try + { + if (Directory.Exists(DirectoryPath)) + { + Directory.Delete(DirectoryPath, recursive: true); + } + } + catch + { + // Ignore cleanup failures – plugin load contexts may keep files locked on Windows. + } + } + + private static IReadOnlyCollection CollectMetadataReferences() + { + var referencePaths = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") is string tpa) + { + foreach (var path in tpa.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + referencePaths.Add(path); + } + } + + referencePaths.Add(typeof(object).Assembly.Location); + referencePaths.Add(typeof(ServiceBindingAttribute).Assembly.Location); + referencePaths.Add(typeof(IDependencyInjectionRoutine).Assembly.Location); + referencePaths.Add(typeof(ServiceLifetime).Assembly.Location); + + return referencePaths + .Select(path => MetadataReference.CreateFromFile(path)) + .ToArray(); + } + } +} diff --git a/src/StellaOps.Plugin.Tests/StellaOps.Plugin.Tests.csproj b/src/StellaOps.Plugin.Tests/StellaOps.Plugin.Tests.csproj index 04c131b3..5d477369 100644 --- a/src/StellaOps.Plugin.Tests/StellaOps.Plugin.Tests.csproj +++ b/src/StellaOps.Plugin.Tests/StellaOps.Plugin.Tests.csproj @@ -9,10 +9,13 @@ - - - - + + + + + + + diff --git a/src/StellaOps.Plugin/TASKS.md b/src/StellaOps.Plugin/TASKS.md index 1ffb19aa..ea4687eb 100644 --- a/src/StellaOps.Plugin/TASKS.md +++ b/src/StellaOps.Plugin/TASKS.md @@ -1,7 +1,7 @@ # TASKS | Task | Owner(s) | Depends on | Notes | |---|---|---|---| -|PLUGIN-DI-08-001 Scoped service support in plugin bootstrap|Plugin Platform Guild (DONE 2025-10-19)|StellaOps.DependencyInjection|Introduced `ServiceBindingAttribute` metadata for scoped DI, taught plugin/job loaders to consume it with duplicate-safe registration, added coverage, and refreshed the plug-in SDK guide.| +|PLUGIN-DI-08-001 Scoped service support in plugin bootstrap|Plugin Platform Guild (DONE 2025-10-21)|StellaOps.DependencyInjection|Scoped DI metadata primitives landed; dynamic plugin integration tests now verify `RegisterPluginRoutines` honours `[ServiceBinding]` lifetimes and remains idempotent.| |PLUGIN-DI-08-002.COORD Authority scoped-service handshake|Plugin Platform Guild, Authority Core (DONE 2025-10-20)|PLUGIN-DI-08-001|Workshop held 2025-10-20 15:00–16:05 UTC; outcomes/notes captured in `docs/dev/authority-plugin-di-coordination.md`, follow-up action items assigned for PLUGIN-DI-08-002 implementation plan.| |PLUGIN-DI-08-002 Authority plugin integration updates|Plugin Platform Guild, Authority Core (DONE 2025-10-20)|PLUGIN-DI-08-001, PLUGIN-DI-08-002.COORD|Standard registrar now registers scoped credential/provisioning stores + identity-provider plugins, registry Acquire scopes instances, and regression suites (`dotnet test src/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StellaOps.Authority.Plugin.Standard.Tests.csproj`, `dotnet test src/StellaOps.Authority/StellaOps.Authority.Tests/StellaOps.Authority.Tests.csproj`) cover scoped lifetimes + handles.| |PLUGIN-DI-08-003 Authority registry scoped resolution|Plugin Platform Guild, Authority Core (DONE 2025-10-20)|PLUGIN-DI-08-002.COORD|Reworked `IAuthorityIdentityProviderRegistry` to expose metadata + scoped handles, updated OpenIddict flows/Program health endpoints, and added coverage via `AuthorityIdentityProviderRegistryTests`.| diff --git a/src/StellaOps.Web/README.md b/src/StellaOps.Web/README.md index 8e3e2bae..9010f72b 100644 --- a/src/StellaOps.Web/README.md +++ b/src/StellaOps.Web/README.md @@ -14,9 +14,14 @@ Run `ng generate component component-name` to generate a new component. You can Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. -## Running unit tests - -Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). +## Running unit tests + +The suite runs headlessly through Karma. Install dependencies with `npm install`, then execute `npm test` (alias for `ng test --watch=false`). The run expects a Chromium-compatible browser: + +- On developer machines ensure Google Chrome or Chromium is available on `PATH`; otherwise set `CHROME_BIN` to the browser executable. +- In CI you can rely on Puppeteer by exporting `PUPPETEER_EXECUTABLE_PATH` to the downloaded Chromium binary; the test harness automatically adopts it. + +For interactive development, use `npm run test:watch` (invokes `ng test --watch`) and optionally pass `--browsers=Chrome` to open a full browser. ## Running end-to-end tests diff --git a/src/StellaOps.Web/TASKS.md b/src/StellaOps.Web/TASKS.md index b4c596c2..5b489261 100644 --- a/src/StellaOps.Web/TASKS.md +++ b/src/StellaOps.Web/TASKS.md @@ -3,4 +3,5 @@ | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | |----|--------|----------|------------|-------------|---------------| | WEB1.TRIVY-SETTINGS | DONE (2025-10-21) | UX Specialist, Angular Eng | Backend `/exporters/trivy-db` contract | Implement Trivy DB exporter settings panel with `publishFull`, `publishDelta`, `includeFull`, `includeDelta` toggles and “Run export now” action using future `/exporters/trivy-db/settings` API. | ✅ Angular route `/concelier/trivy-db-settings` backed by `TrivyDbSettingsPageComponent` with reactive form; ✅ Overrides persisted via `ConcelierExporterClient` (`settings`/`run` endpoints); ✅ Manual run button saves current overrides then triggers export and surfaces run metadata. | -| WEB1.TRIVY-SETTINGS-TESTS | BLOCKED (2025-10-21) | UX Specialist, Angular Eng | WEB1.TRIVY-SETTINGS | Add headless UI test run (`ng test --watch=false`) and document steps once Angular CLI tooling is available in CI/local environment. | Angular CLI available (npm scripts chained), Karma suite for Trivy DB components passing locally and in CI, docs note required prerequisites. | +| WEB1.TRIVY-SETTINGS-TESTS | DONE (2025-10-21) | UX Specialist, Angular Eng | WEB1.TRIVY-SETTINGS | **DONE (2025-10-21)** – Added headless Karma harness (`ng test --watch=false`) wired to ChromeHeadless/CI launcher, created `karma.conf.cjs`, updated npm scripts + docs with Chromium prerequisites so CI/offline runners can execute specs deterministically. | Angular CLI available (npm scripts chained), Karma suite for Trivy DB components passing locally and in CI, docs note required prerequisites. | +| WEB1.DEPS-13-001 | TODO | UX Specialist, Angular Eng, DevEx | WEB1.TRIVY-SETTINGS-TESTS | Stabilise Angular workspace dependencies for CI/offline nodes: refresh `package-lock.json`, ensure Puppeteer/Chromium binaries optional, document deterministic install workflow. | `npm install` completes without manual intervention on air-gapped nodes, `npm test` headless run succeeds from clean checkout, README updated with lockfile + cache steps. | diff --git a/src/StellaOps.Web/angular.json b/src/StellaOps.Web/angular.json index 302d21cd..a1c26707 100644 --- a/src/StellaOps.Web/angular.json +++ b/src/StellaOps.Web/angular.json @@ -76,19 +76,20 @@ "buildTarget": "stellaops-web:build" } }, - "test": { - "builder": "@angular-devkit/build-angular:karma", - "options": { - "polyfills": [ - "zone.js", - "zone.js/testing" - ], - "tsConfig": "tsconfig.spec.json", - "inlineStyleLanguage": "scss", - "assets": [ - "src/favicon.ico", - "src/assets" - ], + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "polyfills": [ + "zone.js", + "zone.js/testing" + ], + "tsConfig": "tsconfig.spec.json", + "karmaConfig": "karma.conf.cjs", + "inlineStyleLanguage": "scss", + "assets": [ + "src/favicon.ico", + "src/assets" + ], "styles": [ "src/styles.scss" ], diff --git a/src/StellaOps.Web/karma.conf.cjs b/src/StellaOps.Web/karma.conf.cjs new file mode 100644 index 00000000..b65effdd --- /dev/null +++ b/src/StellaOps.Web/karma.conf.cjs @@ -0,0 +1,49 @@ +const { join } = require('path'); + +const { env } = process; + +if (!env.CHROME_BIN && env.PUPPETEER_EXECUTABLE_PATH) { + env.CHROME_BIN = env.PUPPETEER_EXECUTABLE_PATH; +} + +const isCI = env.CI === 'true' || env.CI === '1'; + +module.exports = function (config) { + config.set({ + basePath: '', + frameworks: ['jasmine', '@angular-devkit/build-angular'], + plugins: [ + require('karma-jasmine'), + require('karma-chrome-launcher'), + require('karma-jasmine-html-reporter'), + require('karma-coverage'), + require('@angular-devkit/build-angular/plugins/karma') + ], + client: { + clearContext: false + }, + jasmineHtmlReporter: { + suppressAll: true + }, + coverageReporter: { + dir: join(__dirname, './coverage/stellaops-web'), + subdir: '.', + reporters: [ + { type: 'html' }, + { type: 'text-summary' } + ] + }, + reporters: ['progress', 'kjhtml'], + port: 9876, + colors: true, + logLevel: config.LOG_INFO, + browsers: [isCI ? 'ChromeHeadlessCI' : 'ChromeHeadless'], + customLaunchers: { + ChromeHeadlessCI: { + base: 'ChromeHeadless', + flags: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'] + } + }, + restartOnFileChange: false + }); +}; diff --git a/src/StellaOps.Web/package.json b/src/StellaOps.Web/package.json index 0d9a9518..2b42c5c8 100644 --- a/src/StellaOps.Web/package.json +++ b/src/StellaOps.Web/package.json @@ -1,13 +1,14 @@ { "name": "stellaops-web", "version": "0.0.0", - "scripts": { - "ng": "ng", - "start": "ng serve", - "build": "ng build", - "watch": "ng build --watch --configuration development", - "test": "ng test" - }, + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "test": "ng test --watch=false", + "test:watch": "ng test --watch" + }, "private": true, "dependencies": { "@angular/animations": "^17.3.0", diff --git a/src/StellaOps.Web/src/app/features/trivy-db-settings/trivy-db-settings-page.component.ts b/src/StellaOps.Web/src/app/features/trivy-db-settings/trivy-db-settings-page.component.ts index 6b7b3292..ef8f6b82 100644 --- a/src/StellaOps.Web/src/app/features/trivy-db-settings/trivy-db-settings-page.component.ts +++ b/src/StellaOps.Web/src/app/features/trivy-db-settings/trivy-db-settings-page.component.ts @@ -7,8 +7,7 @@ import { signal, } from '@angular/core'; import { - FormBuilder, - FormGroup, + NonNullableFormBuilder, ReactiveFormsModule, } from '@angular/forms'; import { firstValueFrom } from 'rxjs'; @@ -28,19 +27,21 @@ type StatusKind = 'idle' | 'loading' | 'saving' | 'running' | 'success' | 'error styleUrls: ['./trivy-db-settings-page.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) +type TrivyDbSettingsFormValue = TrivyDbSettingsDto; + export class TrivyDbSettingsPageComponent implements OnInit { private readonly client = inject(ConcelierExporterClient); - private readonly formBuilder = inject(FormBuilder); + private readonly formBuilder = inject(NonNullableFormBuilder); readonly status = signal('idle'); readonly message = signal(null); readonly lastRun = signal(null); - readonly form: FormGroup = this.formBuilder.group({ - publishFull: [true], - publishDelta: [true], - includeFull: [true], - includeDelta: [true], + readonly form = this.formBuilder.group({ + publishFull: true, + publishDelta: true, + includeFull: true, + includeDelta: true, }); ngOnInit(): void { @@ -52,7 +53,7 @@ export class TrivyDbSettingsPageComponent implements OnInit { this.message.set(null); try { - const settings = await firstValueFrom( + const settings: TrivyDbSettingsDto = await firstValueFrom( this.client.getTrivyDbSettings() ); this.form.patchValue(settings); @@ -73,7 +74,7 @@ export class TrivyDbSettingsPageComponent implements OnInit { try { const payload = this.buildPayload(); - const updated = await firstValueFrom( + const updated: TrivyDbSettingsDto = await firstValueFrom( this.client.updateTrivyDbSettings(payload) ); this.form.patchValue(updated); @@ -98,7 +99,7 @@ export class TrivyDbSettingsPageComponent implements OnInit { // Persist overrides before triggering a run, ensuring parity. await firstValueFrom(this.client.updateTrivyDbSettings(payload)); - const response = await firstValueFrom( + const response: TrivyDbRunResponseDto = await firstValueFrom( this.client.runTrivyDbExport(payload) ); @@ -124,7 +125,7 @@ export class TrivyDbSettingsPageComponent implements OnInit { } private buildPayload(): TrivyDbSettingsDto { - const raw = this.form.getRawValue() as TrivyDbSettingsDto; + const raw = this.form.getRawValue(); return { publishFull: !!raw.publishFull, publishDelta: !!raw.publishDelta,