When we talk about reachability in the context of vulnerability management, we're specifically asking: can an attacker-controlled request reach the vulnerable function? Answering that question reliably requires understanding how call graph analysis works — not just using a tool that produces one, but knowing what the graph represents, how it's constructed, and where it can mislead you.
This is a practitioner's primer. I'll cover the theory briefly, but the focus is on how to read a call graph in the context of dependency security work.
What a Call Graph Is
A call graph is a directed graph where each node represents a function or method, and each directed edge represents a call relationship — "function A calls function B." In a control-flow context, nodes can also represent basic blocks (sequences of instructions with no branches), but for dependency security purposes, function-level granularity is usually what matters.
The graph can be constructed in two broad ways: statically (by analyzing source code or compiled artifacts without running them) or dynamically (by instrumenting a running application and recording actual calls). Static call graph construction is what Patchlynx does for initial triage, because dynamic profiling requires running the application under load to cover all paths — which is expensive and doesn't work as a PR gate.
A static call graph has a root set: the entry points into your application. For a web service, those are typically your HTTP route handlers. For a CLI tool, it's the argument dispatch function. Everything reachable from the root set via directed edges is "reachable" in the graph-theoretic sense.
How Static Analysis Builds the Graph
For JavaScript/TypeScript applications, static call graph construction starts with the module entry point (the file specified in main in package.json, or the entry chunk in your bundler config). The analyzer follows require() / import statements and builds a module dependency graph first, then resolves function-level call edges within and across modules.
Call resolution follows the scope chain. A direct call like utils.sanitize(input) resolves cleanly — the analyzer can follow the reference to utils.js and find the sanitize function. The harder cases are:
- Dynamic dispatch:
obj[method](args)wheremethodis a string variable. The analyzer doesn't know at parse time what stringmethodwill hold. Conservative static analysis adds call edges to all methods that could plausibly be called — which inflates the reachable set. - Callbacks and higher-order functions:
array.map(fn)wherefnis passed in from a calling context. Point-to analysis (tracking what values a variable can hold) is needed to resolve these correctly. - Conditional requires:
const dep = process.env.NODE_ENV === 'test' ? require('test-dep') : require('real-dep'). A conservative analyzer includes both branches.
These ambiguities mean static call graphs are almost always over-approximate — they contain edges that would never fire at runtime. This is by design: missing a real edge (a false negative) is worse than including a phantom edge (a false positive) when you're doing security analysis.
Reading a Call Path Trace
A call path is a specific sequence of edges in the call graph from an entry point to a target function. When Patchlynx confirms a CVE is reachable, it shows you the call path, not just the graph. Here's what one looks like in practice:
ENTRY: POST /api/users/import
→ controllers/users.js:importHandler (line 23)
→ services/csvParser.js:parseUserCsv (line 87)
→ node_modules/csv-parse/lib/index.js:parse (line 412)
→ node_modules/csv-parse/lib/transformer.js:transform (line 201)
→ CVE-2023-22598 [[email protected] — prototype pollution via crafted CSV header]
STATUS: CONFIRMED REACHABLE
Reading this trace: a POST to /api/users/import calls your import handler, which calls your CSV parsing service, which calls csv-parse's public API, which eventually reaches the vulnerable transformer code. An attacker who can POST a crafted CSV to that endpoint can trigger the vulnerability.
Contrast with an unreachable path:
CVE-2022-25883 [[email protected] — ReDoS via malicious version string]
→ No call path found from any entry point
STATUS: UNREACHABLE
NOTE: semver is required by devDependency eslint-config-airbnb, not in production bundle
The semver package is in your lockfile — a scanner will flag CVE-2022-25883. But it's only pulled in by a dev dependency. Your production bundle doesn't include it; no HTTP request can reach it. This is the noise that reachability analysis eliminates.
Entry Point Selection Matters More Than Most Teams Realize
The accuracy of reachability analysis depends on how completely you enumerate your entry points. If you miss an entry point, any CVE reachable only through that entry point will show as unreachable — a false negative.
Common missed entry points we encounter:
- Background job processors (
bull,bee-queue,agendajob handlers) that are registered separately from the HTTP router - Webhook receivers registered at startup outside the main route file
- GraphQL resolver functions not connected to a standard REST router
- gRPC service implementations in microservices
- Message queue consumers (Kafka, SQS) where the handler registration isn't obvious from the module entry point
When we build Patchlynx's entry point discovery for Express.js applications, we scan for router.get/post/put/delete/all patterns, app.use() middleware registration, and common job scheduler patterns. But if your application does something unusual at startup, you should verify the discovered entry points manually on first scan.
What the Call Graph Cannot Tell You
We're not saying call graph reachability is a complete vulnerability assessment. It answers "can this code be reached?" — it doesn't answer "is reaching it sufficient for exploitation?" Those are related but distinct questions.
A CONFIRMED REACHABLE finding means: there exists a call path from at least one of your entry points to the vulnerable function. It doesn't guarantee that the specific input condition required to trigger the vulnerability is attainable in your deployment. Some CVEs require very specific preconditions — a particular database state, a specific header combination, a sequence of calls. Reachability analysis doesn't model those preconditions.
What reachability does is reduce your triage surface. Of 47 advisories, 6 are reachable. Now you spend your analyst time on those 6, doing the deeper "is the trigger condition realistic?" analysis on a manageable set rather than on 47. Your MTTR for the truly exploitable subset improves because you're not spreading attention across noise.
Building Intuition for Call Graph Depth
One thing practitioners underestimate is depth. In a heavily dependency-using application, a call path from an HTTP handler to a vulnerable function in a transitive dependency might be 12–18 hops. At hop 3, you've already left your codebase and entered someone else's library. At hop 9, you're in a dependency of a dependency of a dependency.
When reviewing a call path trace, pay attention to where you cross the boundary from your code into library code. The first library call in the path is where you'd need to sanitize inputs to break the path if patching isn't immediately possible. That's the mitigation point — not the vulnerable function itself, which you can't modify without forking the dependency.
This framing is useful for sprint planning. If the call path to a critical CVE crosses into library code at hop 2 and the fix is a one-version bump with no API changes, that's a low-effort, high-impact patch. If the mitigation point requires refactoring how your controllers handle user input, that's a two-sprint item that needs planning. The call path gives you that visibility before you commit sprint capacity.