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()}";
+ }
+}