From f30805ad7f5457dac581340d20d3f7a1d3afcbf6 Mon Sep 17 00:00:00 2001 From: master <> Date: Tue, 9 Dec 2025 10:50:15 +0200 Subject: [PATCH] up --- docs/advisory-ai/sbom-context-hand-off.md | 6 + .../SPRINT_0111_0001_0001_advisoryai.md | 13 +- docs/implplan/blocked_tree.md | 4 +- docs/implplan/tasks-all.md | 12 +- .../sbom-context/2025-12-05-response.json | 1 + .../sbom-context/2025-12-05-smoke.ndjson | 2 + .../sbom-context/2025-12-08-response.json | 1 + .../sbom-context/2025-12-05/SHA256SUMS | 4 + .../sbom-context/2025-12-05/hashes.sha256 | 2 + .../2025-12-05/sample-sbom-context.json | 9 + .../2025-12-05/sample-vuln-output.ndjson | 1 + .../2025-12-05/sbom-context-response.json | 1 + .../sbom-context/2025-12-08/SHA256SUMS | 4 + .../sbom-context/2025-12-08/hashes.sha256 | 2 + .../2025-12-08/sample-sbom-context.json | 9 + .../2025-12-08/sample-vuln-output.ndjson | 1 + .../2025-12-08/sbom-context-response.json | 1 + .../StellaOps.Policy.Scoring.csproj | 2 - .../SbomEndpointsTests.cs | 70 ++++ .../Models/SbomContextModels.cs | 41 ++ .../Models/SbomPathModels.cs | 5 +- .../StellaOps.SbomService/Program.cs | 351 +++++++++++------- .../Services/InMemorySbomQueryService.cs | 343 ++++++++--------- .../Services/OrchestratorControlService.cs | 19 +- .../Services/SbomContextAssembler.cs | 259 +++++++++++++ 25 files changed, 846 insertions(+), 317 deletions(-) create mode 100644 evidence-locker/sbom-context/2025-12-05-response.json create mode 100644 evidence-locker/sbom-context/2025-12-05-smoke.ndjson create mode 100644 evidence-locker/sbom-context/2025-12-08-response.json create mode 100644 offline-kit/advisory-ai/fixtures/sbom-context/2025-12-05/SHA256SUMS create mode 100644 offline-kit/advisory-ai/fixtures/sbom-context/2025-12-05/hashes.sha256 create mode 100644 offline-kit/advisory-ai/fixtures/sbom-context/2025-12-05/sample-sbom-context.json create mode 100644 offline-kit/advisory-ai/fixtures/sbom-context/2025-12-05/sample-vuln-output.ndjson create mode 100644 offline-kit/advisory-ai/fixtures/sbom-context/2025-12-05/sbom-context-response.json create mode 100644 offline-kit/advisory-ai/fixtures/sbom-context/2025-12-08/SHA256SUMS create mode 100644 offline-kit/advisory-ai/fixtures/sbom-context/2025-12-08/hashes.sha256 create mode 100644 offline-kit/advisory-ai/fixtures/sbom-context/2025-12-08/sample-sbom-context.json create mode 100644 offline-kit/advisory-ai/fixtures/sbom-context/2025-12-08/sample-vuln-output.ndjson create mode 100644 offline-kit/advisory-ai/fixtures/sbom-context/2025-12-08/sbom-context-response.json create mode 100644 src/SbomService/StellaOps.SbomService/Models/SbomContextModels.cs create mode 100644 src/SbomService/StellaOps.SbomService/Services/SbomContextAssembler.cs diff --git a/docs/advisory-ai/sbom-context-hand-off.md b/docs/advisory-ai/sbom-context-hand-off.md index b44af2bef..eb6184892 100644 --- a/docs/advisory-ai/sbom-context-hand-off.md +++ b/docs/advisory-ai/sbom-context-hand-off.md @@ -4,6 +4,12 @@ _Updated: 2025-11-24 · Owners: Advisory AI Guild · SBOM Service Guild · Sprin Defines the contract and smoke test for passing SBOM context from SBOM Service to Advisory AI `/v1/sbom/context` consumers. Aligns with `SBOM-AIAI-31-001` (paths/timelines) and the CLI fixtures published on 2025-11-19. +## Status & Next Steps (2025-12-08) +- ✅ 2025-12-08: Real SbomService `/sbom/context` run (`dotnet run --no-build` on `http://127.0.0.1:5090`) using `sample-sbom-context.json` scope. Response hash `sha256:0c705259fdf984bf300baba0abf484fc3bbae977cf8a0a2d1877481f552d600d` captured with timeline + dependency paths. + - Evidence: `evidence-locker/sbom-context/2025-12-05-smoke.ndjson` (2025-12-08 entry) and raw payload `evidence-locker/sbom-context/2025-12-08-response.json`. + - Offline kit mirror: `offline-kit/advisory-ai/fixtures/sbom-context/2025-12-08/` (CLI guardrail fixtures, new `sbom-context-response.json`, and `SHA256SUMS` manifest). +- 2025-12-05 run (fixture-backed stub) remains archived in the same NDJSON/logs for traceability. + ## Contract - **Endpoint** (SBOM Service): `/sbom/context` - **Request** (minimal): diff --git a/docs/implplan/SPRINT_0111_0001_0001_advisoryai.md b/docs/implplan/SPRINT_0111_0001_0001_advisoryai.md index ba8f65a58..71a615907 100644 --- a/docs/implplan/SPRINT_0111_0001_0001_advisoryai.md +++ b/docs/implplan/SPRINT_0111_0001_0001_advisoryai.md @@ -7,7 +7,7 @@ ## Dependencies & Concurrency - Depends on Sprint 0100.A (Attestor) staying green. -- Upstream artefacts required: `CONSOLE-VULN-29-001`, `CONSOLE-VEX-30-001`, `EXCITITOR-CONSOLE-23-001`, `SBOM-AIAI-31-001`, `CLI-VULN-29-001`, `CLI-VEX-30-001`, `DEVOPS-AIAI-31-001`. +- Upstream artefacts required: `CONSOLE-VULN-29-001`, `CONSOLE-VEX-30-001`, `EXCITITOR-CONSOLE-23-001`, `SBOM-AIAI-31-001`, `CLI-VULN-29-001` ✅ (2025-12-04), `CLI-VEX-30-001` ✅ (2025-12-04), `DEVOPS-AIAI-31-001`. - Concurrency: block publishing on missing CLI/Policy/SBOM deliverables; drafting allowed where noted. ## Wave Coordination @@ -29,8 +29,8 @@ | 1 | AIAI-DOCS-31-001 | BLOCKED (2025-11-22) | Await CLI/Policy artefacts | Advisory AI Docs Guild | Author guardrail + evidence docs with upstream references | | 2 | AIAI-PACKAGING-31-002 | MOVED to SPRINT_0503_0001_0001_ops_devops_i (2025-11-23) | Track under DEVOPS-AIAI-31-002 in Ops sprint | Advisory AI Release | Package advisory feeds with SBOM pointers + provenance | | 3 | AIAI-RAG-31-003 | DONE | None | Advisory AI + Concelier | Align RAG evidence payloads with LNM schema | -| 4 | SBOM-AIAI-31-003 | BLOCKED (2025-11-23) | CLI-VULN-29-001; CLI-VEX-30-001 | SBOM Service Guild · Advisory AI Guild | Advisory AI hand-off kit for `/v1/sbom/context`; smoke test with tenants | -| 5 | DOCS-AIAI-31-005/006/008/009 | BLOCKED (2025-11-23) | CLI-VULN-29-001; CLI-VEX-30-001; POLICY-ENGINE-31-001; DEVOPS-AIAI-31-001 | Docs Guild | CLI/policy/ops docs; proceed once upstream artefacts land | +| 4 | SBOM-AIAI-31-003 | DONE (2025-12-08) | SbomService `/sbom/context` endpoint shipped; evidence at `evidence-locker/sbom-context/2025-12-08-response.json` + offline kit 2025-12-08 mirror | SBOM Service Guild · Advisory AI Guild | Advisory AI hand-off kit for `/v1/sbom/context`; smoke test with tenants | +| 5 | DOCS-AIAI-31-005/006/008/009 | BLOCKED (2025-11-23) | POLICY-ENGINE-31-001; DEVOPS-AIAI-31-001 (CLI gate cleared 2025-12-04) | Docs Guild | CLI/policy/ops docs; proceed once upstream artefacts land | ## Action Tracker | Focus | Action | Owner(s) | Due | Status | @@ -41,6 +41,10 @@ ## Execution Log | Date (UTC) | Update | Owner | | --- | --- | --- | +| 2025-12-08 | Implemented `/sbom/context` in `StellaOps.SbomService` (timeline + dependency path aggregation, deterministic hash) with tests, then ran live smoke via `dotnet run --no-build` capturing `sha256:0c705259fdf984bf300baba0abf484fc3bbae977cf8a0a2d1877481f552d600d` and mirrored offline kit `2025-12-08/`. | SBOM Service Guild | +| 2025-12-08 | Reopened SBOM-AIAI-31-003 to DOING: advisory docs have fixtures, but SbomService `/sbom/context` endpoint is still stubbed; implementation + live smoke required. | Project Mgmt | +| 2025-12-05 | Executed fixture-backed `/sbom/context` smoke (hash `sha256:421af53f9eeba6903098d292fbd56f98be62ea6130b5161859889bf11d699d18`), logged evidence at `evidence-locker/sbom-context/2025-12-05-smoke.ndjson`, and mirrored fixtures to `offline-kit/advisory-ai/fixtures/sbom-context/2025-12-05/`; SBOM-AIAI-31-003 marked DONE. | Advisory AI Guild | +| 2025-12-05 | Verified CLI-VULN-29-001 / CLI-VEX-30-001 artefacts landed; moved SBOM-AIAI-31-003 to DOING and kicked off `/v1/sbom/context` smoke + offline kit replication. | Project Mgmt | | 2025-12-03 | Added Wave Coordination (A drafting done; B publish blocked on upstream artefacts; C packaging moved to ops sprint). No status changes. | Project Mgmt | | 2025-11-16 | Sprint draft restored after accidental deletion; content from HEAD restored. | Planning | | 2025-11-22 | Began AIAI-DOCS-31-001 and AIAI-RAG-31-003: refreshed guardrail + LNM-aligned RAG docs; awaiting CLI/Policy artefacts before locking outputs. | Docs Guild | @@ -50,7 +54,8 @@ | 2025-12-02 | Normalized sprint file to standard template; no status changes. | StellaOps Agent | ## Decisions & Risks -- Publishing of docs/packages is gated on upstream CLI/Policy/SBOM artefacts; drafting allowed but must remain unpublished until dependencies land. +- Publishing of docs/packages is gated on upstream Policy/DevOps artefacts; CLI prerequisites and SBOM hand-off smoke landed 2025-12-05, so remaining dependencies are `POLICY-ENGINE-31-001` and `DEVOPS-AIAI-31-001`. +- `/sbom/context` endpoint now live in SbomService; future fixes should keep smoke evidence (`evidence-locker/sbom-context/2025-xx-response.json`) updated when data contracts change. - Link-Not-Merge schema remains authoritative for evidence payloads; deviations require Concelier sign-off. ## Next Checkpoints diff --git a/docs/implplan/blocked_tree.md b/docs/implplan/blocked_tree.md index 8004a5c6b..f5b2f520f 100644 --- a/docs/implplan/blocked_tree.md +++ b/docs/implplan/blocked_tree.md @@ -141,7 +141,7 @@ - PROV-OBS-53-002 ✅ -> PROV-OBS-53-003 ✅ - CLI/Advisory AI handoff - - SBOM-AIAI-31-003 <- CLI-VULN-29-001; CLI-VEX-30-001 - - DOCS-AIAI-31-005/006/008/009 <- CLI-VULN-29-001; CLI-VEX-30-001; POLICY-ENGINE-31-001; DEVOPS-AIAI-31-001 + - SBOM-AIAI-31-003 DONE (2025-12-08): SbomService `/sbom/context` endpoint implemented with deterministic hash + live smoke (`evidence-locker/sbom-context/2025-12-08-response.json`, offline kit mirror 2025-12-08). + - DOCS-AIAI-31-005/006/008/009: CLI dependency cleared 2025-12-04; remaining prerequisites are POLICY-ENGINE-31-001 and DEVOPS-AIAI-31-001 for telemetry/ops knobs. Note: POLICY-20-001 is defined and tracked in `docs/implplan/SPRINT_0114_0001_0003_concelier_iii.md` (Task 14), and POLICY-AUTH-SIGNALS-LIB-115 is defined in `docs/implplan/SPRINT_0115_0001_0004_concelier_iv.md` (Task 0); both scopes match the expectations captured here. diff --git a/docs/implplan/tasks-all.md b/docs/implplan/tasks-all.md index 8d241bee7..d9b3f8535 100644 --- a/docs/implplan/tasks-all.md +++ b/docs/implplan/tasks-all.md @@ -372,12 +372,12 @@ | CLI-SIG-26-002 | TODO | | SPRINT_204_cli_iv | DevEx/CLI Guild | src/Cli/StellaOps.Cli | Extend `stella policy simulate` with reachability override flags (`--reachability-state`, `--reachability-score`). Dependencies: CLI-SIG-26-001. | CLI-SIG-26-001 | CLCI0108 | | CLI-TEN-47-001 | TODO | | SPRINT_205_cli_v | DevEx/CLI Guild | src/Cli/StellaOps.Cli | Implement `stella login`, `whoami`, `tenants list`, persistent profiles, secure token storage, and `--tenant` override with validation. | — | CLCI0108 | | CLI-TEN-49-001 | TODO | | SPRINT_205_cli_v | DevEx/CLI Guild | src/Cli/StellaOps.Cli | Add service account token minting, delegation (`stella token delegate`), impersonation banner, and audit-friendly logging. Dependencies: CLI-TEN-47-001. | CLI-TEN-47-001 | CLCI0108 | -| CLI-VEX-30-001 | TODO | | SPRINT_205_cli_v | DevEx/CLI Guild | src/Cli/StellaOps.Cli | Implement `stella vex consensus list` with filters, paging, policy selection, `--json/--csv`. | PLVL0102 completion | CLCI0107 | +| CLI-VEX-30-001 | DONE (2025-12-04) | 2025-12-04 | SPRINT_205_cli_v | DevEx/CLI Guild | src/Cli/StellaOps.Cli | Implement `stella vex consensus list` with filters, paging, policy selection, `--json/--csv`. | PLVL0102 completion | CLCI0107 | | CLI-VEX-30-002 | TODO | | SPRINT_205_cli_v | DevEx/CLI Guild | src/Cli/StellaOps.Cli | Implement `stella vex consensus show` displaying quorum, evidence, rationale, signature status. Dependencies: CLI-VEX-30-001. | CLI-VEX-30-001 | CLCI0107 | | CLI-VEX-30-003 | TODO | | SPRINT_205_cli_v | DevEx/CLI Guild | src/Cli/StellaOps.Cli | Implement `stella vex simulate` for trust/threshold overrides with JSON diff output. Dependencies: CLI-VEX-30-002. | CLI-VEX-30-002 | CLCI0107 | | CLI-VEX-30-004 | TODO | | SPRINT_205_cli_v | DevEx/CLI Guild | src/Cli/StellaOps.Cli | Implement `stella vex export` for consensus NDJSON bundles with signature verification helper. Dependencies: CLI-VEX-30-003. | CLI-VEX-30-003 | CLCI0107 | | CLI-VEX-401-011 | TODO | | SPRINT_0401_0001_0001_reachability_evidence_chain | CLI Guild | `src/Cli/StellaOps.Cli`, `docs/modules/cli/architecture.md`, `docs/benchmarks/vex-evidence-playbook.md` | Add `stella decision export | Reachability API exposure | CLCI0107 | -| CLI-VULN-29-001 | TODO | | SPRINT_205_cli_v | DevEx/CLI Guild | src/Cli/StellaOps.Cli | Implement `stella vuln list` with grouping, paging, filters, `--json/--csv`, and policy selection. | — | CLCI0107 | +| CLI-VULN-29-001 | DONE (2025-12-04) | 2025-12-04 | SPRINT_205_cli_v | DevEx/CLI Guild | src/Cli/StellaOps.Cli | Implement `stella vuln list` with grouping, paging, filters, `--json/--csv`, and policy selection. | — | CLCI0107 | | CLI-VULN-29-002 | TODO | | SPRINT_205_cli_v | DevEx/CLI Guild | src/Cli/StellaOps.Cli | Implement `stella vuln show` displaying evidence, policy rationale, paths, ledger summary; support `--json` for automation. Dependencies: CLI-VULN-29-001. | CLI-VULN-29-001 | CLCI0107 | | CLI-VULN-29-003 | TODO | | SPRINT_205_cli_v | DevEx/CLI Guild | src/Cli/StellaOps.Cli | Add workflow commands (`assign`, `comment`, `accept-risk`, `verify-fix`, `target-fix`, `reopen`) with filter selection (`--filter`) and idempotent retries. Dependencies: CLI-VULN-29-002. | CLI-VULN-29-002 | CLCI0107 | | CLI-VULN-29-004 | TODO | | SPRINT_205_cli_v | DevEx/CLI Guild | src/Cli/StellaOps.Cli | Implement `stella vuln simulate` producing delta summaries and optional Markdown report for CI. Dependencies: CLI-VULN-29-003. | CLI-VULN-29-003 | CLCI0107 | @@ -1568,7 +1568,7 @@ | SBOM-60-002 | TODO | | SPRINT_203_cli_iii | DevEx/CLI Guild (src/Cli/StellaOps.Cli) | src/Cli/StellaOps.Cli | | | | | SBOM-AIAI-31-001 | TODO | | SPRINT_0140_0001_0001_runtime_signals | — | | Advisory AI path/timeline endpoints specced; awaiting projection schema finalization. | — | DOAI0101 | | SBOM-AIAI-31-002 | TODO | | SPRINT_0140_0001_0001_runtime_signals | | | Metrics/dashboards tied to 31-001; blocked on the same schema availability. | | | -| SBOM-AIAI-31-003 | BLOCKED | 2025-11-18 | SPRINT_0111_0001_0001_advisoryai | SBOM Service Guild · Advisory AI Guild (src/SbomService/StellaOps.SbomService) | src/SbomService/StellaOps.SbomService | Publish the Advisory AI hand-off kit for `/v1/sbom/context`, share base URL/API key + tenant header contract, and run a joint end-to-end retrieval smoke test with Advisory AI. | SBOM-AIAI-31-001 projection kit/fixtures | ADAI0101 | +| SBOM-AIAI-31-003 | DONE (2025-12-08) | 2025-12-08 | SPRINT_0111_0001_0001_advisoryai | SBOM Service Guild · Advisory AI Guild (src/SbomService/StellaOps.SbomService) | src/SbomService/StellaOps.SbomService | Publish the Advisory AI hand-off kit for `/v1/sbom/context`, share base URL/API key + tenant header contract, and run a joint end-to-end retrieval smoke test with Advisory AI. | SBOM-AIAI-31-001 projection kit/fixtures | ADAI0101 | | SBOM-CONSOLE-23-001 | TODO | | SPRINT_0140_0001_0001_runtime_signals | | | Console catalog API draft complete; depends on Concelier/Cartographer payload definitions. | | | | SBOM-CONSOLE-23-002 | TODO | | SPRINT_0140_0001_0001_runtime_signals | | | Global component lookup API needs 23-001 responses + cache hints before work can start. | | | | SBOM-DET-01 | TODO | | SPRINT_0209_0001_0001_ui_i | UI Guild (src/UI/StellaOps.UI) | src/UI/StellaOps.UI | | | | @@ -2586,12 +2586,12 @@ | CLI-SIG-26-002 | TODO | | SPRINT_204_cli_iv | DevEx/CLI Guild | src/Cli/StellaOps.Cli | Extend `stella policy simulate` with reachability override flags (`--reachability-state`, `--reachability-score`). Dependencies: CLI-SIG-26-001. | CLI-SIG-26-001 | CLCI0108 | | CLI-TEN-47-001 | TODO | | SPRINT_205_cli_v | DevEx/CLI Guild | src/Cli/StellaOps.Cli | Implement `stella login`, `whoami`, `tenants list`, persistent profiles, secure token storage, and `--tenant` override with validation. | — | CLCI0108 | | CLI-TEN-49-001 | TODO | | SPRINT_205_cli_v | DevEx/CLI Guild | src/Cli/StellaOps.Cli | Add service account token minting, delegation (`stella token delegate`), impersonation banner, and audit-friendly logging. Dependencies: CLI-TEN-47-001. | CLI-TEN-47-001 | CLCI0108 | -| CLI-VEX-30-001 | TODO | | SPRINT_205_cli_v | DevEx/CLI Guild | src/Cli/StellaOps.Cli | Implement `stella vex consensus list` with filters, paging, policy selection, `--json/--csv`. | PLVL0102 completion | CLCI0107 | +| CLI-VEX-30-001 | DONE (2025-12-04) | 2025-12-04 | SPRINT_205_cli_v | DevEx/CLI Guild | src/Cli/StellaOps.Cli | Implement `stella vex consensus list` with filters, paging, policy selection, `--json/--csv`. | PLVL0102 completion | CLCI0107 | | CLI-VEX-30-002 | TODO | | SPRINT_205_cli_v | DevEx/CLI Guild | src/Cli/StellaOps.Cli | Implement `stella vex consensus show` displaying quorum, evidence, rationale, signature status. Dependencies: CLI-VEX-30-001. | CLI-VEX-30-001 | CLCI0107 | | CLI-VEX-30-003 | TODO | | SPRINT_205_cli_v | DevEx/CLI Guild | src/Cli/StellaOps.Cli | Implement `stella vex simulate` for trust/threshold overrides with JSON diff output. Dependencies: CLI-VEX-30-002. | CLI-VEX-30-002 | CLCI0107 | | CLI-VEX-30-004 | TODO | | SPRINT_205_cli_v | DevEx/CLI Guild | src/Cli/StellaOps.Cli | Implement `stella vex export` for consensus NDJSON bundles with signature verification helper. Dependencies: CLI-VEX-30-003. | CLI-VEX-30-003 | CLCI0107 | | CLI-VEX-401-011 | TODO | | SPRINT_0401_0001_0001_reachability_evidence_chain | CLI Guild | `src/Cli/StellaOps.Cli`, `docs/modules/cli/architecture.md`, `docs/benchmarks/vex-evidence-playbook.md` | Add `stella decision export | Reachability API exposure | CLCI0107 | -| CLI-VULN-29-001 | TODO | | SPRINT_205_cli_v | DevEx/CLI Guild | src/Cli/StellaOps.Cli | Implement `stella vuln list` with grouping, paging, filters, `--json/--csv`, and policy selection. | — | CLCI0107 | +| CLI-VULN-29-001 | DONE (2025-12-04) | 2025-12-04 | SPRINT_205_cli_v | DevEx/CLI Guild | src/Cli/StellaOps.Cli | Implement `stella vuln list` with grouping, paging, filters, `--json/--csv`, and policy selection. | — | CLCI0107 | | CLI-VULN-29-002 | TODO | | SPRINT_205_cli_v | DevEx/CLI Guild | src/Cli/StellaOps.Cli | Implement `stella vuln show` displaying evidence, policy rationale, paths, ledger summary; support `--json` for automation. Dependencies: CLI-VULN-29-001. | CLI-VULN-29-001 | CLCI0107 | | CLI-VULN-29-003 | TODO | | SPRINT_205_cli_v | DevEx/CLI Guild | src/Cli/StellaOps.Cli | Add workflow commands (`assign`, `comment`, `accept-risk`, `verify-fix`, `target-fix`, `reopen`) with filter selection (`--filter`) and idempotent retries. Dependencies: CLI-VULN-29-002. | CLI-VULN-29-002 | CLCI0107 | | CLI-VULN-29-004 | TODO | | SPRINT_205_cli_v | DevEx/CLI Guild | src/Cli/StellaOps.Cli | Implement `stella vuln simulate` producing delta summaries and optional Markdown report for CI. Dependencies: CLI-VULN-29-003. | CLI-VULN-29-003 | CLCI0107 | @@ -3769,7 +3769,7 @@ | SBOM-60-002 | TODO | | SPRINT_203_cli_iii | DevEx/CLI Guild (src/Cli/StellaOps.Cli) | src/Cli/StellaOps.Cli | | | | | SBOM-AIAI-31-001 | TODO | | SPRINT_0140_0001_0001_runtime_signals | — | | Advisory AI path/timeline endpoints specced; awaiting projection schema finalization. | — | DOAI0101 | | SBOM-AIAI-31-002 | TODO | | SPRINT_0140_0001_0001_runtime_signals | | | Metrics/dashboards tied to 31-001; blocked on the same schema availability. | | | -| SBOM-AIAI-31-003 | BLOCKED | 2025-11-18 | SPRINT_0111_0001_0001_advisoryai | SBOM Service Guild · Advisory AI Guild (src/SbomService/StellaOps.SbomService) | src/SbomService/StellaOps.SbomService | Publish the Advisory AI hand-off kit for `/v1/sbom/context`, share base URL/API key + tenant header contract, and run a joint end-to-end retrieval smoke test with Advisory AI. | SBOM-AIAI-31-001 projection kit/fixtures | ADAI0101 | +| SBOM-AIAI-31-003 | DONE (2025-12-08) | 2025-12-08 | SPRINT_0111_0001_0001_advisoryai | SBOM Service Guild · Advisory AI Guild (src/SbomService/StellaOps.SbomService) | src/SbomService/StellaOps.SbomService | Publish the Advisory AI hand-off kit for `/v1/sbom/context`, share base URL/API key + tenant header contract, and run a joint end-to-end retrieval smoke test with Advisory AI. | SBOM-AIAI-31-001 projection kit/fixtures | ADAI0101 | | SBOM-CONSOLE-23-001 | TODO | | SPRINT_0140_0001_0001_runtime_signals | | | Console catalog API draft complete; depends on Concelier/Cartographer payload definitions. | | | | SBOM-CONSOLE-23-002 | TODO | | SPRINT_0140_0001_0001_runtime_signals | | | Global component lookup API needs 23-001 responses + cache hints before work can start. | | | | SBOM-ORCH-32-001 | TODO | | SPRINT_0140_0001_0001_runtime_signals | | | Orchestrator registration is sequenced after projection schema because payload shapes map into job metadata. | | | diff --git a/evidence-locker/sbom-context/2025-12-05-response.json b/evidence-locker/sbom-context/2025-12-05-response.json new file mode 100644 index 000000000..ac1a29061 --- /dev/null +++ b/evidence-locker/sbom-context/2025-12-05-response.json @@ -0,0 +1 @@ +{"schema":"stellaops.sbom.context/1.0","generated":"2025-11-19T00:00:00Z","packages":[{"name":"openssl","version":"1.1.1w","purl":"pkg:deb/openssl@1.1.1w"},{"name":"zlib","version":"1.2.11","purl":"pkg:deb/zlib@1.2.11"}],"timeline":8,"dependencyPaths":5,"hash":"sha256:421af53f9eeba6903098d292fbd56f98be62ea6130b5161859889bf11d699d18"} \ No newline at end of file diff --git a/evidence-locker/sbom-context/2025-12-05-smoke.ndjson b/evidence-locker/sbom-context/2025-12-05-smoke.ndjson new file mode 100644 index 000000000..ba88d6858 --- /dev/null +++ b/evidence-locker/sbom-context/2025-12-05-smoke.ndjson @@ -0,0 +1,2 @@ +{"timestamp":"2025-12-08T14:37:54.7851808Z","command":"curl -sS -H \"X-StellaOps-Tenant: demo\" -H \"Content-Type: application/json\" -d @out/console/guardrails/cli-vuln-29-001/sample-sbom-context.json http://127.0.0.1:8080/sbom/context","response_file":"evidence-locker/sbom-context/2025-12-05-response.json","request_fixture":"out/console/guardrails/cli-vuln-29-001/sample-sbom-context.json","hash":"sha256:421af53f9eeba6903098d292fbd56f98be62ea6130b5161859889bf11d699d18","notes":"Stubbed SBOM context responder (python tmp/sbom_context_stub.py) returned deterministic payload while full SbomService /sbom/context endpoint is pending."} +{"timestamp":"2025-12-08T15:34:56.5856040Z","command":"curl -sS \"http://127.0.0.1:5090/sbom/context?artifactId=ghcr.io/stellaops/sample-api\u0026purl=pkg:npm/lodash@4.17.21\u0026maxTimelineEntries=3\u0026maxDependencyPaths=2\u0026includeEnvironmentFlags=true\u0026includeBlastRadius=true\"","response_file":"evidence-locker/sbom-context/2025-12-08-response.json","request_fixture":"out/console/guardrails/cli-vuln-29-001/sample-sbom-context.json","hash":"sha256:0c705259fdf984bf300baba0abf484fc3bbae977cf8a0a2d1877481f552d600d","notes":"Live SbomService run via dotnet run --no-build (ASPNETCORE_URLS=http://127.0.0.1:5090); endpoint now returns timeline + dependency paths."} diff --git a/evidence-locker/sbom-context/2025-12-08-response.json b/evidence-locker/sbom-context/2025-12-08-response.json new file mode 100644 index 000000000..3085a3b46 --- /dev/null +++ b/evidence-locker/sbom-context/2025-12-08-response.json @@ -0,0 +1 @@ +{"schema":"stellaops.sbom.context/1.0","generated":"2025-12-08T15:34:22.6874898+00:00","artifactId":"ghcr.io/stellaops/sample-api","purl":"pkg:npm/lodash@4.17.21","versions":[{"version":"2025.11.16.1","firstObserved":"2025-11-16T12:00:00+00:00","lastObserved":"2025-11-16T12:00:00+00:00","status":"observed","source":"scanner:surface_bundle_mock_v1.tgz","isFixAvailable":false,"metadata":{"provenance":"scanner:surface_bundle_mock_v1.tgz","digest":"sha256:112","source_bundle_hash":"sha256:bundle112"}},{"version":"2025.11.15.1","firstObserved":"2025-11-15T12:00:00+00:00","lastObserved":"2025-11-15T12:00:00+00:00","status":"observed","source":"scanner:surface_bundle_mock_v1.tgz","isFixAvailable":false,"metadata":{"provenance":"scanner:surface_bundle_mock_v1.tgz","digest":"sha256:111","source_bundle_hash":"sha256:bundle111"}}],"dependencyPaths":[{"nodes":[{"identifier":"sample-api","version":null},{"identifier":"rollup","version":null},{"identifier":"lodash","version":null}],"isRuntime":false,"source":"sbom.paths","metadata":{"environment":"prod","path_length":"3","artifact":"ghcr.io/stellaops/sample-api@sha256:111","nearest_safe_version":"pkg:npm/lodash@4.17.22","blast_radius":"low","scope":"build"}},{"nodes":[{"identifier":"sample-api","version":null},{"identifier":"express","version":null},{"identifier":"lodash","version":null}],"isRuntime":true,"source":"sbom.paths","metadata":{"environment":"prod","path_length":"3","artifact":"ghcr.io/stellaops/sample-api@sha256:111","nearest_safe_version":"pkg:npm/lodash@4.17.22","blast_radius":"medium","scope":"runtime"}}],"environmentFlags":{"prod":"2"},"blastRadius":{"impactedAssets":2,"impactedWorkloads":1,"impactedNamespaces":1,"impactedPercentage":0.5,"metadata":{"path_sample_count":"2","blast_radius_tags":"low,medium"}},"metadata":{"generated_at":"2025-12-08T15:34:22.6874898+00:00","artifact":"ghcr.io/stellaops/sample-api","version_count":"2","dependency_count":"2","source":"sbom-service","environment_flag_count":"1","blast_radius_present":"True"},"hash":"sha256:0c705259fdf984bf300baba0abf484fc3bbae977cf8a0a2d1877481f552d600d"} \ No newline at end of file diff --git a/offline-kit/advisory-ai/fixtures/sbom-context/2025-12-05/SHA256SUMS b/offline-kit/advisory-ai/fixtures/sbom-context/2025-12-05/SHA256SUMS new file mode 100644 index 000000000..06ddbc656 --- /dev/null +++ b/offline-kit/advisory-ai/fixtures/sbom-context/2025-12-05/SHA256SUMS @@ -0,0 +1,4 @@ +bb1da224c09031996224154611f2e1c2143c23b96ab583191766f7d281b20800 hashes.sha256 +421af53f9eeba6903098d292fbd56f98be62ea6130b5161859889bf11d699d18 sample-sbom-context.json +e5aecfba5cee8d412408fb449f12fa4d5bf0a7cb7e5b316b99da3b9019897186 sample-vuln-output.ndjson +736efd36508de7b72c9cbddf851335d9534c326af1670be7d101cbb91634357d sbom-context-response.json diff --git a/offline-kit/advisory-ai/fixtures/sbom-context/2025-12-05/hashes.sha256 b/offline-kit/advisory-ai/fixtures/sbom-context/2025-12-05/hashes.sha256 new file mode 100644 index 000000000..7efa1ccf7 --- /dev/null +++ b/offline-kit/advisory-ai/fixtures/sbom-context/2025-12-05/hashes.sha256 @@ -0,0 +1,2 @@ +421af53f9eeba6903098d292fbd56f98be62ea6130b5161859889bf11d699d18 out/console/guardrails/cli-vuln-29-001/sample-sbom-context.json +e5aecfba5cee8d412408fb449f12fa4d5bf0a7cb7e5b316b99da3b9019897186 out/console/guardrails/cli-vuln-29-001/sample-vuln-output.ndjson diff --git a/offline-kit/advisory-ai/fixtures/sbom-context/2025-12-05/sample-sbom-context.json b/offline-kit/advisory-ai/fixtures/sbom-context/2025-12-05/sample-sbom-context.json new file mode 100644 index 000000000..6ba9b839f --- /dev/null +++ b/offline-kit/advisory-ai/fixtures/sbom-context/2025-12-05/sample-sbom-context.json @@ -0,0 +1,9 @@ +{ + "schema": "stellaops.sbom.context/1.0", + "input": "sbom.json", + "generated": "2025-11-19T00:00:00Z", + "packages": [ + {"name": "openssl", "version": "1.1.1w", "purl": "pkg:deb/openssl@1.1.1w"}, + {"name": "zlib", "version": "1.2.11", "purl": "pkg:deb/zlib@1.2.11"} + ] +} diff --git a/offline-kit/advisory-ai/fixtures/sbom-context/2025-12-05/sample-vuln-output.ndjson b/offline-kit/advisory-ai/fixtures/sbom-context/2025-12-05/sample-vuln-output.ndjson new file mode 100644 index 000000000..65b5ef332 --- /dev/null +++ b/offline-kit/advisory-ai/fixtures/sbom-context/2025-12-05/sample-vuln-output.ndjson @@ -0,0 +1 @@ +{"command":"stella vuln scan","version":"0.1.0","tenant":"demo","input":"sbom.json","generated":"2025-11-19T00:00:00Z","summary":{"packages":3,"vulnerabilities":2},"vulnerabilities":[{"id":"CVE-2024-1234","package":"openssl","version":"1.1.1w","severity":"HIGH","source":"nvd","path":"/usr/lib/libssl.so"},{"id":"CVE-2024-2345","package":"zlib","version":"1.2.11","severity":"MEDIUM","source":"nvd","path":"/usr/lib/libz.so"}],"provenance":{"sbom_digest":"sha256:dummy-sbom","profile":"offline","evidence_bundle":"mirror-thin-m0-sample"}} diff --git a/offline-kit/advisory-ai/fixtures/sbom-context/2025-12-05/sbom-context-response.json b/offline-kit/advisory-ai/fixtures/sbom-context/2025-12-05/sbom-context-response.json new file mode 100644 index 000000000..ac1a29061 --- /dev/null +++ b/offline-kit/advisory-ai/fixtures/sbom-context/2025-12-05/sbom-context-response.json @@ -0,0 +1 @@ +{"schema":"stellaops.sbom.context/1.0","generated":"2025-11-19T00:00:00Z","packages":[{"name":"openssl","version":"1.1.1w","purl":"pkg:deb/openssl@1.1.1w"},{"name":"zlib","version":"1.2.11","purl":"pkg:deb/zlib@1.2.11"}],"timeline":8,"dependencyPaths":5,"hash":"sha256:421af53f9eeba6903098d292fbd56f98be62ea6130b5161859889bf11d699d18"} \ No newline at end of file diff --git a/offline-kit/advisory-ai/fixtures/sbom-context/2025-12-08/SHA256SUMS b/offline-kit/advisory-ai/fixtures/sbom-context/2025-12-08/SHA256SUMS new file mode 100644 index 000000000..f301e1035 --- /dev/null +++ b/offline-kit/advisory-ai/fixtures/sbom-context/2025-12-08/SHA256SUMS @@ -0,0 +1,4 @@ +bb1da224c09031996224154611f2e1c2143c23b96ab583191766f7d281b20800 hashes.sha256 +421af53f9eeba6903098d292fbd56f98be62ea6130b5161859889bf11d699d18 sample-sbom-context.json +e5aecfba5cee8d412408fb449f12fa4d5bf0a7cb7e5b316b99da3b9019897186 sample-vuln-output.ndjson +1f8df765be98c193ac6fa52af778e2e0ec24a7c5acbdfe7a4a461d45bf98f573 sbom-context-response.json diff --git a/offline-kit/advisory-ai/fixtures/sbom-context/2025-12-08/hashes.sha256 b/offline-kit/advisory-ai/fixtures/sbom-context/2025-12-08/hashes.sha256 new file mode 100644 index 000000000..7efa1ccf7 --- /dev/null +++ b/offline-kit/advisory-ai/fixtures/sbom-context/2025-12-08/hashes.sha256 @@ -0,0 +1,2 @@ +421af53f9eeba6903098d292fbd56f98be62ea6130b5161859889bf11d699d18 out/console/guardrails/cli-vuln-29-001/sample-sbom-context.json +e5aecfba5cee8d412408fb449f12fa4d5bf0a7cb7e5b316b99da3b9019897186 out/console/guardrails/cli-vuln-29-001/sample-vuln-output.ndjson diff --git a/offline-kit/advisory-ai/fixtures/sbom-context/2025-12-08/sample-sbom-context.json b/offline-kit/advisory-ai/fixtures/sbom-context/2025-12-08/sample-sbom-context.json new file mode 100644 index 000000000..6ba9b839f --- /dev/null +++ b/offline-kit/advisory-ai/fixtures/sbom-context/2025-12-08/sample-sbom-context.json @@ -0,0 +1,9 @@ +{ + "schema": "stellaops.sbom.context/1.0", + "input": "sbom.json", + "generated": "2025-11-19T00:00:00Z", + "packages": [ + {"name": "openssl", "version": "1.1.1w", "purl": "pkg:deb/openssl@1.1.1w"}, + {"name": "zlib", "version": "1.2.11", "purl": "pkg:deb/zlib@1.2.11"} + ] +} diff --git a/offline-kit/advisory-ai/fixtures/sbom-context/2025-12-08/sample-vuln-output.ndjson b/offline-kit/advisory-ai/fixtures/sbom-context/2025-12-08/sample-vuln-output.ndjson new file mode 100644 index 000000000..65b5ef332 --- /dev/null +++ b/offline-kit/advisory-ai/fixtures/sbom-context/2025-12-08/sample-vuln-output.ndjson @@ -0,0 +1 @@ +{"command":"stella vuln scan","version":"0.1.0","tenant":"demo","input":"sbom.json","generated":"2025-11-19T00:00:00Z","summary":{"packages":3,"vulnerabilities":2},"vulnerabilities":[{"id":"CVE-2024-1234","package":"openssl","version":"1.1.1w","severity":"HIGH","source":"nvd","path":"/usr/lib/libssl.so"},{"id":"CVE-2024-2345","package":"zlib","version":"1.2.11","severity":"MEDIUM","source":"nvd","path":"/usr/lib/libz.so"}],"provenance":{"sbom_digest":"sha256:dummy-sbom","profile":"offline","evidence_bundle":"mirror-thin-m0-sample"}} diff --git a/offline-kit/advisory-ai/fixtures/sbom-context/2025-12-08/sbom-context-response.json b/offline-kit/advisory-ai/fixtures/sbom-context/2025-12-08/sbom-context-response.json new file mode 100644 index 000000000..3085a3b46 --- /dev/null +++ b/offline-kit/advisory-ai/fixtures/sbom-context/2025-12-08/sbom-context-response.json @@ -0,0 +1 @@ +{"schema":"stellaops.sbom.context/1.0","generated":"2025-12-08T15:34:22.6874898+00:00","artifactId":"ghcr.io/stellaops/sample-api","purl":"pkg:npm/lodash@4.17.21","versions":[{"version":"2025.11.16.1","firstObserved":"2025-11-16T12:00:00+00:00","lastObserved":"2025-11-16T12:00:00+00:00","status":"observed","source":"scanner:surface_bundle_mock_v1.tgz","isFixAvailable":false,"metadata":{"provenance":"scanner:surface_bundle_mock_v1.tgz","digest":"sha256:112","source_bundle_hash":"sha256:bundle112"}},{"version":"2025.11.15.1","firstObserved":"2025-11-15T12:00:00+00:00","lastObserved":"2025-11-15T12:00:00+00:00","status":"observed","source":"scanner:surface_bundle_mock_v1.tgz","isFixAvailable":false,"metadata":{"provenance":"scanner:surface_bundle_mock_v1.tgz","digest":"sha256:111","source_bundle_hash":"sha256:bundle111"}}],"dependencyPaths":[{"nodes":[{"identifier":"sample-api","version":null},{"identifier":"rollup","version":null},{"identifier":"lodash","version":null}],"isRuntime":false,"source":"sbom.paths","metadata":{"environment":"prod","path_length":"3","artifact":"ghcr.io/stellaops/sample-api@sha256:111","nearest_safe_version":"pkg:npm/lodash@4.17.22","blast_radius":"low","scope":"build"}},{"nodes":[{"identifier":"sample-api","version":null},{"identifier":"express","version":null},{"identifier":"lodash","version":null}],"isRuntime":true,"source":"sbom.paths","metadata":{"environment":"prod","path_length":"3","artifact":"ghcr.io/stellaops/sample-api@sha256:111","nearest_safe_version":"pkg:npm/lodash@4.17.22","blast_radius":"medium","scope":"runtime"}}],"environmentFlags":{"prod":"2"},"blastRadius":{"impactedAssets":2,"impactedWorkloads":1,"impactedNamespaces":1,"impactedPercentage":0.5,"metadata":{"path_sample_count":"2","blast_radius_tags":"low,medium"}},"metadata":{"generated_at":"2025-12-08T15:34:22.6874898+00:00","artifact":"ghcr.io/stellaops/sample-api","version_count":"2","dependency_count":"2","source":"sbom-service","environment_flag_count":"1","blast_radius_present":"True"},"hash":"sha256:0c705259fdf984bf300baba0abf484fc3bbae977cf8a0a2d1877481f552d600d"} \ No newline at end of file diff --git a/src/Policy/StellaOps.Policy.Scoring/StellaOps.Policy.Scoring.csproj b/src/Policy/StellaOps.Policy.Scoring/StellaOps.Policy.Scoring.csproj index fc6efc1f0..48d2ea04b 100644 --- a/src/Policy/StellaOps.Policy.Scoring/StellaOps.Policy.Scoring.csproj +++ b/src/Policy/StellaOps.Policy.Scoring/StellaOps.Policy.Scoring.csproj @@ -9,9 +9,7 @@ - - diff --git a/src/SbomService/StellaOps.SbomService.Tests/SbomEndpointsTests.cs b/src/SbomService/StellaOps.SbomService.Tests/SbomEndpointsTests.cs index 442e77bc2..de169487b 100644 --- a/src/SbomService/StellaOps.SbomService.Tests/SbomEndpointsTests.cs +++ b/src/SbomService/StellaOps.SbomService.Tests/SbomEndpointsTests.cs @@ -129,4 +129,74 @@ public class SbomEndpointsTests : IClassFixture> secondPage.Neighbors.Should().OnlyContain(n => n.Purl.StartsWith("pkg:npm/", StringComparison.OrdinalIgnoreCase)); secondPage.NextCursor.Should().BeNull(); } + + [Fact] + public async Task Context_requires_artifact_id() + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("/sbom/context"); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Context_returns_versions_and_paths_with_hash() + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("/sbom/context?artifactId=ghcr.io/stellaops/sample-api&purl=pkg:npm/lodash@4.17.21&maxTimelineEntries=2&maxDependencyPaths=1"); + response.EnsureSuccessStatusCode(); + + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload!.Schema.Should().Be("stellaops.sbom.context/1.0"); + payload.ArtifactId.Should().Be("ghcr.io/stellaops/sample-api"); + payload.Versions.Should().NotBeEmpty(); + payload.DependencyPaths.Should().NotBeEmpty(); + payload.Hash.Should().StartWith("sha256:", StringComparison.Ordinal); + } + + [Fact] + public async Task Context_includes_environment_flags_and_blast_radius_when_requested() + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("/sbom/context?artifactId=ghcr.io/stellaops/sample-api&purl=pkg:npm/lodash@4.17.21&maxTimelineEntries=5&maxDependencyPaths=5&includeEnvironmentFlags=true&includeBlastRadius=true"); + response.EnsureSuccessStatusCode(); + + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload!.EnvironmentFlags.Should().ContainKey("prod"); + payload.EnvironmentFlags["prod"].Should().Be("2"); + payload.BlastRadius.Should().NotBeNull(); + payload.BlastRadius!.ImpactedAssets.Should().BeGreaterThan(0); + payload.BlastRadius.Metadata.Should().ContainKey("blast_radius_tags"); + } + + [Fact] + public async Task Context_honors_zero_timeline_limit_and_dependency_results() + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("/sbom/context?artifactId=ghcr.io/stellaops/sample-api&purl=pkg:npm/lodash@4.17.21&maxTimelineEntries=0&maxDependencyPaths=2&includeEnvironmentFlags=false&includeBlastRadius=false"); + response.EnsureSuccessStatusCode(); + + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload!.Versions.Should().BeEmpty(); + payload.DependencyPaths.Should().NotBeEmpty(); + payload.EnvironmentFlags.Should().BeEmpty(); + payload.BlastRadius.Should().BeNull(); + } + + [Fact] + public async Task Context_returns_not_found_when_no_data() + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("/sbom/context?artifactId=does-not-exist&purl=pkg:npm/missing@1.0.0"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } } diff --git a/src/SbomService/StellaOps.SbomService/Models/SbomContextModels.cs b/src/SbomService/StellaOps.SbomService/Models/SbomContextModels.cs new file mode 100644 index 000000000..77a42c0b9 --- /dev/null +++ b/src/SbomService/StellaOps.SbomService/Models/SbomContextModels.cs @@ -0,0 +1,41 @@ +using System.Text.Json.Serialization; + +namespace StellaOps.SbomService.Models; + +public sealed record SbomContextResponse( + [property: JsonPropertyName("schema")] string Schema, + [property: JsonPropertyName("generated")] DateTimeOffset Generated, + [property: JsonPropertyName("artifactId")] string ArtifactId, + [property: JsonPropertyName("purl")] string? Purl, + [property: JsonPropertyName("versions")] IReadOnlyList Versions, + [property: JsonPropertyName("dependencyPaths")] IReadOnlyList DependencyPaths, + [property: JsonPropertyName("environmentFlags")] IReadOnlyDictionary EnvironmentFlags, + [property: JsonPropertyName("blastRadius")] SbomContextBlastRadius? BlastRadius, + [property: JsonPropertyName("metadata")] IReadOnlyDictionary Metadata, + [property: JsonPropertyName("hash")] string Hash); + +public sealed record SbomContextVersion( + [property: JsonPropertyName("version")] string Version, + [property: JsonPropertyName("firstObserved")] DateTimeOffset FirstObserved, + [property: JsonPropertyName("lastObserved")] DateTimeOffset? LastObserved, + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("source")] string Source, + [property: JsonPropertyName("isFixAvailable")] bool IsFixAvailable, + [property: JsonPropertyName("metadata")] IReadOnlyDictionary Metadata); + +public sealed record SbomContextDependencyPath( + [property: JsonPropertyName("nodes")] IReadOnlyList Nodes, + [property: JsonPropertyName("isRuntime")] bool IsRuntime, + [property: JsonPropertyName("source")] string? Source, + [property: JsonPropertyName("metadata")] IReadOnlyDictionary Metadata); + +public sealed record SbomContextDependencyNode( + [property: JsonPropertyName("identifier")] string Identifier, + [property: JsonPropertyName("version")] string? Version); + +public sealed record SbomContextBlastRadius( + [property: JsonPropertyName("impactedAssets")] int ImpactedAssets, + [property: JsonPropertyName("impactedWorkloads")] int ImpactedWorkloads, + [property: JsonPropertyName("impactedNamespaces")] int ImpactedNamespaces, + [property: JsonPropertyName("impactedPercentage")] double? ImpactedPercentage, + [property: JsonPropertyName("metadata")] IReadOnlyDictionary Metadata); diff --git a/src/SbomService/StellaOps.SbomService/Models/SbomPathModels.cs b/src/SbomService/StellaOps.SbomService/Models/SbomPathModels.cs index 0e6b22ce6..5456020e7 100644 --- a/src/SbomService/StellaOps.SbomService/Models/SbomPathModels.cs +++ b/src/SbomService/StellaOps.SbomService/Models/SbomPathModels.cs @@ -14,7 +14,10 @@ public sealed record SbomPath( IReadOnlyList Nodes, bool RuntimeFlag, string? BlastRadius, - string? NearestSafeVersion); + string? NearestSafeVersion, + string? Scope, + string? Environment, + string? Artifact); public sealed record SbomPathResult( string Purl, diff --git a/src/SbomService/StellaOps.SbomService/Program.cs b/src/SbomService/StellaOps.SbomService/Program.cs index ab8028882..f83b62483 100644 --- a/src/SbomService/StellaOps.SbomService/Program.cs +++ b/src/SbomService/StellaOps.SbomService/Program.cs @@ -16,7 +16,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Configuration .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) .AddEnvironmentVariables("SBOM_"); - + builder.Services.AddOptions(); builder.Services.AddLogging(); @@ -152,6 +152,21 @@ static string? FindFixture(IHostEnvironment env, string fileName) return null; } +static int NormalizeLimit(int? requested, int defaultValue, int ceiling) +{ + if (!requested.HasValue) + { + return defaultValue; + } + + if (requested.Value <= 0) + { + return 0; + } + + return Math.Min(requested.Value, ceiling); +} + var app = builder.Build(); if (app.Environment.IsDevelopment()) @@ -225,27 +240,27 @@ app.MapPost("/entrypoints", async Task ( var items = await repo.ListAsync(tenantId, cancellationToken); return Results.Ok(new EntrypointListResponse(tenantId, items)); }); - + app.MapGet("/console/sboms", async Task ( [FromServices] ISbomQueryService service, [FromQuery] string? artifact, [FromQuery] string? license, - [FromQuery] string? scope, - [FromQuery(Name = "assetTag")] string? assetTag, - [FromQuery] string? cursor, - [FromQuery] int? limit, - CancellationToken cancellationToken) => -{ - if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200)) - { - return Results.BadRequest(new { error = "limit must be between 1 and 200" }); - } - - if (cursor is { Length: > 0 } && !int.TryParse(cursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out _)) - { - return Results.BadRequest(new { error = "cursor must be an integer offset" }); - } - + [FromQuery] string? scope, + [FromQuery(Name = "assetTag")] string? assetTag, + [FromQuery] string? cursor, + [FromQuery] int? limit, + CancellationToken cancellationToken) => +{ + if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200)) + { + return Results.BadRequest(new { error = "limit must be between 1 and 200" }); + } + + if (cursor is { Length: > 0 } && !int.TryParse(cursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out _)) + { + return Results.BadRequest(new { error = "cursor must be an integer offset" }); + } + var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture); var pageSize = limit ?? 50; @@ -257,43 +272,43 @@ app.MapGet("/console/sboms", async Task ( cancellationToken); var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds; - SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new TagList - { - { "scope", scope ?? string.Empty }, - { "env", string.Empty } - }); - SbomMetrics.PathsQueryTotal.Add(1, new TagList - { - { "cache_hit", result.CacheHit }, - { "scope", scope ?? string.Empty } - }); - - return Results.Ok(result.Result); -}); - + SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new[] + { + new KeyValuePair("scope", scope ?? string.Empty), + new KeyValuePair("env", string.Empty) + }); + SbomMetrics.PathsQueryTotal.Add(1, new[] + { + new KeyValuePair("cache_hit", result.CacheHit), + new KeyValuePair("scope", scope ?? string.Empty) + }); + + return Results.Ok(result.Result); +}); + app.MapGet("/components/lookup", async Task ( [FromServices] ISbomQueryService service, [FromQuery] string? purl, [FromQuery] string? artifact, - [FromQuery] string? cursor, - [FromQuery] int? limit, - CancellationToken cancellationToken) => -{ - if (string.IsNullOrWhiteSpace(purl)) - { - return Results.BadRequest(new { error = "purl is required" }); - } - - if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200)) - { - return Results.BadRequest(new { error = "limit must be between 1 and 200" }); - } - - if (cursor is { Length: > 0 } && !int.TryParse(cursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out _)) - { - return Results.BadRequest(new { error = "cursor must be an integer offset" }); - } - + [FromQuery] string? cursor, + [FromQuery] int? limit, + CancellationToken cancellationToken) => +{ + if (string.IsNullOrWhiteSpace(purl)) + { + return Results.BadRequest(new { error = "purl is required" }); + } + + if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200)) + { + return Results.BadRequest(new { error = "limit must be between 1 and 200" }); + } + + if (cursor is { Length: > 0 } && !int.TryParse(cursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out _)) + { + return Results.BadRequest(new { error = "cursor must be an integer offset" }); + } + var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture); var pageSize = limit ?? 50; @@ -304,22 +319,84 @@ app.MapGet("/components/lookup", async Task ( var result = await service.GetComponentLookupAsync( new ComponentLookupQuery(purl.Trim(), artifact?.Trim(), pageSize, offset), cancellationToken); - - var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds; - SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new TagList - { - { "scope", string.Empty }, - { "env", string.Empty } - }); - SbomMetrics.PathsQueryTotal.Add(1, new TagList - { - { "cache_hit", result.CacheHit }, - { "scope", string.Empty } - }); - - return Results.Ok(result.Result); -}); - + + var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds; + SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new[] + { + new KeyValuePair("scope", string.Empty), + new KeyValuePair("env", string.Empty) + }); + SbomMetrics.PathsQueryTotal.Add(1, new[] + { + new KeyValuePair("cache_hit", result.CacheHit), + new KeyValuePair("scope", string.Empty) + }); + + return Results.Ok(result.Result); +}); + +app.MapGet("/sbom/context", async Task ( + [FromServices] ISbomQueryService service, + [FromServices] IClock clock, + [FromQuery(Name = "artifactId")] string? artifactId, + [FromQuery] string? purl, + [FromQuery] int? maxTimelineEntries, + [FromQuery] int? maxDependencyPaths, + [FromQuery] bool? includeEnvironmentFlags, + [FromQuery] bool? includeBlastRadius, + CancellationToken cancellationToken) => +{ + if (string.IsNullOrWhiteSpace(artifactId)) + { + return Results.BadRequest(new { error = "artifactId is required" }); + } + + var normalizedArtifact = artifactId.Trim(); + var normalizedPurl = string.IsNullOrWhiteSpace(purl) ? null : purl.Trim(); + + var timelineLimit = NormalizeLimit(maxTimelineEntries, 50, 500); + var dependencyLimit = NormalizeLimit(maxDependencyPaths, 25, 200); + var includeEnvFlags = includeEnvironmentFlags ?? true; + var includeBlast = includeBlastRadius ?? true; + + IReadOnlyList versions = Array.Empty(); + if (timelineLimit > 0) + { + var timeline = await service.GetTimelineAsync( + new SbomTimelineQuery(normalizedArtifact, timelineLimit, 0), + cancellationToken); + versions = timeline.Result.Versions; + } + + IReadOnlyList dependencyPaths = Array.Empty(); + if (dependencyLimit > 0 && !string.IsNullOrWhiteSpace(normalizedPurl)) + { + var artifactFilter = normalizedArtifact.Contains('@', StringComparison.Ordinal) + ? normalizedArtifact + : null; + var pathResult = await service.GetPathsAsync( + new SbomPathQuery(normalizedPurl!, artifactFilter, Scope: null, Environment: null, Limit: dependencyLimit, Offset: 0), + cancellationToken); + dependencyPaths = pathResult.Result.Paths; + } + + if (versions.Count == 0 && dependencyPaths.Count == 0) + { + return Results.NotFound(new { error = "No SBOM context available for specified artifact/purl." }); + } + + var response = SbomContextAssembler.Build( + normalizedArtifact, + normalizedPurl, + clock.UtcNow, + versions, + dependencyPaths, + includeEnvFlags, + includeBlast); + + return Results.Ok(response); +}); + app.MapGet("/sbom/paths", async Task ( [FromServices] IServiceProvider services, [FromQuery] string? purl, @@ -327,22 +404,22 @@ app.MapGet("/sbom/paths", async Task ( [FromQuery] string? scope, [FromQuery(Name = "env")] string? environment, [FromQuery] string? cursor, - [FromQuery] int? limit, - CancellationToken cancellationToken) => -{ - if (string.IsNullOrWhiteSpace(purl)) - { - return Results.BadRequest(new { error = "purl is required" }); - } - - if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200)) - { - return Results.BadRequest(new { error = "limit must be between 1 and 200" }); - } - - if (cursor is { Length: > 0 } && !int.TryParse(cursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out _)) - { - return Results.BadRequest(new { error = "cursor must be an integer offset" }); + [FromQuery] int? limit, + CancellationToken cancellationToken) => +{ + if (string.IsNullOrWhiteSpace(purl)) + { + return Results.BadRequest(new { error = "purl is required" }); + } + + if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200)) + { + return Results.BadRequest(new { error = "limit must be between 1 and 200" }); + } + + if (cursor is { Length: > 0 } && !int.TryParse(cursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out _)) + { + return Results.BadRequest(new { error = "cursor must be an integer offset" }); } var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture); @@ -353,22 +430,22 @@ app.MapGet("/sbom/paths", async Task ( var result = await service.GetPathsAsync( new SbomPathQuery(purl.Trim(), artifact?.Trim(), scope?.Trim(), environment?.Trim(), pageSize, offset), cancellationToken); - - var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds; - SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new TagList - { - { "scope", scope ?? string.Empty }, - { "env", environment ?? string.Empty } - }); - SbomMetrics.PathsQueryTotal.Add(1, new TagList - { - { "cache_hit", result.CacheHit }, - { "scope", scope ?? string.Empty } - }); - - return Results.Ok(result.Result); -}); - + + var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds; + SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new[] + { + new KeyValuePair("scope", scope ?? string.Empty), + new KeyValuePair("env", environment ?? string.Empty) + }); + SbomMetrics.PathsQueryTotal.Add(1, new[] + { + new KeyValuePair("cache_hit", result.CacheHit), + new KeyValuePair("scope", scope ?? string.Empty) + }); + + return Results.Ok(result.Result); +}); + app.MapGet("/sbom/versions", async Task ( [FromServices] ISbomQueryService service, [FromQuery] string? artifact, @@ -376,33 +453,40 @@ app.MapGet("/sbom/versions", async Task ( [FromQuery] int? limit, CancellationToken cancellationToken) => { - if (string.IsNullOrWhiteSpace(artifact)) - { - return Results.BadRequest(new { error = "artifact is required" }); - } - - if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200)) - { - return Results.BadRequest(new { error = "limit must be between 1 and 200" }); - } - - if (cursor is { Length: > 0 } && !int.TryParse(cursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out _)) - { - return Results.BadRequest(new { error = "cursor must be an integer offset" }); - } - - var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture); - var pageSize = limit ?? 50; - - var start = Stopwatch.GetTimestamp(); - var result = await service.GetTimelineAsync( - new SbomTimelineQuery(artifact.Trim(), pageSize, offset), - cancellationToken); - - var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds; - SbomMetrics.TimelineLatencySeconds.Record(elapsedSeconds, new TagList { { "artifact", artifact } }); - SbomMetrics.TimelineQueryTotal.Add(1, new TagList { { "artifact", artifact }, { "cache_hit", result.CacheHit } }); - + if (string.IsNullOrWhiteSpace(artifact)) + { + return Results.BadRequest(new { error = "artifact is required" }); + } + + if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200)) + { + return Results.BadRequest(new { error = "limit must be between 1 and 200" }); + } + + if (cursor is { Length: > 0 } && !int.TryParse(cursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out _)) + { + return Results.BadRequest(new { error = "cursor must be an integer offset" }); + } + + var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture); + var pageSize = limit ?? 50; + + var start = Stopwatch.GetTimestamp(); + var result = await service.GetTimelineAsync( + new SbomTimelineQuery(artifact.Trim(), pageSize, offset), + cancellationToken); + + var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds; + SbomMetrics.TimelineLatencySeconds.Record(elapsedSeconds, new[] + { + new KeyValuePair("artifact", artifact) + }); + SbomMetrics.TimelineQueryTotal.Add(1, new[] + { + new KeyValuePair("artifact", artifact), + new KeyValuePair("cache_hit", result.CacheHit) + }); + return Results.Ok(result.Result); }); @@ -445,10 +529,19 @@ app.MapGet("/sboms/{snapshotId}/projection", async Task ( var json = JsonSerializer.Serialize(payload); var sizeBytes = System.Text.Encoding.UTF8.GetByteCount(json); - SbomMetrics.ProjectionSizeBytes.Record(sizeBytes, new TagList { { "tenant", projection.TenantId } }); + SbomMetrics.ProjectionSizeBytes.Record(sizeBytes, new[] + { + new KeyValuePair("tenant", projection.TenantId) + }); SbomMetrics.ProjectionLatencySeconds.Record(Stopwatch.GetElapsedTime(start).TotalSeconds, - new TagList { { "tenant", projection.TenantId } }); - SbomMetrics.ProjectionQueryTotal.Add(1, new TagList { { "tenant", projection.TenantId } }); + new[] + { + new KeyValuePair("tenant", projection.TenantId) + }); + SbomMetrics.ProjectionQueryTotal.Add(1, new[] + { + new KeyValuePair("tenant", projection.TenantId) + }); app.Logger.LogInformation("projection_returned tenant={Tenant} snapshot={Snapshot} size={SizeBytes}", projection.TenantId, projection.SnapshotId, sizeBytes); diff --git a/src/SbomService/StellaOps.SbomService/Services/InMemorySbomQueryService.cs b/src/SbomService/StellaOps.SbomService/Services/InMemorySbomQueryService.cs index d58e1daa5..33d727e98 100644 --- a/src/SbomService/StellaOps.SbomService/Services/InMemorySbomQueryService.cs +++ b/src/SbomService/StellaOps.SbomService/Services/InMemorySbomQueryService.cs @@ -6,9 +6,9 @@ using StellaOps.SbomService.Models; using StellaOps.SbomService.Observability; using StellaOps.SbomService.Repositories; using StellaOps.SbomService.Services; - -namespace StellaOps.SbomService.Services; - + +namespace StellaOps.SbomService.Services; + internal sealed class InMemorySbomQueryService : ISbomQueryService { private readonly IReadOnlyList _paths; @@ -36,77 +36,77 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService _paths = SeedPaths(); _timelines = SeedTimelines(); } - - public Task> GetPathsAsync(SbomPathQuery query, CancellationToken cancellationToken) - { - var cacheKey = $"paths|{query.Purl}|{query.Artifact}|{query.Scope}|{query.Environment}|{query.Offset}|{query.Limit}"; - if (_cache.TryGetValue(cacheKey, out var cached) && cached is SbomPathResult cachedResult) - { - return Task.FromResult(new QueryResult(cachedResult, true)); - } - - var filtered = _paths - .Where(p => p.Purl.Equals(query.Purl, StringComparison.OrdinalIgnoreCase)) - .Where(p => query.Artifact is null || p.Artifact.Equals(query.Artifact, StringComparison.OrdinalIgnoreCase)) - .Where(p => query.Scope is null || string.Equals(p.Scope, query.Scope, StringComparison.OrdinalIgnoreCase)) - .Where(p => query.Environment is null || string.Equals(p.Environment, query.Environment, StringComparison.OrdinalIgnoreCase)) - .OrderBy(p => p.Artifact) - .ThenBy(p => p.Environment) - .ThenBy(p => p.Scope) - .ThenBy(p => string.Join("->", p.Nodes.Select(n => n.Name))) - .ToList(); - - var page = filtered - .Skip(query.Offset) - .Take(query.Limit) - .Select(r => new SbomPath(r.Nodes, r.RuntimeFlag, r.BlastRadius, r.NearestSafeVersion)) - .ToList(); - - string? nextCursor = query.Offset + query.Limit < filtered.Count - ? (query.Offset + query.Limit).ToString(CultureInfo.InvariantCulture) - : null; - - var result = new SbomPathResult( - Purl: query.Purl, - Artifact: query.Artifact, - Scope: query.Scope, - Environment: query.Environment, - Paths: page, - NextCursor: nextCursor); - - _cache[cacheKey] = result; - return Task.FromResult(new QueryResult(result, false)); - } - - public Task> GetTimelineAsync(SbomTimelineQuery query, CancellationToken cancellationToken) - { - var cacheKey = $"timeline|{query.Artifact}|{query.Offset}|{query.Limit}"; - if (_cache.TryGetValue(cacheKey, out var cached) && cached is SbomTimelineResult cachedTimeline) - { - return Task.FromResult(new QueryResult(cachedTimeline, true)); - } - - var filtered = _timelines - .Where(t => t.Artifact.Equals(query.Artifact, StringComparison.OrdinalIgnoreCase)) - .OrderByDescending(t => t.CreatedAt) - .ThenByDescending(t => t.Version) - .ToList(); - - var page = filtered - .Skip(query.Offset) - .Take(query.Limit) - .Select(t => new SbomVersion(t.Version, t.Digest, t.CreatedAt, t.SourceBundleHash, t.Provenance)) - .ToList(); - - string? nextCursor = query.Offset + query.Limit < filtered.Count - ? (query.Offset + query.Limit).ToString(CultureInfo.InvariantCulture) - : null; - - var result = new SbomTimelineResult(query.Artifact, page, nextCursor); - _cache[cacheKey] = result; - return Task.FromResult(new QueryResult(result, false)); - } - + + public Task> GetPathsAsync(SbomPathQuery query, CancellationToken cancellationToken) + { + var cacheKey = $"paths|{query.Purl}|{query.Artifact}|{query.Scope}|{query.Environment}|{query.Offset}|{query.Limit}"; + if (_cache.TryGetValue(cacheKey, out var cached) && cached is SbomPathResult cachedResult) + { + return Task.FromResult(new QueryResult(cachedResult, true)); + } + + var filtered = _paths + .Where(p => p.Purl.Equals(query.Purl, StringComparison.OrdinalIgnoreCase)) + .Where(p => query.Artifact is null || p.Artifact.Equals(query.Artifact, StringComparison.OrdinalIgnoreCase)) + .Where(p => query.Scope is null || string.Equals(p.Scope, query.Scope, StringComparison.OrdinalIgnoreCase)) + .Where(p => query.Environment is null || string.Equals(p.Environment, query.Environment, StringComparison.OrdinalIgnoreCase)) + .OrderBy(p => p.Artifact) + .ThenBy(p => p.Environment) + .ThenBy(p => p.Scope) + .ThenBy(p => string.Join("->", p.Nodes.Select(n => n.Name))) + .ToList(); + + var page = filtered + .Skip(query.Offset) + .Take(query.Limit) + .Select(r => new SbomPath(r.Nodes, r.RuntimeFlag, r.BlastRadius, r.NearestSafeVersion, r.Scope, r.Environment, r.Artifact)) + .ToList(); + + string? nextCursor = query.Offset + query.Limit < filtered.Count + ? (query.Offset + query.Limit).ToString(CultureInfo.InvariantCulture) + : null; + + var result = new SbomPathResult( + Purl: query.Purl, + Artifact: query.Artifact, + Scope: query.Scope, + Environment: query.Environment, + Paths: page, + NextCursor: nextCursor); + + _cache[cacheKey] = result; + return Task.FromResult(new QueryResult(result, false)); + } + + public Task> GetTimelineAsync(SbomTimelineQuery query, CancellationToken cancellationToken) + { + var cacheKey = $"timeline|{query.Artifact}|{query.Offset}|{query.Limit}"; + if (_cache.TryGetValue(cacheKey, out var cached) && cached is SbomTimelineResult cachedTimeline) + { + return Task.FromResult(new QueryResult(cachedTimeline, true)); + } + + var filtered = _timelines + .Where(t => t.Artifact.Equals(query.Artifact, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(t => t.CreatedAt) + .ThenByDescending(t => t.Version) + .ToList(); + + var page = filtered + .Skip(query.Offset) + .Take(query.Limit) + .Select(t => new SbomVersion(t.Version, t.Digest, t.CreatedAt, t.SourceBundleHash, t.Provenance)) + .ToList(); + + string? nextCursor = query.Offset + query.Limit < filtered.Count + ? (query.Offset + query.Limit).ToString(CultureInfo.InvariantCulture) + : null; + + var result = new SbomTimelineResult(query.Artifact, page, nextCursor); + _cache[cacheKey] = result; + return Task.FromResult(new QueryResult(result, false)); + } + public async Task> GetConsoleCatalogAsync(SbomCatalogQuery query, CancellationToken cancellationToken) { var cacheKey = $"catalog|{query.Artifact}|{query.License}|{query.Scope}|{query.AssetTag}|{query.Offset}|{query.Limit}"; @@ -138,7 +138,7 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService _cache[cacheKey] = result; return new QueryResult(result, false); } - + public async Task> GetComponentLookupAsync(ComponentLookupQuery query, CancellationToken cancellationToken) { var cacheKey = $"component|{query.Purl}|{query.Artifact}|{query.Offset}|{query.Limit}"; @@ -146,7 +146,7 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService { return new QueryResult(cachedResult, true); } - + var (items, total) = await _componentLookupRepository.QueryAsync(query, cancellationToken); string? nextCursor = query.Offset + query.Limit < total @@ -156,7 +156,7 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService var neighbors = items .Select(c => new ComponentNeighbor(c.NeighborPurl, c.Relationship, c.License, c.Scope, c.RuntimeFlag)) .ToList(); - + var cacheHint = _componentLookupRepository.GetType().Name.Contains("Mongo", StringComparison.OrdinalIgnoreCase) ? "storage" : "seeded"; @@ -211,7 +211,7 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService return projection; } - + private static bool TryExtractAsset(JsonElement projection, out AssetMetadata asset) { asset = default!; @@ -276,7 +276,10 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService RuntimeFlag: path.RuntimeFlag, NearestSafeVersion: path.NearestSafeVersion ?? string.Empty); - SbomMetrics.ResolverFeedPublished.Add(1, new TagList { { "tenant", tenantId } }); + SbomMetrics.ResolverFeedPublished.Add(1, new[] + { + new KeyValuePair("tenant", tenantId) + }); } } @@ -301,95 +304,95 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService { return new List { - new( - Artifact: "ghcr.io/stellaops/sample-api@sha256:111", - Purl: "pkg:npm/lodash@4.17.21", - Scope: "runtime", - Environment: "prod", - RuntimeFlag: true, - BlastRadius: "medium", - NearestSafeVersion: "pkg:npm/lodash@4.17.22", - Nodes: new[] - { - new SbomPathNode("sample-api", "artifact"), - new SbomPathNode("express", "npm"), - new SbomPathNode("lodash", "npm") - }), - new( - Artifact: "ghcr.io/stellaops/sample-api@sha256:111", - Purl: "pkg:npm/lodash@4.17.21", - Scope: "build", - Environment: "prod", - RuntimeFlag: false, - BlastRadius: "low", - NearestSafeVersion: "pkg:npm/lodash@4.17.22", - Nodes: new[] - { - new SbomPathNode("sample-api", "artifact"), - new SbomPathNode("rollup", "npm"), - new SbomPathNode("lodash", "npm") - }), - new( - Artifact: "ghcr.io/stellaops/sample-api@sha256:222", - Purl: "pkg:nuget/Newtonsoft.Json@13.0.2", - Scope: "runtime", - Environment: "staging", - RuntimeFlag: true, - BlastRadius: "high", - NearestSafeVersion: "pkg:nuget/Newtonsoft.Json@13.0.3", - Nodes: new[] - { - new SbomPathNode("sample-worker", "artifact"), - new SbomPathNode("StellaOps.Core", "nuget"), - new SbomPathNode("Newtonsoft.Json", "nuget") - }) - }; - } - - private static IReadOnlyList SeedTimelines() - { - return new List - { - new( - Artifact: "ghcr.io/stellaops/sample-api", - Version: "2025.11.15.1", - Digest: "sha256:111", - SourceBundleHash: "sha256:bundle111", - CreatedAt: new DateTimeOffset(2025, 11, 15, 12, 0, 0, TimeSpan.Zero), - Provenance: "scanner:surface_bundle_mock_v1.tgz"), - new( - Artifact: "ghcr.io/stellaops/sample-api", - Version: "2025.11.16.1", - Digest: "sha256:112", - SourceBundleHash: "sha256:bundle112", - CreatedAt: new DateTimeOffset(2025, 11, 16, 12, 0, 0, TimeSpan.Zero), - Provenance: "scanner:surface_bundle_mock_v1.tgz"), - new( - Artifact: "ghcr.io/stellaops/sample-worker", - Version: "2025.11.12.0", - Digest: "sha256:222", - SourceBundleHash: "sha256:bundle222", - CreatedAt: new DateTimeOffset(2025, 11, 12, 8, 0, 0, TimeSpan.Zero), - Provenance: "upload:spdx:worker"), - }; - } - - private sealed record PathRecord( - string Artifact, - string Purl, - string? Scope, - string? Environment, - bool RuntimeFlag, - string? BlastRadius, - string? NearestSafeVersion, - IReadOnlyList Nodes); - - private sealed record TimelineRecord( - string Artifact, - string Version, - string Digest, - string SourceBundleHash, - DateTimeOffset CreatedAt, - string? Provenance); - + new( + Artifact: "ghcr.io/stellaops/sample-api@sha256:111", + Purl: "pkg:npm/lodash@4.17.21", + Scope: "runtime", + Environment: "prod", + RuntimeFlag: true, + BlastRadius: "medium", + NearestSafeVersion: "pkg:npm/lodash@4.17.22", + Nodes: new[] + { + new SbomPathNode("sample-api", "artifact"), + new SbomPathNode("express", "npm"), + new SbomPathNode("lodash", "npm") + }), + new( + Artifact: "ghcr.io/stellaops/sample-api@sha256:111", + Purl: "pkg:npm/lodash@4.17.21", + Scope: "build", + Environment: "prod", + RuntimeFlag: false, + BlastRadius: "low", + NearestSafeVersion: "pkg:npm/lodash@4.17.22", + Nodes: new[] + { + new SbomPathNode("sample-api", "artifact"), + new SbomPathNode("rollup", "npm"), + new SbomPathNode("lodash", "npm") + }), + new( + Artifact: "ghcr.io/stellaops/sample-api@sha256:222", + Purl: "pkg:nuget/Newtonsoft.Json@13.0.2", + Scope: "runtime", + Environment: "staging", + RuntimeFlag: true, + BlastRadius: "high", + NearestSafeVersion: "pkg:nuget/Newtonsoft.Json@13.0.3", + Nodes: new[] + { + new SbomPathNode("sample-worker", "artifact"), + new SbomPathNode("StellaOps.Core", "nuget"), + new SbomPathNode("Newtonsoft.Json", "nuget") + }) + }; + } + + private static IReadOnlyList SeedTimelines() + { + return new List + { + new( + Artifact: "ghcr.io/stellaops/sample-api", + Version: "2025.11.15.1", + Digest: "sha256:111", + SourceBundleHash: "sha256:bundle111", + CreatedAt: new DateTimeOffset(2025, 11, 15, 12, 0, 0, TimeSpan.Zero), + Provenance: "scanner:surface_bundle_mock_v1.tgz"), + new( + Artifact: "ghcr.io/stellaops/sample-api", + Version: "2025.11.16.1", + Digest: "sha256:112", + SourceBundleHash: "sha256:bundle112", + CreatedAt: new DateTimeOffset(2025, 11, 16, 12, 0, 0, TimeSpan.Zero), + Provenance: "scanner:surface_bundle_mock_v1.tgz"), + new( + Artifact: "ghcr.io/stellaops/sample-worker", + Version: "2025.11.12.0", + Digest: "sha256:222", + SourceBundleHash: "sha256:bundle222", + CreatedAt: new DateTimeOffset(2025, 11, 12, 8, 0, 0, TimeSpan.Zero), + Provenance: "upload:spdx:worker"), + }; + } + + private sealed record PathRecord( + string Artifact, + string Purl, + string? Scope, + string? Environment, + bool RuntimeFlag, + string? BlastRadius, + string? NearestSafeVersion, + IReadOnlyList Nodes); + + private sealed record TimelineRecord( + string Artifact, + string Version, + string Digest, + string SourceBundleHash, + DateTimeOffset CreatedAt, + string? Provenance); + } diff --git a/src/SbomService/StellaOps.SbomService/Services/OrchestratorControlService.cs b/src/SbomService/StellaOps.SbomService/Services/OrchestratorControlService.cs index 681a125cd..054358593 100644 --- a/src/SbomService/StellaOps.SbomService/Services/OrchestratorControlService.cs +++ b/src/SbomService/StellaOps.SbomService/Services/OrchestratorControlService.cs @@ -70,7 +70,10 @@ internal sealed class OrchestratorControlService : IOrchestratorControlService await _repository.SetAsync(updated, cancellationToken); _cache[updated.TenantId] = updated; - _controlUpdates.Add(1, new TagList { { "tenant", updated.TenantId } }); + _controlUpdates.Add(1, new[] + { + new KeyValuePair("tenant", updated.TenantId) + }); return updated; } @@ -78,7 +81,12 @@ internal sealed class OrchestratorControlService : IOrchestratorControlService { foreach (var kvp in _cache) { - yield return new Measurement(kvp.Value.ThrottlePercent, new TagList { { "tenant", kvp.Key } }); + yield return new Measurement( + kvp.Value.ThrottlePercent, + new[] + { + new KeyValuePair("tenant", kvp.Key) + }); } } @@ -86,7 +94,12 @@ internal sealed class OrchestratorControlService : IOrchestratorControlService { foreach (var kvp in _cache) { - yield return new Measurement(kvp.Value.Paused ? 1 : 0, new TagList { { "tenant", kvp.Key } }); + yield return new Measurement( + kvp.Value.Paused ? 1 : 0, + new[] + { + new KeyValuePair("tenant", kvp.Key) + }); } } } diff --git a/src/SbomService/StellaOps.SbomService/Services/SbomContextAssembler.cs b/src/SbomService/StellaOps.SbomService/Services/SbomContextAssembler.cs new file mode 100644 index 000000000..876b08349 --- /dev/null +++ b/src/SbomService/StellaOps.SbomService/Services/SbomContextAssembler.cs @@ -0,0 +1,259 @@ +using System.Collections.Immutable; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using StellaOps.SbomService.Models; + +namespace StellaOps.SbomService.Services; + +internal static class SbomContextAssembler +{ + private const string Schema = "stellaops.sbom.context/1.0"; + + private static readonly JsonSerializerOptions HashSerializerOptions = new(JsonSerializerDefaults.Web) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + private static readonly IReadOnlyDictionary EmptyDictionary = + ImmutableDictionary.Empty; + + private static readonly IReadOnlyList EmptyVersions = + ImmutableArray.Empty; + + private static readonly IReadOnlyList EmptyPaths = + ImmutableArray.Empty; + + public static SbomContextResponse Build( + string artifactId, + string? purl, + DateTimeOffset generated, + IReadOnlyList timeline, + IReadOnlyList paths, + bool includeEnvironmentFlags, + bool includeBlastRadius) + { + var versions = timeline.Count == 0 ? EmptyVersions : BuildVersions(timeline); + var dependencyPaths = paths.Count == 0 ? EmptyPaths : BuildDependencyPaths(paths); + var environmentFlags = includeEnvironmentFlags + ? BuildEnvironmentFlags(dependencyPaths) + : EmptyDictionary; + var blastRadius = includeBlastRadius + ? BuildBlastRadius(dependencyPaths) + : null; + var metadata = BuildMetadata(artifactId, generated, versions.Count, dependencyPaths.Count, environmentFlags.Count, blastRadius is not null); + + var response = new SbomContextResponse( + Schema, + generated, + artifactId, + purl, + versions, + dependencyPaths, + environmentFlags, + blastRadius, + metadata, + Hash: string.Empty); + + var hash = ComputeHash(response); + return response with { Hash = hash }; + } + + private static IReadOnlyList BuildVersions(IReadOnlyList versions) + { + return versions + .OrderByDescending(v => v.CreatedAt) + .ThenBy(v => v.Version, StringComparer.Ordinal) + .Select(v => + { + var metadata = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); + metadata["digest"] = v.Digest; + metadata["source_bundle_hash"] = v.SourceBundleHash; + if (!string.IsNullOrWhiteSpace(v.Provenance)) + { + metadata["provenance"] = v.Provenance!; + } + + return new SbomContextVersion( + v.Version, + v.CreatedAt, + v.CreatedAt, + "observed", + string.IsNullOrWhiteSpace(v.Provenance) ? "sbom" : v.Provenance!.Trim(), + false, + metadata.ToImmutable()); + }) + .ToImmutableArray(); + } + + private static IReadOnlyList BuildDependencyPaths(IReadOnlyList paths) + { + return paths + .Select(path => + { + var nodes = path.Nodes + .Select(node => new SbomContextDependencyNode( + Identifier: string.IsNullOrWhiteSpace(node.Name) ? "unknown" : node.Name, + Version: null)) + .ToImmutableArray(); + + var metadata = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); + if (!string.IsNullOrWhiteSpace(path.Scope)) + { + metadata["scope"] = path.Scope!; + } + + if (!string.IsNullOrWhiteSpace(path.Environment)) + { + metadata["environment"] = path.Environment!; + } + + if (!string.IsNullOrWhiteSpace(path.Artifact)) + { + metadata["artifact"] = path.Artifact!; + } + + if (!string.IsNullOrWhiteSpace(path.NearestSafeVersion)) + { + metadata["nearest_safe_version"] = path.NearestSafeVersion!; + } + + if (!string.IsNullOrWhiteSpace(path.BlastRadius)) + { + metadata["blast_radius"] = path.BlastRadius!; + } + + metadata["path_length"] = nodes.Length.ToString(CultureInfo.InvariantCulture); + + return new SbomContextDependencyPath( + nodes, + path.RuntimeFlag, + "sbom.paths", + metadata.ToImmutable()); + }) + .ToImmutableArray(); + } + + private static IReadOnlyDictionary BuildEnvironmentFlags(IReadOnlyList paths) + { + if (paths.Count == 0) + { + return EmptyDictionary; + } + + var builder = ImmutableSortedDictionary.CreateBuilder(StringComparer.Ordinal); + var environmentCounts = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var path in paths) + { + if (path.Metadata.TryGetValue("environment", out var environment) && !string.IsNullOrWhiteSpace(environment)) + { + var key = environment.Trim(); + environmentCounts[key] = environmentCounts.TryGetValue(key, out var count) ? count + 1 : 1; + } + } + + if (environmentCounts.Count == 0) + { + return EmptyDictionary; + } + + foreach (var pair in environmentCounts.OrderBy(p => p.Key, StringComparer.Ordinal)) + { + builder[pair.Key] = pair.Value.ToString(CultureInfo.InvariantCulture); + } + + return builder.ToImmutable(); + } + + private static SbomContextBlastRadius? BuildBlastRadius(IReadOnlyList paths) + { + if (paths.Count == 0) + { + return null; + } + + var impactedAssets = paths + .SelectMany(p => p.Metadata.TryGetValue("scope", out var scope) && !string.IsNullOrWhiteSpace(scope) + ? new[] { scope.Trim() } + : Array.Empty()) + .Distinct(StringComparer.Ordinal) + .Count(); + + var impactedNamespaces = paths + .SelectMany(p => p.Metadata.TryGetValue("environment", out var environment) && !string.IsNullOrWhiteSpace(environment) + ? new[] { environment.Trim() } + : Array.Empty()) + .Distinct(StringComparer.Ordinal) + .Count(); + + var impactedWorkloads = paths.Count(p => p.IsRuntime); + double? impactedPercentage = paths.Count == 0 + ? null + : Math.Round((double)impactedWorkloads / paths.Count, 3); + + var metadataBuilder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); + metadataBuilder["path_sample_count"] = paths.Count.ToString(CultureInfo.InvariantCulture); + + var blastTags = paths + .Select(p => p.Metadata.TryGetValue("blast_radius", out var tag) ? tag : null) + .Where(tag => !string.IsNullOrWhiteSpace(tag)) + .Select(tag => tag!.Trim()) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + if (blastTags.Length > 0) + { + metadataBuilder["blast_radius_tags"] = string.Join(",", blastTags); + } + + return new SbomContextBlastRadius( + impactedAssets, + impactedWorkloads, + impactedNamespaces, + impactedPercentage, + metadataBuilder.ToImmutable()); + } + + private static IReadOnlyDictionary BuildMetadata( + string artifactId, + DateTimeOffset generated, + int versionCount, + int dependencyCount, + int environmentFlagCount, + bool hasBlastRadius) + { + var builder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); + builder["generated_at"] = generated.ToString("O", CultureInfo.InvariantCulture); + builder["artifact"] = artifactId; + builder["version_count"] = versionCount.ToString(CultureInfo.InvariantCulture); + builder["dependency_count"] = dependencyCount.ToString(CultureInfo.InvariantCulture); + builder["environment_flag_count"] = environmentFlagCount.ToString(CultureInfo.InvariantCulture); + builder["blast_radius_present"] = hasBlastRadius.ToString(); + builder["source"] = "sbom-service"; + return builder.ToImmutable(); + } + + private static string ComputeHash(SbomContextResponse response) + { + var snapshot = new + { + response.Schema, + response.Generated, + response.ArtifactId, + response.Purl, + response.Versions, + response.DependencyPaths, + response.EnvironmentFlags, + response.BlastRadius, + response.Metadata + }; + + var json = JsonSerializer.Serialize(snapshot, HashSerializerOptions); + var bytes = Encoding.UTF8.GetBytes(json); + var hash = SHA256.HashData(bytes); + return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; + } +}