# 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-05-30
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 (scanner.scans.read) and ScannerPolicies.ScansWrite (scanner.scans.write) authorization policies, which map directly to the same scope strings (ScannerAuthorityScopes.ScansRead/.ScansWrite). These are Scanner-local scopes and are not part of the central StellaOpsScopes catalog (which exposes scanner:read, scanner:scan, scanner:write); Authority must grant the scanner.scans.* scopes to clients that call these endpoints.
Headers
Authorization: Bearer <access_token>
X-StellaOps-TenantId: <tenant_id> # optional; tenant scoping for drift/reachability data
Tenant resolution (ScannerRequestContextResolver.TryResolveTenant) is used to isolate stored drift results and call-graph snapshots. The tenant is taken from the stellaops:tenant JWT claim when present; otherwise from a tenant header. The canonical header is X-StellaOps-TenantId, with X-Stella-Tenant (legacy) and X-Tenant-Id (alternate) accepted as aliases. When the request carries no claim and no header, the endpoints fall back to the default tenant. If a tenant header conflicts with the JWT claim, or two tenant headers disagree, the request fails with 400 Bad Request (tenant_conflict).
3. Endpoints
3.1 GET /scans/{scanId}/drift
Returns drift results for the scan. If baseScanId is provided, drift is computed and stored. If omitted, the most recent stored drift result is returned.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| scanId | path | string | yes | Head scan identifier |
| baseScanId | query | string | no | Base scan identifier. When provided, drift is recomputed and stored; when omitted, the most recent stored result is returned. |
| language | query | string | no | 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. When omitted, the router default applies (pinned to dotnet for backward compatibility). |
| 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 language, an invalid tenant context (tenant_conflict), 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)
