Engineering · Jul 27, 2026 · 7 min read

Reachability via call graph: tracing taint from source to sink

A SAST sink is only actionable when a real entry point can reach it. Call graph reachability traces the path from HTTP handlers through every call frame to the vulnerable function - filtering noise before a single probe fires.

SAST identifies sinks where dangerous functions accept untrusted input. What it cannot answer without a call graph is whether any real entry point actually reaches that sink. A vulnerability in a function no request handler calls is not exploitable - it is noise that trains teams to ignore alerts. Call graph reachability is the filter that separates findings worth fixing this week from findings worth scheduling next quarter.

01From CWE to reachable path

Every SAST finding starts at a sink: a SQL query built from a parameter, a file read from a path, a subprocess call wrapping user input. The CWE is accurate. The finding is only actionable when a request from outside the trust boundary can actually reach that function. Call graph analysis builds a directed graph of every function call in the codebase, traces forward from HTTP route handlers, message queue consumers, and scheduled jobs, and flags only the sinks that sit on a path reachable from a real entry point. A sink only called from unit tests, or buried behind an authentication wall that the entry point cannot cross, stays off the list.

Entry point HTTP /api/user?id=... routes/user.py:14 ──► update_profile(request) ──► profile_service.save(user_id) services/profile.py:22 ──► db.execute(query) db/client.py:87 ^^^ SQL sink - taint arrives here via 3 call frames Unit test path: test_profile() ──► profile_service.save() ──► db.execute() (unreachable from the trust boundary - excluded from the queue)
Call graph traces the path from HTTP entry point to SQL sink across three layers. A sink reachable via the HTTP path is a finding; one only reached from a unit test is filtered out before any probe fires.

02Inter-procedural taint: the value follows the call

The taint path is the chain from where user-controlled data enters the program - the source - to where it reaches a dangerous function - the sink - traced through every intermediate call. Inter-procedural analysis follows that chain across function and file boundaries. A handler that passes a request parameter to a service function, which extracts a field and passes it to a repository, which interpolates it into a query string, has a taint path that spans three files and three layers. Intra-procedural analysis sees each layer in isolation and may raise no finding. Inter-procedural analysis follows the value across each call edge and surfaces the finding at the true entry point - the source - not buried at the sink function name.

routes/user.py + services/profile.pypython
# routes/user.py - source: user-controlled value enters here
def update_profile(request):
    user_id = request.args.get("id")    # [taint source]
    return profile_service.save(user_id)   # taint propagates into service layer

# services/profile.py - taint crosses the module boundary
def save(user_id: str):
    query = f"SELECT * FROM users WHERE id = {user_id}"  # [sink]
    return db.execute(query)   # inter-procedural path confirmed: source -> sink

The confirming artifact is the full call path - not just the sink function in isolation. The finding carries the source location, every intermediate call frame, and the sink, so the remediation targets the right layer: parameterise the query at the repository, or reject non-integer input at the route handler. Both are correct fixes; only the call path tells you which one removes the taint earlier.

03SCA reachability: the vulnerable symbol, not just the package

A CVE in a dependency says the package contains a vulnerability. Call graph reachability answers the narrower question: does the application actually call the specific function the CVE describes? A deserialization bug in the XML parsing module of a library the application uses only for JSON serialization is not a path. The CVE is accurate; the finding in this context is noise that masks the ones that need a same-week fix.

apPosture traces call edges from entry points through first-party code and into the dependency tree. If the graph reaches the specific symbol the CVE affects - the vulnerable class method or function, not just the package name - the finding enters the prioritised queue as reachable. If no path connects, the finding is recorded as present-but-unreachable, excluded from the SLA clock, and visible in the posture for completeness without polluting the actionable backlog.

Proven - HighSQL injection - inter-procedural taint - /api/user
Source: request.args.get("id") routes/user.py:14 Path: update_profile ──► profile_service.save ──► db.execute (3 call frames) Sink: f"SELECT * FROM users WHERE id = {user_id}" db/client.py:87 DAST: id=62615533' ──► SQL syntax error with reflected token confirmed in response body
Oracle: inter-procedural call graph (SAST) confirmed by arithmetic-marker DAST probe. Path confirmed across 3 call frames. Severity high - parameterised query removes the sink; no records modified during the probe.

04Static limits and where DAST confirms

Static call graphs have two known weak spots: dynamic dispatch and reflection. A factory method that instantiates the right class at runtime, a plugin loader, a dependency injection container - these create call edges the graph cannot follow from source alone. The analysis may miss paths behind these patterns, or mark more sinks as reachable than actually are under real traffic. Neither problem invalidates the approach; both are reasons to treat static reachability as the filter before a runtime probe, not as the final verdict on exploitability.

Analysis modeWhat it confirmsKnown blind spot
SAST onlySink location and CWE; taint path when statically resolvableDynamic dispatch, reflection, runtime plugin loading
DAST onlyRuntime exploitability for the probed endpoint and parameterSink location in source; code paths not exercised by the probe
Call graph + DASTSource location, full call path, and runtime confirmationNone for statically traceable paths - highest-confidence state

DAST closes the gap in both directions. A runtime probe that successfully triggers a sink confirms the path is exercisable under real conditions, regardless of what the call graph predicted. A probe that fails to trigger a sink the graph said was reachable reveals a dynamic dispatch the static analysis could not resolve. When SAST traces a source-to-sink path and DAST confirms it in the runtime response, the finding reaches the highest-confidence band available: source-located, path-traced, and runtime-proven. That band drives the risk score to its peak and the SLA clock to its shortest interval.

3
call frames traced in the example path
0
destructive writes sent during taint confirmation
2
independent signals for highest-confidence (SAST path + DAST runtime)

The call graph is the filter applied before DAST probes run, so runtime confirmation effort concentrates on paths that are statically plausible. A finding that arrives with a full source-to-sink trace, a DAST-confirmed runtime response, and a remediation that targets the source rather than a symptom three layers downstream is the kind of report a team acts on the same day it arrives - not the kind it queues behind a hundred unverified pattern matches.

Evidence over heuristicsReachable beats foundMonitor → Block: rolling out a gate without slowing teamsIaC-grounded threat modelingAir-gapped AppSec with no phone-homeOne platform vs three tools
See it on your own app →