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 the scanner:Api:BasePath group (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:

  1. Scan submission and deterministic manifests / replay
  2. Score proof bundles
  3. Call-graph ingestion and reachability analysis
  4. Unknowns management

Design Principles:


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; the score-replay flow (Endpoint 3) currently refuses scoring because the manifest does not bind attributable frozen measurements. The 201 Created manifest-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:

Errors:

Status / progress: GET /api/v1/scans/{scanId} (scanner.scans.status) and GET /api/v1/scans/{scanId}/events (SSE progress stream).


2. Retrieve Scan Manifest

New signed manifests require schemaVersion: 2 and DSSE payload type scanmanifest.stella/v2. The forward migration renames the input columns without rewriting stored hashes or JSON. Historical v1 signed artifacts retain their original bytes, digest and signature: generic DSSE verification remains available, but typed replay and DSSE manifest retrieval reject v1 (422, scan_manifest_schema_unsupported) before re-signing. Download and retain historical proof bundles as raw artifacts; obtaining a v2 replay requires a new scan. No field aliases or automatic signature conversion are provided. SHA-256 content addresses and RFC 9530 Content-Digest remain fixed wire contracts, computed through the central cryptography library.

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.

Verified manifest contract (2026-10-05, PCD-303): The plain response projects sbomHash, vexSnapshotHash, vulnerabilitiesSnapshotHash and policyHash. The negotiated DSSE form also carries schemaVersion, latticePolicyHash, seed and knobs. There is no _links block and no ETag/If-None-Match caching; the plain response carries an RFC 9530 Content-Digest (sha-256=:...:).

Response (200 OK, application/json, ScanManifestResponse):

{
 "manifestId": "manifest-001",
 "scanId": "550e8400-e29b-41d4-a716-446655440000",
 "manifestHash": "sha256:manifest123...",
 "sbomHash": "sha256:sbom...",
 "vexSnapshotHash": "sha256:rules...",
 "vulnerabilitiesSnapshotHash": "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:


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 ScoreReplayRequest with optional manifestHash and freezeTimestamp (deterministic replay against a frozen manifest); there is no overrides/snapshot-substitution body. The flat ScoreReplayResponse defines the stored success contract, rather than a nested scoreProof.nodes[] ledger. The /api/v1/score/{scanId}/* aliases are absent.

Current default behavior (PCD-403, 2026-10-06): a valid signed v2 manifest supplies snapshot addresses, seed and time; it does not supply attributable frozen CVSS, EPSS, reachability or provenance measurements. Default replay returns 422 with code scanner.score_replay.measurements_unavailable before creating scoring nodes, a bundle or a history entry. Repeated attempts produce the same refusal. Unknown scans retain 404; manifest schema/signature validation runs before scoring. Generic manifest knobs, digest bytes and current owner data cannot substitute for missing historical measurements.

Replay lock lifetime (PCD-406): within one ScoreReplayService instance, callers for the same scan ID (case-insensitive) share one semaphore through manifest lookup, signature validation, scoring and proof/history writes. Waiting callers reserve the same entry before waiting. Cancellation releases only that caller’s reservation; a caller releases the semaphore only after acquiring it. The final departing caller removes and disposes the entry, including after an unknown scan, measurement refusal, validation failure or write failure. Retained entries are bounded by distinct scan IDs with active or waiting replay callers; different scans proceed independently. This bound covers lock entries. Accepted replay history and canonical-binding metadata retain their existing process-local lifetime.

Lock lifetime verified against 34a66ad1c36412298f962eedb5ffef69a748825e on 2026-10-06. Re-verify with ScoreReplayLockLifecycleTests, ScoreReplayUnavailableInputsTests and ScoreReplayEndpointsTests through tools/scripts/test-targeted-xunit.ps1; acceptance evidence (pruned evidence docs/implplan/_evidence/20261006-scanner-replay-lock-lifecycle/PCD-406.md at ac2b8d47e7).

Request Body (ScoreReplayRequest, all optional):

{
 "manifestHash": "sha256:manifest123...",
 "freezeTimestamp": "2025-12-17T13:00:00Z"
}

Response (422 Unprocessable Entity, ProblemDetails, current default):

{
 "status": 422,
 "code": "scanner.score_replay.measurements_unavailable",
 "detail": "scanner.score_replay.measurements_unavailable: snapshot addresses do not provide attributable frozen CVSS, EPSS, reachability or provenance measurements."
}

Related score endpoints (same ScoreReplayEndpoints.cs group, under /scans/{scanId}/score):

Verification reads only the scan’s stored bundle selected by the expected root hash; the request has no bundle location member, and a bundleUri in the body is not read. Stored bundle retrieval and cryptographic verification retain their existing contracts. Signature/hash validity establishes artifact integrity; it does not establish that an old factor vector represented real measurements. Across service restart, verification requires the expected canonical hash and canonical payload together. No default production numeric formula is accepted for the address-only manifest contract. The withdrawn v2.factorized algorithm converted digest bytes into metric-labelled factors; it must not produce new proofs.

Errors:

Changing a vulnerability snapshot address alone cannot rescore historical measurements. Re-verify with DeterministicScoringServiceTests, ScoreReplayUnavailableInputsTests and ScoreReplayEndpointsTests through tools/scripts/test-targeted-xunit.ps1. Verified against bf5d3c5b5f024db40f200593bb052746def175f1 on 2026-10-06; acceptance evidence (pruned evidence docs/implplan/_evidence/20261006-scanner-jobengine-carry/PCD-403.md at d887e8d5e2).


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-level scanKey field is required in addition to nodes/edges. The response is a CallGraphAcceptedResponseDto (callgraphId, nodeCount, edgeCount, digest) — there is no scanId, entrypointsCount, status, or _links field on the response. A duplicate Content-Digest returns 409 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:

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:


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 is ComputeReachabilityResponseDto (jobId, status, estimatedDuration) with no scanId or _links block. The default Scanner wiring registers a NullReachabilityComputeService; 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:


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 cve and status (not cveId). The response is a ReachabilityFindingListDto with a flat items[] of ReachabilityFindingDto and a total count — there is no scanId, computedAt, per-finding path/evidence/_links, nor a summary aggregate object. Use Endpoint 7 for path witnesses. A sibling route GET /scans/{scanId}/reachability/components (params purl, status) returns ComponentReachabilityListDto, and GET /scans/{scanId}/reachability/traces/export (params format, includeRuntimeEvidence, minReachabilityScore, runtimeConfirmedOnly) emits a deterministic callable-path / sensor-gap trace export.

Query Parameters:

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:


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 a pathWitness string list (e.g. "A -> B -> C"), the reasons are a why[] list of {code, description, impact}, and supporting data is grouped under evidence (staticAnalysis, runtimeEvidence, policyEvaluation), plus an optional spineId. There is no explanation.shortestPath[], confidenceFactors, alternativePaths, or _links object. Missing cve or purl returns 400, not 404.

Query Parameters:

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:


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 an application/zip archive, and there are no Content-Disposition / X-Proof-Root-Hash / X-Manifest-Hash headers. A sibling route GET /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 via GET /api/v1/scans/{scanId}/score/bundle?rootHash=... (see Endpoint 3). No CLI stella proof verify --bundle proof.zip path was confirmed against the WebService surface.

Path Parameters:

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:


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 are artifactDigest, vulnId, band, sortBy (score | created | updated), sortOrder (asc | desc), limit (default 50, clamped 1–500), and offset. The response is UnknownsListResponse (items[], totalCount, limit, offset); list items expose id, artifactDigest, vulnerabilityId, packagePurl, score, band, createdAtUtc, updatedAtUtc. The popularity/potentialExploit/uncertainty/evidence fields, the pagination.next link, and the per-item _links.escalate link do not exist.

Query Parameters:

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):

Errors:


10. Escalate Unknown to Rescan — NOT IMPLEMENTED

Removed / not implemented (2026-05-29): No POST /unknowns/{id}/escalate endpoint exists in Endpoints/UnknownsEndpoints.cs (which is read-only: only the GET routes listed in Endpoint 9). There is likewise no resolve or summary route on the unknowns group. The 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, vexSnapshotHash, vulnerabilitiesSnapshotHash, policyHash (plus versions/timestamps) — see Endpoint 2.

ProofNode

Conceptual model for the score-proof ledger; ProofNode arrays are not returned inline in the stored success contract; default replay now refuses unavailable measurements (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) via ProblemResultFactory, not from a fixed type slug registry. The slugs below are illustrative. The invalid-manifest, duplicate-scan, snapshot-not-found, and escalation-conflict rows describe the removed manifest-creation / escalate contracts and do not correspond to live endpoints.

Type (illustrative)StatusDescription
scan-not-found404Scan ID not found
invalid-callgraph400Call-graph schema validation failed
callgraph-duplicate409Duplicate call-graph (Content-Digest)
payload-too-large413Request body exceeds size limit
proof-not-found404Proof root hash not found
reachability-in-progress409Reachability computation already running
reachability-unavailable501Reachability runtime not configured

Rate Limiting

Correction (2026-05-29): Only GET /scans/{scanId}/manifest is rate-limited in code (RequireRateLimiting(ManifestPolicy), wired via AddScannerRateLimiting()). The per-tenant hourly quotas previously listed for POST /scans, replay, callgraphs, and reachability are not enforced by the current WebService and were aspirational. Standard X-RateLimit-* headers and 429 responses 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

EndpointBacking classEndpoint name
POST /api/v1/scansEndpoints/ScanEndpoints.csscanner.scans.submit
GET /api/v1/scans/{scanId}Endpoints/ScanEndpoints.csscanner.scans.status
GET /api/v1/scans/{scanId}/manifestEndpoints/ManifestEndpoints.csscanner.scans.manifest
GET /api/v1/scans/{scanId}/proofsEndpoints/ManifestEndpoints.csscanner.scans.proofs.list
GET /api/v1/scans/{scanId}/proofs/{rootHash}Endpoints/ManifestEndpoints.csscanner.scans.proofs.get
POST /api/v1/scans/{scanId}/score/replayEndpoints/ScoreReplayEndpoints.csscanner.scans.score.replay
GET /api/v1/scans/{scanId}/score/bundleEndpoints/ScoreReplayEndpoints.csscanner.scans.score.bundle
POST /api/v1/scans/{scanId}/score/verifyEndpoints/ScoreReplayEndpoints.csscanner.scans.score.verify
GET /api/v1/scans/{scanId}/score/historyEndpoints/ScoreReplayEndpoints.csscanner.scans.score.history
POST /api/v1/scans/{scanId}/callgraphsEndpoints/CallGraphEndpoints.csscanner.scans.callgraphs.submit
POST /api/v1/scans/{scanId}/compute-reachabilityEndpoints/ReachabilityEndpoints.csscanner.scans.compute-reachability
GET /api/v1/scans/{scanId}/reachability/findingsEndpoints/ReachabilityEndpoints.csscanner.scans.reachability.findings
GET /api/v1/scans/{scanId}/reachability/componentsEndpoints/ReachabilityEndpoints.csscanner.scans.reachability.components
GET /api/v1/scans/{scanId}/reachability/explainEndpoints/ReachabilityEndpoints.csscanner.scans.reachability.explain
GET /api/v1/scans/{scanId}/reachability/traces/exportEndpoints/ReachabilityEndpoints.csscanner.scans.reachability.traces.export
GET /api/v1/scans/{scanId}/spinesEndpoints/ProofSpineEndpoints.csscanner.spines.list-by-scan
GET /api/v1/spines/{spineId}Endpoints/ProofSpineEndpoints.csscanner.spines.get
GET /api/v1/unknowns (+ /stats,/bands,/{id},/{id}/evidence,/{id}/history)Endpoints/UnknownsEndpoints.csscanner.unknowns.*
POST /api/v1/triage/proof-bundleEndpoints/Triage/ProofBundleEndpoints.csscanner.triage.proof-bundle

Base path group is app.MapGroup(resolvedOptions.Api.BasePath) in Program.cs (Options/ScannerWebServiceOptions.cs → Api.BasePath default /api/v1).


References


Last Updated: 2026-05-29 (doc↔code reconciliation) API Version: 2.0 Next Review: Sprint 3500.0004.0001 (CLI integration)