Explainer
Reachability analysis explained: what it proves, and what it can't
Sources, sinks, taint and sanitizers in practitioner terms — then the part vendors skip: partial indexing, dynamic dispatch, and framework magic that breaks the graph.
Reachability is the question underneath most of a SAST queue. A rule fired at line 118. Can anything an attacker controls actually arrive there, and does anything on the way make it harmless?
It is also the term most likely to mean four different things in one meeting. This post defines it, shows what an analysis can genuinely establish, and then spends a third of its length on what it cannot — because the limits are where triage decisions actually get made.
The three questions people call “reachability”
- Code reachability. Is this method callable at all from an entry point that exists in the deployed application? Dead code, unregistered controllers, and a debug utility nobody wires up all fail this.
- Dataflow reachability (taint). Does a value from an untrusted source arrive at a dangerous sink without being neutralised? This is the one SAST rules usually mean.
- Dependency reachability. Does your code call the vulnerable function in that library? Same word, entirely different analysis, and mixing it into a conversation about tainted parameters confuses everyone.
This post is about the first two. They compose: an unreachable method makes the dataflow question moot, and a sanitized flow makes a reachable method uninteresting.
Source, sink, sanitizer
Taint analysis models a program as a graph and asks whether a marked value can travel from one node to another. Three definitions do all the work.
- Source. Where untrusted data enters — a request parameter, a header, a message body, a filename from an upload, a value read out of an environment the attacker can influence.
- Sink. Where it becomes dangerous — string-concatenated into a query, passed to a process builder, written into a template, used to construct a filesystem path.
- Sanitizer. Anything on the path that removes the danger for that specific sink: parameter binding for SQL, contextual output encoding for HTML, canonicalisation plus a prefix check for paths, an allowlist that constrains the value to a fixed set.
The critical property, and the one most often lost: sanitization is sink-specific. HTML-escaping a value does not make it safe to concatenate into SQL. A tool that treats “was sanitized” as a boolean rather than a pairing with a sink class will close things it should not.
Sources and sinks are also configuration, not physics. They come from a rule pack someone wrote. If your framework wraps request data in a type the rule pack has never heard of, the source does not exist as far as the analysis is concerned, and the flow is invisible — not proved safe, just unseen.
The output of a real analysis is a path, and a path is checkable by a human in about a minute:
FLOW CWE-89 src/main/java/.../ReportController.java:41 source ReportController.export(@RequestParam String sort) :41 → ReportService.buildQuery(sort) :77 → SortSpec.parse(sort) :22 [allowlist: 4 values] sink JdbcTemplate.query(sql) :91 decisive signal value constrained to enum SortSpec at :22 assumption SortSpec.parse throws on unknown input
That last line is the part worth arguing with, and the part most tools do not print.
What reachability genuinely proves
When an analysis reports a complete path from a defined source to a defined sink with no sanitizer, it has established something real: within the code it indexed, under its model of the language, there is a route. That is strong evidence for promoting a finding, and it is a far better reason to spend an engineer’s afternoon than a severity label.
When it reports a constraining sanitizer on every path, it has established something narrower but still useful: within the code it indexed, every route it could see is neutralised for that sink class. That is a defensible basis for a close — provided the qualifier travels with it.
Note that both statements are scoped by the same clause. Everything below is about how large that clause gets in practice.
Where it breaks: partial indexing
An analysis can only reason about code it has parsed. Real repositories defeat this constantly. A polyglot service has modules in languages the indexer does not support. Generated sources do not exist until a build step runs, so an analysis over a clean checkout misses them entirely. Parse errors on a handful of files are normal, and a file that failed to parse is silently absent from the graph rather than reported as a gap.
The failure mode is asymmetric and that is what makes it dangerous. Missing code cannot create a false path — it can only hide a real one. So partial indexing does not produce noisy output that draws attention to itself. It produces confident-looking “no path found” results, which is the same output you get from code that is genuinely safe. A tool that does not tell you its coverage is asking you to trust an absence.
This is why we state indexing coverage as a limitation on every published run rather than in a footnote. We index Java and Python for deep evidence today; JavaScript and TypeScript are not indexed. A finding in an unindexed language does not get a quiet close.
Where it breaks: dynamic dispatch
Interfaces are the standard problem. A call through PaymentProvider.charge() with nine implementations forces a choice, and every option is wrong in a different way. Follow all nine and you get paths that no configuration ever produces — false flows that train people to ignore the tool. Follow none and you lose the one implementation that is unsafe. Guess with a heuristic and you have an unstated assumption sitting under a verdict.
Reflection, dependency injection resolved at runtime, service loaders, dynamic proxies, method handles, and language features like eval or dynamic imports have the same effect: the edge exists at runtime and not in the graph. There is no analysis that fixes this in general, which is a known result and not a tooling gap — deciding precisely which flows a program admits is undecidable, so every real analyser picks a side to be wrong on and calls it soundness or precision.
Where it breaks: framework magic
Modern frameworks move control flow out of code you can read. A method with an annotation becomes an HTTP endpoint with no call site anywhere in the repository. A field with an annotation gets populated from a request body by machinery the analyser is not modelling. Aspects interpose on calls. Interceptor chains run before your controller. Configuration in YAML decides which beans exist at all.
This cuts both directions. Miss the framework’s entry points and every controller looks unreachable — a fast way to close a queue and a fast way to be very wrong. Model them naively and every annotated field looks tainted regardless of the validation the framework applied on the way in. Either way the deciding facts live in configuration and annotations rather than in the call graph, which is why framework context has to be evidence in its own right rather than something the graph is expected to imply.
Two more limits worth naming, briefly. Analyses bound their exploration — path depth, call depth, timeouts — and when a budget runs out the result is a silent absence, not an error. And build-time or deploy-time configuration decides real behaviour: the same code is safe under one profile and exposed under another, and the analysis usually sees only one of them. OWASP’s summary of SAST strengths and weaknesses names the false-positive volume this produces, and SARIF gives producers a place to report partial runs and tool execution notifications — a field worth reading before you trust a clean result.
Why an unreachable finding still needs its evidence stated
Everything above adds up to one practical conclusion. “Not reachable” is not a fact about your program. It is a fact about an analysis: no path was found, given this index, this call graph resolution, these source and sink definitions, and these budgets. Those qualifiers are the entire content of the claim.
So a close on reachability has to carry them. Which entry points were considered. Which implementation the dispatch resolved to, and why. Which sanitizer was credited, for which sink class, at which line. What was not indexed. Without that, the record cannot be checked, cannot be disputed, and — the part that costs teams most — cannot be invalidated. When someone adds a tenth implementation of that interface next quarter, a close with a stated assumption is a finding that should reopen. A close with a status of “not reachable” is a finding nobody will ever look at again.
That is the standard we hold our own closes to: how it works shows the evidence attached to a verdict, and every run in the directory publishes its indexing limitations next to its numbers rather than underneath them.