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.csscanner.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 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

GET /api/v1/scans/{scanId}/manifest

Source: Endpoints/ManifestEndpoints.csscanner.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 documented concelierSnapshotHash / excititorSnapshotHash / latticePolicyHash / seed / knobs fields. 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...",
  "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:


3. Replay Score Computation

POST /api/v1/scans/{scanId}/score/replay

Source: Endpoints/ScoreReplayEndpoints.csscanner.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 response is a flat ScoreReplayResponse carrying a factorised score, not a nested scoreProof.nodes[] ledger, and there is no _links block. A legacy alias POST /api/v1/score/{scanId}/replay is 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):

Errors:

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.csscanner.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.csscanner.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.csscanner.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.csscanner.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 as previously documented. 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.csscanner.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.csscanner.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 previously documented 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 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) 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.csApi.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)