← AppSec Signal

The Reachability Analysis False Positive: When We Get It Wrong

Derek Voss 10 min read
Call graph with highlighted uncertain edges representing static analysis blind spots

We spend most of our writing explaining why reachability analysis produces fewer false positives than CVSS-sorted SCA output. That's true — it's the core value we built. But it would be intellectually dishonest not to publish the other side: the cases where static call graph analysis gets it wrong, adds a path that doesn't exist, misses a path that does, or produces a result that's technically correct but misleading.

This post is our current known-issues list. We're publishing it because AppSec engineers using any reachability tool — including Patchlynx — need to know where to apply additional scrutiny, not just where to trust automated output.

False Positives vs False Negatives: Which Is Worse?

Before getting into specific patterns, it's worth being precise about direction. In security tooling, false positives and false negatives have different consequences:

  • A false positive in reachability terms means we say a CVE is reachable when it isn't. The cost: you spend time triaging something that wasn't a real risk. Wasteful, but safe.
  • A false negative means we say a CVE is unreachable when it actually is reachable. The cost: you skip a real vulnerability because the tool told you it didn't matter. Dangerous.

Static call graph analysis is designed to be conservatively over-approximate: when uncertain, it adds an edge rather than omitting one. This biases toward false positives, not false negatives. The patterns I'll describe below are all mechanisms that can cause false positives — we claim reachability when runtime behavior would show the path isn't actually traversed.

We're not aware of systematic false negative patterns in our analysis today. That doesn't mean they don't exist — it means we haven't instrumented for them yet at scale. We're working on runtime trace comparison to validate static graph results against actual call frequency data.

Dynamic Dispatch and Computed Property Access

JavaScript's dynamic nature is the single largest source of over-approximation in our static analysis. When code does something like:

const handler = config.handlers[eventType];
handler.process(payload);

The static analyzer sees a call to .process() on something that could be any of the objects in config.handlers. Without knowing the runtime value of eventType, a conservative analyzer adds call edges to .process() on every plausible handler type. If one of those handler types is in a package with a CVE, the CVE shows as reachable — even if the specific event type that would invoke that handler is never triggered by the entry point you care about.

The fix for this pattern requires either points-to analysis (tracking what values eventType can actually hold) or heuristics based on the naming conventions of the handler registry. We use both, but points-to analysis has its own limits when the values come from external config files or environment variables that aren't available at analysis time.

Reflection and Dynamic require() Patterns

Code that dynamically constructs module names and calls require() or import() with a computed string creates edges that static analysis can't reliably resolve:

// Plugin loader pattern
const plugins = fs.readdirSync('./plugins');
plugins.forEach(name => {
  const plugin = require(`./plugins/${name}`);
  plugin.init(app);
});

A conservative static analyzer will attempt to include all files that match the pattern. If one of those plugins transitively depends on a vulnerable package, the CVE appears reachable. Whether it's actually reachable depends on which plugin files are present at runtime — something static analysis can't determine without either the actual plugin directory contents or a separate discovery step.

For Patchlynx, we handle this by scanning the actual file system at analysis time for static plugin directories, but we flag these call edges with lower confidence. When you see a finding marked [REACHABLE — dynamic dispatch, lower confidence], this is one of the patterns that generated that flag.

Dead Code That Looks Reachable

Code that's present in the module but never called at runtime is still present in the syntax tree. If a file exports both a parseConfig() function that's used everywhere and a legacyParse() function that was used two years ago and never removed, both are in the call graph from the perspective of any code that imports the module.

Static analysis generally can't distinguish "exported but never imported by anything in the reachable set" from "exported and imported by things in the reachable set" without tracking every import site. We do this tracking, but it's not perfect when barrel exports (export * from './module') are involved. A barrel export effectively imports everything and re-exports it, which can transitively include dead code in the reachable set.

Test Code Accidentally Included in Production Bundles

This one surprised us when we first encountered it at scale. Some build configurations inadvertently include test utilities in the production bundle. If webpack.config.js has a misconfigured entry that pulls in test helpers, and those test helpers depend on, say, sinon or jest packages with open CVEs, those CVEs appear reachable in your production scan.

They might be reachable in the bundle sense — the code is present — but not reachable in the attack sense, because nothing your application does at runtime invokes the test utility functions from an HTTP handler. Static analysis sees a call edge; runtime would show zero invocations.

The practical fix here isn't a reachability analysis improvement — it's fixing the build config to exclude test code from production bundles. We flag these findings with a note when we can detect that the reachable path goes through a package that's declared as a devDependency, which is a signal that bundle misconfiguration might be involved.

Polymorphic Call Sites in Java and Go

For Java and Go applications, interface-based polymorphism creates similar over-approximation challenges. A call to interface.method() in Java adds edges to every implementing class in the analysis scope, even if only one implementation is ever instantiated at that call site. In a large codebase with many interface implementations, this can generate many edges that don't correspond to runtime behavior.

Class Hierarchy Analysis (CHA) is the conservative approach and what we use by default. Rapid Type Analysis (RTA) is more precise — it only considers types that are actually instantiated somewhere in the reachable code. We're implementing RTA for our Java analysis module. The tradeoff: RTA is slower to compute and can miss edge cases in frameworks that instantiate classes via reflection (Spring's dependency injection, for example).

What to Do With These Patterns

The practical takeaway is: when Patchlynx marks a CVE as reachable in code that uses dynamic dispatch, plugin loaders, or heavy interface polymorphism, apply an additional review step before treating it as confirmed. The call path trace will show you where the dynamic edge is — look for the comment [dynamic dispatch] or [interface polymorphism] in the path.

For those findings, spend 10 minutes answering: does the specific condition that would resolve the dynamic call to the vulnerable function actually occur in your application's normal operation? If yes, it's a real finding. If the dynamic dispatch is constrained by application logic that would never route to the vulnerable handler, document that and suppress.

We're not saying the tool is wrong to flag these — being conservative is the right default for security analysis. We're saying you, as the analyst, hold context the tool doesn't: knowledge of your application's specific runtime behavior. Use that context on the edge cases. Trust the tool on the clear call paths.