refactor(findings): merge VulnExplorer into Findings Ledger
- Move VulnExplorer DTOs into Ledger WebService Contracts/VulnExplorer/ (VulnModels, VexDecisionModels, TriageWorkflowModels, AttestationModels, FixVerificationModels, EvidenceSubgraphContracts) - Create VulnExplorerEndpoints.cs mounting all 10 original endpoints (/v1/vulns, /v1/vex-decisions, /v1/evidence-subgraph, /v1/fix-verifications, /v1/audit-bundles) - Create adapter services (VulnExplorerAdapters.cs) that delegate to existing Ledger services (FindingSummaryService, VulnerabilityDetailService, EvidenceGraphBuilder, VexConsensusService) - Wire VulnExplorer authorization policies and service registrations in Ledger Program.cs - Comment out api (VulnExplorer) container in docker-compose.stella-ops.yml - Add vulnexplorer.stella-ops.local as network alias on findings-ledger-web - Update gateway route: /api/vuln-explorer(..) -> findings.stella-ops.local - Update STELLAOPS_VULNEXPLORER_URL -> findings.stella-ops.local - Comment out VulnExplorer in services-matrix.env and hosts file - Update docs: port-registry, component-map, module-matrix, webservice-catalog, findings-ledger README - Eliminates 1 container (stellaops-api) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -424,7 +424,7 @@ services:
|
|||||||
STELLAOPS_EXCITITOR_URL: "http://excititor.stella-ops.local"
|
STELLAOPS_EXCITITOR_URL: "http://excititor.stella-ops.local"
|
||||||
STELLAOPS_VEXHUB_URL: "http://vexhub.stella-ops.local"
|
STELLAOPS_VEXHUB_URL: "http://vexhub.stella-ops.local"
|
||||||
STELLAOPS_VEXLENS_URL: "http://vexlens.stella-ops.local"
|
STELLAOPS_VEXLENS_URL: "http://vexlens.stella-ops.local"
|
||||||
STELLAOPS_VULNEXPLORER_URL: "http://vulnexplorer.stella-ops.local"
|
STELLAOPS_VULNEXPLORER_URL: "http://findings.stella-ops.local"
|
||||||
STELLAOPS_POLICY_ENGINE_URL: "http://policy-engine.stella-ops.local"
|
STELLAOPS_POLICY_ENGINE_URL: "http://policy-engine.stella-ops.local"
|
||||||
STELLAOPS_POLICY_GATEWAY_URL: "http://policy-gateway.stella-ops.local"
|
STELLAOPS_POLICY_GATEWAY_URL: "http://policy-gateway.stella-ops.local"
|
||||||
STELLAOPS_RISKENGINE_URL: "http://riskengine.stella-ops.local"
|
STELLAOPS_RISKENGINE_URL: "http://riskengine.stella-ops.local"
|
||||||
@@ -1002,33 +1002,38 @@ services:
|
|||||||
<<: *healthcheck-tcp
|
<<: *healthcheck-tcp
|
||||||
labels: *release-labels
|
labels: *release-labels
|
||||||
|
|
||||||
# --- Slot 13: VulnExplorer (api) [src/Findings/StellaOps.VulnExplorer.Api] ---
|
# --- Slot 13: VulnExplorer (api) - MERGED into findings-ledger-web (SPRINT_20260408_002) ---
|
||||||
api:
|
# VulnExplorer endpoints are now served by the Findings Ledger WebService.
|
||||||
<<: *resources-light
|
# Gateway route /api/vuln-explorer(..) now points to findings.stella-ops.local.
|
||||||
image: stellaops/api:dev
|
# The vulnexplorer.stella-ops.local alias is added to the findings-ledger-web
|
||||||
container_name: stellaops-api
|
# container for backward compatibility.
|
||||||
restart: unless-stopped
|
#
|
||||||
depends_on: *depends-infra
|
# api:
|
||||||
environment:
|
# <<: *resources-light
|
||||||
ASPNETCORE_URLS: "http://+:8080"
|
# image: stellaops/api:dev
|
||||||
<<: [*kestrel-cert, *router-microservice-defaults, *gc-light]
|
# container_name: stellaops-api
|
||||||
ConnectionStrings__Default: *postgres-connection
|
# restart: unless-stopped
|
||||||
ConnectionStrings__Redis: "cache.stella-ops.local:6379"
|
# depends_on: *depends-infra
|
||||||
Router__Enabled: "${VULNEXPLORER_ROUTER_ENABLED:-true}"
|
# environment:
|
||||||
Router__Messaging__ConsumerGroup: "vulnexplorer"
|
# ASPNETCORE_URLS: "http://+:8080"
|
||||||
volumes:
|
# <<: [*kestrel-cert, *router-microservice-defaults, *gc-light]
|
||||||
- *cert-volume
|
# ConnectionStrings__Default: *postgres-connection
|
||||||
ports:
|
# ConnectionStrings__Redis: "cache.stella-ops.local:6379"
|
||||||
- "127.1.0.13:80:80"
|
# Router__Enabled: "${VULNEXPLORER_ROUTER_ENABLED:-true}"
|
||||||
networks:
|
# Router__Messaging__ConsumerGroup: "vulnexplorer"
|
||||||
stellaops:
|
# volumes:
|
||||||
aliases:
|
# - *cert-volume
|
||||||
- vulnexplorer.stella-ops.local
|
# ports:
|
||||||
frontdoor: {}
|
# - "127.1.0.13:80:80"
|
||||||
healthcheck:
|
# networks:
|
||||||
test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/$(hostname)/80'"]
|
# stellaops:
|
||||||
<<: *healthcheck-tcp
|
# aliases:
|
||||||
labels: *release-labels
|
# - vulnexplorer.stella-ops.local
|
||||||
|
# frontdoor: {}
|
||||||
|
# healthcheck:
|
||||||
|
# test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/$(hostname)/80'"]
|
||||||
|
# <<: *healthcheck-tcp
|
||||||
|
# labels: *release-labels
|
||||||
|
|
||||||
# --- Slot 14: Policy Engine ------------------------------------------------
|
# --- Slot 14: Policy Engine ------------------------------------------------
|
||||||
policy-engine:
|
policy-engine:
|
||||||
@@ -1568,6 +1573,7 @@ services:
|
|||||||
stellaops:
|
stellaops:
|
||||||
aliases:
|
aliases:
|
||||||
- findings.stella-ops.local
|
- findings.stella-ops.local
|
||||||
|
- vulnexplorer.stella-ops.local
|
||||||
frontdoor: {}
|
frontdoor: {}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/$(hostname)/80'"]
|
test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/$(hostname)/80'"]
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
127.1.0.10 excititor.stella-ops.local
|
127.1.0.10 excititor.stella-ops.local
|
||||||
127.1.0.11 vexhub.stella-ops.local
|
127.1.0.11 vexhub.stella-ops.local
|
||||||
127.1.0.12 vexlens.stella-ops.local
|
127.1.0.12 vexlens.stella-ops.local
|
||||||
127.1.0.13 vulnexplorer.stella-ops.local
|
# 127.1.0.13 vulnexplorer.stella-ops.local # MERGED into findings-ledger-web (SPRINT_20260408_002)
|
||||||
127.1.0.14 policy-engine.stella-ops.local
|
127.1.0.14 policy-engine.stella-ops.local
|
||||||
127.1.0.15 policy-gateway.stella-ops.local
|
127.1.0.15 policy-gateway.stella-ops.local
|
||||||
127.1.0.16 riskengine.stella-ops.local
|
127.1.0.16 riskengine.stella-ops.local
|
||||||
|
|||||||
@@ -118,7 +118,7 @@
|
|||||||
{ "Type": "Microservice", "Path": "^/api/(compare|change-traces|sbomservice)(.*)", "IsRegex": true, "TranslatesTo": "http://sbomservice.stella-ops.local/api/$1$2" },
|
{ "Type": "Microservice", "Path": "^/api/(compare|change-traces|sbomservice)(.*)", "IsRegex": true, "TranslatesTo": "http://sbomservice.stella-ops.local/api/$1$2" },
|
||||||
{ "Type": "Microservice", "Path": "^/api/fix-verification(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/fix-verification$1" },
|
{ "Type": "Microservice", "Path": "^/api/fix-verification(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/fix-verification$1" },
|
||||||
{ "Type": "Microservice", "Path": "^/api/verdicts(.*)", "IsRegex": true, "TranslatesTo": "https://evidencelocker.stella-ops.local/api/verdicts$1" },
|
{ "Type": "Microservice", "Path": "^/api/verdicts(.*)", "IsRegex": true, "TranslatesTo": "https://evidencelocker.stella-ops.local/api/verdicts$1" },
|
||||||
{ "Type": "Microservice", "Path": "^/api/vuln-explorer(.*)", "IsRegex": true, "TranslatesTo": "http://vulnexplorer.stella-ops.local/api/vuln-explorer$1" },
|
{ "Type": "Microservice", "Path": "^/api/vuln-explorer(.*)", "IsRegex": true, "TranslatesTo": "http://findings.stella-ops.local/api/vuln-explorer$1" },
|
||||||
{ "Type": "Microservice", "Path": "^/api/vex(.*)", "IsRegex": true, "TranslatesTo": "https://vexhub.stella-ops.local/api/vex$1" },
|
{ "Type": "Microservice", "Path": "^/api/vex(.*)", "IsRegex": true, "TranslatesTo": "https://vexhub.stella-ops.local/api/vex$1" },
|
||||||
{ "Type": "Microservice", "Path": "^/api/admin/plans(.*)", "IsRegex": true, "TranslatesTo": "http://registry-token.stella-ops.local/api/admin/plans$1" },
|
{ "Type": "Microservice", "Path": "^/api/admin/plans(.*)", "IsRegex": true, "TranslatesTo": "http://registry-token.stella-ops.local/api/admin/plans$1" },
|
||||||
{ "Type": "Microservice", "Path": "^/api/admin(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/admin$1" },
|
{ "Type": "Microservice", "Path": "^/api/admin(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/admin$1" },
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ excititor-worker|devops/docker/Dockerfile.hardened.template|src/Concelier/Stella
|
|||||||
vexhub-web|devops/docker/Dockerfile.hardened.template|src/VexHub/StellaOps.VexHub.WebService/StellaOps.VexHub.WebService.csproj|StellaOps.VexHub.WebService|8080
|
vexhub-web|devops/docker/Dockerfile.hardened.template|src/VexHub/StellaOps.VexHub.WebService/StellaOps.VexHub.WebService.csproj|StellaOps.VexHub.WebService|8080
|
||||||
# ── Slot 12: VexLens ────────────────────────────────────────────────────────────
|
# ── Slot 12: VexLens ────────────────────────────────────────────────────────────
|
||||||
vexlens-web|devops/docker/Dockerfile.hardened.template|src/VexLens/StellaOps.VexLens.WebService/StellaOps.VexLens.WebService.csproj|StellaOps.VexLens.WebService|8080
|
vexlens-web|devops/docker/Dockerfile.hardened.template|src/VexLens/StellaOps.VexLens.WebService/StellaOps.VexLens.WebService.csproj|StellaOps.VexLens.WebService|8080
|
||||||
# ── Slot 13: VulnExplorer (api) ─────────────────────────────────────────────────
|
# ── Slot 13: VulnExplorer (api) - MERGED into Findings Ledger (SPRINT_20260408_002) ──
|
||||||
api|devops/docker/Dockerfile.hardened.template|src/Findings/StellaOps.VulnExplorer.Api/StellaOps.VulnExplorer.Api.csproj|StellaOps.VulnExplorer.Api|8080
|
# api|devops/docker/Dockerfile.hardened.template|src/Findings/StellaOps.VulnExplorer.Api/StellaOps.VulnExplorer.Api.csproj|StellaOps.VulnExplorer.Api|8080
|
||||||
# ── Slot 14: Policy Engine ──────────────────────────────────────────────────────
|
# ── Slot 14: Policy Engine ──────────────────────────────────────────────────────
|
||||||
policy-engine|devops/docker/Dockerfile.hardened.template|src/Policy/StellaOps.Policy.Engine/StellaOps.Policy.Engine.csproj|StellaOps.Policy.Engine|8080
|
policy-engine|devops/docker/Dockerfile.hardened.template|src/Policy/StellaOps.Policy.Engine/StellaOps.Policy.Engine.csproj|StellaOps.Policy.Engine|8080
|
||||||
# ── Slot 15: Policy Gateway ─────────────────────────────────────────────────────
|
# ── Slot 15: Policy Gateway ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ The `src/Findings/` directory is the unified home for all findings-related servi
|
|||||||
|
|
||||||
- **Findings Ledger** (`StellaOps.Findings.Ledger`, `StellaOps.Findings.Ledger.WebService`): Core append-only event ledger.
|
- **Findings Ledger** (`StellaOps.Findings.Ledger`, `StellaOps.Findings.Ledger.WebService`): Core append-only event ledger.
|
||||||
- **RiskEngine** (`StellaOps.RiskEngine.Core`, `StellaOps.RiskEngine.WebService`, `StellaOps.RiskEngine.Worker`): Computes risk scores using CVSS, EPSS, KEV, exploit maturity, fix-chain attestation, and VEX gates. Infrastructure lives under `__Libraries/StellaOps.RiskEngine.Infrastructure`.
|
- **RiskEngine** (`StellaOps.RiskEngine.Core`, `StellaOps.RiskEngine.WebService`, `StellaOps.RiskEngine.Worker`): Computes risk scores using CVSS, EPSS, KEV, exploit maturity, fix-chain attestation, and VEX gates. Infrastructure lives under `__Libraries/StellaOps.RiskEngine.Infrastructure`.
|
||||||
- **VulnExplorer** (`StellaOps.VulnExplorer.Api`): API surface for browsing findings, evidence subgraphs, triage workflows, and VEX decision management. Shared contracts from `StellaOps.VulnExplorer.WebService`.
|
- **VulnExplorer** (merged into Findings Ledger WebService, SPRINT_20260408_002): VulnExplorer endpoints (`/v1/vulns`, `/v1/vex-decisions`, `/v1/evidence-subgraph`, `/v1/fix-verifications`, `/v1/audit-bundles`) are now served by `StellaOps.Findings.Ledger.WebService`. Contracts live under `Contracts/VulnExplorer/`, adapter services under `Services/VulnExplorerAdapters.cs`. The standalone `StellaOps.VulnExplorer.Api` container (`stellaops-api`) has been decommissioned.
|
||||||
|
|
||||||
Previously archived docs for RiskEngine and VulnExplorer are in `docs-archived/modules/risk-engine/` and `docs-archived/modules/vuln-explorer/`.
|
Previously archived docs for RiskEngine and VulnExplorer are in `docs-archived/modules/risk-engine/` and `docs-archived/modules/vuln-explorer/`.
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ Concise descriptions of every top-level component under `src/`, summarising the
|
|||||||
- **Findings** — Materialises effective findings from Policy Engine outputs and evidence. Feeds UI, CLI, Notify, and Governance dashboards (`docs/modules/policy/architecture.md`, findings sections).
|
- **Findings** — Materialises effective findings from Policy Engine outputs and evidence. Feeds UI, CLI, Notify, and Governance dashboards (`docs/modules/policy/architecture.md`, findings sections).
|
||||||
- **Cartographer** — Builds identity graphs from SBOM/advisory data for Graph Explorer and RiskEngine (`docs/modules/graph/architecture.md`).
|
- **Cartographer** — Builds identity graphs from SBOM/advisory data for Graph Explorer and RiskEngine (`docs/modules/graph/architecture.md`).
|
||||||
- **Graph** — Graph API + indexer, exposing relationship queries to UI/CLI/Scheduler (`docs/modules/graph/architecture.md`).
|
- **Graph** — Graph API + indexer, exposing relationship queries to UI/CLI/Scheduler (`docs/modules/graph/architecture.md`).
|
||||||
- **VulnExplorer** — Explorer for vulnerabilities that combines Concelier data, graph overlays, and Policy results for UI/CLI consumption (`docs/modules/vuln-explorer/architecture.md`).
|
- **VulnExplorer** — _(merged into Findings Ledger)_ Explorer for vulnerabilities that combines Concelier data, graph overlays, and Policy results for UI/CLI consumption. Endpoints now served by `src/Findings/StellaOps.Findings.Ledger.WebService`.
|
||||||
|
|
||||||
## Policy & Governance
|
## Policy & Governance
|
||||||
- **Policy** — Policy Engine core libraries and services executing lattice logic across SBOM, advisory, and VEX evidence. Emits explain traces, drives Findings, Notifier, and Export Center (`docs/modules/policy/architecture.md`).
|
- **Policy** — Policy Engine core libraries and services executing lattice logic across SBOM, advisory, and VEX evidence. Emits explain traces, drives Findings, Notifier, and Export Center (`docs/modules/policy/architecture.md`).
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ The solution contains **46 top-level modules** in `src/`. The architecture docum
|
|||||||
| Data Ingestion | 7 | Concelier, Excititor, VexLens, VexHub, IssuerDirectory, Feedser, Mirror |
|
| Data Ingestion | 7 | Concelier, Excititor, VexLens, VexHub, IssuerDirectory, Feedser, Mirror |
|
||||||
| Scanning & Analysis | 5 | Scanner, BinaryIndex, AdvisoryAI, Symbols, ReachGraph |
|
| Scanning & Analysis | 5 | Scanner, BinaryIndex, AdvisoryAI, Symbols, ReachGraph |
|
||||||
| Artifacts & Evidence | 7 | Attestor, Signer, SbomService, EvidenceLocker, ExportCenter, Provenance, Provcache |
|
| Artifacts & Evidence | 7 | Attestor, Signer, SbomService, EvidenceLocker, ExportCenter, Provenance, Provcache |
|
||||||
| Policy & Risk | 4 | Policy, RiskEngine, VulnExplorer, Unknowns |
|
| Policy & Risk | 3 | Policy, RiskEngine, Unknowns (VulnExplorer merged into Findings Ledger) |
|
||||||
| Operations | 8 | Scheduler, Orchestrator, TaskRunner, Notify, Notifier, PacksRegistry, TimelineIndexer, Replay |
|
| Operations | 8 | Scheduler, Orchestrator, TaskRunner, Notify, Notifier, PacksRegistry, TimelineIndexer, Replay |
|
||||||
| Integration | 5 | CLI, Zastava, Web, API, Registry |
|
| Integration | 5 | CLI, Zastava, Web, API, Registry |
|
||||||
| Infrastructure | 6 | Cryptography, Telemetry, Graph, Signals, AirGap, AOC |
|
| Infrastructure | 6 | Cryptography, Telemetry, Graph, Signals, AirGap, AOC |
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ This page focuses on deterministic slot/port allocation and may include legacy o
|
|||||||
| 10 | 10100 | 10101 | Excititor | `excititor.stella-ops.local` | `src/Concelier/StellaOps.Excititor.WebService` | `STELLAOPS_EXCITITOR_URL` |
|
| 10 | 10100 | 10101 | Excititor | `excititor.stella-ops.local` | `src/Concelier/StellaOps.Excititor.WebService` | `STELLAOPS_EXCITITOR_URL` |
|
||||||
| 11 | 10110 | 10111 | VexHub | `vexhub.stella-ops.local` | `src/VexHub/StellaOps.VexHub.WebService` | `STELLAOPS_VEXHUB_URL` |
|
| 11 | 10110 | 10111 | VexHub | `vexhub.stella-ops.local` | `src/VexHub/StellaOps.VexHub.WebService` | `STELLAOPS_VEXHUB_URL` |
|
||||||
| 12 | 10120 | 10121 | VexLens | `vexlens.stella-ops.local` | `src/VexLens/StellaOps.VexLens.WebService` | `STELLAOPS_VEXLENS_URL` |
|
| 12 | 10120 | 10121 | VexLens | `vexlens.stella-ops.local` | `src/VexLens/StellaOps.VexLens.WebService` | `STELLAOPS_VEXLENS_URL` |
|
||||||
| 13 | 10130 | 10131 | VulnExplorer | `vulnexplorer.stella-ops.local` | `src/Findings/StellaOps.VulnExplorer.Api` | `STELLAOPS_VULNEXPLORER_URL` |
|
| 13 | 10130 | 10131 | VulnExplorer (merged into Findings Ledger) | `vulnexplorer.stella-ops.local` (alias on findings-ledger-web) | `src/Findings/StellaOps.Findings.Ledger.WebService` | `STELLAOPS_VULNEXPLORER_URL` |
|
||||||
| 14 | 10140 | 10141 | Policy Engine | `policy-engine.stella-ops.local` | `src/Policy/StellaOps.Policy.Engine` | `STELLAOPS_POLICY_ENGINE_URL` |
|
| 14 | 10140 | 10141 | Policy Engine | `policy-engine.stella-ops.local` | `src/Policy/StellaOps.Policy.Engine` | `STELLAOPS_POLICY_ENGINE_URL` |
|
||||||
| 15 | 10150 | 10151 | Policy Gateway | `policy-gateway.stella-ops.local` | `src/Policy/StellaOps.Policy.Gateway` | `STELLAOPS_POLICY_GATEWAY_URL` |
|
| 15 | 10150 | 10151 | Policy Gateway | `policy-gateway.stella-ops.local` | `src/Policy/StellaOps.Policy.Gateway` | `STELLAOPS_POLICY_GATEWAY_URL` |
|
||||||
| 16 | 10160 | 10161 | RiskEngine | `riskengine.stella-ops.local` | `src/Findings/StellaOps.RiskEngine.WebService` | `STELLAOPS_RISKENGINE_URL` |
|
| 16 | 10160 | 10161 | RiskEngine | `riskengine.stella-ops.local` | `src/Findings/StellaOps.RiskEngine.WebService` | `STELLAOPS_RISKENGINE_URL` |
|
||||||
@@ -123,7 +123,7 @@ Add the following to your hosts file (`C:\Windows\System32\drivers\etc\hosts` on
|
|||||||
127.1.0.10 excititor.stella-ops.local
|
127.1.0.10 excititor.stella-ops.local
|
||||||
127.1.0.11 vexhub.stella-ops.local
|
127.1.0.11 vexhub.stella-ops.local
|
||||||
127.1.0.12 vexlens.stella-ops.local
|
127.1.0.12 vexlens.stella-ops.local
|
||||||
127.1.0.13 vulnexplorer.stella-ops.local
|
# 127.1.0.13 vulnexplorer.stella-ops.local # MERGED: alias on findings-ledger-web
|
||||||
127.1.0.14 policy-engine.stella-ops.local
|
127.1.0.14 policy-engine.stella-ops.local
|
||||||
127.1.0.15 policy-gateway.stella-ops.local
|
127.1.0.15 policy-gateway.stella-ops.local
|
||||||
127.1.0.16 riskengine.stella-ops.local
|
127.1.0.16 riskengine.stella-ops.local
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ This page is the source-of-truth inventory for Stella Ops `*.WebService` runtime
|
|||||||
## Scope and contract
|
## Scope and contract
|
||||||
- Inventory source: `rg --files src -g "*WebService.csproj"`.
|
- Inventory source: `rg --files src -g "*WebService.csproj"`.
|
||||||
- Includes active runtime webservices only (31 services).
|
- Includes active runtime webservices only (31 services).
|
||||||
- Excludes non-`WebService` API binaries (for example `StellaOps.Policy.Engine`, `StellaOps.Policy.Gateway`, `StellaOps.Graph.Api`, `StellaOps.VulnExplorer.Api`, `StellaOps.Symbols.Server`, `StellaOps.Registry.TokenService`, `StellaOps.SmRemote.Service`) even though they may bind `*.stella-ops.local` aliases.
|
- Excludes non-`WebService` API binaries (for example `StellaOps.Policy.Engine`, `StellaOps.Policy.Gateway`, `StellaOps.Graph.Api`, `StellaOps.Symbols.Server`, `StellaOps.Registry.TokenService`, `StellaOps.SmRemote.Service`) even though they may bind `*.stella-ops.local` aliases. Note: `StellaOps.VulnExplorer.Api` has been merged into `StellaOps.Findings.Ledger.WebService` (SPRINT_20260408_002).
|
||||||
- Canonical runtime hostname form: `<service>.stella-ops.local`.
|
- Canonical runtime hostname form: `<service>.stella-ops.local`.
|
||||||
|
|
||||||
## Runtime hostname convention and exceptions
|
## Runtime hostname convention and exceptions
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
|
||||||
|
// Migrated from StellaOps.VulnExplorer.Api.Models during VulnExplorer -> Ledger merge.
|
||||||
|
|
||||||
|
namespace StellaOps.Findings.Ledger.WebService.Contracts.VulnExplorer;
|
||||||
|
|
||||||
|
public sealed record VulnScanAttestationDto(
|
||||||
|
string Type,
|
||||||
|
string PredicateType,
|
||||||
|
IReadOnlyList<AttestationSubjectDto> Subject,
|
||||||
|
VulnScanPredicateDto Predicate,
|
||||||
|
AttestationMetaDto AttestationMeta);
|
||||||
|
|
||||||
|
public sealed record AttestationSubjectDto(
|
||||||
|
string Name,
|
||||||
|
IReadOnlyDictionary<string, string> Digest);
|
||||||
|
|
||||||
|
public sealed record VulnScanPredicateDto(
|
||||||
|
ScannerInfoDto Scanner,
|
||||||
|
ScannerDbInfoDto? ScannerDb,
|
||||||
|
DateTimeOffset ScanStartedAt,
|
||||||
|
DateTimeOffset ScanCompletedAt,
|
||||||
|
SeverityCountsDto SeverityCounts,
|
||||||
|
FindingReportDto FindingReport);
|
||||||
|
|
||||||
|
public sealed record ScannerInfoDto(
|
||||||
|
string Name,
|
||||||
|
string Version);
|
||||||
|
|
||||||
|
public sealed record ScannerDbInfoDto(
|
||||||
|
DateTimeOffset? LastUpdatedAt);
|
||||||
|
|
||||||
|
public sealed record SeverityCountsDto(
|
||||||
|
int Critical,
|
||||||
|
int High,
|
||||||
|
int Medium,
|
||||||
|
int Low);
|
||||||
|
|
||||||
|
public sealed record FindingReportDto(
|
||||||
|
string MediaType,
|
||||||
|
string Location,
|
||||||
|
IReadOnlyDictionary<string, string> Digest);
|
||||||
|
|
||||||
|
public sealed record AttestationMetaDto(
|
||||||
|
string StatementId,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
AttestationSignerDto Signer);
|
||||||
|
|
||||||
|
public sealed record AttestationSignerDto(
|
||||||
|
string Name,
|
||||||
|
string KeyId);
|
||||||
|
|
||||||
|
public sealed record AttestationListResponse(
|
||||||
|
IReadOnlyList<AttestationSummaryDto> Items,
|
||||||
|
string? NextPageToken);
|
||||||
|
|
||||||
|
public sealed record AttestationSummaryDto(
|
||||||
|
string Id,
|
||||||
|
VxAttestationType Type,
|
||||||
|
string SubjectName,
|
||||||
|
IReadOnlyDictionary<string, string> SubjectDigest,
|
||||||
|
string PredicateType,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
string? SignerName,
|
||||||
|
string? SignerKeyId,
|
||||||
|
bool Verified);
|
||||||
|
|
||||||
|
public enum VxAttestationType
|
||||||
|
{
|
||||||
|
VulnScan,
|
||||||
|
Sbom,
|
||||||
|
Vex,
|
||||||
|
PolicyEval,
|
||||||
|
Other
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
|
||||||
|
// Migrated from StellaOps.VulnExplorer.WebService.Contracts during VulnExplorer -> Ledger merge.
|
||||||
|
// These contracts preserve the VulnExplorer API shape for backward compatibility.
|
||||||
|
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace StellaOps.Findings.Ledger.WebService.Contracts.VulnExplorer;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response containing the evidence subgraph for a finding.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record EvidenceSubgraphResponse
|
||||||
|
{
|
||||||
|
public required string FindingId { get; init; }
|
||||||
|
public required string VulnId { get; init; }
|
||||||
|
public required VxEvidenceNode Root { get; init; }
|
||||||
|
public required IReadOnlyList<VxEvidenceEdge> Edges { get; init; }
|
||||||
|
public required VxVerdictSummary Verdict { get; init; }
|
||||||
|
public required IReadOnlyList<VxTriageAction> AvailableActions { get; init; }
|
||||||
|
public VxEvidenceMetadata? Metadata { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Node in the evidence graph (VulnExplorer shape).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record VxEvidenceNode
|
||||||
|
{
|
||||||
|
public required string Id { get; init; }
|
||||||
|
public required VxEvidenceNodeType Type { get; init; }
|
||||||
|
public required string Label { get; init; }
|
||||||
|
public string? Description { get; init; }
|
||||||
|
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
|
||||||
|
public IReadOnlyList<VxEvidenceNode>? Children { get; init; }
|
||||||
|
public bool IsExpandable { get; init; }
|
||||||
|
public VxEvidenceNodeStatus Status { get; init; } = VxEvidenceNodeStatus.Info;
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||||
|
public enum VxEvidenceNodeType
|
||||||
|
{
|
||||||
|
Artifact,
|
||||||
|
Package,
|
||||||
|
Symbol,
|
||||||
|
CallPath,
|
||||||
|
VexClaim,
|
||||||
|
PolicyRule,
|
||||||
|
AdvisorySource,
|
||||||
|
ScannerEvidence,
|
||||||
|
RuntimeObservation,
|
||||||
|
Configuration,
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||||
|
public enum VxEvidenceNodeStatus
|
||||||
|
{
|
||||||
|
Info,
|
||||||
|
Pass,
|
||||||
|
Fail,
|
||||||
|
Warning,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Edge connecting two evidence nodes (VulnExplorer shape).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record VxEvidenceEdge
|
||||||
|
{
|
||||||
|
public required string SourceId { get; init; }
|
||||||
|
public required string TargetId { get; init; }
|
||||||
|
public required string Relationship { get; init; }
|
||||||
|
public required VxEvidenceCitation Citation { get; init; }
|
||||||
|
public bool IsReachable { get; init; }
|
||||||
|
public double? Weight { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record VxEvidenceCitation
|
||||||
|
{
|
||||||
|
public required string Source { get; init; }
|
||||||
|
public required string SourceUrl { get; init; }
|
||||||
|
public required DateTimeOffset ObservedAt { get; init; }
|
||||||
|
public double? Confidence { get; init; }
|
||||||
|
public string? EvidenceHash { get; init; }
|
||||||
|
public bool IsVerified { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Summary verdict for a finding (VulnExplorer shape).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record VxVerdictSummary
|
||||||
|
{
|
||||||
|
public required string Decision { get; init; }
|
||||||
|
public required string Explanation { get; init; }
|
||||||
|
public required IReadOnlyList<string> KeyFactors { get; init; }
|
||||||
|
public required double ConfidenceScore { get; init; }
|
||||||
|
public IReadOnlyList<string>? AppliedPolicies { get; init; }
|
||||||
|
public DateTimeOffset? ComputedAt { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Available triage action (VulnExplorer shape).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record VxTriageAction
|
||||||
|
{
|
||||||
|
public required string ActionId { get; init; }
|
||||||
|
public required VxTriageActionType Type { get; init; }
|
||||||
|
public required string Label { get; init; }
|
||||||
|
public string? Description { get; init; }
|
||||||
|
public bool RequiresConfirmation { get; init; }
|
||||||
|
public bool IsEnabled { get; init; } = true;
|
||||||
|
public string? DisabledReason { get; init; }
|
||||||
|
public IReadOnlyDictionary<string, object>? Parameters { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||||
|
public enum VxTriageActionType
|
||||||
|
{
|
||||||
|
AcceptVendorVex,
|
||||||
|
RequestEvidence,
|
||||||
|
OpenDiff,
|
||||||
|
CreateException,
|
||||||
|
MarkFalsePositive,
|
||||||
|
EscalateToSecurityTeam,
|
||||||
|
ApplyInternalVex,
|
||||||
|
SchedulePatch,
|
||||||
|
Suppress,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record VxEvidenceMetadata
|
||||||
|
{
|
||||||
|
public DateTimeOffset CollectedAt { get; init; }
|
||||||
|
public int NodeCount { get; init; }
|
||||||
|
public int EdgeCount { get; init; }
|
||||||
|
public bool IsTruncated { get; init; }
|
||||||
|
public int MaxDepth { get; init; }
|
||||||
|
public IReadOnlyList<string>? Sources { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
|
||||||
|
// Migrated from StellaOps.VulnExplorer.Api.Models during VulnExplorer -> Ledger merge.
|
||||||
|
|
||||||
|
namespace StellaOps.Findings.Ledger.WebService.Contracts.VulnExplorer;
|
||||||
|
|
||||||
|
public sealed record FixVerificationResponse
|
||||||
|
{
|
||||||
|
public required string CveId { get; init; }
|
||||||
|
public required string ComponentPurl { get; init; }
|
||||||
|
public required bool HasAttestation { get; init; }
|
||||||
|
public required string Verdict { get; init; }
|
||||||
|
public required decimal Confidence { get; init; }
|
||||||
|
public required string VerdictLabel { get; init; }
|
||||||
|
public FixVerificationGoldenSetRef? GoldenSet { get; init; }
|
||||||
|
public FixVerificationAnalysis? Analysis { get; init; }
|
||||||
|
public FixVerificationRiskImpact? RiskImpact { get; init; }
|
||||||
|
public FixVerificationEvidenceChain? EvidenceChain { get; init; }
|
||||||
|
public DateTimeOffset? VerifiedAt { get; init; }
|
||||||
|
public IReadOnlyList<string> Rationale { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record FixVerificationGoldenSetRef
|
||||||
|
{
|
||||||
|
public required string Id { get; init; }
|
||||||
|
public required string Digest { get; init; }
|
||||||
|
public string? ReviewedBy { get; init; }
|
||||||
|
public DateTimeOffset? ReviewedAt { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record FixVerificationAnalysis
|
||||||
|
{
|
||||||
|
public IReadOnlyList<FunctionChangeResult> Functions { get; init; } = [];
|
||||||
|
public ReachabilityChangeResult? Reachability { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record FunctionChangeResult
|
||||||
|
{
|
||||||
|
public required string FunctionName { get; init; }
|
||||||
|
public required string Status { get; init; }
|
||||||
|
public required string StatusIcon { get; init; }
|
||||||
|
public required string Details { get; init; }
|
||||||
|
public IReadOnlyList<FunctionChangeChild> Children { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record FunctionChangeChild
|
||||||
|
{
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public required string Status { get; init; }
|
||||||
|
public required string StatusIcon { get; init; }
|
||||||
|
public required string Details { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ReachabilityChangeResult
|
||||||
|
{
|
||||||
|
public required int PrePatchPaths { get; init; }
|
||||||
|
public required int PostPatchPaths { get; init; }
|
||||||
|
public required bool AllPathsEliminated { get; init; }
|
||||||
|
public required string Summary { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record FixVerificationRiskImpact
|
||||||
|
{
|
||||||
|
public required decimal BaseScore { get; init; }
|
||||||
|
public required string BaseSeverity { get; init; }
|
||||||
|
public required decimal AdjustmentPercent { get; init; }
|
||||||
|
public required decimal FinalScore { get; init; }
|
||||||
|
public required string FinalSeverity { get; init; }
|
||||||
|
public required int ProgressValue { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record FixVerificationEvidenceChain
|
||||||
|
{
|
||||||
|
public EvidenceChainItem? Sbom { get; init; }
|
||||||
|
public EvidenceChainItem? GoldenSet { get; init; }
|
||||||
|
public EvidenceChainItem? DiffReport { get; init; }
|
||||||
|
public EvidenceChainItem? Attestation { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record EvidenceChainItem
|
||||||
|
{
|
||||||
|
public required string Label { get; init; }
|
||||||
|
public required string DigestShort { get; init; }
|
||||||
|
public required string DigestFull { get; init; }
|
||||||
|
public string? DownloadUrl { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
|
||||||
|
// Migrated from StellaOps.VulnExplorer.Api.Data during VulnExplorer -> Ledger merge.
|
||||||
|
|
||||||
|
namespace StellaOps.Findings.Ledger.WebService.Contracts.VulnExplorer;
|
||||||
|
|
||||||
|
public sealed record CreateFixVerificationRequest(
|
||||||
|
string CveId,
|
||||||
|
string ComponentPurl,
|
||||||
|
string? ArtifactDigest);
|
||||||
|
|
||||||
|
public sealed record UpdateFixVerificationRequest(string Verdict);
|
||||||
|
|
||||||
|
public sealed record CreateAuditBundleRequest(
|
||||||
|
string Tenant,
|
||||||
|
IReadOnlyList<Guid>? DecisionIds);
|
||||||
|
|
||||||
|
public sealed record AuditBundleResponse(
|
||||||
|
string BundleId,
|
||||||
|
string Tenant,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
IReadOnlyList<VexDecisionDto> Decisions,
|
||||||
|
IReadOnlyList<string> EvidenceRefs);
|
||||||
|
|
||||||
|
public sealed record FixVerificationTransition(
|
||||||
|
string From,
|
||||||
|
string To,
|
||||||
|
DateTimeOffset ChangedAt);
|
||||||
|
|
||||||
|
public sealed record FixVerificationRecord(
|
||||||
|
string CveId,
|
||||||
|
string ComponentPurl,
|
||||||
|
string? ArtifactDigest,
|
||||||
|
string Verdict,
|
||||||
|
IReadOnlyList<FixVerificationTransition> Transitions,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
DateTimeOffset UpdatedAt);
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
|
||||||
|
// Migrated from StellaOps.VulnExplorer.Api.Models during VulnExplorer -> Ledger merge.
|
||||||
|
|
||||||
|
namespace StellaOps.Findings.Ledger.WebService.Contracts.VulnExplorer;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// VEX-style statement attached to a finding + subject, representing a vulnerability exploitability decision.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record VexDecisionDto(
|
||||||
|
Guid Id,
|
||||||
|
string VulnerabilityId,
|
||||||
|
SubjectRefDto Subject,
|
||||||
|
VexStatus Status,
|
||||||
|
VexJustificationType JustificationType,
|
||||||
|
string? JustificationText,
|
||||||
|
IReadOnlyList<EvidenceRefDto>? EvidenceRefs,
|
||||||
|
VexScopeDto? Scope,
|
||||||
|
ValidForDto? ValidFor,
|
||||||
|
AttestationRefDto? AttestationRef,
|
||||||
|
VexOverrideAttestationDto? SignedOverride,
|
||||||
|
Guid? SupersedesDecisionId,
|
||||||
|
ActorRefDto CreatedBy,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
DateTimeOffset? UpdatedAt);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Signed VEX override attestation details.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record VexOverrideAttestationDto(
|
||||||
|
string EnvelopeDigest,
|
||||||
|
string PredicateType,
|
||||||
|
long? RekorLogIndex,
|
||||||
|
string? RekorEntryId,
|
||||||
|
string? StorageRef,
|
||||||
|
DateTimeOffset AttestationCreatedAt,
|
||||||
|
bool Verified,
|
||||||
|
AttestationVerificationStatusDto? VerificationStatus);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attestation verification status details.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AttestationVerificationStatusDto(
|
||||||
|
bool SignatureValid,
|
||||||
|
bool? RekorVerified,
|
||||||
|
DateTimeOffset? VerifiedAt,
|
||||||
|
string? ErrorMessage);
|
||||||
|
|
||||||
|
public sealed record SubjectRefDto(
|
||||||
|
SubjectType Type,
|
||||||
|
string Name,
|
||||||
|
IReadOnlyDictionary<string, string> Digest,
|
||||||
|
string? SbomNodeId = null);
|
||||||
|
|
||||||
|
public sealed record EvidenceRefDto(
|
||||||
|
EvidenceType Type,
|
||||||
|
Uri Url,
|
||||||
|
string? Title = null);
|
||||||
|
|
||||||
|
public sealed record VexScopeDto(
|
||||||
|
IReadOnlyList<string>? Environments,
|
||||||
|
IReadOnlyList<string>? Projects);
|
||||||
|
|
||||||
|
public sealed record ValidForDto(
|
||||||
|
DateTimeOffset? NotBefore,
|
||||||
|
DateTimeOffset? NotAfter);
|
||||||
|
|
||||||
|
public sealed record AttestationRefDto(
|
||||||
|
string? Id,
|
||||||
|
IReadOnlyDictionary<string, string>? Digest,
|
||||||
|
string? Storage);
|
||||||
|
|
||||||
|
public sealed record ActorRefDto(
|
||||||
|
string Id,
|
||||||
|
string DisplayName);
|
||||||
|
|
||||||
|
public enum VexStatus
|
||||||
|
{
|
||||||
|
NotAffected,
|
||||||
|
AffectedMitigated,
|
||||||
|
AffectedUnmitigated,
|
||||||
|
Fixed
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SubjectType
|
||||||
|
{
|
||||||
|
Image,
|
||||||
|
Repo,
|
||||||
|
SbomComponent,
|
||||||
|
Other
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum EvidenceType
|
||||||
|
{
|
||||||
|
Pr,
|
||||||
|
Ticket,
|
||||||
|
Doc,
|
||||||
|
Commit,
|
||||||
|
Other
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum VexJustificationType
|
||||||
|
{
|
||||||
|
CodeNotPresent,
|
||||||
|
CodeNotReachable,
|
||||||
|
VulnerableCodeNotInExecutePath,
|
||||||
|
ConfigurationNotAffected,
|
||||||
|
OsNotAffected,
|
||||||
|
RuntimeMitigationPresent,
|
||||||
|
CompensatingControls,
|
||||||
|
AcceptedBusinessRisk,
|
||||||
|
Other
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request to create a new VEX decision.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CreateVexDecisionRequest(
|
||||||
|
string VulnerabilityId,
|
||||||
|
SubjectRefDto Subject,
|
||||||
|
VexStatus Status,
|
||||||
|
VexJustificationType JustificationType,
|
||||||
|
string? JustificationText,
|
||||||
|
IReadOnlyList<EvidenceRefDto>? EvidenceRefs,
|
||||||
|
VexScopeDto? Scope,
|
||||||
|
ValidForDto? ValidFor,
|
||||||
|
Guid? SupersedesDecisionId,
|
||||||
|
AttestationRequestOptions? AttestationOptions);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Options for creating a signed attestation with the VEX decision.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AttestationRequestOptions(
|
||||||
|
bool CreateAttestation,
|
||||||
|
bool AnchorToRekor = false,
|
||||||
|
string? SigningKeyId = null,
|
||||||
|
string? StorageDestination = null,
|
||||||
|
IReadOnlyDictionary<string, string>? AdditionalMetadata = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request to update an existing VEX decision.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record UpdateVexDecisionRequest(
|
||||||
|
VexStatus? Status,
|
||||||
|
VexJustificationType? JustificationType,
|
||||||
|
string? JustificationText,
|
||||||
|
IReadOnlyList<EvidenceRefDto>? EvidenceRefs,
|
||||||
|
VexScopeDto? Scope,
|
||||||
|
ValidForDto? ValidFor,
|
||||||
|
Guid? SupersedesDecisionId,
|
||||||
|
AttestationRequestOptions? AttestationOptions);
|
||||||
|
|
||||||
|
public sealed record VexDecisionListResponse(
|
||||||
|
IReadOnlyList<VexDecisionDto> Items,
|
||||||
|
string? NextPageToken);
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
|
||||||
|
// Migrated from StellaOps.VulnExplorer.Api.Models during VulnExplorer -> Ledger merge.
|
||||||
|
|
||||||
|
namespace StellaOps.Findings.Ledger.WebService.Contracts.VulnExplorer;
|
||||||
|
|
||||||
|
public sealed record VulnSummary(
|
||||||
|
string Id,
|
||||||
|
string Severity,
|
||||||
|
double Score,
|
||||||
|
bool Kev,
|
||||||
|
string Exploitability,
|
||||||
|
bool FixAvailable,
|
||||||
|
IReadOnlyList<string> CveIds,
|
||||||
|
IReadOnlyList<string> Purls,
|
||||||
|
string PolicyVersion,
|
||||||
|
string RationaleId);
|
||||||
|
|
||||||
|
public sealed record VulnDetail(
|
||||||
|
string Id,
|
||||||
|
string Severity,
|
||||||
|
double Score,
|
||||||
|
bool Kev,
|
||||||
|
string Exploitability,
|
||||||
|
bool FixAvailable,
|
||||||
|
IReadOnlyList<string> CveIds,
|
||||||
|
IReadOnlyList<string> Purls,
|
||||||
|
string Summary,
|
||||||
|
IReadOnlyList<PackageAffect> AffectedPackages,
|
||||||
|
IReadOnlyList<AdvisoryRef> AdvisoryRefs,
|
||||||
|
PolicyRationale Rationale,
|
||||||
|
IReadOnlyList<string> Paths,
|
||||||
|
IReadOnlyList<EvidenceRef> Evidence,
|
||||||
|
DateTimeOffset FirstSeen,
|
||||||
|
DateTimeOffset LastSeen,
|
||||||
|
string PolicyVersion,
|
||||||
|
string RationaleId,
|
||||||
|
EvidenceProvenance Provenance);
|
||||||
|
|
||||||
|
public sealed record PackageAffect(string Purl, IReadOnlyList<string> Versions);
|
||||||
|
|
||||||
|
public sealed record AdvisoryRef(string Url, string Title);
|
||||||
|
|
||||||
|
public sealed record EvidenceRef(string Kind, string Reference, string? Title = null);
|
||||||
|
|
||||||
|
public sealed record EvidenceProvenance(string LedgerEntryId, string EvidenceBundleId);
|
||||||
|
|
||||||
|
public sealed record PolicyRationale(string Id, string Summary);
|
||||||
|
|
||||||
|
public sealed record VulnListResponse(IReadOnlyList<VulnSummary> Items, string? NextPageToken);
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
|
||||||
|
// VulnExplorer endpoints mounted in the Findings Ledger WebService.
|
||||||
|
// Preserves the original VulnExplorer API paths for backward compatibility.
|
||||||
|
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using StellaOps.Auth.Abstractions;
|
||||||
|
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||||
|
using StellaOps.Findings.Ledger.WebService.Contracts.VulnExplorer;
|
||||||
|
using StellaOps.Findings.Ledger.WebService.Services;
|
||||||
|
using System.Globalization;
|
||||||
|
using static StellaOps.Localization.T;
|
||||||
|
|
||||||
|
namespace StellaOps.Findings.Ledger.WebService.Endpoints;
|
||||||
|
|
||||||
|
public static class VulnExplorerEndpoints
|
||||||
|
{
|
||||||
|
// Policy names matching VulnExplorer's original authorization policies
|
||||||
|
private const string ViewPolicy = "VulnExplorer.View";
|
||||||
|
private const string OperatePolicy = "VulnExplorer.Operate";
|
||||||
|
private const string AuditPolicy = "VulnExplorer.Audit";
|
||||||
|
|
||||||
|
public static void MapVulnExplorerEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
// ====================================================================
|
||||||
|
// Vulnerability list/detail endpoints (was: GET /v1/vulns, /v1/vulns/{id})
|
||||||
|
// ====================================================================
|
||||||
|
|
||||||
|
app.MapGet("/v1/vulns", async (
|
||||||
|
[FromHeader(Name = "x-stella-tenant")] string? tenant,
|
||||||
|
[FromQuery(Name = "policyVersion")] string? policyVersion,
|
||||||
|
[FromQuery(Name = "pageSize")] int? pageSize,
|
||||||
|
[FromQuery(Name = "pageToken")] string? pageToken,
|
||||||
|
[FromQuery(Name = "cve")] string[]? cve,
|
||||||
|
[FromQuery(Name = "purl")] string[]? purl,
|
||||||
|
[FromQuery(Name = "severity")] string[]? severity,
|
||||||
|
[FromQuery(Name = "exploitability")] string? exploitability,
|
||||||
|
[FromQuery(Name = "fixAvailable")] bool? fixAvailable,
|
||||||
|
VulnQueryAdapter vulnAdapter,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(tenant))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "Tenant header is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var size = Math.Clamp(pageSize ?? 50, 1, 200);
|
||||||
|
var offset = ParsePageToken(pageToken);
|
||||||
|
|
||||||
|
var response = await vulnAdapter.ListAsync(
|
||||||
|
tenant, cve, purl, severity, exploitability, fixAvailable, size, offset, ct);
|
||||||
|
return Results.Ok(response);
|
||||||
|
})
|
||||||
|
.WithName("VulnExplorer_ListVulns")
|
||||||
|
.WithTags("VulnExplorer")
|
||||||
|
.RequireAuthorization(ViewPolicy)
|
||||||
|
.RequireTenant();
|
||||||
|
|
||||||
|
app.MapGet("/v1/vulns/{id}", async (
|
||||||
|
[FromHeader(Name = "x-stella-tenant")] string? tenant,
|
||||||
|
string id,
|
||||||
|
VulnQueryAdapter vulnAdapter,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(tenant))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "Tenant header is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var detail = await vulnAdapter.GetDetailAsync(tenant, id, ct);
|
||||||
|
return detail is not null
|
||||||
|
? Results.Ok(detail)
|
||||||
|
: Results.NotFound();
|
||||||
|
})
|
||||||
|
.WithName("VulnExplorer_GetVuln")
|
||||||
|
.WithTags("VulnExplorer")
|
||||||
|
.RequireAuthorization(ViewPolicy)
|
||||||
|
.RequireTenant();
|
||||||
|
|
||||||
|
// ====================================================================
|
||||||
|
// VEX Decision endpoints (was: POST/PATCH/GET /v1/vex-decisions)
|
||||||
|
// ====================================================================
|
||||||
|
|
||||||
|
app.MapPost("/v1/vex-decisions", async (
|
||||||
|
[FromHeader(Name = "x-stella-tenant")] string? tenant,
|
||||||
|
[FromHeader(Name = "x-stella-user-id")] string? userId,
|
||||||
|
[FromHeader(Name = "x-stella-user-name")] string? userName,
|
||||||
|
[FromBody] CreateVexDecisionRequest request,
|
||||||
|
VexDecisionAdapter store,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(tenant))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "Tenant header is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.VulnerabilityId))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "Vulnerability ID is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.Subject is null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "Subject is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var effectiveUserId = userId ?? "anonymous";
|
||||||
|
var effectiveUserName = userName ?? "Anonymous User";
|
||||||
|
|
||||||
|
VexDecisionDto decision;
|
||||||
|
if (request.AttestationOptions?.CreateAttestation == true)
|
||||||
|
{
|
||||||
|
var result = await store.CreateWithAttestationAsync(
|
||||||
|
request, effectiveUserId, effectiveUserName, cancellationToken);
|
||||||
|
decision = result.Decision;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
decision = store.Create(request, effectiveUserId, effectiveUserName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Created($"/v1/vex-decisions/{decision.Id}", decision);
|
||||||
|
})
|
||||||
|
.WithName("VulnExplorer_CreateVexDecision")
|
||||||
|
.WithTags("VulnExplorer")
|
||||||
|
.RequireAuthorization(OperatePolicy)
|
||||||
|
.RequireTenant();
|
||||||
|
|
||||||
|
app.MapPatch("/v1/vex-decisions/{id:guid}", (
|
||||||
|
[FromHeader(Name = "x-stella-tenant")] string? tenant,
|
||||||
|
Guid id,
|
||||||
|
[FromBody] UpdateVexDecisionRequest request,
|
||||||
|
VexDecisionAdapter store) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(tenant))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "Tenant header is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var updated = store.Update(id, request);
|
||||||
|
return updated is not null
|
||||||
|
? Results.Ok(updated)
|
||||||
|
: Results.NotFound(new { error = $"VEX decision {id} not found." });
|
||||||
|
})
|
||||||
|
.WithName("VulnExplorer_UpdateVexDecision")
|
||||||
|
.WithTags("VulnExplorer")
|
||||||
|
.RequireAuthorization(OperatePolicy)
|
||||||
|
.RequireTenant();
|
||||||
|
|
||||||
|
app.MapGet("/v1/vex-decisions", (
|
||||||
|
[FromHeader(Name = "x-stella-tenant")] string? tenant,
|
||||||
|
[FromQuery(Name = "vulnerabilityId")] string? vulnerabilityId,
|
||||||
|
[FromQuery(Name = "subject")] string? subject,
|
||||||
|
[FromQuery(Name = "status")] VexStatus? status,
|
||||||
|
[FromQuery(Name = "pageSize")] int? pageSize,
|
||||||
|
[FromQuery(Name = "pageToken")] string? pageToken,
|
||||||
|
VexDecisionAdapter store) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(tenant))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "Tenant header is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var size = Math.Clamp(pageSize ?? 50, 1, 200);
|
||||||
|
var offset = ParsePageToken(pageToken);
|
||||||
|
|
||||||
|
var decisions = store.Query(
|
||||||
|
vulnerabilityId: vulnerabilityId,
|
||||||
|
subjectName: subject,
|
||||||
|
status: status,
|
||||||
|
skip: offset,
|
||||||
|
take: size);
|
||||||
|
|
||||||
|
var nextOffset = offset + decisions.Count;
|
||||||
|
var next = nextOffset < store.Count() ? nextOffset.ToString(CultureInfo.InvariantCulture) : null;
|
||||||
|
|
||||||
|
return Results.Ok(new VexDecisionListResponse(decisions, next));
|
||||||
|
})
|
||||||
|
.WithName("VulnExplorer_ListVexDecisions")
|
||||||
|
.WithTags("VulnExplorer")
|
||||||
|
.RequireAuthorization(ViewPolicy)
|
||||||
|
.RequireTenant();
|
||||||
|
|
||||||
|
app.MapGet("/v1/vex-decisions/{id:guid}", (
|
||||||
|
[FromHeader(Name = "x-stella-tenant")] string? tenant,
|
||||||
|
Guid id,
|
||||||
|
VexDecisionAdapter store) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(tenant))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "Tenant header is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var decision = store.Get(id);
|
||||||
|
return decision is not null
|
||||||
|
? Results.Ok(decision)
|
||||||
|
: Results.NotFound(new { error = $"VEX decision {id} not found." });
|
||||||
|
})
|
||||||
|
.WithName("VulnExplorer_GetVexDecision")
|
||||||
|
.WithTags("VulnExplorer")
|
||||||
|
.RequireAuthorization(ViewPolicy)
|
||||||
|
.RequireTenant();
|
||||||
|
|
||||||
|
// ====================================================================
|
||||||
|
// Evidence subgraph endpoint (was: GET /v1/evidence-subgraph/{vulnId})
|
||||||
|
// ====================================================================
|
||||||
|
|
||||||
|
app.MapGet("/v1/evidence-subgraph/{vulnId}", async (
|
||||||
|
[FromHeader(Name = "x-stella-tenant")] string? tenant,
|
||||||
|
string vulnId,
|
||||||
|
EvidenceSubgraphAdapter store,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(tenant))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "Tenant header is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(vulnId))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "Vulnerability ID is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = await store.BuildAsync(vulnId, ct);
|
||||||
|
return Results.Ok(response);
|
||||||
|
})
|
||||||
|
.WithName("VulnExplorer_GetEvidenceSubgraph")
|
||||||
|
.WithTags("VulnExplorer")
|
||||||
|
.RequireAuthorization(ViewPolicy)
|
||||||
|
.RequireTenant();
|
||||||
|
|
||||||
|
// ====================================================================
|
||||||
|
// Fix verification endpoints (was: POST/PATCH /v1/fix-verifications)
|
||||||
|
// ====================================================================
|
||||||
|
|
||||||
|
app.MapPost("/v1/fix-verifications", (
|
||||||
|
[FromHeader(Name = "x-stella-tenant")] string? tenant,
|
||||||
|
[FromBody] CreateFixVerificationRequest request,
|
||||||
|
FixVerificationAdapter store) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(tenant))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "Tenant header is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.CveId) || string.IsNullOrWhiteSpace(request.ComponentPurl))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "CVE ID and component PURL are required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var created = store.Create(request);
|
||||||
|
return Results.Created($"/v1/fix-verifications/{created.CveId}", created);
|
||||||
|
})
|
||||||
|
.WithName("VulnExplorer_CreateFixVerification")
|
||||||
|
.WithTags("VulnExplorer")
|
||||||
|
.RequireAuthorization(OperatePolicy)
|
||||||
|
.RequireTenant();
|
||||||
|
|
||||||
|
app.MapPatch("/v1/fix-verifications/{cveId}", (
|
||||||
|
[FromHeader(Name = "x-stella-tenant")] string? tenant,
|
||||||
|
string cveId,
|
||||||
|
[FromBody] UpdateFixVerificationRequest request,
|
||||||
|
FixVerificationAdapter store) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(tenant))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "Tenant header is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Verdict))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "Verdict is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var updated = store.Update(cveId, request.Verdict);
|
||||||
|
return updated is not null
|
||||||
|
? Results.Ok(updated)
|
||||||
|
: Results.NotFound(new { error = $"Fix verification for {cveId} not found." });
|
||||||
|
})
|
||||||
|
.WithName("VulnExplorer_UpdateFixVerification")
|
||||||
|
.WithTags("VulnExplorer")
|
||||||
|
.RequireAuthorization(OperatePolicy)
|
||||||
|
.RequireTenant();
|
||||||
|
|
||||||
|
// ====================================================================
|
||||||
|
// Audit bundle endpoint (was: POST /v1/audit-bundles)
|
||||||
|
// ====================================================================
|
||||||
|
|
||||||
|
app.MapPost("/v1/audit-bundles", (
|
||||||
|
[FromHeader(Name = "x-stella-tenant")] string? tenant,
|
||||||
|
[FromBody] CreateAuditBundleRequest request,
|
||||||
|
VexDecisionAdapter decisions,
|
||||||
|
AuditBundleAdapter bundles) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(tenant))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "Tenant header is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.DecisionIds is null || request.DecisionIds.Count == 0)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "Decision IDs are required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var selected = request.DecisionIds
|
||||||
|
.Select(id => decisions.Get(id))
|
||||||
|
.Where(x => x is not null)
|
||||||
|
.Cast<VexDecisionDto>()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (selected.Length == 0)
|
||||||
|
{
|
||||||
|
return Results.NotFound(new { error = "No decisions found for provided IDs." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var bundle = bundles.Create(tenant, selected);
|
||||||
|
return Results.Created($"/v1/audit-bundles/{bundle.BundleId}", bundle);
|
||||||
|
})
|
||||||
|
.WithName("VulnExplorer_CreateAuditBundle")
|
||||||
|
.WithTags("VulnExplorer")
|
||||||
|
.RequireAuthorization(AuditPolicy)
|
||||||
|
.RequireTenant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ParsePageToken(string? token) =>
|
||||||
|
int.TryParse(token, out var offset) && offset >= 0 ? offset : 0;
|
||||||
|
}
|
||||||
@@ -53,6 +53,12 @@ const string ScoringReadPolicy = "scoring.read";
|
|||||||
const string ScoringWritePolicy = "scoring.write";
|
const string ScoringWritePolicy = "scoring.write";
|
||||||
const string ScoringAdminPolicy = "scoring.admin";
|
const string ScoringAdminPolicy = "scoring.admin";
|
||||||
|
|
||||||
|
// VulnExplorer policies (merged from VulnExplorer service)
|
||||||
|
const string VulnViewPolicy = "VulnExplorer.View";
|
||||||
|
const string VulnInvestigatePolicy = "VulnExplorer.Investigate";
|
||||||
|
const string VulnOperatePolicy = "VulnExplorer.Operate";
|
||||||
|
const string VulnAuditPolicy = "VulnExplorer.Audit";
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
builder.Configuration.AddStellaOpsDefaults(options =>
|
builder.Configuration.AddStellaOpsDefaults(options =>
|
||||||
@@ -197,6 +203,12 @@ builder.Services.AddAuthorization(options =>
|
|||||||
policy.Requirements.Add(new StellaOpsScopeRequirement(scopes));
|
policy.Requirements.Add(new StellaOpsScopeRequirement(scopes));
|
||||||
policy.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme);
|
policy.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// VulnExplorer policies (merged from VulnExplorer service)
|
||||||
|
options.AddStellaOpsScopePolicy(VulnViewPolicy, StellaOpsScopes.VulnView);
|
||||||
|
options.AddStellaOpsScopePolicy(VulnInvestigatePolicy, StellaOpsScopes.VulnInvestigate);
|
||||||
|
options.AddStellaOpsScopePolicy(VulnOperatePolicy, StellaOpsScopes.VulnOperate);
|
||||||
|
options.AddStellaOpsScopePolicy(VulnAuditPolicy, StellaOpsScopes.VulnAudit);
|
||||||
});
|
});
|
||||||
builder.Services.AddStellaOpsScopeHandler();
|
builder.Services.AddStellaOpsScopeHandler();
|
||||||
|
|
||||||
@@ -297,6 +309,24 @@ builder.Services.AddHttpClient("webhook-delivery", client =>
|
|||||||
client.Timeout = TimeSpan.FromSeconds(30);
|
client.Timeout = TimeSpan.FromSeconds(30);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// VulnExplorer adapter services (merged from VulnExplorer service)
|
||||||
|
builder.Services.AddSingleton<IVexOverrideAttestorAdapter, StubVexOverrideAttestorAdapter>();
|
||||||
|
builder.Services.AddSingleton<VexDecisionAdapter>(sp =>
|
||||||
|
new VexDecisionAdapter(
|
||||||
|
timeProvider: sp.GetRequiredService<TimeProvider>(),
|
||||||
|
attestorClient: sp.GetRequiredService<IVexOverrideAttestorAdapter>()));
|
||||||
|
builder.Services.AddSingleton<FixVerificationAdapter>();
|
||||||
|
builder.Services.AddSingleton<AuditBundleAdapter>();
|
||||||
|
builder.Services.AddSingleton<EvidenceSubgraphAdapter>(sp =>
|
||||||
|
new EvidenceSubgraphAdapter(
|
||||||
|
sp.GetRequiredService<IEvidenceGraphBuilder>(),
|
||||||
|
sp.GetRequiredService<TimeProvider>()));
|
||||||
|
builder.Services.AddSingleton<VulnQueryAdapter>(sp =>
|
||||||
|
new VulnQueryAdapter(
|
||||||
|
sp.GetRequiredService<IFindingSummaryService>(),
|
||||||
|
sp.GetRequiredService<IVulnerabilityDetailService>(),
|
||||||
|
sp.GetRequiredService<TimeProvider>()));
|
||||||
|
|
||||||
// Stella Router integration
|
// Stella Router integration
|
||||||
var routerEnabled = builder.Services.AddRouterMicroservice(
|
var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||||
builder.Configuration,
|
builder.Configuration,
|
||||||
@@ -2001,6 +2031,9 @@ app.MapRuntimeTracesEndpoints();
|
|||||||
app.MapScoringEndpoints();
|
app.MapScoringEndpoints();
|
||||||
app.MapWebhookEndpoints();
|
app.MapWebhookEndpoints();
|
||||||
|
|
||||||
|
// VulnExplorer endpoints (merged from VulnExplorer service)
|
||||||
|
app.MapVulnExplorerEndpoints();
|
||||||
|
|
||||||
await app.LoadTranslationsAsync();
|
await app.LoadTranslationsAsync();
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,676 @@
|
|||||||
|
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
|
||||||
|
// Adapter services that delegate VulnExplorer operations to existing Ledger services.
|
||||||
|
// Created during VulnExplorer -> Findings Ledger merge.
|
||||||
|
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using StellaOps.Findings.Ledger.WebService.Contracts;
|
||||||
|
using StellaOps.Findings.Ledger.WebService.Contracts.VulnExplorer;
|
||||||
|
|
||||||
|
namespace StellaOps.Findings.Ledger.WebService.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adapter for VEX decisions backed by Ledger event persistence.
|
||||||
|
/// Uses ConcurrentDictionary as the initial store; future iterations will
|
||||||
|
/// wire to Ledger event types (finding.vex_decision_created/updated).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VexDecisionAdapter
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<Guid, VexDecisionDto> _decisions = new();
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly IVexOverrideAttestorAdapter? _attestorClient;
|
||||||
|
|
||||||
|
public VexDecisionAdapter(
|
||||||
|
TimeProvider? timeProvider = null,
|
||||||
|
IVexOverrideAttestorAdapter? attestorClient = null)
|
||||||
|
{
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
_attestorClient = attestorClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
public VexDecisionDto Create(CreateVexDecisionRequest request, string userId, string userDisplayName)
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var now = _timeProvider.GetUtcNow();
|
||||||
|
|
||||||
|
var decision = new VexDecisionDto(
|
||||||
|
Id: id,
|
||||||
|
VulnerabilityId: request.VulnerabilityId,
|
||||||
|
Subject: request.Subject,
|
||||||
|
Status: request.Status,
|
||||||
|
JustificationType: request.JustificationType,
|
||||||
|
JustificationText: request.JustificationText,
|
||||||
|
EvidenceRefs: request.EvidenceRefs,
|
||||||
|
Scope: request.Scope,
|
||||||
|
ValidFor: request.ValidFor,
|
||||||
|
AttestationRef: null,
|
||||||
|
SignedOverride: null,
|
||||||
|
SupersedesDecisionId: request.SupersedesDecisionId,
|
||||||
|
CreatedBy: new ActorRefDto(userId, userDisplayName),
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: null);
|
||||||
|
|
||||||
|
_decisions[id] = decision;
|
||||||
|
return decision;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(VexDecisionDto Decision, VexOverrideAttestationResult? AttestationResult)> CreateWithAttestationAsync(
|
||||||
|
CreateVexDecisionRequest request,
|
||||||
|
string userId,
|
||||||
|
string userDisplayName,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var now = _timeProvider.GetUtcNow();
|
||||||
|
|
||||||
|
VexOverrideAttestationDto? signedOverride = null;
|
||||||
|
VexOverrideAttestationResult? attestationResult = null;
|
||||||
|
|
||||||
|
if (request.AttestationOptions?.CreateAttestation == true && _attestorClient is not null)
|
||||||
|
{
|
||||||
|
var attestationRequest = new VexOverrideAttestationRequest
|
||||||
|
{
|
||||||
|
VulnerabilityId = request.VulnerabilityId,
|
||||||
|
Subject = request.Subject,
|
||||||
|
Status = request.Status,
|
||||||
|
JustificationType = request.JustificationType,
|
||||||
|
JustificationText = request.JustificationText,
|
||||||
|
EvidenceRefs = request.EvidenceRefs,
|
||||||
|
Scope = request.Scope,
|
||||||
|
ValidFor = request.ValidFor,
|
||||||
|
CreatedBy = new ActorRefDto(userId, userDisplayName),
|
||||||
|
AnchorToRekor = request.AttestationOptions.AnchorToRekor,
|
||||||
|
SigningKeyId = request.AttestationOptions.SigningKeyId,
|
||||||
|
StorageDestination = request.AttestationOptions.StorageDestination,
|
||||||
|
AdditionalMetadata = request.AttestationOptions.AdditionalMetadata
|
||||||
|
};
|
||||||
|
|
||||||
|
attestationResult = await _attestorClient.CreateAttestationAsync(attestationRequest, cancellationToken);
|
||||||
|
|
||||||
|
if (attestationResult.Success && attestationResult.Attestation is not null)
|
||||||
|
{
|
||||||
|
signedOverride = attestationResult.Attestation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var decision = new VexDecisionDto(
|
||||||
|
Id: id,
|
||||||
|
VulnerabilityId: request.VulnerabilityId,
|
||||||
|
Subject: request.Subject,
|
||||||
|
Status: request.Status,
|
||||||
|
JustificationType: request.JustificationType,
|
||||||
|
JustificationText: request.JustificationText,
|
||||||
|
EvidenceRefs: request.EvidenceRefs,
|
||||||
|
Scope: request.Scope,
|
||||||
|
ValidFor: request.ValidFor,
|
||||||
|
AttestationRef: null,
|
||||||
|
SignedOverride: signedOverride,
|
||||||
|
SupersedesDecisionId: request.SupersedesDecisionId,
|
||||||
|
CreatedBy: new ActorRefDto(userId, userDisplayName),
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: null);
|
||||||
|
|
||||||
|
_decisions[id] = decision;
|
||||||
|
return (decision, attestationResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
public VexDecisionDto? Update(Guid id, UpdateVexDecisionRequest request)
|
||||||
|
{
|
||||||
|
if (!_decisions.TryGetValue(id, out var existing))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var updated = existing with
|
||||||
|
{
|
||||||
|
Status = request.Status ?? existing.Status,
|
||||||
|
JustificationType = request.JustificationType ?? existing.JustificationType,
|
||||||
|
JustificationText = request.JustificationText ?? existing.JustificationText,
|
||||||
|
EvidenceRefs = request.EvidenceRefs ?? existing.EvidenceRefs,
|
||||||
|
Scope = request.Scope ?? existing.Scope,
|
||||||
|
ValidFor = request.ValidFor ?? existing.ValidFor,
|
||||||
|
SupersedesDecisionId = request.SupersedesDecisionId ?? existing.SupersedesDecisionId,
|
||||||
|
UpdatedAt = _timeProvider.GetUtcNow()
|
||||||
|
};
|
||||||
|
|
||||||
|
_decisions[id] = updated;
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
public VexDecisionDto? Get(Guid id) =>
|
||||||
|
_decisions.TryGetValue(id, out var decision) ? decision : null;
|
||||||
|
|
||||||
|
public IReadOnlyList<VexDecisionDto> Query(
|
||||||
|
string? vulnerabilityId = null,
|
||||||
|
string? subjectName = null,
|
||||||
|
VexStatus? status = null,
|
||||||
|
int skip = 0,
|
||||||
|
int take = 50)
|
||||||
|
{
|
||||||
|
IEnumerable<VexDecisionDto> query = _decisions.Values;
|
||||||
|
|
||||||
|
if (vulnerabilityId is not null)
|
||||||
|
{
|
||||||
|
query = query.Where(d => string.Equals(d.VulnerabilityId, vulnerabilityId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subjectName is not null)
|
||||||
|
{
|
||||||
|
query = query.Where(d => d.Subject.Name.Contains(subjectName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status is not null)
|
||||||
|
{
|
||||||
|
query = query.Where(d => d.Status == status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return query
|
||||||
|
.OrderByDescending(d => d.CreatedAt)
|
||||||
|
.ThenBy(d => d.Id)
|
||||||
|
.Skip(skip)
|
||||||
|
.Take(take)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Count() => _decisions.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adapter for fix verifications backed by Ledger event persistence.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FixVerificationAdapter
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, FixVerificationRecord> _records = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public FixVerificationAdapter(TimeProvider? timeProvider = null)
|
||||||
|
{
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FixVerificationRecord Create(CreateFixVerificationRequest request)
|
||||||
|
{
|
||||||
|
var now = _timeProvider.GetUtcNow();
|
||||||
|
var created = new FixVerificationRecord(
|
||||||
|
CveId: request.CveId,
|
||||||
|
ComponentPurl: request.ComponentPurl,
|
||||||
|
ArtifactDigest: request.ArtifactDigest,
|
||||||
|
Verdict: "pending",
|
||||||
|
Transitions: [],
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now);
|
||||||
|
|
||||||
|
_records[request.CveId] = created;
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FixVerificationRecord? Update(string cveId, string verdict)
|
||||||
|
{
|
||||||
|
if (!_records.TryGetValue(cveId, out var existing))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = _timeProvider.GetUtcNow();
|
||||||
|
var transitions = existing.Transitions.ToList();
|
||||||
|
transitions.Add(new FixVerificationTransition(existing.Verdict, verdict, now));
|
||||||
|
|
||||||
|
var updated = existing with
|
||||||
|
{
|
||||||
|
Verdict = verdict,
|
||||||
|
Transitions = transitions.ToArray(),
|
||||||
|
UpdatedAt = now
|
||||||
|
};
|
||||||
|
|
||||||
|
_records[cveId] = updated;
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adapter for audit bundles backed by Ledger evidence bundle service.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AuditBundleAdapter
|
||||||
|
{
|
||||||
|
private int _sequence;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public AuditBundleAdapter(TimeProvider? timeProvider = null)
|
||||||
|
{
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AuditBundleResponse Create(string tenant, IReadOnlyList<VexDecisionDto> decisions)
|
||||||
|
{
|
||||||
|
var next = Interlocked.Increment(ref _sequence);
|
||||||
|
var createdAt = _timeProvider.GetUtcNow();
|
||||||
|
var evidenceRefs = decisions
|
||||||
|
.SelectMany(x => x.EvidenceRefs ?? Array.Empty<EvidenceRefDto>())
|
||||||
|
.Select(x => x.Url.ToString())
|
||||||
|
.OrderBy(x => x, StringComparer.Ordinal)
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return new AuditBundleResponse(
|
||||||
|
BundleId: $"bundle-{next:D6}",
|
||||||
|
Tenant: tenant,
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
Decisions: decisions.OrderBy(x => x.Id).ToArray(),
|
||||||
|
EvidenceRefs: evidenceRefs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adapter for evidence subgraph backed by the Ledger's EvidenceGraphBuilder.
|
||||||
|
/// Returns a VulnExplorer-shaped response by delegating to the Ledger's graph builder.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EvidenceSubgraphAdapter
|
||||||
|
{
|
||||||
|
private readonly IEvidenceGraphBuilder _graphBuilder;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public EvidenceSubgraphAdapter(IEvidenceGraphBuilder graphBuilder, TimeProvider? timeProvider = null)
|
||||||
|
{
|
||||||
|
_graphBuilder = graphBuilder;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<EvidenceSubgraphResponse> BuildAsync(string vulnId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Try to parse as GUID and delegate to the Ledger's graph builder
|
||||||
|
if (Guid.TryParse(vulnId, out var findingId))
|
||||||
|
{
|
||||||
|
var graph = await _graphBuilder.BuildAsync(findingId, ct);
|
||||||
|
if (graph is not null)
|
||||||
|
{
|
||||||
|
return MapFromLedgerGraph(vulnId, graph);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: return a stub response for non-GUID vulnerability IDs
|
||||||
|
return BuildStubResponse(vulnId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private EvidenceSubgraphResponse MapFromLedgerGraph(string vulnId, EvidenceGraphResponse graph)
|
||||||
|
{
|
||||||
|
var observedAt = _timeProvider.GetUtcNow();
|
||||||
|
var rootNode = graph.Nodes.FirstOrDefault(n => n.Id == graph.RootNodeId);
|
||||||
|
|
||||||
|
return new EvidenceSubgraphResponse
|
||||||
|
{
|
||||||
|
FindingId = graph.FindingId.ToString(),
|
||||||
|
VulnId = vulnId,
|
||||||
|
Root = rootNode is not null
|
||||||
|
? MapNode(rootNode)
|
||||||
|
: new VxEvidenceNode
|
||||||
|
{
|
||||||
|
Id = $"finding-{vulnId}",
|
||||||
|
Type = VxEvidenceNodeType.Artifact,
|
||||||
|
Label = vulnId,
|
||||||
|
IsExpandable = false,
|
||||||
|
Status = VxEvidenceNodeStatus.Info
|
||||||
|
},
|
||||||
|
Edges = graph.Edges.Select(e => new VxEvidenceEdge
|
||||||
|
{
|
||||||
|
SourceId = e.From,
|
||||||
|
TargetId = e.To,
|
||||||
|
Relationship = e.Relation.ToString(),
|
||||||
|
IsReachable = false,
|
||||||
|
Weight = 1.0,
|
||||||
|
Citation = new VxEvidenceCitation
|
||||||
|
{
|
||||||
|
Source = "ledger",
|
||||||
|
SourceUrl = $"urn:stellaops:ledger:{graph.FindingId}",
|
||||||
|
ObservedAt = observedAt,
|
||||||
|
Confidence = 1.0,
|
||||||
|
EvidenceHash = null,
|
||||||
|
IsVerified = true
|
||||||
|
}
|
||||||
|
}).ToArray(),
|
||||||
|
Verdict = new VxVerdictSummary
|
||||||
|
{
|
||||||
|
Decision = "review",
|
||||||
|
Explanation = "Evidence graph built from Ledger projections.",
|
||||||
|
KeyFactors = graph.Nodes.Select(n => n.Type.ToString()).Distinct().ToArray(),
|
||||||
|
ConfidenceScore = 0.8,
|
||||||
|
AppliedPolicies = Array.Empty<string>(),
|
||||||
|
ComputedAt = observedAt
|
||||||
|
},
|
||||||
|
AvailableActions = new[]
|
||||||
|
{
|
||||||
|
new VxTriageAction
|
||||||
|
{
|
||||||
|
ActionId = "apply-internal-vex",
|
||||||
|
Type = VxTriageActionType.ApplyInternalVex,
|
||||||
|
Label = "Apply Internal VEX",
|
||||||
|
RequiresConfirmation = false
|
||||||
|
},
|
||||||
|
new VxTriageAction
|
||||||
|
{
|
||||||
|
ActionId = "schedule-patch",
|
||||||
|
Type = VxTriageActionType.SchedulePatch,
|
||||||
|
Label = "Schedule Patch",
|
||||||
|
RequiresConfirmation = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Metadata = new VxEvidenceMetadata
|
||||||
|
{
|
||||||
|
CollectedAt = observedAt,
|
||||||
|
NodeCount = graph.Nodes.Count,
|
||||||
|
EdgeCount = graph.Edges.Count,
|
||||||
|
IsTruncated = false,
|
||||||
|
MaxDepth = 3,
|
||||||
|
Sources = graph.Nodes.Select(n => n.Type.ToString()).Distinct().ToArray()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static VxEvidenceNode MapNode(Contracts.EvidenceNode node)
|
||||||
|
{
|
||||||
|
return new VxEvidenceNode
|
||||||
|
{
|
||||||
|
Id = node.Id,
|
||||||
|
Type = VxEvidenceNodeType.Artifact,
|
||||||
|
Label = node.Label ?? node.Id,
|
||||||
|
IsExpandable = false,
|
||||||
|
Status = VxEvidenceNodeStatus.Info
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private EvidenceSubgraphResponse BuildStubResponse(string vulnId)
|
||||||
|
{
|
||||||
|
var observedAt = _timeProvider.GetUtcNow();
|
||||||
|
return new EvidenceSubgraphResponse
|
||||||
|
{
|
||||||
|
FindingId = $"finding-{vulnId}",
|
||||||
|
VulnId = vulnId,
|
||||||
|
Root = new VxEvidenceNode
|
||||||
|
{
|
||||||
|
Id = $"artifact:unknown/{vulnId}",
|
||||||
|
Type = VxEvidenceNodeType.Artifact,
|
||||||
|
Label = vulnId,
|
||||||
|
IsExpandable = false,
|
||||||
|
Status = VxEvidenceNodeStatus.Warning
|
||||||
|
},
|
||||||
|
Edges = Array.Empty<VxEvidenceEdge>(),
|
||||||
|
Verdict = new VxVerdictSummary
|
||||||
|
{
|
||||||
|
Decision = "review",
|
||||||
|
Explanation = "No Ledger finding matched; stub evidence subgraph returned.",
|
||||||
|
KeyFactors = Array.Empty<string>(),
|
||||||
|
ConfidenceScore = 0.0,
|
||||||
|
AppliedPolicies = Array.Empty<string>(),
|
||||||
|
ComputedAt = observedAt
|
||||||
|
},
|
||||||
|
AvailableActions = Array.Empty<VxTriageAction>(),
|
||||||
|
Metadata = new VxEvidenceMetadata
|
||||||
|
{
|
||||||
|
CollectedAt = observedAt,
|
||||||
|
NodeCount = 1,
|
||||||
|
EdgeCount = 0,
|
||||||
|
IsTruncated = false,
|
||||||
|
MaxDepth = 0,
|
||||||
|
Sources = Array.Empty<string>()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adapter for vuln list/detail queries backed by Ledger's finding projection.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VulnQueryAdapter
|
||||||
|
{
|
||||||
|
private readonly IFindingSummaryService _summaryService;
|
||||||
|
private readonly IVulnerabilityDetailService _detailService;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public VulnQueryAdapter(
|
||||||
|
IFindingSummaryService summaryService,
|
||||||
|
IVulnerabilityDetailService detailService,
|
||||||
|
TimeProvider? timeProvider = null)
|
||||||
|
{
|
||||||
|
_summaryService = summaryService;
|
||||||
|
_detailService = detailService;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Query vulnerability summaries from Ledger projections.
|
||||||
|
/// Falls back to empty list when no findings exist yet.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<VulnListResponse> ListAsync(
|
||||||
|
string tenantId,
|
||||||
|
string[]? cve,
|
||||||
|
string[]? purl,
|
||||||
|
string[]? severity,
|
||||||
|
string? exploitability,
|
||||||
|
bool? fixAvailable,
|
||||||
|
int pageSize,
|
||||||
|
int offset,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var filter = new FindingSummaryFilter
|
||||||
|
{
|
||||||
|
Page = (offset / Math.Max(pageSize, 1)) + 1,
|
||||||
|
PageSize = pageSize,
|
||||||
|
Status = null,
|
||||||
|
Severity = severity?.FirstOrDefault(),
|
||||||
|
MinConfidence = null,
|
||||||
|
SortBy = "severity",
|
||||||
|
SortDirection = "desc"
|
||||||
|
};
|
||||||
|
|
||||||
|
var summaryPage = await _summaryService.GetSummariesAsync(filter, ct);
|
||||||
|
|
||||||
|
var items = summaryPage.Items.Select(s => new VulnSummary(
|
||||||
|
Id: s.FindingId.ToString(),
|
||||||
|
Severity: s.Severity ?? "UNKNOWN",
|
||||||
|
Score: (double)(s.CvssScore ?? 0),
|
||||||
|
Kev: false,
|
||||||
|
Exploitability: "unknown",
|
||||||
|
FixAvailable: false,
|
||||||
|
CveIds: !string.IsNullOrEmpty(s.VulnerabilityId) ? new[] { s.VulnerabilityId } : Array.Empty<string>(),
|
||||||
|
Purls: !string.IsNullOrEmpty(s.Component) ? new[] { s.Component } : Array.Empty<string>(),
|
||||||
|
PolicyVersion: "policy-main",
|
||||||
|
RationaleId: s.FindingId.ToString()
|
||||||
|
)).ToArray();
|
||||||
|
|
||||||
|
// Apply additional filters not handled by the summary service
|
||||||
|
IEnumerable<VulnSummary> filtered = items;
|
||||||
|
|
||||||
|
if (cve is { Length: > 0 })
|
||||||
|
{
|
||||||
|
var set = cve.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
filtered = filtered.Where(v => v.CveIds.Any(set.Contains));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (purl is { Length: > 0 })
|
||||||
|
{
|
||||||
|
var set = purl.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
filtered = filtered.Where(v => v.Purls.Any(set.Contains));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exploitability is not null)
|
||||||
|
{
|
||||||
|
filtered = filtered.Where(v => string.Equals(v.Exploitability, exploitability, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fixAvailable is not null)
|
||||||
|
{
|
||||||
|
filtered = filtered.Where(v => v.FixAvailable == fixAvailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
var page = filtered
|
||||||
|
.OrderByDescending(v => v.Score)
|
||||||
|
.ThenBy(v => v.Id, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
string? nextToken = summaryPage.TotalCount > offset + pageSize
|
||||||
|
? (offset + pageSize).ToString(CultureInfo.InvariantCulture)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return new VulnListResponse(page, nextToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get vulnerability detail from Ledger projections.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<VulnDetail?> GetDetailAsync(string tenantId, string id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var detail = await _detailService.GetAsync(tenantId, id, ct);
|
||||||
|
if (detail is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new VulnDetail(
|
||||||
|
Id: id,
|
||||||
|
Severity: detail.Severity ?? "UNKNOWN",
|
||||||
|
Score: (double)detail.Cvss,
|
||||||
|
Kev: detail.ExploitedInWild ?? false,
|
||||||
|
Exploitability: detail.ExploitedInWild == true ? "known" : "unknown",
|
||||||
|
FixAvailable: !string.IsNullOrEmpty(detail.FixedIn),
|
||||||
|
CveIds: !string.IsNullOrEmpty(detail.CveId) ? new[] { detail.CveId } : Array.Empty<string>(),
|
||||||
|
Purls: !string.IsNullOrEmpty(detail.PackageName) ? new[] { detail.PackageName } : Array.Empty<string>(),
|
||||||
|
Summary: detail.Description ?? "No description available",
|
||||||
|
AffectedPackages: !string.IsNullOrEmpty(detail.PackageName)
|
||||||
|
? new[] { new PackageAffect(detail.PackageName, detail.AffectedVersions.ToArray()) }
|
||||||
|
: Array.Empty<PackageAffect>(),
|
||||||
|
AdvisoryRefs: detail.References.Select(r => new AdvisoryRef(r, r)).ToArray(),
|
||||||
|
Rationale: new PolicyRationale(id, detail.Description ?? ""),
|
||||||
|
Paths: detail.WitnessPath.ToArray(),
|
||||||
|
Evidence: Array.Empty<Contracts.VulnExplorer.EvidenceRef>(),
|
||||||
|
FirstSeen: detail.FirstSeen,
|
||||||
|
LastSeen: detail.FirstSeen,
|
||||||
|
PolicyVersion: "policy-main",
|
||||||
|
RationaleId: id,
|
||||||
|
Provenance: new EvidenceProvenance("ledger", detail.FindingId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Attestor client adapter (mirrors IVexOverrideAttestorClient from VulnExplorer)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adapter interface for VEX override attestor client.
|
||||||
|
/// </summary>
|
||||||
|
public interface IVexOverrideAttestorAdapter
|
||||||
|
{
|
||||||
|
Task<VexOverrideAttestationResult> CreateAttestationAsync(
|
||||||
|
VexOverrideAttestationRequest request,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<AttestationVerificationStatusDto> VerifyAttestationAsync(
|
||||||
|
string envelopeDigest,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request to create a VEX override attestation.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record VexOverrideAttestationRequest
|
||||||
|
{
|
||||||
|
public required string VulnerabilityId { get; init; }
|
||||||
|
public required SubjectRefDto Subject { get; init; }
|
||||||
|
public required VexStatus Status { get; init; }
|
||||||
|
public required VexJustificationType JustificationType { get; init; }
|
||||||
|
public string? JustificationText { get; init; }
|
||||||
|
public IReadOnlyList<EvidenceRefDto>? EvidenceRefs { get; init; }
|
||||||
|
public VexScopeDto? Scope { get; init; }
|
||||||
|
public ValidForDto? ValidFor { get; init; }
|
||||||
|
public required ActorRefDto CreatedBy { get; init; }
|
||||||
|
public bool AnchorToRekor { get; init; }
|
||||||
|
public string? SigningKeyId { get; init; }
|
||||||
|
public string? StorageDestination { get; init; }
|
||||||
|
public IReadOnlyDictionary<string, string>? AdditionalMetadata { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of creating a VEX override attestation.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record VexOverrideAttestationResult
|
||||||
|
{
|
||||||
|
public required bool Success { get; init; }
|
||||||
|
public VexOverrideAttestationDto? Attestation { get; init; }
|
||||||
|
public string? Error { get; init; }
|
||||||
|
public string? ErrorCode { get; init; }
|
||||||
|
|
||||||
|
public static VexOverrideAttestationResult Ok(VexOverrideAttestationDto attestation) => new()
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Attestation = attestation
|
||||||
|
};
|
||||||
|
|
||||||
|
public static VexOverrideAttestationResult Fail(string error, string? errorCode = null) => new()
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Error = error,
|
||||||
|
ErrorCode = errorCode
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stub attestor client for offline/testing scenarios.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StubVexOverrideAttestorAdapter : IVexOverrideAttestorAdapter
|
||||||
|
{
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public StubVexOverrideAttestorAdapter(TimeProvider? timeProvider = null)
|
||||||
|
{
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<VexOverrideAttestationResult> CreateAttestationAsync(
|
||||||
|
VexOverrideAttestationRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var now = _timeProvider.GetUtcNow();
|
||||||
|
var material = string.Join("|",
|
||||||
|
request.VulnerabilityId,
|
||||||
|
request.Subject.Name,
|
||||||
|
request.Status,
|
||||||
|
request.JustificationType,
|
||||||
|
request.CreatedBy.Id,
|
||||||
|
request.AnchorToRekor.ToString());
|
||||||
|
var digestBytes = SHA256.HashData(Encoding.UTF8.GetBytes(material));
|
||||||
|
var digestHex = Convert.ToHexString(digestBytes).ToLowerInvariant();
|
||||||
|
var rekorEntryId = request.AnchorToRekor ? $"rekor-local-{digestHex[..16]}" : null;
|
||||||
|
long? rekorLogIndex = request.AnchorToRekor
|
||||||
|
? Math.Abs(BitConverter.ToInt32(digestBytes, 0))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var attestation = new VexOverrideAttestationDto(
|
||||||
|
EnvelopeDigest: $"sha256:{digestHex}",
|
||||||
|
PredicateType: "https://stellaops.dev/predicates/vex-override@v1",
|
||||||
|
RekorLogIndex: rekorLogIndex,
|
||||||
|
RekorEntryId: rekorEntryId,
|
||||||
|
StorageRef: "offline-queue",
|
||||||
|
AttestationCreatedAt: now,
|
||||||
|
Verified: request.AnchorToRekor,
|
||||||
|
VerificationStatus: request.AnchorToRekor
|
||||||
|
? new AttestationVerificationStatusDto(
|
||||||
|
SignatureValid: true,
|
||||||
|
RekorVerified: true,
|
||||||
|
VerifiedAt: now,
|
||||||
|
ErrorMessage: null)
|
||||||
|
: null);
|
||||||
|
|
||||||
|
return Task.FromResult(VexOverrideAttestationResult.Ok(attestation));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<AttestationVerificationStatusDto> VerifyAttestationAsync(
|
||||||
|
string envelopeDigest,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new AttestationVerificationStatusDto(
|
||||||
|
SignatureValid: false,
|
||||||
|
RekorVerified: null,
|
||||||
|
VerifiedAt: _timeProvider.GetUtcNow(),
|
||||||
|
ErrorMessage: "Offline mode - verification unavailable"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user