# Scanner Drift API Reference
Module: Scanner Version: 1.0 Base Path: /api/v1 (configurable via Api.BasePath; scan segment via Api.ScansSegment, default scans) Last Updated: 2026-10-19
1. Overview
Audience: CI engineers and platform integrators gating releases on reachability drift, and tools that read or recompute drift between two scans.
The Scanner Drift API computes and retrieves reachability drift between scans. Drift detection identifies when code changes introduce new paths to sensitive sinks or remove existing paths to them — the signal that a release has materially changed an artifact’s attack surface.
2. Authentication and Authorization
Required Scopes
| Endpoint | Authorization policy / scope |
|---|---|
Read drift results (GET /scans/{scanId}/drift) | scanner.scans.read |
List drifted sinks (GET /drift/{driftId}/sinks) | scanner.scans.read |
| List reachability components / findings / explain / trace export | scanner.scans.read |
Compute reachability (POST /scans/{scanId}/compute-reachability) | scanner.scans.write |
The Scanner WebService enforces these via the ScannerPolicies.ScansRead and ScannerPolicies.ScansWrite authorization policies. ScansRead is an any-of policy: every read row above also accepts the central scanner:read scope (StellaOpsScopes.ScannerRead, which Console sessions carry) besides the Scanner-local scanner.scans.read scope. scanner.scans.write is Scanner-local and is not part of the central StellaOpsScopes catalog; Authority must grant it to clients that call the compute route.
Headers
Authorization: Bearer <access_token>
X-StellaOps-TenantId: <tenant_id> # optional; tenant scoping for drift/reachability data
Tenant resolution (ScannerRequestContextResolver.TryResolveTenant, a facade over the shared StellaOpsTenantResolver) is used to isolate stored drift results and call-graph snapshots. The tenant is taken from the stellaops:tenant JWT claim. Only a principal with stellaops:admin=true may select another tenant with the canonical X-StellaOps-TenantId header; the legacy X-Stella-Tenant and X-Tenant-Id headers are not tenancy sources. A request with no resolvable tenant fails with 400 Bad Request (tenant_missing); there is no default tenant fallback.
3. Endpoints
3.1 GET /scans/{scanId}/drift
Returns drift results for the scan. If baseScanId is provided, drift is computed from the stored base and head call-graph snapshots and returned; it is not stored. If omitted, the most recent stored drift result is returned. The route is read-scoped and performs no writes (SPRINT_20261017_006 PCD-847): the computed result’s id is not addressable through the sinks route (§3.2), which pages stored results only.
Where stored results come from (SPRINT_20261017_006 PCD-874): the scan worker’s
reachability-driftstage stores every scan’s extracted call graph inscanner.call_graph_snapshots. When the scan was submitted with the metadata keyreachability.drift.base_scan_idnaming an earlier scan of the same tenant, the stage also stores the code changes and the drift result against that base; that stored result is what this route returns withoutbaseScanId, and what §3.2 pages. The scan submit route (scanner.scans.submit) refuses a declared base the submitting tenant cannot see (unknown or foreign scan) with 400 before creating the scan. AbaseScanIdrequest computes from the same stored graphs, so it answers404 Base call graph not foundfor a base scan whose graph was not stored (no extracted graph, or a different language).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| scanId | path | string | yes | Head scan identifier |
| baseScanId | query | string | no | Base scan identifier. When provided, drift is computed and returned without being stored; when omitted, the most recent stored result is returned. |
| language | query | string | yes | Drift language. Normalized through IDriftLanguageRouter; only wired languages are accepted (canonical set includes dotnet, java, go, python, node; aliases such as csharp are normalized). An unsupported/unrecognized language returns 400. An omitted language also returns 400; there is no implicit dotnet default. |
| includeFullPath | query | boolean | no | Include full path nodes (fullPath) in compressed paths (default false). Only honoured when drift is recomputed (i.e. baseScanId is supplied). |
Response: 200 OK
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"baseScanId": "base123",
"headScanId": "head456",
"language": "dotnet",
"detectedAt": "2025-12-22T10:30:00Z",
"newlyReachable": [
{
"id": "660e8400-e29b-41d4-a716-446655440001",
"sinkNodeId": "MyApp.Services.DbService.ExecuteQuery(string)",
"symbol": "DbService.ExecuteQuery",
"sinkCategory": "SQL_RAW",
"direction": "became_reachable",
"cause": {
"kind": "guard_removed",
"description": "Guard condition removed in AuthMiddleware.Validate",
"changedSymbol": "AuthMiddleware.Validate",
"changedFile": "src/Middleware/AuthMiddleware.cs",
"changedLine": 42,
"codeChangeId": "770e8400-e29b-41d4-a716-446655440002"
},
"path": {
"entrypoint": {
"nodeId": "MyApp.Controllers.UserController.GetUser(int)",
"symbol": "UserController.GetUser",
"file": "src/Controllers/UserController.cs",
"line": 15,
"package": "app",
"isChanged": false,
"changeKind": null
},
"sink": {
"nodeId": "MyApp.Services.DbService.ExecuteQuery(string)",
"symbol": "DbService.ExecuteQuery",
"file": "src/Services/DbService.cs",
"line": 88,
"package": "app",
"isChanged": false,
"changeKind": null
},
"intermediateCount": 3,
"keyNodes": [
{
"nodeId": "MyApp.Middleware.AuthMiddleware.Validate()",
"symbol": "AuthMiddleware.Validate",
"file": "src/Middleware/AuthMiddleware.cs",
"line": 42,
"package": "app",
"isChanged": true,
"changeKind": "guard_changed"
}
]
},
"associatedVulns": []
}
],
"newlyUnreachable": [],
"resultDigest": "sha256:a1b2c3d4...",
"totalDriftCount": 1,
"hasMaterialDrift": true
}
Response: 400 Bad Request
Returned for an invalid head scanId, an invalid baseScanId, an unsupported or omitted language, a missing tenant (tenant_missing), or an invalid drift request surfaced by the detector (ArgumentException).
Response: 404 Not Found
Returned when any of the following cannot be located (each is a distinct, specific Problem Details title):
- the head scan (
Scan not found); - the base scan, when
baseScanIdis supplied (Base scan not found); - the base or head call-graph snapshot for the resolved language (
Base call graph not found/Head call graph not found); - a stored drift result, when
baseScanIdis omitted (Drift result not found).
3.2 GET /drift/{driftId}/sinks
Returns drifted sinks for a drift result.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| driftId | path | uuid | yes | Drift result identifier (must be a non-empty GUID) |
| direction | query | string | no | became_reachable or became_unreachable. Defaults to became_reachable when omitted. Aliases are accepted and normalized: newly_reachable/reachable/up -> became_reachable; newly_unreachable/unreachable/down -> became_unreachable. Any other value returns 400. |
| offset | query | integer | no | Offset (default: 0; must be >= 0) |
| limit | query | integer | no | Page size (default: 100; must be between 1 and 500) |
Response: 200 OK
{
"driftId": "550e8400-e29b-41d4-a716-446655440000",
"direction": "became_reachable",
"offset": 0,
"limit": 100,
"count": 1,
"sinks": [
{
"id": "660e8400-e29b-41d4-a716-446655440001",
"sinkNodeId": "MyApp.Services.DbService.ExecuteQuery(string)",
"symbol": "DbService.ExecuteQuery",
"sinkCategory": "SQL_RAW",
"direction": "became_reachable",
"cause": {
"kind": "guard_removed",
"description": "Guard condition removed in AuthMiddleware.Validate",
"changedSymbol": "AuthMiddleware.Validate",
"changedFile": "src/Middleware/AuthMiddleware.cs",
"changedLine": 42,
"codeChangeId": "770e8400-e29b-41d4-a716-446655440002"
},
"path": {
"entrypoint": {
"nodeId": "MyApp.Controllers.UserController.GetUser(int)",
"symbol": "UserController.GetUser",
"file": "src/Controllers/UserController.cs",
"line": 15,
"package": "app",
"isChanged": false,
"changeKind": null
},
"sink": {
"nodeId": "MyApp.Services.DbService.ExecuteQuery(string)",
"symbol": "DbService.ExecuteQuery",
"file": "src/Services/DbService.cs",
"line": 88,
"package": "app",
"isChanged": false,
"changeKind": null
},
"intermediateCount": 3,
"keyNodes": []
},
"associatedVulns": []
}
]
}
Response: 400 Bad Request
Returned for an empty/invalid driftId, an invalid direction, a negative offset, a limit outside 1..500, or an invalid tenant context.
Response: 404 Not Found
Returned when no drift result exists for the supplied driftId (and tenant).
3.3 POST /scans/{scanId}/compute-reachability
Triggers reachability computation for a scan.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| scanId | path | string | yes | Scan identifier |
Request Body
{
"forceRecompute": false,
"entrypoints": ["MyApp.Controllers.UserController.GetUser"],
"targets": ["pkg:nuget/Dapper@2.0.123"]
}
Response: 202 Accepted
{
"jobId": "reachability_head456",
"status": "scheduled",
"estimatedDuration": null
}
estimatedDuration is a nullable string (e.g. an ISO-8601 duration), not a numeric field. This endpoint is audited (AuditModules.Scanner / AuditActions.Scanner.Compute, target reachability).
Response: 400 Bad Request
Returned for an invalid scanId.
Response: 404 Not Found
Returned when the scan cannot be located.
Response: 409 Conflict
Returned when computation is already in progress for the scan.
Response: 501 Not Implemented
Returned when no reachability compute runtime is configured for this Scanner deployment (ReachabilityRuntimeUnavailableException). The same 501 applies to §3.4–§3.7 below when the reachability query/explain backend is unavailable.
3.4 GET /scans/{scanId}/reachability/components
Lists components with reachability status.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| scanId | path | string | yes | Scan identifier |
| purl | query | string | no | Filter by PURL |
| status | query | string | no | Filter by status |
Response: 200 OK
{
"items": [
{
"purl": "pkg:nuget/Newtonsoft.Json@13.0.1",
"status": "reachable",
"confidence": 0.92,
"latticeState": "confirmed",
"why": ["entrypoint:UserController.GetUser"]
}
],
"total": 1
}
3.5 GET /scans/{scanId}/reachability/findings
Lists reachability findings for CVEs.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| scanId | path | string | yes | Scan identifier |
| cve | query | string | no | Filter by CVE |
| status | query | string | no | Filter by status |
Response: 200 OK
{
"items": [
{
"cveId": "CVE-2024-12345",
"purl": "pkg:nuget/Dapper@2.0.123",
"status": "reachable",
"confidence": 0.81,
"latticeState": "likely",
"severity": "critical",
"affectedVersions": "< 2.0.200"
}
],
"total": 1
}
3.6 GET /scans/{scanId}/reachability/explain
Explains reachability for a CVE and PURL.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| scanId | path | string | yes | Scan identifier |
| cve | query | string | yes | CVE identifier |
| purl | query | string | yes | Package URL |
Response: 200 OK
{
"cveId": "CVE-2024-12345",
"purl": "pkg:nuget/Dapper@2.0.123",
"status": "reachable",
"confidence": 0.81,
"latticeState": "likely",
"pathWitness": ["entrypoint:UserController.GetUser", "sink:Dapper.Query"],
"why": [
{ "code": "call_graph", "description": "Path exists from HTTP entrypoint", "impact": 0.6 }
],
"evidence": {
"staticAnalysis": {
"callgraphDigest": "sha256:...",
"pathLength": 4,
"edgeTypes": ["direct", "virtual"]
},
"runtimeEvidence": {
"observed": false,
"hitCount": 0,
"lastObserved": null
},
"policyEvaluation": {
"policyDigest": "sha256:...",
"verdict": "block",
"verdictReason": "delta_reachable > 0"
}
},
"spineId": "spine:sha256:..."
}
Response: 400 Bad Request
Returned for an invalid scanId, or when cve or purl is missing/blank (both are required).
Response: 404 Not Found
Returned when the scan cannot be located, or when no explanation exists for the supplied cve/purl pair.
3.7 GET /scans/{scanId}/reachability/traces/export
Exports a callable-path reachability trace (with runtime-evidence sensor gaps) for the scan. Output is deterministic and content-addressed (contentDigest is a SHA-256 over the canonicalized export).
Authorization: scanner.scans.read.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| scanId | path | string | yes | Scan identifier |
| format | query | string | no | Export format (default json-lines). Accepted: json-lines (aliases jsonl, ndjson), json, graphson, sarif. Any other value returns 400. |
| includeRuntimeEvidence | query | boolean | no | Include runtime-evidence fields (default true). |
| minReachabilityScore | query | number | no | Minimum reachability score filter; must be between 0 and 1 inclusive. |
| runtimeConfirmedOnly | query | boolean | no | Restrict to runtime-confirmed nodes/edges (default false). |
Response: 200 OK
When reachable callable paths are available, status is callable_path_proof and nodes/edges are populated; otherwise status is sensor_gap with an explanatory sensorGaps entry. Runtime trace evidence is reported as a sensor gap (runtime-trace-backend-unavailable) unless a runtime trace collector is configured.
{
"scanId": "head456",
"status": "sensor_gap",
"format": "json-lines",
"canonicalizationMethod": "stellaops.reachability.trace-export.sensor-gap.v1",
"contentDigest": "sha256:...",
"timestamp": "2026-05-30T10:30:00Z",
"nodeCount": 0,
"edgeCount": 0,
"runtimeCoverage": 0,
"averageReachabilityScore": null,
"nodes": [],
"edges": [],
"sensorGaps": [
{
"id": "runtime-trace-backend-unavailable",
"category": "runtime-sensor",
"severity": "gap",
"summary": "Runtime trace evidence is not available for this scan.",
"detail": "No runtime trace collector or durable trace store is configured ...",
"evidence": ["sensor-gap:runtime-trace-backend-unavailable", "scan:head456"],
"remediation": "Configure runtime sensor ingestion and trace storage, then rerun reachability ..."
}
],
"evidence": ["scan:head456", "sensor-gap:runtime-trace-backend-unavailable"]
}
Response: 400 Bad Request
Returned for an invalid scanId, an unsupported format, or a minReachabilityScore outside 0..1.
Response: 404 Not Found
Returned when the scan cannot be located.
4. Request and Response Models
Key models (JSON names shown). totalDriftCount and hasMaterialDrift are server-computed (read-only): totalDriftCount = newlyReachable.length + newlyUnreachable.length, and hasMaterialDrift = newlyReachable.length > 0.
Drift models (StellaOps.Scanner.ReachabilityDrift):
ReachabilityDriftResult:id,baseScanId,headScanId,language,detectedAt,newlyReachable,newlyUnreachable,resultDigest,totalDriftCount,hasMaterialDrift.DriftedSink:id,sinkNodeId,symbol,sinkCategory,direction,cause,path,associatedVulns.DriftCause:kind,description,changedSymbol(optional),changedFile(optional),changedLine(optional),codeChangeId(optional).CompressedPath:entrypoint,sink,intermediateCount,keyNodes,fullPath(optional, populated only whenincludeFullPath=true).PathNode:nodeId,symbol,file(optional),line(optional),package(optional),isChanged,changeKind(optional).AssociatedVuln:cveId,epss(optional number),cvss(optional number),vexStatus(optional),packagePurl(optional). Carried insideDriftedSink.associatedVulns(defaults to empty array).
Compute / query / explain DTOs (StellaOps.Scanner.WebService.Contracts):
ComputeReachabilityRequestDto:forceRecompute(defaultfalse),entrypoints(optional),targets(optional).ComputeReachabilityResponseDto:jobId,status,estimatedDuration(optional string).ComponentReachabilityListDto:items,total;ComponentReachabilityDto:purl,status,confidence,latticeState(optional),why(optional).ReachabilityFindingListDto:items,total;ReachabilityFindingDto:cveId,purl,status,confidence,latticeState(optional),severity(optional),affectedVersions(optional).ReachabilityExplanationDto:cveId,purl,status,confidence,latticeState(optional),pathWitness(optional),why(optional),evidence(optional),spineId(optional).ExplanationReasonDto:code,description,impact(optional).EvidenceChainDto:staticAnalysis(optional),runtimeEvidence(optional),policyEvaluation(optional).StaticAnalysisEvidenceDto:callgraphDigest(optional),pathLength(optional),edgeTypes(optional).RuntimeEvidenceDto:observed,hitCount(default 0),lastObserved(optional).PolicyEvaluationEvidenceDto:policyDigest(optional),verdict(optional),verdictReason(optional).
Trace export DTOs (StellaOps.Scanner.WebService.Endpoints.ReachabilityEndpoints):
ReachabilityTraceExportDto:scanId,status(callable_path_proof|sensor_gap),format,canonicalizationMethod,contentDigest,timestamp,nodeCount,edgeCount,runtimeCoverage,averageReachabilityScore(optional),nodes,edges,sensorGaps(optional),evidence(optional).TraceNodeDto:id,symbolId,reachabilityScore(optional),runtimeConfirmed(optional),runtimeObservationCount(optional),evidence(optional).TraceEdgeDto:from,to,kind,confidence,runtimeConfirmed(optional),runtimeObservationCount(optional),evidence(optional).TraceSensorGapDto:id,category,severity,summary,detail,evidence,remediation(optional).
5. Enumerations
DriftDirection
became_reachable
became_unreachable
DriftCauseKind
guard_removed
guard_added
new_public_route
visibility_escalated
dependency_upgraded
symbol_removed
unknown
CodeChangeKind
added
removed
signature_changed
guard_changed
dependency_changed
visibility_changed
SinkCategory
CMD_EXEC
UNSAFE_DESER
SQL_RAW
SQL_INJECTION
SSRF
FILE_WRITE
PATH_TRAVERSAL
TEMPLATE_INJECTION
CRYPTO_WEAK
AUTHZ_BYPASS
LDAP_INJECTION
XPATH_INJECTION
XXE
CODE_INJECTION
LOG_INJECTION
REFLECTION
OPEN_REDIRECT
6. Errors
Endpoints return Problem Details (RFC 7807) for errors. The type is one of the https://stellaops.org/problems/... URIs (validation, not-found, conflict, not-implemented, internal). Common cases:
- 400 (
validation): invalid scan/base-scan/drift identifier, invalid direction, unsupported language, invalidoffset/limit, invalid trace-exportformatorminReachabilityScore, missing requiredcve/purl, or tenant context conflict (tenant_conflict). - 404 (
not-found): scan / base scan not found, call graph snapshot missing, drift result not found, or explanation not found. - 409 (
conflict): reachability computation already in progress. - 501 (
not-implemented): reachability compute runtime or query/explain backend not configured for this deployment (§3.3–§3.7). - 500 (
internal): unexpected server error.
7. Examples
7.1 cURL - Get Drift Results
curl -X GET \
'https://scanner.example/api/v1/scans/head456/drift?baseScanId=base123&language=dotnet' \
-H 'Authorization: Bearer <token>'
7.2 cURL - List Drifted Sinks
curl -X GET \
'https://scanner.example/api/v1/drift/550e8400-e29b-41d4-a716-446655440000/sinks?direction=became_reachable&offset=0&limit=100' \
-H 'Authorization: Bearer <token>'
7.3 cURL - Compute Reachability
curl -X POST \
'https://scanner.example/api/v1/scans/head456/compute-reachability' \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-d '{"forceRecompute": false}'
8. References
- Scanner reachability-drift module doc
- Reachability drift operations guide
src/Scanner/StellaOps.Scanner.WebService/Endpoints/ReachabilityDriftEndpoints.cs(§3.1–§3.2)src/Scanner/StellaOps.Scanner.WebService/Endpoints/ReachabilityEndpoints.cs(§3.3–§3.7)src/Scanner/StellaOps.Scanner.WebService/Contracts/ReachabilityContracts.cs(compute/query/explain DTOs)src/Scanner/__Libraries/StellaOps.Scanner.ReachabilityDrift/Models/DriftModels.cs(drift models +DriftDirection/DriftCauseKind/CodeChangeKindenums)src/Scanner/__Libraries/StellaOps.Scanner.Contracts/SinkCategory.cs(SinkCategoryenum)src/Scanner/StellaOps.Scanner.WebService/Security/ScannerPolicies.cs,ScannerAuthorityScopes.cs(authorization policies / scopes)
