# Reachability Drift Detection - Architecture

Module: Scanner Version: 1.1 Status: Implemented (core drift engine + API + Worker producer, SPRINT_20261017_006 PCD-874; Node Babel integration pending; tier-2 router wiring complete — Sprint 20260512_032). Last Updated: 2026-10-19


1. Overview

Reachability Drift Detection tracks function-level reachability changes between scans. It highlights when code changes create new paths to sensitive sinks or remove existing paths, producing deterministic evidence for triage and VEX workflows.

Key outcomes:


2. Key Concepts

2.1 Call Graph

A directed graph of function calls:

2.2 Reachability Analysis

Multi-source traversal from entrypoints to sinks to determine exploitability.

2.3 Drift Detection

Compares reachability between base and head scans:

2.4 Cause Attribution

Explains why drift happened by correlating code changes with paths.


3. Data Flow

flowchart TD
    A[Source or binary] --> B[Call graph extractor]
    B --> C[CallGraphSnapshot]
    C --> D[Reachability analyzer]
    D --> E[ReachabilityResult]
    C --> F[Code change extractor]
    E --> G[ReachabilityDriftDetector]
    F --> G
    G --> H[ReachabilityDriftResult]
    H --> I[Storage + API]

4. Component Architecture

4.1 Call Graph Extractors

Registered extractors are configured in CallGraphServiceCollectionExtensions. The drift API tier is set by IDriftLanguageRouter (src/Scanner/__Libraries/StellaOps.Scanner.ReachabilityDrift/Routing/DriftLanguageRouter.cs), which is the canonical source of supported languages and alias normalization. Tier-1 aliases: csharp/c#/cs/.net/net -> dotnet, nodejs/javascript/js/typescript/ts -> node, golang -> go, py -> python, jvm -> java. Tier-2 aliases (SPRINT_20260512_032): bunjs -> bun, rb -> ruby, rs -> rust; deno and php have no aliases. The GET /api/v1/scans/{scanId}/drift endpoint returns HTTP 400 for any language outside the union of tier-1 and tier-2 sets (currently cobol, binary, and any unrecognised value).

