Scanner WebService API — Score Proofs & Reachability Extensions
Version: 2.0 Base URL: /api/v1 (configurable via scanner:Api:BasePath; default /api/v1) Authentication: Bearer token (OpTok with DPoP/mTLS) Sprint: SPRINT_3500_0002_0003, SPRINT_3500_0003_0003
Reconciliation note (2026-05-29): Routes in this document were corrected against
src/Scanner/StellaOps.Scanner.WebService/. The real service mounts every endpoint under thescanner:Api:BasePathgroup (default/api/v1, NOT/api/v1/scanner). Scan resources therefore live at/api/v1/scans/.... See the per-endpoint headers below and the Source Map for the backing endpoint class for each route.
Overview
Audience: Backend integrators and CI pipelines calling the Scanner WebService to submit scans, ingest call graphs, replay deterministic scores, and read reachability and unknowns data.
This document specifies the Scanner.WebService HTTP surface for:
- Scan submission and deterministic manifests / replay
- Score proof bundles
- Call-graph ingestion and reachability analysis
- Unknowns management
Design Principles:
- All endpoints return canonical JSON (deterministic serialization)
- Idempotency via
Content-Digestheaders (SHA-256) - DSSE signatures returned for proof / manifest artifacts
- Offline-first (proof bundles built content-addressed for air-gap verification)
Endpoints
1. Submit Scan
POST /api/v1/scans
Source: Endpoints/ScanEndpoints.cs → scanner.scans.submit. Scope policy: ScansEnqueue (any-of scanner.scans.enqueue, scanner.scans.write, scanner:scan, scanner:write).
Correction (2026-05-29): The implemented submit endpoint does not accept a pre-built manifest with feed/VEX/policy snapshot hashes. It accepts an image reference and/or artifact digest and returns 202 Accepted with a scan id. The deterministic manifest is built by the worker and read back via Endpoint 2; a score proof bundle is produced via the score-replay flow (Endpoint 3). The previously documented
201 Createdmanifest-creation contract did not exist.
Description: Submits a new scan for an OCI artifact.
Request Body (ScanSubmitRequest):
{
"image": {
"reference": "registry.example.com/myapp:1.0.0",
"digest": "sha256:abc123..."
},
"metadata": { "env": "stage" }
}
artifactRef / artifactDigest top-level fields are accepted as aliases for image.reference / image.digest. At least one of reference or digest is required; a supplied digest must be a valid sha256: digest.
Response (202 Accepted, ScanSubmitResponse):
{
"scanId": "550e8400-e29b-41d4-a716-446655440000",
"status": "Pending",
"location": "/api/v1/scans/550e8400-e29b-41d4-a716-446655440000",
"created": true
}
Headers:
Location:/api/v1/scans/{scanId}
Errors:
400 Bad Request— Neither image reference nor digest supplied, or invalid digest prefix409 Conflict— Duplicate submission
Status / progress: GET /api/v1/scans/{scanId} (scanner.scans.status) and GET /api/v1/scans/{scanId}/events (SSE progress stream).
2. Retrieve Scan Manifest
GET /api/v1/scans/{scanId}/manifest
Source: Endpoints/ManifestEndpoints.cs → scanner.scans.manifest. Scope policy: ScansRead (any-of scanner.scans.read, scanner:read). Rate-limited (ManifestPolicy).
Description: Retrieves the scan manifest with the canonical input hashes used for deterministic replay. Supports content negotiation: send Accept: application/dsse+json to receive the DSSE-signed manifest envelope.
Correction (2026-05-29): The implemented manifest carries the canonical scan input hashes —
sbomHash,rulesHash,feedHash,policyHash— not the previously documentedconcelierSnapshotHash/excititorSnapshotHash/latticePolicyHash/seed/knobsfields. There is no_linksblock and noETag/If-None-Matchcaching; the plain response carries an RFC 9530Content-Digest(sha-256=:...:).
Response (200 OK, application/json, ScanManifestResponse):
{
"manifestId": "manifest-001",
"scanId": "550e8400-e29b-41d4-a716-446655440000",
"manifestHash": "sha256:manifest123...",
"sbomHash": "sha256:sbom...",
"rulesHash": "sha256:rules...",
"feedHash": "sha256:feed...",
"policyHash": "sha256:policy...",
"scanStartedAt": "2025-12-17T12:00:00Z",
"scanCompletedAt": "2025-12-17T12:02:00Z",
"scannerVersion": "1.0.0",
"createdAt": "2025-12-17T12:00:00Z",
"contentDigest": "sha-256=:<base64>:"
}
Response (200 OK, application/dsse+json, SignedScanManifestResponse): wraps the manifest above plus manifestHash, a DSSE envelope (payloadType, payload, signatures[]), signedAt, and signatureValid.
Errors:
404 Not Found— Scan ID not found, invalid id format, or manifest not yet produced429 Too Many Requests— Rate limit exceeded
3. Replay Score Computation
POST /api/v1/scans/{scanId}/score/replay
Source: Endpoints/ScoreReplayEndpoints.cs → scanner.scans.score.replay. Scope policy: ScansWrite (any-of scanner.scans.write, scanner:write). Only registered when scanner:ScoreReplay:Enabled is true.
Correction (2026-05-29): The request body is
ScoreReplayRequestwith optionalmanifestHashandfreezeTimestamp(deterministic replay against a frozen manifest); there is nooverrides/snapshot-substitution body. The response is a flatScoreReplayResponsecarrying a factorised score, not a nestedscoreProof.nodes[]ledger, and there is no_linksblock. A legacy aliasPOST /api/v1/score/{scanId}/replayis also registered while clients migrate.
Request Body (ScoreReplayRequest, all optional):
{
"manifestHash": "sha256:manifest123...",
"freezeTimestamp": "2025-12-17T13:00:00Z"
}
Response (200 OK, ScoreReplayResponse):
{
"score": 0.50,
"rootHash": "sha256:proof123...",
"bundleUri": "/api/v1/scans/550e8400-e29b-41d4-a716-446655440000/score/bundle?rootHash=sha256:proof123...",
"manifestHash": "sha256:manifest123...",
"manifestDigest": "sha256:mdigest...",
"canonicalInputHash": "sha256:cinput...",
"canonicalInputPayload": "{...}",
"seedHex": "0102030405...",
"factors": [
{ "name": "cvss_base", "weight": 0.5, "raw": 9.1, "weighted": 0.50, "source": "cvss" }
],
"verificationStatus": "verified",
"replayedAt": "2025-12-17T13:00:00Z",
"deterministic": true
}
Related score endpoints (same ScoreReplayEndpoints.cs group, under /scans/{scanId}/score):
GET /api/v1/scans/{scanId}/score/bundle?rootHash=...(ScansRead) — proof bundle metadata + manifest DSSE validityPOST /api/v1/scans/{scanId}/score/verify(ScansWrite) — verify a bundle against an expected root hashGET /api/v1/scans/{scanId}/score/history(ScansRead) — deterministic replay history
Errors:
400 Bad Request— Missing/invalid scan id404 Not Found— Scan ID not found422 Unprocessable Entity— Replay failed (invalid operation)
Use Case: Nightly rescore job when Concelier publishes a new advisory snapshot.
4. Upload Call-Graph
POST /api/v1/scans/{scanId}/callgraphs
Source: Endpoints/CallGraphEndpoints.cs → scanner.scans.callgraphs.submit. Scope policy: CallGraphIngest (requires scope scanner.callgraph.ingest). A Content-Digest request header is required (used as the idempotency key).
Description: Uploads a call-graph extracted by language-specific workers (dotnet, java, node, python, go, rust, binary, ruby, php).
Correction (2026-05-29): The schema must be exactly
stella.callgraph.v1, and a top-levelscanKeyfield is required in addition tonodes/edges. The response is aCallGraphAcceptedResponseDto(callgraphId,nodeCount,edgeCount,digest) — there is noscanId,entrypointsCount,status, or_linksfield on the response. A duplicateContent-Digestreturns409 Conflict(not a silent re-return of the existing graph).
Request Body (application/json, CallGraphV1Dto):
{
"schema": "stella.callgraph.v1",
"scanKey": "550e8400-e29b-41d4-a716-446655440000",
"language": "dotnet",
"artifacts": [
{ "artifactKey": "MyApp.WebApi.dll", "kind": "assembly", "sha256": "sha256:artifact123..." }
],
"nodes": [
{
"nodeId": "sha256:node1...",
"artifactKey": "MyApp.WebApi.dll",
"symbolKey": "MyApp.Controllers.OrdersController::Get(System.Guid)",
"visibility": "public",
"isEntrypointCandidate": true
}
],
"edges": [
{ "from": "sha256:node1...", "to": "sha256:node2...", "kind": "static", "reason": "direct_call", "weight": 1.0 }
],
"entrypoints": [
{ "nodeId": "sha256:node1...", "kind": "http", "route": "/api/orders/{id}", "framework": "aspnetcore" }
]
}
Headers:
Content-Digest:sha256=<hash>(required — idempotency key)
Response (202 Accepted, CallGraphAcceptedResponseDto):
{
"callgraphId": "cg-001",
"nodeCount": 1234,
"edgeCount": 5678,
"digest": "sha256:cg123..."
}
A Location: /api/scans/{scanId}/callgraphs/{callgraphId} header is set.
Errors:
400 Bad Request— MissingContent-Digest, invalid identifier, or invalid call-graph schema404 Not Found— Scan ID not found409 Conflict— Duplicate submission (sameContent-Digest)413 Payload Too Large— Call-graph exceeds the configured size limit
5. Compute Reachability
POST /api/v1/scans/{scanId}/compute-reachability
Source: Endpoints/ReachabilityEndpoints.cs → scanner.scans.compute-reachability. Scope policy: ScansWrite.
Correction (2026-05-29): The implemented route is
/scans/{scanId}/compute-reachability, not/scans/{scanId}/reachability/compute. The response isComputeReachabilityResponseDto(jobId,status,estimatedDuration) with noscanIdor_linksblock. The default Scanner wiring registers aNullReachabilityComputeService; when no reachability runtime is configured the endpoint returns 501 Not Implemented (scanner.reachability.unavailable). There is no documented Scanner/jobs/{jobId}polling route — poll the findings endpoint (Endpoint 6) instead.
Request Body (ComputeReachabilityRequestDto, optional): forceRecompute, entrypoints, targets.
Response (202 Accepted, ComputeReachabilityResponseDto):
{
"jobId": "reachability-job-001",
"status": "queued",
"estimatedDuration": "30s"
}
Errors:
400 Bad Request— Invalid scan identifier404 Not Found— Scan ID not found409 Conflict— Computation already in progress501 Not Implemented— No reachability runtime configured for this deployment
6. Get Reachability Findings
GET /api/v1/scans/{scanId}/reachability/findings
Source: Endpoints/ReachabilityEndpoints.cs → scanner.scans.reachability.findings. Scope policy: ScansRead.
Correction (2026-05-29): The query parameters are
cveandstatus(notcveId). The response is aReachabilityFindingListDtowith a flatitems[]ofReachabilityFindingDtoand atotalcount — there is noscanId,computedAt, per-findingpath/evidence/_links, nor asummaryaggregate object. Use Endpoint 7 for path witnesses. A sibling routeGET /scans/{scanId}/reachability/components(paramspurl,status) returnsComponentReachabilityListDto, andGET /scans/{scanId}/reachability/traces/export(paramsformat,includeRuntimeEvidence,minReachabilityScore,runtimeConfirmedOnly) emits a deterministic callable-path / sensor-gap trace export.
Query Parameters:
cve(optional): Filter by CVE IDstatus(optional): Filter by reachability status
Response (200 OK, ReachabilityFindingListDto):
{
"items": [
{
"cveId": "CVE-2024-1234",
"purl": "pkg:npm/lodash@4.17.20",
"status": "reachable",
"confidence": 0.70,
"latticeState": "REACHABLE_STATIC",
"severity": "high",
"affectedVersions": ["<4.17.21"]
}
],
"total": 1
}
Errors:
400 Bad Request— Invalid scan identifier404 Not Found— Scan ID not found501 Not Implemented— Reachability query backend not configured
7. Explain Reachability
GET /api/v1/scans/{scanId}/reachability/explain
Source: Endpoints/ReachabilityEndpoints.cs → scanner.scans.reachability.explain. Scope policy: ScansRead.
Correction (2026-05-29): Response is
ReachabilityExplanationDto. The path witness is apathWitnessstring list (e.g."A -> B -> C"), the reasons are awhy[]list of{code, description, impact}, and supporting data is grouped underevidence(staticAnalysis,runtimeEvidence,policyEvaluation), plus an optionalspineId. There is noexplanation.shortestPath[],confidenceFactors,alternativePaths, or_linksobject as previously documented. Missingcveorpurlreturns 400, not 404.
Query Parameters:
cve(required): CVE IDpurl(required): Package URL
Response (200 OK, ReachabilityExplanationDto):
{
"cveId": "CVE-2024-1234",
"purl": "pkg:npm/lodash@4.17.20",
"status": "reachable",
"confidence": 0.70,
"latticeState": "REACHABLE_STATIC",
"pathWitness": [
"MyApp.Controllers.OrdersController::Get -> MyApp.Services.OrderService::Process -> Lodash.merge"
],
"why": [
{ "code": "static_path", "description": "Static call path exists from HTTP entrypoint", "impact": "positive" }
],
"evidence": {
"staticAnalysis": { "callgraphDigest": "sha256:cg123...", "pathLength": 3, "edgeTypes": ["static"] },
"runtimeEvidence": null,
"policyEvaluation": { "policyDigest": "sha256:policy...", "verdict": "blocked", "verdictReason": "reachable_critical" }
},
"spineId": "spine-001"
}
Errors:
400 Bad Request— Invalid scan id, or missingcve/purl404 Not Found— Scan or explanation not found501 Not Implemented— Reachability runtime not configured
8. Fetch Proof Bundle Metadata
GET /api/v1/scans/{scanId}/proofs/{rootHash}
Source: Endpoints/ManifestEndpoints.cs → scanner.scans.proofs.get. Scope policy: ScansRead.
Correction (2026-05-29): This endpoint returns a JSON
ProofBundleResponsedescribing the proof bundle (hashes, signature metadata, DSSE validity) — it does not stream anapplication/ziparchive, and there are noContent-Disposition/X-Proof-Root-Hash/X-Manifest-Hashheaders. A sibling routeGET /api/v1/scans/{scanId}/proofs(scanner.scans.proofs.list) lists all proof bundles for a scan. The score-replay flow’s bundle pointer is exposed viaGET /api/v1/scans/{scanId}/score/bundle?rootHash=...(see Endpoint 3). No CLIstella proof verify --bundle proof.zippath was confirmed against the WebService surface.
Path Parameters:
rootHash: Proof root hash (e.g.,sha256:proof123...)
Response (200 OK, application/json, ProofBundleResponse):
{
"scanId": "550e8400-e29b-41d4-a716-446655440000",
"rootHash": "sha256:proof123...",
"bundleType": "score-proof",
"bundleHash": "sha256:bundle...",
"ledgerHash": "sha256:ledger...",
"manifestHash": "sha256:manifest...",
"sbomHash": "sha256:sbom...",
"vexHash": "sha256:vex...",
"signatureKeyId": "ecdsa-p256-key-001",
"signatureAlgorithm": "ES256",
"createdAt": "2025-12-17T12:00:00Z",
"expiresAt": null,
"signatureValid": true,
"verificationError": null,
"contentDigest": "sha-256=:<base64>:"
}
Errors:
404 Not Found— Scan, invalid id format, or proof root hash not found
9. List Unknowns
GET /api/v1/unknowns
Source: Endpoints/UnknownsEndpoints.cs → scanner.unknowns.list. Scope policy: ScansRead.
Correction (2026-05-29): Unknowns are mounted at
/api/v1/unknowns(NOT/api/v1/scanner/unknowns). Query parameters areartifactDigest,vulnId,band,sortBy(score|created|updated),sortOrder(asc|desc),limit(default 50, clamped 1–500), andoffset. The response isUnknownsListResponse(items[],totalCount,limit,offset); list items exposeid,artifactDigest,vulnerabilityId,packagePurl,score,band,createdAtUtc,updatedAtUtc. The previously documentedpopularity/potentialExploit/uncertainty/evidencefields, thepagination.nextlink, and the per-item_links.escalatelink do not exist.
Query Parameters:
band(optional): Filter byHOT,WARM,COLDartifactDigest,vulnId(optional): filterssortBy,sortOrder,limit,offset(optional)
Response (200 OK, UnknownsListResponse):
{
"items": [
{
"id": "unk-3f1c...",
"artifactDigest": "sha256:abc123...",
"vulnerabilityId": "CVE-2024-1234",
"packagePurl": "pkg:npm/lodash@4.17.20",
"score": 0.72,
"band": "HOT",
"createdAtUtc": "2025-12-15T10:00:00Z",
"updatedAtUtc": "2025-12-16T10:00:00Z"
}
],
"totalCount": 156,
"limit": 50,
"offset": 0
}
Other implemented unknowns routes (same UnknownsEndpoints.cs group, all ScansRead):
GET /api/v1/unknowns/stats→UnknownsStatsResponse(total,hot,warm,cold)GET /api/v1/unknowns/bands→ band distribution mapGET /api/v1/unknowns/{id}→UnknownDetailResponseGET /api/v1/unknowns/{id}/evidence→UnknownEvidenceResponse(id,proofRef,lastUpdatedAtUtc)GET /api/v1/unknowns/{id}/history→UnknownHistoryResponse
Errors:
400 Bad Request— Invalid band value or tenant context
10. Escalate Unknown to Rescan — NOT IMPLEMENTED
Removed / not implemented (2026-05-29): No
POST /unknowns/{id}/escalateendpoint exists inEndpoints/UnknownsEndpoints.cs(which is read-only: only theGETroutes listed in Endpoint 9). There is likewise noresolveorsummaryroute on the unknowns group. The previously documented escalate/resolve/summary endpoints were never backed by a controller and have been removed. If escalation is required, file a sprint to add a write route + audit action before re-documenting it.
Data Models
ScanManifest
See src/Scanner/__Libraries/StellaOps.Scanner.Core/ScanManifest.cs for the internal manifest model. Note that the wire shape returned by GET /scans/{scanId}/manifest (ScanManifestResponse, Contracts/ManifestContracts.cs) projects only sbomHash, rulesHash, feedHash, policyHash (plus versions/timestamps) — see Endpoint 2.
ProofNode
Conceptual model for the score-proof ledger; ProofNode arrays are not returned inline by the replay endpoint (which returns a factorised score, see Endpoint 3).
interface ProofNode {
id: string;
kind: "Input" | "Transform" | "Delta" | "Score";
ruleId: string;
parentIds: string[];
evidenceRefs: string[];
delta: number;
total: number;
actor: string;
tsUtc: string; // ISO 8601
seed: string; // base64
nodeHash: string; // sha256:...
}
DsseEnvelope
interface DsseEnvelope {
payloadType: string;
payload: string; // base64 canonical JSON
signatures: DsseSignature[];
}
interface DsseSignature {
keyid: string;
sig: string; // base64
}
ReachabilityStatus
enum ReachabilityStatus {
UNREACHABLE = "UNREACHABLE",
POSSIBLY_REACHABLE = "POSSIBLY_REACHABLE",
REACHABLE_STATIC = "REACHABLE_STATIC",
REACHABLE_PROVEN = "REACHABLE_PROVEN",
UNKNOWN = "UNKNOWN"
}
Error Responses
All errors follow RFC 7807 (Problem Details):
{
"type": "https://stella-ops.org/errors/scan-not-found",
"title": "Scan Not Found",
"status": 404,
"detail": "Scan ID '550e8400-e29b-41d4-a716-446655440000' does not exist.",
"instance": "/api/v1/scans/550e8400-e29b-41d4-a716-446655440000",
"traceId": "trace-001"
}
Error Types
Note (2026-05-29): The codebase builds problem responses from localization keys (e.g.
scanner.scan.not_found,scanner.callgraph.invalid) viaProblemResultFactory, not from a fixedtypeslug registry. The slugs below are illustrative. Theinvalid-manifest,duplicate-scan,snapshot-not-found, andescalation-conflictrows describe the removed manifest-creation / escalate contracts and do not correspond to live endpoints.
| Type (illustrative) | Status | Description |
|---|---|---|
scan-not-found | 404 | Scan ID not found |
invalid-callgraph | 400 | Call-graph schema validation failed |
callgraph-duplicate | 409 | Duplicate call-graph (Content-Digest) |
payload-too-large | 413 | Request body exceeds size limit |
proof-not-found | 404 | Proof root hash not found |
reachability-in-progress | 409 | Reachability computation already running |
reachability-unavailable | 501 | Reachability runtime not configured |
Rate Limiting
Correction (2026-05-29): Only
GET /scans/{scanId}/manifestis rate-limited in code (RequireRateLimiting(ManifestPolicy), wired viaAddScannerRateLimiting()). The per-tenant hourly quotas previously listed forPOST /scans, replay, callgraphs, and reachability are not enforced by the current WebService and were aspirational. StandardX-RateLimit-*headers and429responses apply to the manifest endpoint per the configured policy.
Webhooks (Future)
Webhook registration (scan.completed, reachability.computed, etc.) is not implemented. The WebhookEndpoints.cs group exists for inbound PR-annotation webhook handling, not for event-subscription registration. No POST /webhooks subscription route was confirmed.
OpenAPI Specification
File: src/Api/StellaOps.Api.OpenApi/scanner/openapi.yaml (present in repo).
Source Map
| Endpoint | Backing class | Endpoint name |
|---|---|---|
POST /api/v1/scans | Endpoints/ScanEndpoints.cs | scanner.scans.submit |
GET /api/v1/scans/{scanId} | Endpoints/ScanEndpoints.cs | scanner.scans.status |
GET /api/v1/scans/{scanId}/manifest | Endpoints/ManifestEndpoints.cs | scanner.scans.manifest |
GET /api/v1/scans/{scanId}/proofs | Endpoints/ManifestEndpoints.cs | scanner.scans.proofs.list |
GET /api/v1/scans/{scanId}/proofs/{rootHash} | Endpoints/ManifestEndpoints.cs | scanner.scans.proofs.get |
POST /api/v1/scans/{scanId}/score/replay | Endpoints/ScoreReplayEndpoints.cs | scanner.scans.score.replay |
GET /api/v1/scans/{scanId}/score/bundle | Endpoints/ScoreReplayEndpoints.cs | scanner.scans.score.bundle |
POST /api/v1/scans/{scanId}/score/verify | Endpoints/ScoreReplayEndpoints.cs | scanner.scans.score.verify |
GET /api/v1/scans/{scanId}/score/history | Endpoints/ScoreReplayEndpoints.cs | scanner.scans.score.history |
POST /api/v1/scans/{scanId}/callgraphs | Endpoints/CallGraphEndpoints.cs | scanner.scans.callgraphs.submit |
POST /api/v1/scans/{scanId}/compute-reachability | Endpoints/ReachabilityEndpoints.cs | scanner.scans.compute-reachability |
GET /api/v1/scans/{scanId}/reachability/findings | Endpoints/ReachabilityEndpoints.cs | scanner.scans.reachability.findings |
GET /api/v1/scans/{scanId}/reachability/components | Endpoints/ReachabilityEndpoints.cs | scanner.scans.reachability.components |
GET /api/v1/scans/{scanId}/reachability/explain | Endpoints/ReachabilityEndpoints.cs | scanner.scans.reachability.explain |
GET /api/v1/scans/{scanId}/reachability/traces/export | Endpoints/ReachabilityEndpoints.cs | scanner.scans.reachability.traces.export |
GET /api/v1/scans/{scanId}/spines | Endpoints/ProofSpineEndpoints.cs | scanner.spines.list-by-scan |
GET /api/v1/spines/{spineId} | Endpoints/ProofSpineEndpoints.cs | scanner.spines.get |
GET /api/v1/unknowns (+ /stats,/bands,/{id},/{id}/evidence,/{id}/history) | Endpoints/UnknownsEndpoints.cs | scanner.unknowns.* |
POST /api/v1/triage/proof-bundle | Endpoints/Triage/ProofBundleEndpoints.cs | scanner.triage.proof-bundle |
Base path group is app.MapGroup(resolvedOptions.Api.BasePath) in Program.cs (Options/ScannerWebServiceOptions.cs → Api.BasePath default /api/v1).
References
SPRINT_3500_0002_0001_score_proofs_foundations.md— implementation sprintSPRINT_3500_0002_0003_proof_replay_api.md— API implementation sprintSPRINT_3500_0003_0003_graph_attestations_rekor.md— reachability API sprint- Architecture overview — API contracts section
- Scanner schema specification — database schema
- Score Proofs & Reachability API Reference — companion reference (same surface, with the Attestor proof-chain context)
Last Updated: 2026-05-29 (doc↔code reconciliation) API Version: 2.0 Next Review: Sprint 3500.0004.0001 (CLI integration)
