diff --git a/devops/compose/docker-compose.stella-ops.yml b/devops/compose/docker-compose.stella-ops.yml index f49618d26..510ebbe4a 100644 --- a/devops/compose/docker-compose.stella-ops.yml +++ b/devops/compose/docker-compose.stella-ops.yml @@ -424,7 +424,7 @@ services: STELLAOPS_EXCITITOR_URL: "http://excititor.stella-ops.local" STELLAOPS_VEXHUB_URL: "http://vexhub.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_GATEWAY_URL: "http://policy-gateway.stella-ops.local" STELLAOPS_RISKENGINE_URL: "http://riskengine.stella-ops.local" @@ -1002,33 +1002,38 @@ services: <<: *healthcheck-tcp labels: *release-labels - # --- Slot 13: VulnExplorer (api) [src/Findings/StellaOps.VulnExplorer.Api] --- - api: - <<: *resources-light - image: stellaops/api:dev - container_name: stellaops-api - restart: unless-stopped - depends_on: *depends-infra - environment: - ASPNETCORE_URLS: "http://+:8080" - <<: [*kestrel-cert, *router-microservice-defaults, *gc-light] - ConnectionStrings__Default: *postgres-connection - ConnectionStrings__Redis: "cache.stella-ops.local:6379" - Router__Enabled: "${VULNEXPLORER_ROUTER_ENABLED:-true}" - Router__Messaging__ConsumerGroup: "vulnexplorer" - volumes: - - *cert-volume - ports: - - "127.1.0.13:80:80" - networks: - stellaops: - aliases: - - vulnexplorer.stella-ops.local - frontdoor: {} - healthcheck: - test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/$(hostname)/80'"] - <<: *healthcheck-tcp - labels: *release-labels + # --- Slot 13: VulnExplorer (api) - MERGED into findings-ledger-web (SPRINT_20260408_002) --- + # VulnExplorer endpoints are now served by the Findings Ledger WebService. + # Gateway route /api/vuln-explorer(..) now points to findings.stella-ops.local. + # The vulnexplorer.stella-ops.local alias is added to the findings-ledger-web + # container for backward compatibility. + # + # api: + # <<: *resources-light + # image: stellaops/api:dev + # container_name: stellaops-api + # restart: unless-stopped + # depends_on: *depends-infra + # environment: + # ASPNETCORE_URLS: "http://+:8080" + # <<: [*kestrel-cert, *router-microservice-defaults, *gc-light] + # ConnectionStrings__Default: *postgres-connection + # ConnectionStrings__Redis: "cache.stella-ops.local:6379" + # Router__Enabled: "${VULNEXPLORER_ROUTER_ENABLED:-true}" + # Router__Messaging__ConsumerGroup: "vulnexplorer" + # volumes: + # - *cert-volume + # ports: + # - "127.1.0.13:80:80" + # networks: + # stellaops: + # aliases: + # - 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 ------------------------------------------------ policy-engine: @@ -1568,6 +1573,7 @@ services: stellaops: aliases: - findings.stella-ops.local + - vulnexplorer.stella-ops.local frontdoor: {} healthcheck: test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/$(hostname)/80'"] diff --git a/devops/compose/hosts.stellaops.local b/devops/compose/hosts.stellaops.local index 753384edb..d424ad384 100644 --- a/devops/compose/hosts.stellaops.local +++ b/devops/compose/hosts.stellaops.local @@ -19,7 +19,7 @@ 127.1.0.10 excititor.stella-ops.local 127.1.0.11 vexhub.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.15 policy-gateway.stella-ops.local 127.1.0.16 riskengine.stella-ops.local diff --git a/devops/compose/router-gateway-local.json b/devops/compose/router-gateway-local.json index 4c3e4d323..ac9dd76e6 100644 --- a/devops/compose/router-gateway-local.json +++ b/devops/compose/router-gateway-local.json @@ -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/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/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/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" }, diff --git a/devops/docker/services-matrix.env b/devops/docker/services-matrix.env index 8f8fdcdc8..04b242d2b 100644 --- a/devops/docker/services-matrix.env +++ b/devops/docker/services-matrix.env @@ -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 # ── Slot 12: VexLens ──────────────────────────────────────────────────────────── vexlens-web|devops/docker/Dockerfile.hardened.template|src/VexLens/StellaOps.VexLens.WebService/StellaOps.VexLens.WebService.csproj|StellaOps.VexLens.WebService|8080 -# ── Slot 13: VulnExplorer (api) ───────────────────────────────────────────────── -api|devops/docker/Dockerfile.hardened.template|src/Findings/StellaOps.VulnExplorer.Api/StellaOps.VulnExplorer.Api.csproj|StellaOps.VulnExplorer.Api|8080 +# ── 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 # ── Slot 14: Policy Engine ────────────────────────────────────────────────────── policy-engine|devops/docker/Dockerfile.hardened.template|src/Policy/StellaOps.Policy.Engine/StellaOps.Policy.Engine.csproj|StellaOps.Policy.Engine|8080 # ── Slot 15: Policy Gateway ───────────────────────────────────────────────────── diff --git a/docs/modules/findings-ledger/README.md b/docs/modules/findings-ledger/README.md index 0f6827b59..cb8491296 100644 --- a/docs/modules/findings-ledger/README.md +++ b/docs/modules/findings-ledger/README.md @@ -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. - **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/`. diff --git a/docs/technical/architecture/component-map.md b/docs/technical/architecture/component-map.md index 00c584b7f..f54bfe7d1 100644 --- a/docs/technical/architecture/component-map.md +++ b/docs/technical/architecture/component-map.md @@ -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). - **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`). -- **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** — 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`). diff --git a/docs/technical/architecture/module-matrix.md b/docs/technical/architecture/module-matrix.md index 703665bc3..a0fb6865c 100644 --- a/docs/technical/architecture/module-matrix.md +++ b/docs/technical/architecture/module-matrix.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 | | Scanning & Analysis | 5 | Scanner, BinaryIndex, AdvisoryAI, Symbols, ReachGraph | | 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 | | Integration | 5 | CLI, Zastava, Web, API, Registry | | Infrastructure | 6 | Cryptography, Telemetry, Graph, Signals, AirGap, AOC | diff --git a/docs/technical/architecture/port-registry.md b/docs/technical/architecture/port-registry.md index f5ff6294b..5c84ffbe0 100644 --- a/docs/technical/architecture/port-registry.md +++ b/docs/technical/architecture/port-registry.md @@ -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` | | 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` | -| 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` | | 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` | @@ -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.11 vexhub.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.15 policy-gateway.stella-ops.local 127.1.0.16 riskengine.stella-ops.local diff --git a/docs/technical/architecture/webservice-catalog.md b/docs/technical/architecture/webservice-catalog.md index 8b6b2ebec..7b34940de 100644 --- a/docs/technical/architecture/webservice-catalog.md +++ b/docs/technical/architecture/webservice-catalog.md @@ -5,7 +5,7 @@ This page is the source-of-truth inventory for Stella Ops `*.WebService` runtime ## Scope and contract - Inventory source: `rg --files src -g "*WebService.csproj"`. - 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: `.stella-ops.local`. ## Runtime hostname convention and exceptions diff --git a/src/Findings/StellaOps.Findings.Ledger.WebService/Contracts/VulnExplorer/AttestationModels.cs b/src/Findings/StellaOps.Findings.Ledger.WebService/Contracts/VulnExplorer/AttestationModels.cs new file mode 100644 index 000000000..3e03e9161 --- /dev/null +++ b/src/Findings/StellaOps.Findings.Ledger.WebService/Contracts/VulnExplorer/AttestationModels.cs @@ -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 Subject, + VulnScanPredicateDto Predicate, + AttestationMetaDto AttestationMeta); + +public sealed record AttestationSubjectDto( + string Name, + IReadOnlyDictionary 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 Digest); + +public sealed record AttestationMetaDto( + string StatementId, + DateTimeOffset CreatedAt, + AttestationSignerDto Signer); + +public sealed record AttestationSignerDto( + string Name, + string KeyId); + +public sealed record AttestationListResponse( + IReadOnlyList Items, + string? NextPageToken); + +public sealed record AttestationSummaryDto( + string Id, + VxAttestationType Type, + string SubjectName, + IReadOnlyDictionary SubjectDigest, + string PredicateType, + DateTimeOffset CreatedAt, + string? SignerName, + string? SignerKeyId, + bool Verified); + +public enum VxAttestationType +{ + VulnScan, + Sbom, + Vex, + PolicyEval, + Other +} diff --git a/src/Findings/StellaOps.Findings.Ledger.WebService/Contracts/VulnExplorer/EvidenceSubgraphContracts.cs b/src/Findings/StellaOps.Findings.Ledger.WebService/Contracts/VulnExplorer/EvidenceSubgraphContracts.cs new file mode 100644 index 000000000..f632c1eaa --- /dev/null +++ b/src/Findings/StellaOps.Findings.Ledger.WebService/Contracts/VulnExplorer/EvidenceSubgraphContracts.cs @@ -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; + +/// +/// Response containing the evidence subgraph for a finding. +/// +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 Edges { get; init; } + public required VxVerdictSummary Verdict { get; init; } + public required IReadOnlyList AvailableActions { get; init; } + public VxEvidenceMetadata? Metadata { get; init; } +} + +/// +/// Node in the evidence graph (VulnExplorer shape). +/// +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? Metadata { get; init; } + public IReadOnlyList? 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, +} + +/// +/// Edge connecting two evidence nodes (VulnExplorer shape). +/// +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 verdict for a finding (VulnExplorer shape). +/// +public sealed record VxVerdictSummary +{ + public required string Decision { get; init; } + public required string Explanation { get; init; } + public required IReadOnlyList KeyFactors { get; init; } + public required double ConfidenceScore { get; init; } + public IReadOnlyList? AppliedPolicies { get; init; } + public DateTimeOffset? ComputedAt { get; init; } +} + +/// +/// Available triage action (VulnExplorer shape). +/// +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? 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? Sources { get; init; } +} diff --git a/src/Findings/StellaOps.Findings.Ledger.WebService/Contracts/VulnExplorer/FixVerificationModels.cs b/src/Findings/StellaOps.Findings.Ledger.WebService/Contracts/VulnExplorer/FixVerificationModels.cs new file mode 100644 index 000000000..f39e60ef7 --- /dev/null +++ b/src/Findings/StellaOps.Findings.Ledger.WebService/Contracts/VulnExplorer/FixVerificationModels.cs @@ -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 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 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 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; } +} diff --git a/src/Findings/StellaOps.Findings.Ledger.WebService/Contracts/VulnExplorer/TriageWorkflowModels.cs b/src/Findings/StellaOps.Findings.Ledger.WebService/Contracts/VulnExplorer/TriageWorkflowModels.cs new file mode 100644 index 000000000..1f9d8bda2 --- /dev/null +++ b/src/Findings/StellaOps.Findings.Ledger.WebService/Contracts/VulnExplorer/TriageWorkflowModels.cs @@ -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? DecisionIds); + +public sealed record AuditBundleResponse( + string BundleId, + string Tenant, + DateTimeOffset CreatedAt, + IReadOnlyList Decisions, + IReadOnlyList EvidenceRefs); + +public sealed record FixVerificationTransition( + string From, + string To, + DateTimeOffset ChangedAt); + +public sealed record FixVerificationRecord( + string CveId, + string ComponentPurl, + string? ArtifactDigest, + string Verdict, + IReadOnlyList Transitions, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); diff --git a/src/Findings/StellaOps.Findings.Ledger.WebService/Contracts/VulnExplorer/VexDecisionModels.cs b/src/Findings/StellaOps.Findings.Ledger.WebService/Contracts/VulnExplorer/VexDecisionModels.cs new file mode 100644 index 000000000..36370788e --- /dev/null +++ b/src/Findings/StellaOps.Findings.Ledger.WebService/Contracts/VulnExplorer/VexDecisionModels.cs @@ -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; + +/// +/// VEX-style statement attached to a finding + subject, representing a vulnerability exploitability decision. +/// +public sealed record VexDecisionDto( + Guid Id, + string VulnerabilityId, + SubjectRefDto Subject, + VexStatus Status, + VexJustificationType JustificationType, + string? JustificationText, + IReadOnlyList? EvidenceRefs, + VexScopeDto? Scope, + ValidForDto? ValidFor, + AttestationRefDto? AttestationRef, + VexOverrideAttestationDto? SignedOverride, + Guid? SupersedesDecisionId, + ActorRefDto CreatedBy, + DateTimeOffset CreatedAt, + DateTimeOffset? UpdatedAt); + +/// +/// Signed VEX override attestation details. +/// +public sealed record VexOverrideAttestationDto( + string EnvelopeDigest, + string PredicateType, + long? RekorLogIndex, + string? RekorEntryId, + string? StorageRef, + DateTimeOffset AttestationCreatedAt, + bool Verified, + AttestationVerificationStatusDto? VerificationStatus); + +/// +/// Attestation verification status details. +/// +public sealed record AttestationVerificationStatusDto( + bool SignatureValid, + bool? RekorVerified, + DateTimeOffset? VerifiedAt, + string? ErrorMessage); + +public sealed record SubjectRefDto( + SubjectType Type, + string Name, + IReadOnlyDictionary Digest, + string? SbomNodeId = null); + +public sealed record EvidenceRefDto( + EvidenceType Type, + Uri Url, + string? Title = null); + +public sealed record VexScopeDto( + IReadOnlyList? Environments, + IReadOnlyList? Projects); + +public sealed record ValidForDto( + DateTimeOffset? NotBefore, + DateTimeOffset? NotAfter); + +public sealed record AttestationRefDto( + string? Id, + IReadOnlyDictionary? 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 +} + +/// +/// Request to create a new VEX decision. +/// +public sealed record CreateVexDecisionRequest( + string VulnerabilityId, + SubjectRefDto Subject, + VexStatus Status, + VexJustificationType JustificationType, + string? JustificationText, + IReadOnlyList? EvidenceRefs, + VexScopeDto? Scope, + ValidForDto? ValidFor, + Guid? SupersedesDecisionId, + AttestationRequestOptions? AttestationOptions); + +/// +/// Options for creating a signed attestation with the VEX decision. +/// +public sealed record AttestationRequestOptions( + bool CreateAttestation, + bool AnchorToRekor = false, + string? SigningKeyId = null, + string? StorageDestination = null, + IReadOnlyDictionary? AdditionalMetadata = null); + +/// +/// Request to update an existing VEX decision. +/// +public sealed record UpdateVexDecisionRequest( + VexStatus? Status, + VexJustificationType? JustificationType, + string? JustificationText, + IReadOnlyList? EvidenceRefs, + VexScopeDto? Scope, + ValidForDto? ValidFor, + Guid? SupersedesDecisionId, + AttestationRequestOptions? AttestationOptions); + +public sealed record VexDecisionListResponse( + IReadOnlyList Items, + string? NextPageToken); diff --git a/src/Findings/StellaOps.Findings.Ledger.WebService/Contracts/VulnExplorer/VulnModels.cs b/src/Findings/StellaOps.Findings.Ledger.WebService/Contracts/VulnExplorer/VulnModels.cs new file mode 100644 index 000000000..7084530cc --- /dev/null +++ b/src/Findings/StellaOps.Findings.Ledger.WebService/Contracts/VulnExplorer/VulnModels.cs @@ -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 CveIds, + IReadOnlyList Purls, + string PolicyVersion, + string RationaleId); + +public sealed record VulnDetail( + string Id, + string Severity, + double Score, + bool Kev, + string Exploitability, + bool FixAvailable, + IReadOnlyList CveIds, + IReadOnlyList Purls, + string Summary, + IReadOnlyList AffectedPackages, + IReadOnlyList AdvisoryRefs, + PolicyRationale Rationale, + IReadOnlyList Paths, + IReadOnlyList Evidence, + DateTimeOffset FirstSeen, + DateTimeOffset LastSeen, + string PolicyVersion, + string RationaleId, + EvidenceProvenance Provenance); + +public sealed record PackageAffect(string Purl, IReadOnlyList 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 Items, string? NextPageToken); diff --git a/src/Findings/StellaOps.Findings.Ledger.WebService/Endpoints/VulnExplorerEndpoints.cs b/src/Findings/StellaOps.Findings.Ledger.WebService/Endpoints/VulnExplorerEndpoints.cs new file mode 100644 index 000000000..a32d9743c --- /dev/null +++ b/src/Findings/StellaOps.Findings.Ledger.WebService/Endpoints/VulnExplorerEndpoints.cs @@ -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() + .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; +} diff --git a/src/Findings/StellaOps.Findings.Ledger.WebService/Program.cs b/src/Findings/StellaOps.Findings.Ledger.WebService/Program.cs index adbd84f73..5dbdabbdf 100644 --- a/src/Findings/StellaOps.Findings.Ledger.WebService/Program.cs +++ b/src/Findings/StellaOps.Findings.Ledger.WebService/Program.cs @@ -53,6 +53,12 @@ const string ScoringReadPolicy = "scoring.read"; const string ScoringWritePolicy = "scoring.write"; 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); builder.Configuration.AddStellaOpsDefaults(options => @@ -197,6 +203,12 @@ builder.Services.AddAuthorization(options => policy.Requirements.Add(new StellaOpsScopeRequirement(scopes)); 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(); @@ -297,6 +309,24 @@ builder.Services.AddHttpClient("webhook-delivery", client => client.Timeout = TimeSpan.FromSeconds(30); }); +// VulnExplorer adapter services (merged from VulnExplorer service) +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => + new VexDecisionAdapter( + timeProvider: sp.GetRequiredService(), + attestorClient: sp.GetRequiredService())); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => + new EvidenceSubgraphAdapter( + sp.GetRequiredService(), + sp.GetRequiredService())); +builder.Services.AddSingleton(sp => + new VulnQueryAdapter( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + // Stella Router integration var routerEnabled = builder.Services.AddRouterMicroservice( builder.Configuration, @@ -2001,6 +2031,9 @@ app.MapRuntimeTracesEndpoints(); app.MapScoringEndpoints(); app.MapWebhookEndpoints(); +// VulnExplorer endpoints (merged from VulnExplorer service) +app.MapVulnExplorerEndpoints(); + await app.LoadTranslationsAsync(); app.Run(); diff --git a/src/Findings/StellaOps.Findings.Ledger.WebService/Services/VulnExplorerAdapters.cs b/src/Findings/StellaOps.Findings.Ledger.WebService/Services/VulnExplorerAdapters.cs new file mode 100644 index 000000000..69baf3dfc --- /dev/null +++ b/src/Findings/StellaOps.Findings.Ledger.WebService/Services/VulnExplorerAdapters.cs @@ -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; + +/// +/// 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). +/// +public sealed class VexDecisionAdapter +{ + private readonly ConcurrentDictionary _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 Query( + string? vulnerabilityId = null, + string? subjectName = null, + VexStatus? status = null, + int skip = 0, + int take = 50) + { + IEnumerable 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; +} + +/// +/// Adapter for fix verifications backed by Ledger event persistence. +/// +public sealed class FixVerificationAdapter +{ + private readonly ConcurrentDictionary _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; + } +} + +/// +/// Adapter for audit bundles backed by Ledger evidence bundle service. +/// +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 decisions) + { + var next = Interlocked.Increment(ref _sequence); + var createdAt = _timeProvider.GetUtcNow(); + var evidenceRefs = decisions + .SelectMany(x => x.EvidenceRefs ?? Array.Empty()) + .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); + } +} + +/// +/// Adapter for evidence subgraph backed by the Ledger's EvidenceGraphBuilder. +/// Returns a VulnExplorer-shaped response by delegating to the Ledger's graph builder. +/// +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 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(), + 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(), + Verdict = new VxVerdictSummary + { + Decision = "review", + Explanation = "No Ledger finding matched; stub evidence subgraph returned.", + KeyFactors = Array.Empty(), + ConfidenceScore = 0.0, + AppliedPolicies = Array.Empty(), + ComputedAt = observedAt + }, + AvailableActions = Array.Empty(), + Metadata = new VxEvidenceMetadata + { + CollectedAt = observedAt, + NodeCount = 1, + EdgeCount = 0, + IsTruncated = false, + MaxDepth = 0, + Sources = Array.Empty() + } + }; + } +} + +/// +/// Adapter for vuln list/detail queries backed by Ledger's finding projection. +/// +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; + } + + /// + /// Query vulnerability summaries from Ledger projections. + /// Falls back to empty list when no findings exist yet. + /// + public async Task 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(), + Purls: !string.IsNullOrEmpty(s.Component) ? new[] { s.Component } : Array.Empty(), + PolicyVersion: "policy-main", + RationaleId: s.FindingId.ToString() + )).ToArray(); + + // Apply additional filters not handled by the summary service + IEnumerable 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); + } + + /// + /// Get vulnerability detail from Ledger projections. + /// + public async Task 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(), + Purls: !string.IsNullOrEmpty(detail.PackageName) ? new[] { detail.PackageName } : Array.Empty(), + Summary: detail.Description ?? "No description available", + AffectedPackages: !string.IsNullOrEmpty(detail.PackageName) + ? new[] { new PackageAffect(detail.PackageName, detail.AffectedVersions.ToArray()) } + : Array.Empty(), + AdvisoryRefs: detail.References.Select(r => new AdvisoryRef(r, r)).ToArray(), + Rationale: new PolicyRationale(id, detail.Description ?? ""), + Paths: detail.WitnessPath.ToArray(), + Evidence: Array.Empty(), + FirstSeen: detail.FirstSeen, + LastSeen: detail.FirstSeen, + PolicyVersion: "policy-main", + RationaleId: id, + Provenance: new EvidenceProvenance("ledger", detail.FindingId)); + } +} + +// ============================================================================ +// Attestor client adapter (mirrors IVexOverrideAttestorClient from VulnExplorer) +// ============================================================================ + +/// +/// Adapter interface for VEX override attestor client. +/// +public interface IVexOverrideAttestorAdapter +{ + Task CreateAttestationAsync( + VexOverrideAttestationRequest request, + CancellationToken cancellationToken = default); + + Task VerifyAttestationAsync( + string envelopeDigest, + CancellationToken cancellationToken = default); +} + +/// +/// Request to create a VEX override attestation. +/// +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? 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? AdditionalMetadata { get; init; } +} + +/// +/// Result of creating a VEX override attestation. +/// +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 + }; +} + +/// +/// Stub attestor client for offline/testing scenarios. +/// +public sealed class StubVexOverrideAttestorAdapter : IVexOverrideAttestorAdapter +{ + private readonly TimeProvider _timeProvider; + + public StubVexOverrideAttestorAdapter(TimeProvider? timeProvider = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + } + + public Task 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 VerifyAttestationAsync( + string envelopeDigest, + CancellationToken cancellationToken = default) + { + return Task.FromResult(new AttestationVerificationStatusDto( + SignatureValid: false, + RekorVerified: null, + VerifiedAt: _timeProvider.GetUtcNow(), + ErrorMessage: "Offline mode - verification unavailable")); + } +}