Merge branch 'worktree-agent-a09ac2bf'
This commit is contained in:
@@ -429,7 +429,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 removed: gateway merged into policy-engine
|
||||
STELLAOPS_RISKENGINE_URL: "http://riskengine.stella-ops.local"
|
||||
@@ -1008,33 +1008,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:
|
||||
@@ -1509,6 +1514,7 @@ services:
|
||||
stellaops:
|
||||
aliases:
|
||||
- findings.stella-ops.local
|
||||
- vulnexplorer.stella-ops.local
|
||||
frontdoor: {}
|
||||
healthcheck:
|
||||
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.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.14 policy-gateway.stella-ops.local # backwards-compat alias (merged into policy-engine)
|
||||
127.1.0.16 riskengine.stella-ops.local
|
||||
|
||||
@@ -117,7 +117,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" },
|
||||
|
||||
@@ -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 (MERGED into policy-engine, Slot 14) ───────────────
|
||||
|
||||
@@ -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/`.
|
||||
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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~~ (merged into Policy Engine, Slot 14) | `policy-gateway.stella-ops.local` -> `policy-engine.stella-ops.local` | _removed_ | _removed_ |
|
||||
| 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.14 policy-gateway.stella-ops.local # alias -> policy-engine (merged)
|
||||
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
|
||||
- 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: `<service>.stella-ops.local`.
|
||||
|
||||
## 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 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<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
|
||||
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();
|
||||
|
||||
|
||||
@@ -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