call graphstatic analysisreachability by Derek Voss

A Practical Primer on Call Graph Analysis for Application Security

Call graph analysis sounds complex but the core idea is simple: follow the path from your code to the vulnerable function. Here's how it works in practice.

A Practical Primer on Call Graph Analysis for Application Security

Call graph analysis is one of those concepts that sounds more academic than it is. Once you understand the core idea, it's hard to unsee — and you start noticing exactly where security tools that skip it are flying blind.

This is a practical primer. No compiler theory, no formal semantics. Just the mechanics of how call graphs work, what they tell you about vulnerability reachability, and where static analysis of this kind runs into real-world complications.

What a call graph is

A call graph is a directed graph where each node represents a function (or method), and each edge represents a call from one function to another. If function A calls function B, there's an edge from A to B.

In a real application, the call graph starts from entry points — the functions that are invoked by the runtime to begin execution. For a web service, entry points are typically your HTTP route handlers. For a CLI tool, they're the command dispatch functions. For a background worker, they're the job processing functions that the queue system invokes.

From those entry points, the call graph traces outward: your handler calls a validation function, which calls a schema parser, which calls a string utility from a third-party package, which calls a lower-level parsing function. Each step is an edge in the graph. The entire reachable call graph for your application is the set of all functions that can be invoked starting from any entry point, following all possible call chains.

Why entry points matter for security

The concept of entry points is crucial for vulnerability reachability analysis. A vulnerable function in a dependency is only a risk if execution can actually reach it from somewhere in your running application. The call graph starting from your entry points defines the complete set of code that can execute in your production environment.

Here's a concrete example. Imagine a TypeScript REST API with a file upload endpoint. The call graph from that endpoint's handler includes express router → upload handler → multer → busboy → dicer. If dicer has a known vulnerability, that vulnerability is reachable — there's a path from an HTTP request hitting your API to the vulnerable code in dicer.

Now imagine the same application also has xml2js as a transitive dependency somewhere in the tree — perhaps pulled in by a test utility that's accidentally included in the production build, or by an analytics library. If no code path from any of your HTTP handlers or background workers ever calls into xml2js, then any CVE in xml2js is unreachable. It exists in your lockfile. It may even exist in your node_modules. But it can't be triggered by an attacker interacting with your running service.

Static vs. dynamic call graph construction

There are two broad approaches to building a call graph: static analysis and dynamic analysis.

Dynamic analysis instruments the running application and records actual function calls as they occur during execution. This produces a precise call graph for the code paths that were actually executed — but it's only as complete as your test coverage and runtime observation window. Paths that are exercised by obscure user interactions or rare error conditions might not appear in the dynamic call graph even though they're reachable.

Static analysis reads the source code (or compiled bytecode/IR) and constructs the call graph by analyzing the code structure without running it. This is what reachability-based SCA tools typically use, because it can run in your CI pipeline without requiring a live instrumented environment. Static analysis is conservative by design — it may include edges in the call graph that would never execute at runtime (false edges), but it won't miss edges that can execute (assuming the analysis is complete).

For security purposes, you generally want the conservative over-approximation of static analysis rather than the precise but incomplete picture from dynamic analysis. The risk of a false negative (missing a reachable vulnerability) is worse than the risk of a false positive (flagging an unreachable one).

The precision problem in dynamically-typed languages

Static call graph construction is straightforward for compiled languages with explicit types: the compiler knows exactly which function each call site resolves to. For dynamically-typed languages like JavaScript, Python, and Ruby, it's harder.

Consider this JavaScript:

const handler = config.mode === 'streaming' ? streamParser : batchParser;
handler.process(data);

Which function does handler.process call? A static analyzer has to reason about the possible values of config.mode to answer this. If it can't resolve the value statically, it must conservatively assume both streamParser.process and batchParser.process are reachable — which adds edges that may not correspond to runtime behavior.

Dynamic imports, require() with variable paths, monkey-patching, and prototype chain manipulation all create similar challenges. A well-designed static analysis engine handles common patterns precisely and falls back to conservative approximation for complex dynamic patterns — flagging the ambiguity rather than silently dropping coverage.

What "reachable via call chain" actually means in a scan result

When a reachability-aware scanner reports a vulnerability as REACHABLE, it should be able to show you the specific call chain it found. Not just "this CVE is reachable" but "here's the path from your code to the vulnerable function."

A properly formatted reachability report looks something like this:

CVE-2024-38372 | HIGH | [email protected]
Status: REACHABLE
Call chain:
  src/jobs/notification-worker.ts:44 → parseNotificationPayload()
  src/lib/proto-utils.ts:89          → protobuf.load()
  node_modules/protobufjs/src/root.js:201 → Root.prototype.load() [VULNERABLE]

Fix: upgrade to [email protected]

That call chain trace is verifiable. A developer can open the files, follow the path, and confirm the analysis is correct. They can also understand the remediation context: the vulnerability is triggered by loading untrusted protobuf definitions, so the fix is either upgrading the package or ensuring parseNotificationPayload() only processes trusted schema sources.

Compare this to an advisory-only alert: "[email protected] is affected by CVE-2024-38372." Same CVE, zero context. Is it reachable? Is the fix urgent? Is it safe to upgrade? The developer has to do that investigation themselves — and in a noisy alert environment, they often don't.

Interprocedural analysis and depth limits

A full application call graph is interprocedural — it crosses function boundaries. Following a call chain from your HTTP handler through 8 layers of abstraction into a third-party library's internal functions requires the analysis to track call edges across every function boundary along the path.

Most practical static analysis implementations set a depth limit on interprocedural analysis — how many call frames deep they're willing to follow. Too shallow, and you miss reachable vulnerabilities that sit deep in the call stack. Too deep, and analysis time grows exponentially (in pathological cases) and you accumulate more false edges from conservative approximation at each step.

We're not saying deep interprocedural analysis is always necessary — many real vulnerability call chains are 3-5 levels deep, not 15. But a reachability tool should be transparent about its depth and approximation strategy. "We trace up to N frames" is an honest statement. "We do full call graph analysis" without a depth disclosure is not.

Where call graph analysis fits in your pipeline

In a CI/CD context, call graph analysis runs after the lockfile diff is computed (you only need to re-analyze CVEs introduced or affected by the current PR's dependency changes). The analysis scope is bounded by which packages changed and which CVEs those package versions introduce. You're not rebuilding the entire call graph on every commit — you're tracing call chains relevant to the delta.

This scoping is what makes PR-level reachability analysis practical at high commit velocity. The bounded analysis per PR keeps latency in the 10-30 second range for most codebases, which is acceptable for a CI check. Full-repo baseline analysis (for initial onboarding or periodic audits) runs longer but typically on a background schedule rather than in the PR hot path.

The output — a reachability verdict with call chain evidence for each flagged CVE — is what your developers actually need to make a good decision. Not a raw advisory match. The call chain is the evidence that converts a potential risk into a confirmed one, and it's what makes the difference between an alert your team acts on and one that gets muted.

SCA in High-Velocity Pipelines: Security That Doesn't Slow Y... False Positives Kill Developer Trust: The Hidden Cost of Noi...