This commit is contained in:
master
2025-12-09 10:50:15 +02:00
parent cc69d332e3
commit f30805ad7f
25 changed files with 846 additions and 317 deletions

View File

@@ -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. 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 ## Contract
- **Endpoint** (SBOM Service): `/sbom/context` - **Endpoint** (SBOM Service): `/sbom/context`
- **Request** (minimal): - **Request** (minimal):

View File

@@ -7,7 +7,7 @@
## Dependencies & Concurrency ## Dependencies & Concurrency
- Depends on Sprint 0100.A (Attestor) staying green. - 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. - Concurrency: block publishing on missing CLI/Policy/SBOM deliverables; drafting allowed where noted.
## Wave Coordination ## 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 | | 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 | | 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 | | 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 | | 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) | 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 | | 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 ## Action Tracker
| Focus | Action | Owner(s) | Due | Status | | Focus | Action | Owner(s) | Due | Status |
@@ -41,6 +41,10 @@
## Execution Log ## Execution Log
| Date (UTC) | Update | Owner | | 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-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-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 | | 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 | | 2025-12-02 | Normalized sprint file to standard template; no status changes. | StellaOps Agent |
## Decisions & Risks ## 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. - Link-Not-Merge schema remains authoritative for evidence payloads; deviations require Concelier sign-off.
## Next Checkpoints ## Next Checkpoints

View File

@@ -141,7 +141,7 @@
- PROV-OBS-53-002 -> PROV-OBS-53-003 ✅ - PROV-OBS-53-002 -> PROV-OBS-53-003 ✅
- CLI/Advisory AI handoff - CLI/Advisory AI handoff
- SBOM-AIAI-31-003 <- CLI-VULN-29-001; CLI-VEX-30-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-VULN-29-001; CLI-VEX-30-001; POLICY-ENGINE-31-001; DEVOPS-AIAI-31-001 - 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. 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.

View File

@@ -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-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-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-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-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-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-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-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-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-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 | | 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-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-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-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-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-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 | | | | | 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-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-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-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-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-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-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-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-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-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 | | 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-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-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-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-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-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. | | | | 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. | | |

View File

@@ -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"}

View File

@@ -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."}

View File

@@ -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"}

View File

@@ -0,0 +1,4 @@
bb1da224c09031996224154611f2e1c2143c23b96ab583191766f7d281b20800 hashes.sha256
421af53f9eeba6903098d292fbd56f98be62ea6130b5161859889bf11d699d18 sample-sbom-context.json
e5aecfba5cee8d412408fb449f12fa4d5bf0a7cb7e5b316b99da3b9019897186 sample-vuln-output.ndjson
736efd36508de7b72c9cbddf851335d9534c326af1670be7d101cbb91634357d sbom-context-response.json

View File

@@ -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

View File

@@ -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"}
]
}

View File

@@ -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"}}

View File

@@ -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"}

View File

@@ -0,0 +1,4 @@
bb1da224c09031996224154611f2e1c2143c23b96ab583191766f7d281b20800 hashes.sha256
421af53f9eeba6903098d292fbd56f98be62ea6130b5161859889bf11d699d18 sample-sbom-context.json
e5aecfba5cee8d412408fb449f12fa4d5bf0a7cb7e5b316b99da3b9019897186 sample-vuln-output.ndjson
1f8df765be98c193ac6fa52af778e2e0ec24a7c5acbdfe7a4a461d45bf98f573 sbom-context-response.json

View File

@@ -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

View File

@@ -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"}
]
}

View File

@@ -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"}}

View File

@@ -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"}

View File

@@ -9,9 +9,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="System.Text.Json" Version="10.0.0" />
<PackageReference Include="JsonSchema.Net" Version="5.3.0" /> <PackageReference Include="JsonSchema.Net" Version="5.3.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<ProjectReference Include="..\..\Attestor\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" /> <ProjectReference Include="..\..\Attestor\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
</ItemGroup> </ItemGroup>

View File

@@ -129,4 +129,74 @@ public class SbomEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
secondPage.Neighbors.Should().OnlyContain(n => n.Purl.StartsWith("pkg:npm/", StringComparison.OrdinalIgnoreCase)); secondPage.Neighbors.Should().OnlyContain(n => n.Purl.StartsWith("pkg:npm/", StringComparison.OrdinalIgnoreCase));
secondPage.NextCursor.Should().BeNull(); 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<SbomContextResponse>();
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<SbomContextResponse>();
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<SbomContextResponse>();
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);
}
} }

View File