LanguageExtractorStatusNotes
.NETDotNetCallGraphExtractorWired (tier 1)Roslyn semantic model; ASP.NET controllers, minimal APIs, hosted services, CLI main.
JavaJavaCallGraphExtractorWired (tier 1)ASM bytecode parsing; Spring MVC, JAX-RS (javax + jakarta), Servlet, Micronaut, Quarkus, CLI main.
GoGoCallGraphExtractorWired (tier 1)SSA analysis; net/http, gin, echo, chi, fiber, gorilla/mux, gRPC, cobra CLI.
PythonPythonCallGraphExtractorWired (tier 1)AST analysis; Flask, FastAPI, Django, Celery, CLI main.
Node.jsNodeCallGraphExtractorWired (tier 1)Babel AST / trace fallback; Express, Fastify, Next.js, Hapi, Koa, AWS Lambda.
BunBunCallGraphExtractorRouter wired (tier 2, SPRINT_20260512_032)Bun.serve / Elysia / Hono routes, @cron/@scheduled annotations, WebSocket handlers. Extractor registration pending follow-up sprint.
DenoDenoCallGraphExtractorRouter wired (tier 2, SPRINT_20260512_032)Deno.serve, Fresh handler exports, Oak / Hono routes, Deno Deploy handlers, Deno.cron, WebSocket. Extractor registration pending follow-up sprint.
PHPPhpCallGraphExtractorRouter wired (tier 2, SPRINT_20260512_032)Symfony route attributes, Laravel *Controller actions, Job/Listener handle(), Symfony Command::execute, scheduled tasks. Extractor registration pending follow-up sprint.
RubyRubyCallGraphExtractorRouter wired (tier 2, SPRINT_20260512_032)Rails controllers (CRUD + public), Sinatra::Base / Grape::API, Sidekiq/ActiveJob perform/call, rake tasks, *CLI/*Command. Extractor registration pending follow-up sprint.
RustRustCallGraphExtractorRouter wired (tier 2, SPRINT_20260512_032)tokio/async_std main, actix-web / axum / rocket / warp / tonic, AWS Lambda runtime, clap CLI, FFI export. Extractor delivered by Sprint 030; registration pending follow-up sprint.
JavaScriptJavaScriptCallGraphExtractorLibrary present, not router-wired (tier 3)Use node (tier 1) for Node.js code; standalone JS pending.
BinaryBinaryCallGraphExtractorLibrary present, not router-wired (tier 3)Native call edge extraction.

4.2 Reachability Analyzer

Located in src/__Libraries/StellaOps.Scanner.CallGraph/Analysis/.

4.3 Drift Detector

ReachabilityDriftDetector compares base and head snapshots and produces ReachabilityDriftResult with compressed paths.

4.4 Path Compressor and Cause Explainer


5. Language Support Matrix

Sprint 20260512_032 (TASK-032-04) extended the drift router to ten canonical languages. Tier-1 has full extractor + classifier + drift wiring. Tier-2 has router recognition (alias normalization + HTTP 200 instead of 400) and an existing classifier source documenting recognised framework patterns; the extractor registration in CallGraphServiceCollectionExtensions is still pending a follow-up sprint.

CapabilityTier-1 (.NET, Java, Go, Python, Node)Tier-2 (Bun, Deno, PHP, Ruby, Rust)Tier-3 (JavaScript-standalone, Binary)
Drift router (IDriftLanguageRouter)Wired (Sprint 031)Wired (Sprint 032)Not wired
Call graph extractionSupportedLibrary present (extractor registration pending)Library present, not router-wired
Entrypoint detectionSupportedSupported (per-language <Lang>EntrypointClassifier.cs)Library present, not router-wired
Sink detectionSupportedSupported (per-language <Lang>SinkMatcher.cs)Library present, not router-wired

Per-tier-2 framework conventions recognised by the existing classifiers (pinned in DriftLanguageRouterTests.Frameworks_<Lang>_Match_..._Conventions):

LanguageAliasesFrameworks (documentation surface)
bunbunjsbun-serve, elysia, hono, scheduled-cron, websocket
deno(none)deno-cron, deno-deploy, deno-serve, fresh, hono, oak, websocket
php(none)laravel-controller, laravel-job, laravel-listener, laravel-scheduled, symfony-command, symfony-route
rubyrbgrape, rails-cli, rails-controller, rake-task, sidekiq, sinatra
rustrsactix-web, aws-lambda, axum, clap-cli, ffi-export, grpc-tonic, rocket, tokio-main, warp

The drift API supported set is the canonical contract; adding a new tier (e.g. JavaScript-standalone) requires a sprint task that:

  1. Extends DriftLanguageRouter.BuildTier2Descriptors() (or a new BuildTier3Descriptors() helper) with the new descriptor + alias list + framework list — matching the patterns already recognised by the corresponding <Lang>EntrypointClassifier.cs source;
  2. Registers the corresponding ICallGraphExtractor in AddCallGraphServices();
  3. Adds tier-2a behavioral coverage to ReachabilityDriftEndpointsTests.

6. Storage Schema

Migrations are in src/Scanner/__Libraries/StellaOps.Scanner.Persistence/Migrations/ (the drift tables are part of the consolidated baseline 001_scanner_consolidated_baseline.sql).

Core tables:

Writer (SPRINT_20261017_006 PCD-874, owner ruling 2026-10-18 “build the producer”): the Scanner Worker stage reachability-drift (ReachabilityDriftStageExecutor), which runs after publish-signals-unknowns:

  1. It stores the call graph prepare-reachability-inputs extracted in call_graph_snapshots, under the drift router’s canonical language (an extracted javascript graph is stored as node, its digest recomputed because the digest covers the language). A language outside the router’s set is not stored. The extractor yields one language per scan (the first candidate with a non-empty graph), so a scan stores one snapshot.
  2. The tenant is the scan’s owner in scan_runtime_state, never lease metadata. A scan without a recorded owner stores nothing.
  3. When the scan submission declared a base scan in the metadata key reachability.drift.base_scan_id, it loads the base scan’s stored graph of the same tenant and language, runs CodeChangeFactExtractor.Extract and ReachabilityDriftDetector.Detect, and stores the code changes and then the drift result (drifted_sinks.code_change_id references code_changes). code_changes keeps one row per scan pair, language, symbol and change kind, so the stage passes the first fact of each such identity to the detector and the store. A base scan of another tenant, or one without a stored graph in that language, yields no drift and a warning log. A storage failure fails the stage.

The scan submit route (scanner.scans.submit) accepts the declared base only when the scan coordinator resolves it for the submitting tenant; an unknown or foreign scan is refused with 400 before a scan exists, and the worker receives the canonical key with the base scan’s own identifier. The read-scoped drift GET (§8) computes a drift for a supplied baseScanId without writing anything (PCD-847); without baseScanId it returns the result the stage stored, and the drifted-sinks route pages that result.

Retention: call_graph_snapshots is source-of-truth and kept until its scan is explicitly disposed (migration 009); no age sweep exists, because a later scan may name any earlier scan as its base. The three derived tables are rebuilt by the same stage from the retained snapshots (migration 010).


7. Cache and Determinism

If the call graph cache is enabled (CallGraph:Cache), cached keys follow this pattern:

Determinism is enforced by stable ordering and deterministic IDs (see DeterministicIds).


8. API Endpoints

Base path: /api/v1

MethodPathDescription
GET/scans/{scanId}/driftGet or compute drift results for a scan.
GET/drift/{driftId}/sinksList drifted sinks (paged).
POST/scans/{scanId}/compute-reachabilityTrigger reachability computation.
GET/scans/{scanId}/reachability/componentsList components with reachability.
GET/scans/{scanId}/reachability/findingsList findings with reachability.
GET/scans/{scanId}/reachability/explainExplain reachability for a CVE and PURL.

See docs/api/scanner-drift-api.md for details. The Console screen /security/reachability/drift (scan id and language as query parameters) reads the stored result with the drift GET and pages its sinks with the drifted-sinks route; it requires scanner:read.


9. Integration Points


10. Performance Characteristics (Targets)

MetricTargetNotes
Call graph extraction (100K LOC)< 60sPer language extractor.
Reachability analysis< 5sBFS traversal on trimmed graphs.
Drift detection< 10sGraph comparison and compression.
Cache hit improvement10xValkey cache vs recompute.

11. References