# 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

EndpointAuthorization 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 exportscanner.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-drift stage stores every scan’s extracted call graph in scanner.call_graph_snapshots. When the scan was submitted with the metadata key reachability.drift.base_scan_id naming 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 without baseScanId, 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. A baseScanId request computes from the same stored graphs, so it answers 404 Base call graph not found for a base scan whose graph was not stored (no extracted graph, or a different language).

Parameters

NameInTypeRequiredDescription
scanIdpathstringyesHead scan identifier
baseScanIdquerystringnoBase scan identifier. When provided, drift is computed and returned without being stored; when omitted, the most recent stored result is returned.
languagequerystringyesDrift 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.
includeFullPathquerybooleannoInclude 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):


3.2 GET /drift/{driftId}/sinks

Returns drifted sinks for a drift result.

Parameters

NameInTypeRequiredDescription
driftIdpathuuidyesDrift result identifier (must be a non-empty GUID)
directionquerystringnobecame_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.
offsetqueryintegernoOffset (default: 0; must be >= 0)
limitqueryintegernoPage 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

NameInTypeRequiredDescription
scanIdpathstringyesScan 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

NameInTypeRequiredDescription
scanIdpathstringyesScan identifier
purlquerystringnoFilter by PURL
statusquerystringnoFilter 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

NameInTypeRequiredDescription
scanIdpathstringyesScan identifier
cvequerystringnoFilter by CVE
statusquerystringnoFilter 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

NameInTypeRequiredDescription
scanIdpathstringyesScan identifier
cvequerystringyesCVE identifier
purlquerystringyesPackage 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

NameInTypeRequiredDescription
scanIdpathstringyesScan identifier
formatquerystringnoExport format (default json-lines). Accepted: json-lines (aliases jsonl, ndjson), json, graphson, sarif. Any other value returns 400.
includeRuntimeEvidencequerybooleannoInclude runtime-evidence fields (default true).
minReachabilityScorequerynumbernoMinimum reachability score filter; must be between 0 and 1 inclusive.
runtimeConfirmedOnlyquerybooleannoRestrict 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):

Compute / query / explain DTOs (StellaOps.Scanner.WebService.Contracts):

Trace export DTOs (StellaOps.Scanner.WebService.Endpoints.ReachabilityEndpoints):


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:


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