skip to content

When should you distrust a call-graph analyser's unreachable verdict on a dependency finding?

level: seniorimportance: should knowfreq 46%

answer

  1. the analyser needs an edge to follow
  2. targets named at runtime draw no edge
  3. check which entry points were modelled
  4. unreachable is a hypothesis, not proof
  5. observed loading beats a static negative

basics

~20 s

Whenever the code reaches the dependency by a mechanism the analyser cannot draw an edge for — dynamic imports, reflection, framework wiring, deserialization — or when it modelled the wrong entry points. Unreachable is weak negative evidence, not proof.

solid answer

~50 s

Static reachability needs a call edge to follow. Any construct that names a target at runtime rather than in the source erases that edge: importing a module named in a config string, reflective instantiation, service-loader and plugin registries, dependency-injection wiring by annotation, dynamic proxies, or a template and deserialization layer that invokes code the source never mentions. A Python ETL job that resolves its parser through `importlib` from a config value will be reported unreachable while executing the vulnerable code on every run. Verdicts also depend on the entry-point set the analyser modelled — miss the message consumer or the admin CLI and whole subgraphs vanish. So treat the results asymmetrically: reachable is strong positive evidence, unreachable is a hypothesis. Corroborate with runtime evidence of what actually loads, and use unreachable to deprioritize rather than to close.

go deeper

for a junior

Know that reachability tools follow call paths in code, and that code loaded by name at runtime can escape them, so an unreachable label is not a guarantee.

for a middle

Be able to list the constructs that erase a call edge — dynamic import, reflection, framework and container wiring, deserialization — and explain why the entry-point set bounds every result.

for a senior

Show the asymmetry in practice: reachable is constructive proof, unreachable is a hypothesis, observed loading refutes it, and non-observation is limited by coverage. Say what you record and what would flip the call.

for a principal

Decide where the organisation is allowed to rely on the tool at all: which language and exposure combinations require corroboration, and how you keep an unreviewable verdict out of a suppression that outlives the code it described.

## Why the verdict is asymmetric A call-graph reachability analyser starts from a set of entry points, follows call edges it can resolve in the source or bytecode, and asks whether the vulnerable function of a dependency lies in the transitive closure. When it says **reachable**, it has exhibited a path — that is constructive, and it is usually right modulo dead code. When it says **unreachable**, it has only failed to find a path *in its model of your program*. Those are not evidence of the same strength, and treating them as if they were is the mistake this question exists to catch. ## What erases a call edge The analyser needs a target it can name statically. Anything that supplies the target at runtime breaks the chain: - **Dynamic import and dynamic dispatch.** A Python job that does `importlib.import_module(cfg["parser"])`, or attribute lookup via `getattr`, has no source-level edge to the module it ends up loading. The analyser reports unreachable; the vulnerable parser runs on every execution. The same hole exists for a computed `require()` path in JavaScript and for class-name-driven loading anywhere. - **Reflection and service loaders.** Instantiating a class from a name held in configuration, plugin registries that scan the classpath, and annotation-driven dependency injection all wire the graph after the compiler is finished. - **Framework-invoked code.** Your source may never call the handler at all; the framework does. If the analyser does not model that framework, everything downstream of it disappears. - **Deserialization and templating.** Code selected by the *data* — a serialized type name, a template expression — cannot be resolved statically by construction. - **Native or out-of-process boundaries.** A shell-out, a native library load, or a call across a process boundary ends the graph. - **Vendored and shaded copies.** The vulnerable code is present under a different symbol path than the advisory implies, so neither the match nor the graph lines up. ## The other failure: the entry-point set Even with perfect edge resolution, a reachability result is only as broad as the roots it started from. Real services have more entry points than the obvious HTTP handlers: message and event consumers, scheduled jobs, admin CLIs, migration and backfill scripts, health and debug endpoints, startup hooks. Ask any tool for the entry points it modelled; if the list is shorter than your service's actual surface, the unreachable verdicts covering that gap mean nothing. Configuration-gated code paths — a feature flag off in the environment that was analysed — behave the same way. ## What runtime evidence adds, and its own limit An agent that records which classes or modules actually loaded, or which functions executed, in staging or production gives you the complementary signal — and its asymmetry runs the other way. **Observing the vulnerable module load is definitive positive evidence**: the static verdict of unreachable is simply refuted, and this is the case worth planning for, because it is the one that keeps a real exposure out of the queue. **Not observing it is bounded by coverage**: the window may have been short, the feature flag off, the code path seasonal (a quarterly export, an error handler, a failover branch), or staging may simply not exercise what production does. So the two sources are complementary rather than ranked. Where they conflict, the positive claim wins in both directions: static-reachable beats runtime-not-observed, and runtime-observed beats static-unreachable. Where both are negative you have a reasonable, still time-limited hypothesis. ## What the triage record must carry A reachability verdict is only meaningful with its provenance attached, because it is a claim about a specific program at a specific moment. Retain: the artifact version or commit the analysis ran against; which analyser and which entry-point set produced it; the language constructs it is known not to model; the runtime observation window and what traffic it covered; and the condition that would flip the verdict. "Unreachable" alone, written into a suppression, is an assertion nobody can audit and nobody can invalidate. ## The operational stance Use unreachable to change the *queue order*, not to end the conversation: deprioritize, set a re-evaluation trigger, and keep the finding visible. Reserve harder scepticism for the combinations where being wrong is expensive — a dynamic language, an internet-facing or multi-tenant entry surface, and an asset such as other customers' data. In those cases require corroboration before an unreachable verdict lowers anyone's urgency, and say so explicitly when an interviewer asks how far you would trust the tool.

  • A runtime agent in staging never sees the vulnerable module load. Can you close the finding?
    No. Absence of observation is bounded by what staging exercised — traffic mix, feature flags, error paths, seasonal jobs. It is useful corroboration alongside a static unreachable verdict, and together they justify deprioritizing with a re-check. Treated as proof, it is exactly the reasoning that leaves an exposed path unpatched until someone finds it for you.
  • Which languages or stacks make you least willing to trust static unreachability?
    Anything where targets are resolved at runtime as a matter of style: dynamic imports and attribute dispatch, reflection-heavy and annotation-wired frameworks, plugin and service-loader architectures, deserialization and templating layers. In those stacks a large share of real call edges never appear in source, so the analyser's model is systematically thinner than the program.
  • What do you ask the tool vendor's output for before you accept an unreachable verdict?
    The entry points it enumerated, the constructs it admits it cannot resolve, and the artifact version it analysed. Those three turn a bare verdict into a claim with stated assumptions, which is what makes it reviewable later and what lets you spot the case where your real attack surface was never in the root set.

A map showing no road to a village is not proof the village is unreachable. The helicopter route was never drawn on it.

saying these in an interview costs you the question

  • Closes findings on a static unreachable verdict alone
  • Assumes the analyser resolves reflection and dynamic imports
  • Treats absence in staging as proof of absence
  • Never asks which entry points were modelled
  • Ranks static and runtime evidence without noting each one's direction

context