@@ -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<SbomContextVersion> Versions,
[property: JsonPropertyName("dependencyPaths")] IReadOnlyList<SbomContextDependencyPath> DependencyPaths,
[property: JsonPropertyName("environmentFlags")] IReadOnlyDictionary<string, string> EnvironmentFlags,
[property: JsonPropertyName("blastRadius")] SbomContextBlastRadius? BlastRadius,
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, string> 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<string, string> Metadata);
public sealed record SbomContextDependencyPath(
[property: JsonPropertyName("nodes")] IReadOnlyList<SbomContextDependencyNode> Nodes,
[property: JsonPropertyName("isRuntime")] bool IsRuntime,
[property: JsonPropertyName("source")] string? Source,
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, string> 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<string, string> Metadata);

View File

@@ -14,7 +14,10 @@ public sealed record SbomPath(
IReadOnlyList<SbomPathNode> Nodes, IReadOnlyList<SbomPathNode> Nodes,
bool RuntimeFlag, bool RuntimeFlag,
string? BlastRadius, string? BlastRadius,
string? NearestSafeVersion); string? NearestSafeVersion,
string? Scope,
string? Environment,
string? Artifact);
public sealed record SbomPathResult( public sealed record SbomPathResult(
string Purl, string Purl,

View File

@@ -16,7 +16,7 @@ var builder = WebApplication.CreateBuilder(args);
builder.Configuration builder.Configuration
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
.AddEnvironmentVariables("SBOM_"); .AddEnvironmentVariables("SBOM_");
builder.Services.AddOptions(); builder.Services.AddOptions();
builder.Services.AddLogging(); builder.Services.AddLogging();
@@ -152,6 +152,21 @@ static string? FindFixture(IHostEnvironment env, string fileName)
return null; 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(); var app = builder.Build();
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
@@ -225,27 +240,27 @@ app.MapPost("/entrypoints", async Task<IResult> (
var items = await repo.ListAsync(tenantId, cancellationToken); var items = await repo.ListAsync(tenantId, cancellationToken);
return Results.Ok(new EntrypointListResponse(tenantId, items)); return Results.Ok(new EntrypointListResponse(tenantId, items));
}); });
app.MapGet("/console/sboms", async Task<IResult> ( app.MapGet("/console/sboms", async Task<IResult> (
[FromServices] ISbomQueryService service, [FromServices] ISbomQueryService service,
[FromQuery] string? artifact, [FromQuery] string? artifact,
[FromQuery] string? license, [FromQuery] string? license,
[FromQuery] string? scope, [FromQuery] string? scope,
[FromQuery(Name = "assetTag")] string? assetTag, [FromQuery(Name = "assetTag")] string? assetTag,
[FromQuery] string? cursor, [FromQuery] string? cursor,
[FromQuery] int? limit, [FromQuery] int? limit,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
{ {
if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200)) if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200))
{ {
return Results.BadRequest(new { error = "limit must be between 1 and 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 _)) if (cursor is { Length: > 0 } && !int.TryParse(cursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
{ {
return Results.BadRequest(new { error = "cursor must be an integer offset" }); return Results.BadRequest(new { error = "cursor must be an integer offset" });
} }
var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture); var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture);
var pageSize = limit ?? 50; var pageSize = limit ?? 50;
@@ -257,43 +272,43 @@ app.MapGet("/console/sboms", async Task<IResult> (
cancellationToken); cancellationToken);
var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds; var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new TagList SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new[]
{ {
{ "scope", scope ?? string.Empty }, new KeyValuePair<string, object?>("scope", scope ?? string.Empty),
{ "env", string.Empty } new KeyValuePair<string, object?>("env", string.Empty)
}); });
SbomMetrics.PathsQueryTotal.Add(1, new TagList SbomMetrics.PathsQueryTotal.Add(1, new[]
{ {
{ "cache_hit", result.CacheHit }, new KeyValuePair<string, object?>("cache_hit", result.CacheHit),
{ "scope", scope ?? string.Empty } new KeyValuePair<string, object?>("scope", scope ?? string.Empty)
}); });
return Results.Ok(result.Result); return Results.Ok(result.Result);
}); });
app.MapGet("/components/lookup", async Task<IResult> ( app.MapGet("/components/lookup", async Task<IResult> (
[FromServices] ISbomQueryService service, [FromServices] ISbomQueryService service,
[FromQuery] string? purl, [FromQuery] string? purl,
[FromQuery] string? artifact, [FromQuery] string? artifact,
[FromQuery] string? cursor, [FromQuery] string? cursor,
[FromQuery] int? limit, [FromQuery] int? limit,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
{ {
if (string.IsNullOrWhiteSpace(purl)) if (string.IsNullOrWhiteSpace(purl))
{ {
return Results.BadRequest(new { error = "purl is required" }); return Results.BadRequest(new { error = "purl is required" });
} }
if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200)) if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200))
{ {
return Results.BadRequest(new { error = "limit must be between 1 and 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 _)) if (cursor is { Length: > 0 } && !int.TryParse(cursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
{ {
return Results.BadRequest(new { error = "cursor must be an integer offset" }); return Results.BadRequest(new { error = "cursor must be an integer offset" });
} }
var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture); var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture);
var pageSize = limit ?? 50; var pageSize = limit ?? 50;
@@ -304,22 +319,84 @@ app.MapGet("/components/lookup", async Task<IResult> (
var result = await service.GetComponentLookupAsync( var result = await service.GetComponentLookupAsync(
new ComponentLookupQuery(purl.Trim(), artifact?.Trim(), pageSize, offset), new ComponentLookupQuery(purl.Trim(), artifact?.Trim(), pageSize, offset),
cancellationToken); cancellationToken);
var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds; var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new TagList SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new[]
{ {
{ "scope", string.Empty }, new KeyValuePair<string, object?>("scope", string.Empty),
{ "env", string.Empty } new KeyValuePair<string, object?>("env", string.Empty)
}); });
SbomMetrics.PathsQueryTotal.Add(1, new TagList SbomMetrics.PathsQueryTotal.Add(1, new[]
{ {
{ "cache_hit", result.CacheHit }, new KeyValuePair<string, object?>("cache_hit", result.CacheHit),
{ "scope", string.Empty } new KeyValuePair<string, object?>("scope", string.Empty)
}); });
return Results.Ok(result.Result); return Results.Ok(result.Result);
}); });
app.MapGet("/sbom/context", async Task<IResult> (
[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<SbomVersion> versions = Array.Empty<SbomVersion>();
if (timelineLimit > 0)
{
var timeline = await service.GetTimelineAsync(
new SbomTimelineQuery(normalizedArtifact, timelineLimit, 0),
cancellationToken);
versions = timeline.Result.Versions;
}
IReadOnlyList<SbomPath> dependencyPaths = Array.Empty<SbomPath>();
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<IResult> ( app.MapGet("/sbom/paths", async Task<IResult> (
[FromServices] IServiceProvider services, [FromServices] IServiceProvider services,
[FromQuery] string? purl, [FromQuery] string? purl,
@@ -327,22 +404,22 @@ app.MapGet("/sbom/paths", async Task<IResult> (
[FromQuery] string? scope, [FromQuery] string? scope,
[FromQuery(Name = "env")] string? environment, [FromQuery(Name = "env")] string? environment,
[FromQuery] string? cursor, [FromQuery] string? cursor,
[FromQuery] int? limit, [FromQuery] int? limit,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
{ {
if (string.IsNullOrWhiteSpace(purl)) if (string.IsNullOrWhiteSpace(purl))
{ {
return Results.BadRequest(new { error = "purl is required" }); return Results.BadRequest(new { error = "purl is required" });
} }
if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200)) if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200))
{ {
return Results.BadRequest(new { error = "limit must be between 1 and 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 _)) if (cursor is { Length: > 0 } && !int.TryParse(cursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
{ {
return Results.BadRequest(new { error = "cursor must be an integer offset" }); return Results.BadRequest(new { error = "cursor must be an integer offset" });
} }
var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture); var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture);
@@ -353,22 +430,22 @@ app.MapGet("/sbom/paths", async Task<IResult> (
var result = await service.GetPathsAsync( var result = await service.GetPathsAsync(
new SbomPathQuery(purl.Trim(), artifact?.Trim(), scope?.Trim(), environment?.Trim(), pageSize, offset), new SbomPathQuery(purl.Trim(), artifact?.Trim(), scope?.Trim(), environment?.Trim(), pageSize, offset),
cancellationToken); cancellationToken);
var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds; var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new TagList SbomMetrics.PathsLatencySeconds.Record(elapsedSeconds, new[]
{ {
{ "scope", scope ?? string.Empty }, new KeyValuePair<string, object?>("scope", scope ?? string.Empty),
{ "env", environment ?? string.Empty } new KeyValuePair<string, object?>("env", environment ?? string.Empty)
}); });
SbomMetrics.PathsQueryTotal.Add(1, new TagList SbomMetrics.PathsQueryTotal.Add(1, new[]
{ {
{ "cache_hit", result.CacheHit }, new KeyValuePair<string, object?>("cache_hit", result.CacheHit),
{ "scope", scope ?? string.Empty } new KeyValuePair<string, object?>("scope", scope ?? string.Empty)
}); });
return Results.Ok(result.Result); return Results.Ok(result.Result);
}); });
app.MapGet("/sbom/versions", async Task<IResult> ( app.MapGet("/sbom/versions", async Task<IResult> (
[FromServices] ISbomQueryService service, [FromServices] ISbomQueryService service,
[FromQuery] string? artifact, [FromQuery] string? artifact,
@@ -376,33 +453,40 @@ app.MapGet("/sbom/versions", async Task<IResult> (
[FromQuery] int? limit, [FromQuery] int? limit,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
{ {
if (string.IsNullOrWhiteSpace(artifact)) if (string.IsNullOrWhiteSpace(artifact))
{ {
return Results.BadRequest(new { error = "artifact is required" }); return Results.BadRequest(new { error = "artifact is required" });
} }
if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200)) if (limit is { } requestedLimit && (requestedLimit <= 0 || requestedLimit > 200))
{ {
return Results.BadRequest(new { error = "limit must be between 1 and 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 _)) if (cursor is { Length: > 0 } && !int.TryParse(cursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
{ {
return Results.BadRequest(new { error = "cursor must be an integer offset" }); return Results.BadRequest(new { error = "cursor must be an integer offset" });
} }
var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture); var offset = cursor is null ? 0 : int.Parse(cursor, CultureInfo.InvariantCulture);
var pageSize = limit ?? 50; var pageSize = limit ?? 50;
var start = Stopwatch.GetTimestamp(); var start = Stopwatch.GetTimestamp();
var result = await service.GetTimelineAsync( var result = await service.GetTimelineAsync(
new SbomTimelineQuery(artifact.Trim(), pageSize, offset), new SbomTimelineQuery(artifact.Trim(), pageSize, offset),
cancellationToken); cancellationToken);
var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds; var elapsedSeconds = Stopwatch.GetElapsedTime(start).TotalSeconds;
SbomMetrics.TimelineLatencySeconds.Record(elapsedSeconds, new TagList { { "artifact", artifact } }); SbomMetrics.TimelineLatencySeconds.Record(elapsedSeconds, new[]
SbomMetrics.TimelineQueryTotal.Add(1, new TagList { { "artifact", artifact }, { "cache_hit", result.CacheHit } }); {
new KeyValuePair<string, object?>("artifact", artifact)
});
SbomMetrics.TimelineQueryTotal.Add(1, new[]
{
new KeyValuePair<string, object?>("artifact", artifact),
new KeyValuePair<string, object?>("cache_hit", result.CacheHit)
});
return Results.Ok(result.Result); return Results.Ok(result.Result);
}); });
@@ -445,10 +529,19 @@ app.MapGet("/sboms/{snapshotId}/projection", async Task<IResult> (
var json = JsonSerializer.Serialize(payload); var json = JsonSerializer.Serialize(payload);
var sizeBytes = System.Text.Encoding.UTF8.GetByteCount(json); var sizeBytes = System.Text.Encoding.UTF8.GetByteCount(json);
SbomMetrics.ProjectionSizeBytes.Record(sizeBytes, new TagList { { "tenant", projection.TenantId } }); SbomMetrics.ProjectionSizeBytes.Record(sizeBytes, new[]
{
new KeyValuePair<string, object?>("tenant", projection.TenantId)
});
SbomMetrics.ProjectionLatencySeconds.Record(Stopwatch.GetElapsedTime(start).TotalSeconds, SbomMetrics.ProjectionLatencySeconds.Record(Stopwatch.GetElapsedTime(start).TotalSeconds,
new TagList { { "tenant", projection.TenantId } }); new[]
SbomMetrics.ProjectionQueryTotal.Add(1, new TagList { { "tenant", projection.TenantId } }); {
new KeyValuePair<string, object?>("tenant", projection.TenantId)
});
SbomMetrics.ProjectionQueryTotal.Add(1, new[]
{
new KeyValuePair<string, object?>("tenant", projection.TenantId)
});
app.Logger.LogInformation("projection_returned tenant={Tenant} snapshot={Snapshot} size={SizeBytes}", projection.TenantId, projection.SnapshotId, sizeBytes); app.Logger.LogInformation("projection_returned tenant={Tenant} snapshot={Snapshot} size={SizeBytes}", projection.TenantId, projection.SnapshotId, sizeBytes);

View File

@@ -6,9 +6,9 @@ using StellaOps.SbomService.Models;
using StellaOps.SbomService.Observability; using StellaOps.SbomService.Observability;
using StellaOps.SbomService.Repositories; using StellaOps.SbomService.Repositories;
using StellaOps.SbomService.Services; using StellaOps.SbomService.Services;
namespace StellaOps.SbomService.Services; namespace StellaOps.SbomService.Services;
internal sealed class InMemorySbomQueryService : ISbomQueryService internal sealed class InMemorySbomQueryService : ISbomQueryService
{ {
private readonly IReadOnlyList<PathRecord> _paths; private readonly IReadOnlyList<PathRecord> _paths;
@@ -36,77 +36,77 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
_paths = SeedPaths(); _paths = SeedPaths();
_timelines = SeedTimelines(); _timelines = SeedTimelines();
} }
public Task<QueryResult<SbomPathResult>> GetPathsAsync(SbomPathQuery query, CancellationToken cancellationToken) public Task<QueryResult<SbomPathResult>> GetPathsAsync(SbomPathQuery query, CancellationToken cancellationToken)
{ {
var cacheKey = $"paths|{query.Purl}|{query.Artifact}|{query.Scope}|{query.Environment}|{query.Offset}|{query.Limit}"; 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) if (_cache.TryGetValue(cacheKey, out var cached) && cached is SbomPathResult cachedResult)
{ {
return Task.FromResult(new QueryResult<SbomPathResult>(cachedResult, true)); return Task.FromResult(new QueryResult<SbomPathResult>(cachedResult, true));
} }
var filtered = _paths var filtered = _paths
.Where(p => p.Purl.Equals(query.Purl, StringComparison.OrdinalIgnoreCase)) .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.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.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)) .Where(p => query.Environment is null || string.Equals(p.Environment, query.Environment, StringComparison.OrdinalIgnoreCase))
.OrderBy(p => p.Artifact) .OrderBy(p => p.Artifact)
.ThenBy(p => p.Environment) .ThenBy(p => p.Environment)
.ThenBy(p => p.Scope) .ThenBy(p => p.Scope)
.ThenBy(p => string.Join("->", p.Nodes.Select(n => n.Name))) .ThenBy(p => string.Join("->", p.Nodes.Select(n => n.Name)))
.ToList(); .ToList();
var page = filtered var page = filtered
.Skip(query.Offset) .Skip(query.Offset)
.Take(query.Limit) .Take(query.Limit)
.Select(r => new SbomPath(r.Nodes, r.RuntimeFlag, r.BlastRadius, r.NearestSafeVersion)) .Select(r => new SbomPath(r.Nodes, r.RuntimeFlag, r.BlastRadius, r.NearestSafeVersion, r.Scope, r.Environment, r.Artifact))
.ToList(); .ToList();
string? nextCursor = query.Offset + query.Limit < filtered.Count string? nextCursor = query.Offset + query.Limit < filtered.Count
? (query.Offset + query.Limit).ToString(CultureInfo.InvariantCulture) ? (query.Offset + query.Limit).ToString(CultureInfo.InvariantCulture)
: null; : null;
var result = new SbomPathResult( var result = new SbomPathResult(
Purl: query.Purl, Purl: query.Purl,
Artifact: query.Artifact, Artifact: query.Artifact,
Scope: query.Scope, Scope: query.Scope,
Environment: query.Environment, Environment: query.Environment,
Paths: page, Paths: page,
NextCursor: nextCursor); NextCursor: nextCursor);
_cache[cacheKey] = result; _cache[cacheKey] = result;
return Task.FromResult(new QueryResult<SbomPathResult>(result, false)); return Task.FromResult(new QueryResult<SbomPathResult>(result, false));
} }
public Task<QueryResult<SbomTimelineResult>> GetTimelineAsync(SbomTimelineQuery query, CancellationToken cancellationToken) public Task<QueryResult<SbomTimelineResult>> GetTimelineAsync(SbomTimelineQuery query, CancellationToken cancellationToken)
{ {
var cacheKey = $"timeline|{query.Artifact}|{query.Offset}|{query.Limit}"; var cacheKey = $"timeline|{query.Artifact}|{query.Offset}|{query.Limit}";
if (_cache.TryGetValue(cacheKey, out var cached) && cached is SbomTimelineResult cachedTimeline) if (_cache.TryGetValue(cacheKey, out var cached) && cached is SbomTimelineResult cachedTimeline)
{ {
return Task.FromResult(new QueryResult<SbomTimelineResult>(cachedTimeline, true)); return Task.FromResult(new QueryResult<SbomTimelineResult>(cachedTimeline, true));
} }
var filtered = _timelines var filtered = _timelines
.Where(t => t.Artifact.Equals(query.Artifact, StringComparison.OrdinalIgnoreCase)) .Where(t => t.Artifact.Equals(query.Artifact, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(t => t.CreatedAt) .OrderByDescending(t => t.CreatedAt)
.ThenByDescending(t => t.Version) .ThenByDescending(t => t.Version)
.ToList(); .ToList();
var page = filtered var page = filtered
.Skip(query.Offset) .Skip(query.Offset)
.Take(query.Limit) .Take(query.Limit)
.Select(t => new SbomVersion(t.Version, t.Digest, t.CreatedAt, t.SourceBundleHash, t.Provenance)) .Select(t => new SbomVersion(t.Version, t.Digest, t.CreatedAt, t.SourceBundleHash, t.Provenance))
.ToList(); .ToList();
string? nextCursor = query.Offset + query.Limit < filtered.Count string? nextCursor = query.Offset + query.Limit < filtered.Count
? (query.Offset + query.Limit).ToString(CultureInfo.InvariantCulture) ? (query.Offset + query.Limit).ToString(CultureInfo.InvariantCulture)
: null; : null;
var result = new SbomTimelineResult(query.Artifact, page, nextCursor); var result = new SbomTimelineResult(query.Artifact, page, nextCursor);
_cache[cacheKey] = result; _cache[cacheKey] = result;
return Task.FromResult(new QueryResult<SbomTimelineResult>(result, false)); return Task.FromResult(new QueryResult<SbomTimelineResult>(result, false));
} }
public async Task<QueryResult<SbomCatalogResult>> GetConsoleCatalogAsync(SbomCatalogQuery query, CancellationToken cancellationToken) public async Task<QueryResult<SbomCatalogResult>> GetConsoleCatalogAsync(SbomCatalogQuery query, CancellationToken cancellationToken)
{ {
var cacheKey = $"catalog|{query.Artifact}|{query.License}|{query.Scope}|{query.AssetTag}|{query.Offset}|{query.Limit}"; 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; _cache[cacheKey] = result;
return new QueryResult<SbomCatalogResult>(result, false); return new QueryResult<SbomCatalogResult>(result, false);
} }
public async Task<QueryResult<ComponentLookupResult>> GetComponentLookupAsync(ComponentLookupQuery query, CancellationToken cancellationToken) public async Task<QueryResult<ComponentLookupResult>> GetComponentLookupAsync(ComponentLookupQuery query, CancellationToken cancellationToken)
{ {
var cacheKey = $"component|{query.Purl}|{query.Artifact}|{query.Offset}|{query.Limit}"; var cacheKey = $"component|{query.Purl}|{query.Artifact}|{query.Offset}|{query.Limit}";
@@ -146,7 +146,7 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
{ {
return new QueryResult<ComponentLookupResult>(cachedResult, true); return new QueryResult<ComponentLookupResult>(cachedResult, true);
} }
var (items, total) = await _componentLookupRepository.QueryAsync(query, cancellationToken); var (items, total) = await _componentLookupRepository.QueryAsync(query, cancellationToken);
string? nextCursor = query.Offset + query.Limit < total string? nextCursor = query.Offset + query.Limit < total
@@ -156,7 +156,7 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
var neighbors = items var neighbors = items
.Select(c => new ComponentNeighbor(c.NeighborPurl, c.Relationship, c.License, c.Scope, c.RuntimeFlag)) .Select(c => new ComponentNeighbor(c.NeighborPurl, c.Relationship, c.License, c.Scope, c.RuntimeFlag))
.ToList(); .ToList();
var cacheHint = _componentLookupRepository.GetType().Name.Contains("Mongo", StringComparison.OrdinalIgnoreCase) var cacheHint = _componentLookupRepository.GetType().Name.Contains("Mongo", StringComparison.OrdinalIgnoreCase)
? "storage" ? "storage"
: "seeded"; : "seeded";
@@ -211,7 +211,7 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
return projection; return projection;
} }
private static bool TryExtractAsset(JsonElement projection, out AssetMetadata asset) private static bool TryExtractAsset(JsonElement projection, out AssetMetadata asset)
{ {
asset = default!; asset = default!;
@@ -276,7 +276,10 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
RuntimeFlag: path.RuntimeFlag, RuntimeFlag: path.RuntimeFlag,
NearestSafeVersion: path.NearestSafeVersion ?? string.Empty); NearestSafeVersion: path.NearestSafeVersion ?? string.Empty);
SbomMetrics.ResolverFeedPublished.Add(1, new TagList { { "tenant", tenantId } }); SbomMetrics.ResolverFeedPublished.Add(1, new[]
{
new KeyValuePair<string, object?>("tenant", tenantId)
});
} }
} }
@@ -301,95 +304,95 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
{ {
return new List<PathRecord> return new List<PathRecord>
{ {
new( new(
Artifact: "ghcr.io/stellaops/sample-api@sha256:111", Artifact: "ghcr.io/stellaops/sample-api@sha256:111",
Purl: "pkg:npm/lodash@4.17.21", Purl: "pkg:npm/lodash@4.17.21",
Scope: "runtime", Scope: "runtime",
Environment: "prod", Environment: "prod",
RuntimeFlag: true, RuntimeFlag: true,
BlastRadius: "medium", BlastRadius: "medium",
NearestSafeVersion: "pkg:npm/lodash@4.17.22", NearestSafeVersion: "pkg:npm/lodash@4.17.22",
Nodes: new[] Nodes: new[]
{ {
new SbomPathNode("sample-api", "artifact"), new SbomPathNode("sample-api", "artifact"),
new SbomPathNode("express", "npm"), new SbomPathNode("express", "npm"),
new SbomPathNode("lodash", "npm") new SbomPathNode("lodash", "npm")
}), }),
new( new(
Artifact: "ghcr.io/stellaops/sample-api@sha256:111", Artifact: "ghcr.io/stellaops/sample-api@sha256:111",
Purl: "pkg:npm/lodash@4.17.21", Purl: "pkg:npm/lodash@4.17.21",
Scope: "build", Scope: "build",
Environment: "prod", Environment: "prod",
RuntimeFlag: false, RuntimeFlag: false,
BlastRadius: "low", BlastRadius: "low",
NearestSafeVersion: "pkg:npm/lodash@4.17.22", NearestSafeVersion: "pkg:npm/lodash@4.17.22",
Nodes: new[] Nodes: new[]
{ {
new SbomPathNode("sample-api", "artifact"), new SbomPathNode("sample-api", "artifact"),
new SbomPathNode("rollup", "npm"), new SbomPathNode("rollup", "npm"),
new SbomPathNode("lodash", "npm") new SbomPathNode("lodash", "npm")
}), }),
new( new(
Artifact: "ghcr.io/stellaops/sample-api@sha256:222", Artifact: "ghcr.io/stellaops/sample-api@sha256:222",
Purl: "pkg:nuget/Newtonsoft.Json@13.0.2", Purl: "pkg:nuget/Newtonsoft.Json@13.0.2",
Scope: "runtime", Scope: "runtime",
Environment: "staging", Environment: "staging",
RuntimeFlag: true, RuntimeFlag: true,
BlastRadius: "high", BlastRadius: "high",
NearestSafeVersion: "pkg:nuget/Newtonsoft.Json@13.0.3", NearestSafeVersion: "pkg:nuget/Newtonsoft.Json@13.0.3",
Nodes: new[] Nodes: new[]
{ {
new SbomPathNode("sample-worker", "artifact"), new SbomPathNode("sample-worker", "artifact"),
new SbomPathNode("StellaOps.Core", "nuget"), new SbomPathNode("StellaOps.Core", "nuget"),
new SbomPathNode("Newtonsoft.Json", "nuget") new SbomPathNode("Newtonsoft.Json", "nuget")
}) })
}; };
} }
private static IReadOnlyList<TimelineRecord> SeedTimelines() private static IReadOnlyList<TimelineRecord> SeedTimelines()
{ {
return new List<TimelineRecord> return new List<TimelineRecord>
{ {
new( new(
Artifact: "ghcr.io/stellaops/sample-api", Artifact: "ghcr.io/stellaops/sample-api",
Version: "2025.11.15.1", Version: "2025.11.15.1",
Digest: "sha256:111", Digest: "sha256:111",
SourceBundleHash: "sha256:bundle111", SourceBundleHash: "sha256:bundle111",
CreatedAt: new DateTimeOffset(2025, 11, 15, 12, 0, 0, TimeSpan.Zero), CreatedAt: new DateTimeOffset(2025, 11, 15, 12, 0, 0, TimeSpan.Zero),
Provenance: "scanner:surface_bundle_mock_v1.tgz"), Provenance: "scanner:surface_bundle_mock_v1.tgz"),
new( new(
Artifact: "ghcr.io/stellaops/sample-api", Artifact: "ghcr.io/stellaops/sample-api",
Version: "2025.11.16.1", Version: "2025.11.16.1",
Digest: "sha256:112", Digest: "sha256:112",
SourceBundleHash: "sha256:bundle112", SourceBundleHash: "sha256:bundle112",
CreatedAt: new DateTimeOffset(2025, 11, 16, 12, 0, 0, TimeSpan.Zero), CreatedAt: new DateTimeOffset(2025, 11, 16, 12, 0, 0, TimeSpan.Zero),
Provenance: "scanner:surface_bundle_mock_v1.tgz"), Provenance: "scanner:surface_bundle_mock_v1.tgz"),
new( new(
Artifact: "ghcr.io/stellaops/sample-worker", Artifact: "ghcr.io/stellaops/sample-worker",
Version: "2025.11.12.0", Version: "2025.11.12.0",
Digest: "sha256:222", Digest: "sha256:222",
SourceBundleHash: "sha256:bundle222", SourceBundleHash: "sha256:bundle222",
CreatedAt: new DateTimeOffset(2025, 11, 12, 8, 0, 0, TimeSpan.Zero), CreatedAt: new DateTimeOffset(2025, 11, 12, 8, 0, 0, TimeSpan.Zero),
Provenance: "upload:spdx:worker"), Provenance: "upload:spdx:worker"),
}; };
} }
private sealed record PathRecord( private sealed record PathRecord(
string Artifact, string Artifact,
string Purl, string Purl,
string? Scope, string? Scope,
string? Environment, string? Environment,
bool RuntimeFlag, bool RuntimeFlag,
string? BlastRadius, string? BlastRadius,
string? NearestSafeVersion, string? NearestSafeVersion,
IReadOnlyList<SbomPathNode> Nodes); IReadOnlyList<SbomPathNode> Nodes);
private sealed record TimelineRecord( private sealed record TimelineRecord(
string Artifact, string Artifact,
string Version, string Version,
string Digest, string Digest,
string SourceBundleHash, string SourceBundleHash,
DateTimeOffset CreatedAt, DateTimeOffset CreatedAt,
string? Provenance); string? Provenance);
} }

View File

@@ -70,7 +70,10 @@ internal sealed class OrchestratorControlService : IOrchestratorControlService
await _repository.SetAsync(updated, cancellationToken); await _repository.SetAsync(updated, cancellationToken);
_cache[updated.TenantId] = updated; _cache[updated.TenantId] = updated;
_controlUpdates.Add(1, new TagList { { "tenant", updated.TenantId } }); _controlUpdates.Add(1, new[]
{
new KeyValuePair<string, object?>("tenant", updated.TenantId)
});
return updated; return updated;
} }
@@ -78,7 +81,12 @@ internal sealed class OrchestratorControlService : IOrchestratorControlService
{ {
foreach (var kvp in _cache) foreach (var kvp in _cache)
{ {
yield return new Measurement<int>(kvp.Value.ThrottlePercent, new TagList { { "tenant", kvp.Key } }); yield return new Measurement<int>(
kvp.Value.ThrottlePercent,
new[]
{
new KeyValuePair<string, object?>("tenant", kvp.Key)
});
} }
} }
@@ -86,7 +94,12 @@ internal sealed class OrchestratorControlService : IOrchestratorControlService
{ {
foreach (var kvp in _cache) foreach (var kvp in _cache)
{ {
yield return new Measurement<int>(kvp.Value.Paused ? 1 : 0, new TagList { { "tenant", kvp.Key } }); yield return new Measurement<int>(
kvp.Value.Paused ? 1 : 0,
new[]
{
new KeyValuePair<string, object?>("tenant", kvp.Key)
});
} }
} }
} }

View File

@@ -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<string, string> EmptyDictionary =
ImmutableDictionary<string, string>.Empty;
private static readonly IReadOnlyList<SbomContextVersion> EmptyVersions =
ImmutableArray<SbomContextVersion>.Empty;
private static readonly IReadOnlyList<SbomContextDependencyPath> EmptyPaths =
ImmutableArray<SbomContextDependencyPath>.Empty;
public static SbomContextResponse Build(
string artifactId,
string? purl,
DateTimeOffset generated,
IReadOnlyList<SbomVersion> timeline,
IReadOnlyList<SbomPath> 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<SbomContextVersion> BuildVersions(IReadOnlyList<SbomVersion> versions)
{
return versions
.OrderByDescending(v => v.CreatedAt)
.ThenBy(v => v.Version, StringComparer.Ordinal)
.Select(v =>
{
var metadata = ImmutableDictionary.CreateBuilder<string, string>(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<SbomContextDependencyPath> BuildDependencyPaths(IReadOnlyList<SbomPath> 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<string, string>(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<string, string> BuildEnvironmentFlags(IReadOnlyList<SbomContextDependencyPath> paths)
{
if (paths.Count == 0)
{
return EmptyDictionary;
}
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
var environmentCounts = new Dictionary<string, int>(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<SbomContextDependencyPath> 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<string>())
.Distinct(StringComparer.Ordinal)
.Count();
var impactedNamespaces = paths
.SelectMany(p => p.Metadata.TryGetValue("environment", out var environment) && !string.IsNullOrWhiteSpace(environment)
? new[] { environment.Trim() }
: Array.Empty<string>())
.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<string, string>(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<string, string> BuildMetadata(
string artifactId,
DateTimeOffset generated,
int versionCount,
int dependencyCount,
int environmentFlagCount,
bool hasBlastRadius)
{
var builder = ImmutableDictionary.CreateBuilder<string, string>(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()}";
}
}