Merge branch 'worktree-agent-a09ac2bf'

This commit is contained in:
master
2026-04-08 13:45:25 +03:00
18 changed files with 1615 additions and 38 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();

View File

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