Reachability Analysis in SCA: Call Graphs, VEX and Cutting Vulnerability Noise (2026)

Reachability Analysis in SCA: Call Graphs, VEX and Cutting Vulnerability Noise (2026)

Reachability Analysis in SCA: Call Graphs, VEX and Cutting Vulnerability Noise

A software composition analysis (SCA) scan lands on Monday morning with 1,400 findings. Nobody on the team believes 1,400 things are exploitable, yet each one carries a CVSS score, a ticket, and an SLA clock. Engineers triage by severity, patch what they can, and quietly learn to distrust the scanner. The problem is not that the scanner is wrong. It answers “is a vulnerable package in my dependency tree?” when the question that matters is “can an attacker make my running code execute the vulnerable part?”

Reachability analysis SCA closes that gap by building a call graph of your application and its dependencies, then asking whether any path leads from your entry points to the vulnerable function. Done well, it demotes most findings to documented non-issues and expresses that decision in a machine-readable form, a VEX statement, that downstream consumers can trust.

What this covers: why CVSS-only triage produces noise, how static call graphs are built (CHA, RTA, points-to), what runtime evidence adds, how Java, Go, JavaScript and Python differ, how EPSS and CISA KEV complement reachability, how to emit correct VEX statements, the false-negative risks, a runnable Python toy, an OpenVEX example, and a CI integration pattern.

Context and Background

Classic SCA works at package granularity. The scanner reads a lockfile or SBOM, extracts name and version pairs, and matches them against an advisory feed such as the GitHub Advisory Database, OSV, or the National Vulnerability Database. A match produces a finding whose severity is inherited from the advisory. Nothing in this pipeline looks at your code. If your application depends on a library that contains a vulnerable parser, you get the finding whether you call the parser, never import that module, or only ship the library as a transitive dependency of a build plugin.

Transitive dependencies make the volume worse. A modern service pulls in hundreds of packages it never chose directly. Academic work quantifies the gap between “present” and “reachable.” A large study of the Maven ecosystem published on arXiv reports that roughly a third of about 3 million Maven packages are flagged as vulnerable once transitive dependencies are counted, while fewer than 1 percent have an actual call path into vulnerable code. The authors conclude that dependency-level analysis substantially overstates how many projects are affected. That is one ecosystem and one methodology, so treat the exact ratio as indicative, but the direction has been replicated by vendors and researchers for years.

Two other ingredients matter. The first is exploit likelihood: EPSS, the Exploit Prediction Scoring System maintained by FIRST, estimates daily the probability that exploitation activity will be observed for a CVE in the next 30 days. The second is observed exploitation: CISA’s Known Exploited Vulnerabilities (KEV) catalog lists CVEs with evidence of active exploitation. Neither says anything about your code. Reachability says everything about your code and nothing about attackers. The combination is what produces a defensible queue.

The output side has also matured. VEX, the Vulnerability Exploitability eXchange, lets a supplier state that a product is not affected by a given CVE, with a justification. If you have not yet chosen an SBOM format, our comparison of CycloneDX vs SPDX for supply chain vulnerability triage covers how SBOMs and VEX documents fit together, and our piece on VEX, CSAF and OpenVEX triage automation covers the format landscape in depth. This article focuses on the step between them: the analysis that earns the right to write “not affected.”

The thesis is narrow and practical. Reachability is not a score, it is evidence. Its best use is not to rank findings but to generate auditable not_affected decisions, and its main danger is a false negative that silently suppresses a real vulnerability. Everything below is organized around making that trade explicit.

Why CVSS-Only Triage Creates Noise, and the Reachability Pipeline That Fixes It

Direct answer: CVSS base scores describe the worst-case severity of a flaw in the abstract, independent of your deployment, your call paths, or attacker behaviour. Reachability analysis adds the missing context by testing whether your code can execute the vulnerable function, then recording the result as a VEX status so the decision is repeatable and auditable.

Reachability analysis SCA pipeline from SBOM through call graph check, VEX statement, and EPSS and KEV ranked queue

Figure 1: A reachability-aware SCA pipeline. Findings flow through a reachability test; unreachable ones become VEX not_affected statements, reachable or unknown ones go to an EPSS and KEV enriched queue.

Figure 1 shows the shape of the pipeline. The SBOM or lockfile gives components, the advisory match gives raw findings, and the reachability stage splits them into three outcomes: reached, not reached, and unknown. The third outcome deserves emphasis. A responsible tool must treat “I could not tell” as affected, not as safe, and that single rule determines whether reachability reduces noise or hides risk.

Why CVSS alone misleads

CVSS was designed to describe intrinsic characteristics of a vulnerability. The base score assumes the vulnerable component is deployed and reachable by the attack vector the metric names. It cannot know that your service uses only the Client class of a library whose flaw lives in Server, or that the flaw requires a configuration flag you never set. The score is the same for everyone, which is exactly why it cannot be a prioritization on its own.

There is also a base-rate problem. The share of published CVEs that are ever exploited in the wild is small compared with the number published each year. FIRST’s EPSS documentation describes the model as trained on observed exploitation data for exactly this reason: severity and exploitation correlate only loosely. If you sort by CVSS 9.0 and above, you will fix many critical-rated flaws that nobody exploits while a medium-rated flaw on KEV waits its turn.

Finally, there is an organizational cost. Every false positive burns a developer’s attention and erodes trust. After a few weeks of chasing unreachable findings, teams begin to snooze scanners or raise thresholds to meaningless values. Reachability is partly a trust-restoration mechanism: it lets the scanner say less, and be right more often.

The three questions reachability answers

A useful way to structure the analysis is as three nested questions, each narrower than the last.

  1. Presence. Is the vulnerable component actually in the artifact? A dependency in the lockfile may be a dev-only tool, pruned by a bundler, or excluded from the container image. Presence tests need no call graph, only an honest inventory of what shipped.
  2. Code presence. Is the vulnerable symbol, the specific function or class, in the build? Shading, tree-shaking, and linker dead-code elimination can remove it. Go binaries embed symbol tables that make this check cheap.
  3. Execution. Is there a path from an entry point in your application to that symbol? This is the call graph question and the most expensive one.

Each maps to a VEX justification we will meet later: component_not_present, vulnerable_code_not_present, and vulnerable_code_not_in_execute_path. A fourth question, whether an attacker can control the input that reaches the vulnerable code, sits beyond what static analysis can prove and is usually a human or runtime-evidence judgement.

What the advisory needs to provide

Reachability is only as good as the vulnerability metadata. To test “does any path reach the vulnerable function,” someone has to say which function that is. Many advisories name only a package and version range, so the tool cannot go below package granularity. The Go vulnerability database is the clearest positive example: its entries list affected packages and symbols, which is what lets the Go tooling reason at function level. Other ecosystems rely on vendor-curated symbol data, which varies in coverage and is often proprietary. When a tool claims reachability for an advisory without symbol data, ask what it analyzes instead; the usual answer is a heuristic such as “is the package imported at all,” which is much weaker than a call path.

How Static Call Graph Construction Works

Static reachability starts with a call graph: nodes are functions or methods, edges are possible calls. The hard part is not the direct calls, which the compiler resolves trivially, but the dynamic ones: virtual methods, interface calls, function pointers, callbacks, and reflection. The precision and soundness of the whole analysis depend on how those are resolved.

Call graph analysis for vulnerability reachability showing CHA, RTA and points-to refinement from entry points to a vulnerable symbol

Figure 2: Call graph construction narrows call targets in stages. CHA over-approximates, RTA prunes by instantiated types, points-to analysis prunes further using data flow; the reachability test is a graph walk from entry points to the advisory’s vulnerable symbol.

Class Hierarchy Analysis (CHA)

CHA is the cheapest algorithm. At a virtual call x.foo() where x has declared type T, CHA adds an edge to foo in T and in every subtype of T that overrides it. It needs only the class hierarchy and no data-flow information, so it scales to large codebases.

The price is imprecision. If T is an interface with 40 implementations across your dependency tree, every call through it gets 40 edges, and a vulnerability in any implementation looks reachable. In practice CHA is a safe over-approximation: it rarely misses a real path, but it flags many impossible ones. That is a sound choice if your goal is “never suppress a real finding” and a poor one if your goal is noise reduction.

Rapid Type Analysis (RTA)

RTA refines CHA with one observation: a call can only dispatch to a class that is actually instantiated somewhere in the reachable program. RTA computes the call graph and the set of instantiated types together, iterating until they stabilize. A subtype never constructed in reachable code drops out of the dispatch set.

For dependency-heavy applications this matters. A logging framework may ship ten appenders, of which your configuration uses two. Under CHA, a vulnerable appender is reachable through the Appender interface; under RTA, it is only reachable if something reachable creates it. RTA is still cheap, and it is the workhorse in several production tools. It still cannot tell which of two instantiated subtypes a particular variable holds.

Points-to analysis

Points-to (or pointer) analysis tracks which allocation sites each variable might refer to. A virtual call dispatches only to methods of the types that its receiver can actually hold. Variants differ in sensitivity: context-insensitive analysis merges all callers of a function, while context-sensitive variants (k-call-site or object-sensitive) keep calling contexts separate for more precision at higher cost. Whole-program, field-sensitive, context-sensitive points-to analysis on a large Java service can take minutes to hours and gigabytes of memory, which is why tools usually offer it as an optional tier or apply it only around candidate vulnerable methods.

A sensible mental model is a ladder: CHA is fast and conservative, RTA prunes cheaply, points-to prunes most but costs the most. Frameworks such as Soot, WALA, and OPAL for JVM bytecode, and the call graph packages in Go’s x/tools module, expose these algorithms. I have not benchmarked them here; relative cost depends heavily on the codebase.

Entry points are half the battle

A call graph needs roots. For a command-line program, main is enough. For a web service, the roots include every HTTP handler, message consumer, scheduled job, and framework callback, and many of these are invoked by reflection or dependency injection that the analysis cannot see. If you omit a root, everything reachable only from it is flagged unreachable: a false negative. Mature tools ship framework models (Spring controllers, JAX-RS resources, Express routes) to synthesize roots. When evaluating a tool, ask which frameworks it models and what it does for the ones it does not.

Library code versus application code

There are two distinct reachability questions. In the first, your code calls library A, which calls library B, which contains the vulnerable function. This is the transitive path, and it needs call-graph edges across library boundaries, so the tool must analyze the dependencies’ bytecode or source, not just yours. In the second, the vulnerable function is in a library but is only invoked from library-internal paths that your entry points cannot trigger, for example an internal admin interface of a library you use only as a client. The second case is where you gain most of your noise reduction, and also the case most sensitive to root-set mistakes.

Dynamic Reachability, Language Differences, and Complementary Signals

Static analysis answers “could this run.” Runtime evidence answers “did this run.” The two have opposite failure modes, so the strongest programs use both.

Dynamic and runtime reachability

Runtime approaches instrument the application and record which functions execute. Sources include a language agent (a JVM agent, a Python tracer), eBPF-based observation of loaded libraries and executed symbols, or coverage data collected from tests and staging traffic. The result is a set of observed call sites. A vulnerable function that appears in that set is strong evidence of reachability; one that never appears is only weak evidence of unreachability, because absence of evidence depends on coverage.

That asymmetry is the key design point. Observed execution of a vulnerable function can safely escalate a finding. Non-execution should never, by itself, justify suppression, unless your workload coverage is demonstrably complete, which it almost never is. Rare paths such as error handlers, import jobs run quarterly, and attacker-crafted inputs are exactly where vulnerable parsers tend to sit. Use runtime data to promote findings and to sharpen the static result (for example, to resolve which implementation a reflective lookup picks), not to demote them.

There are operational costs too. Agents add overhead and an attack surface, and collecting production traces raises data-handling questions. Loaded-library telemetry, which records which shared objects or JARs the process mapped, is cheap and gives a strong presence signal even when it says nothing about specific functions.

Language differences

Java and JVM languages. Bytecode makes whole-program analysis tractable: the class hierarchy is explicit, and tools can analyze dependency JARs without source. The main obstacles are reflection, dynamic proxies, service loaders, serialization, and dependency-injection frameworks, all of which create calls invisible in bytecode. Shading and relocation of packages (renaming classes inside an uber-JAR) can also defeat name-based symbol matching if the tool does not normalize them. The Maven study cited earlier used call-graph generation over Maven Central, which shows the approach scales to an ecosystem when the algorithm is cheap.

Go. Go has the best-documented ecosystem story. The official tool is govulncheck, from the golang.org/x/vuln module, backed by the Go vulnerability database at vuln.go.dev. According to its documentation it uses static analysis to narrow reports to vulnerabilities that could affect your code, and it reports a short call summary such as which package function calls the vulnerable symbol; -show traces prints full call stacks. In source mode (the default) it analyzes the module; in binary mode (-mode binary) it reads the compiled binary’s symbol table, which works without source but omits call stacks and can report false positives for code that is present yet unreachable. The documentation states its limitations plainly: function-pointer and interface calls are handled conservatively (possible false positives), calls through the reflect package are invisible to source scanning (false negatives), and the unsafe package may cause false negatives. Two integration details matter for CI: by default it exits non-zero when it finds vulnerabilities, but with -json, -format sarif or -format openvex it exits zero regardless, so your pipeline must parse the output to gate. It can emit OpenVEX directly, which is convenient for the VEX step discussed below.

JavaScript and TypeScript. This is the hard case. Dynamic property access (obj[name]()), eval, prototype mutation, require with computed paths, monkey-patching, and callbacks passed through many layers defeat naive call graph construction. Bundlers and tree-shakers change what ships. Static tools typically use approximations such as field-based analysis that over-approximate dispatch, so they tend toward false positives, or they fall back to import-level reachability (“is the vulnerable module required anywhere”), which is a much weaker claim. Be skeptical of JavaScript reachability claims that do not state their method, and prefer to pair them with runtime evidence in staging.

Python. Python shares JavaScript’s dynamism: duck typing, getattr, decorators, importlib, and metaclasses make call targets depend on runtime values. Tools generally offer import-level and approximate function-level analysis. A common pitfall is that Python packages frequently install extension modules or optional features that are only loaded under certain environment conditions, so presence in site-packages does not equal presence in the process. Runtime confirmation (which modules did sys.modules contain under load) is unusually valuable here.

Native and polyglot. C and C++ dependencies reached through FFI, JNI, or cgo cross language boundaries where most call graph tools stop. Treat a vulnerable native library invoked from a managed language as “unknown” unless you have explicit evidence. I could not verify cgo handling in the govulncheck documentation I read, so do not assume it.

Ecosystem Static feasibility Main blind spots Typical posture
Java High (bytecode, explicit hierarchy) Reflection, DI, proxies, shading Static plus framework models
Go High (official function-level tooling) reflect, unsafe, cgo govulncheck in CI, binary mode for artifacts
JavaScript Low to moderate Dynamic dispatch, eval, bundling Import-level plus runtime evidence
Python Low to moderate getattr, importlib, optional extensions Import-level plus runtime evidence

EPSS and CISA KEV as complements

Reachability tells you whether your code can run the vulnerable function. Two external feeds tell you whether anyone is likely to try.

EPSS is published by FIRST. Its documentation describes a daily estimate of the probability that exploitation activity will be observed for a CVE over the next 30 days, produced by a machine learning model trained on vulnerability attributes and observed exploitation. Because it is a probability on a published CVE, it can be refreshed daily without touching your code. Use it as a ranking signal inside the reachable set, not as a gate: a low EPSS is not a guarantee, and new CVEs start with little exploitation history for the model to learn from.

The KEV catalog is different in kind. CISA lists vulnerabilities for which it has evidence of active exploitation, and, per the catalog’s published criteria, which carry a CVE identifier and clear remediation guidance (see CISA’s KEV catalog). Federal civilian agencies are required to remediate KEV entries by a due date under Binding Operational Directive 22-01, and many private organizations adopt KEV membership as an automatic escalation rule. I could not load the catalog page during this run, so confirm the current inclusion criteria on CISA’s site before quoting them.

A practical combination looks like this:

  • KEV member and reachable or unknown: highest priority, fix on an emergency timeline.
  • KEV member and demonstrably not present: document with VEX, do not page anyone.
  • Reachable and high EPSS: normal expedited queue.
  • Reachable and low EPSS: scheduled maintenance.
  • Not reachable with a defensible justification: VEX not_affected, review on dependency change.

Notice that KEV overrides a reachability “no” only when the “no” is weak. If your evidence is a root-set guess in a reflection-heavy framework, a KEV entry is a good reason to re-check the guess rather than trust it.

A Runnable Toy: Reachability as a Graph Walk

The following Python script is a deliberately small model of the core idea. It builds a call graph from explicit edges plus a class hierarchy, resolves virtual calls under CHA and under RTA, and checks whether a vulnerable symbol is reachable from entry points. The data is invented for illustration. It is not a substitute for a real analyzer, but running it makes the CHA versus RTA difference concrete.

from collections import defaultdict, deque

# Class hierarchy: interface -> implementations (illustrative data)
HIERARCHY = {
    "Appender": ["ConsoleAppender", "FileAppender", "JndiAppender"],
}

# Direct call edges: caller -> callees. A callee of the form "virtual:Appender.write"
# means a call through the Appender interface.
CALLS = {
    "app.main": ["app.setup", "virtual:Appender.write"],
    "app.setup": ["new:ConsoleAppender", "new:FileAppender"],
    "ConsoleAppender.write": ["io.print"],
    "FileAppender.write": ["io.flush"],
    "JndiAppender.write": ["lib.jndi_lookup"],   # vulnerable symbol lives here
}

ENTRY = ["app.main"]
VULNERABLE = "lib.jndi_lookup"


def build_graph(use_rta: bool):
    edges = defaultdict(set)
    instantiated = set()
    worklist = deque(ENTRY)
    seen = set()
    # RTA needs a fixed point because instantiation can unlock more dispatch.
    changed = True
    while changed:
        changed = False
        worklist = deque(ENTRY)
        seen = set()
        while worklist:
            fn = worklist.popleft()
            if fn in seen:
                continue
            seen.add(fn)
            for callee in CALLS.get(fn, []):
                if callee.startswith("new:"):
                    cls = callee.split(":", 1)[1]
                    if cls not in instantiated:
                        instantiated.add(cls)
                        changed = True
                    continue
                if callee.startswith("virtual:"):
                    iface, method = callee.split(":", 1)[1].split(".")
                    for impl in HIERARCHY.get(iface, []):
                        if use_rta and impl not in instantiated:
                            continue          # RTA: skip never-built types
                        target = f"{impl}.{method}"
                        edges[fn].add(target)
                        worklist.append(target)
                else:
                    edges[fn].add(callee)
                    worklist.append(callee)
    return edges


def reachable(edges, target):
    parents = {e: None for e in ENTRY}
    queue = deque(ENTRY)
    while queue:
        fn = queue.popleft()
        if fn == target:
            path = []
            while fn is not None:
                path.append(fn)
                fn = parents[fn]
            return list(reversed(path))
        for nxt in edges.get(fn, ()):
            if nxt not in parents:
                parents[nxt] = fn
                queue.append(nxt)
    return None


for name, rta in (("CHA", False), ("RTA", True)):
    path = reachable(build_graph(rta), VULNERABLE)
    verdict = " -> ".join(path) if path else "no path"
    print(f"{name}: {verdict}")

Run it and CHA reports a path through JndiAppender.write to the vulnerable symbol, because the interface has that implementation. RTA reports no path, because nothing reachable ever constructs a JndiAppender. That difference is exactly the noise a refined call graph removes, and it also shows the danger: if some code outside your modeled roots (a plugin loader, a configuration-driven factory) builds a JndiAppender by reflection, RTA’s “no path” is a false negative. The toy’s new: edges stand in for constructor calls that a real analyzer must discover in bytecode or source.

Two practical lessons follow. First, the verdict is a path or its absence, and a path is something a human can audit: the output names each hop. Prefer tools that surface the path, because “reachable” with no evidence is a claim you cannot challenge. Second, soundness depends on the model of instantiation, so the tool’s handling of reflection and dependency injection deserves as much scrutiny as the algorithm name.

Producing VEX Statements from Reachability Evidence

A reachability result is only useful if it survives beyond the scan log. VEX turns it into a durable, shareable claim: for this product and this vulnerability, here is the status and why. OpenVEX, the lightweight format from the OpenVEX project, is a convenient target, and CycloneDX and CSAF have their own VEX encodings (our VEX, CSAF and OpenVEX automation guide compares them).

VEX statements decision tree mapping reachability evidence to not_affected justifications or affected status

Figure 3: Mapping evidence to a VEX status. Each “no” answer yields a specific not_affected justification; any “yes” or “unknown” along the way keeps the finding open as affected or under investigation.

Statuses and justifications

Per the OpenVEX specification (v0.2.0), a statement has a status of not_affected, affected, fixed, or under_investigation. A not_affected statement must carry either a justification or an impact_statement, and the spec recommends a machine-readable justification with an optional free-text impact statement, since the impact statement is not meant to be parsed. The defined justifications are component_not_present, vulnerable_code_not_present, vulnerable_code_not_in_execute_path, vulnerable_code_cannot_be_controlled_by_adversary, and inline_mitigations_already_exist.

These map neatly onto the questions from earlier:

  • Not in the artifact: component_not_present.
  • Code stripped or never compiled in: vulnerable_code_not_present.
  • No call path from entry points: vulnerable_code_not_in_execute_path. This is the reachability justification.
  • Path exists but attacker cannot influence the dangerous argument: vulnerable_code_cannot_be_controlled_by_adversary.
  • A guard, WAF rule, or sandbox blocks exploitation: inline_mitigations_already_exist.

Choose the weakest sufficient claim. vulnerable_code_not_in_execute_path from a static analysis with known blind spots is a bounded statement; say so in the impact statement (“govulncheck source mode, entry points main and HTTP handlers, reflection not modeled”) so the next reader knows what was and was not checked.

An OpenVEX example

{
  "@context": "https://openvex.dev/ns/v0.2.0",
  "@id": "https://example.com/vex/payments-api/2026-10-31-001",
  "author": "Example Corp Product Security",
  "role": "Document Creator",
  "timestamp": "2026-10-31T09:00:00Z",
  "version": 1,
  "tooling": "internal-reachability-pipeline (illustrative)",
  "statements": [
    {
      "vulnerability": { "name": "CVE-2026-00000" },
      "products": [
        { "@id": "pkg:oci/payments-api@sha256%3A0000000000000000000000000000000000000000000000000000000000000000" }
      ],
      "status": "not_affected",
      "justification": "vulnerable_code_not_in_execute_path",
      "impact_statement": "Static call graph (RTA) from main and registered HTTP handlers found no path to the vulnerable parser. Reflection and plugin loading not modeled; re-run on dependency change."
    }
  ]
}

The CVE identifier and image digest are placeholders. Required document fields in v0.2.0 are @context, @id, author, timestamp, version, and statements; each statement needs a vulnerability and status, plus a product and timestamp (set on the statement or inherited from the document). Product identifiers are usually package URLs (purls) or container image references matching the SBOM, which is why the SBOM and the VEX document must agree on identity. A VEX statement about a product the SBOM never names cannot be applied by a consumer.

Lifecycle: VEX statements expire

A not_affected claim is true for a specific build and dependency set. Upgrade a library, add a handler, or enable a plugin and the call graph changes. Treat each VEX document as pinned to an artifact digest, version it (the version field increments when you revise), and regenerate it on every build. Stale VEX is worse than no VEX because it carries false authority. Where you distribute VEX to customers, pair it with provenance so recipients can verify who produced it; our SLSA and Sigstore architecture guide covers signing and attestation of such artifacts.

Wiring It into CI: A Practical Integration Pattern

Sequence diagram of SCA reachability in CI: scan, advisory match, call graph check, VEX emission, and gating on reachable or KEV findings

Figure 4: CI integration sequence. The pipeline matches components, tests reachability, emits VEX as a build artifact, and fails the build only for reachable or KEV-listed findings.

The CI flow in Figure 4 has five stages, and the order matters.

1. Build and inventory. Produce the artifact and an SBOM from the same build step, so component identity matches. Generate the SBOM from the built image or binary, not just the lockfile, to catch what actually shipped.

2. Match and enrich. Look up advisories for each component and attach EPSS and KEV data. These feeds change daily, so cache with a short TTL and record the retrieval time in the report.

3. Reachability. Run the language-appropriate analyzer. For Go, that can be govulncheck ./... in source mode for the service and govulncheck -mode binary against the final binary as a cross-check. Keep the full output (SARIF or JSON) as a build artifact. For JVM and other ecosystems, run your SCA vendor’s or open-source tool’s reachability tier.

4. Emit VEX. Convert reachability verdicts into statements. Findings with a defensible “no path” get not_affected; findings with a path stay affected or under_investigation; “unknown” never becomes not_affected. Store the VEX with the artifact.

5. Gate. A reasonable policy: fail the build when a finding is reachable or unknown and either in KEV or above an EPSS threshold you choose after looking at your own backlog. Warn on the rest. Avoid gating on raw CVSS. Whatever the threshold, make it explicit in code review so a change to it is visible.

Two operational tips. First, run a nightly job that re-scans existing releases against fresh advisory data, since a build that passed on Monday can be affected by a CVE published on Friday. Second, add a diff step: when a PR changes dependencies, report only the delta in reachable findings, which keeps review comments short and credible.

A word on cost control, using illustrative numbers only. Suppose a scan yields 1,000 package-level findings and a reachability pass classifies 85 percent as unreachable with a defensible justification. That leaves 150 findings; if triage costs 20 minutes each, you move from roughly 333 engineer-hours to about 50, before accounting for the time to review the justifications themselves. The 85 percent is invented for the arithmetic and your ratio will differ. The point is that the saving depends on trusting the “no,” which is why sampling and auditing the unreachable set is part of the program, not an optional extra.

Trade-offs, Gotchas, and What Goes Wrong

False negatives are the central risk. Every limitation named earlier produces them: reflection that hides edges, dependency injection that hides instantiation, unmodeled framework entry points, unsafe code, native extensions, and plugin systems that load classes by name from configuration. A tool that prunes aggressively gives a satisfying noise reduction and, quietly, an unknown amount of missed exposure. Prefer tools that report their unresolved dynamic call sites and let you treat any vulnerable function reachable only from an unresolved site as unknown.

Over-trusting vendor “reachable” labels. Methods vary from true call-path analysis to “the package is imported somewhere.” Ask five questions of any product: what is the call graph algorithm, what are the roots, which frameworks are modeled, what happens at reflection, and does it work from symbol-level advisory data or package-level data?

Advisory data quality. Reachability needs symbols. If the advisory names only a package, the tool falls back to coarse analysis. When curated symbol data disagrees across vendors, results diverge, and you cannot tell which is right without reading the patch.

Context blindness. Reachability is about code paths, not exposure. A reachable vulnerable function behind an authenticated internal-only endpoint is less urgent than one on an internet-facing route. Pair call graph results with exposure data (is the entry point public) to avoid either extreme.

Container and OS layers. Most reachability tooling covers language dependencies. OS packages in the base image (libc, OpenSSL, system libraries) are matched at package level, and linking to them from application code is hard to see. For those, presence-based triage plus runtime evidence of loaded libraries is usually the best available.

VEX misuse. VEX is a claim, not a proof. Auto-generating not_affected for every unreachable finding and shipping it to customers without review creates liability if a claim is wrong. Gate external publication on a human or policy review for high-severity items.

Regression drift. Reachability results change when code changes. Without the nightly and per-PR re-evaluation described above, last quarter’s “unreachable” silently goes stale.

Performance. Precise whole-program analysis can be too slow for pull-request checks. Tier it: fast CHA or RTA on every PR, deeper points-to analysis nightly or only for candidate high-severity findings.

Practical Recommendations

Start with the question of what you are trying to buy: fewer tickets, or fewer incidents. Reachability reduces tickets reliably; it reduces incidents only if the “no” answers are sound. Design the program around that distinction.

Adopt it incrementally. Begin in report-only mode, run reachability alongside your existing CVSS gate for a few weeks, and sample the findings it would have suppressed. Read the call paths, or the lack of them, for a random subset. If you find even a handful of real exposures among the “unreachable” set, the cause is almost always a missing entry point or an unmodeled framework, and fixing the root set is cheaper than distrusting the tool.

Keep the layers separate in your data model. Presence, code presence, reachability, exploit likelihood (EPSS), observed exploitation (KEV), and exposure are different signals with different freshness. Store them as separate fields rather than blending them into one opaque score, so you can explain any decision and change policy without re-running analysis.

Checklist:

  • Generate the SBOM from the built artifact and keep component identifiers (purls, digests) consistent with your VEX documents.
  • Use symbol-level advisory data wherever it exists; ask vendors where their symbol data comes from.
  • Document entry points and framework models for each service; review them when routing or plugin configuration changes.
  • Treat “unknown” as affected. Never convert unknown into not_affected.
  • Promote findings on runtime evidence; never demote on absence of runtime evidence alone.
  • Escalate KEV members automatically and re-check any weak “not reachable” verdict on them.
  • Emit VEX per build, pinned to the artifact digest, and re-evaluate nightly and on dependency changes.
  • Record the method, roots, and known blind spots in each impact_statement.
  • Audit a sample of suppressed findings every quarter and track the miss rate as a program metric.
  • For OT and embedded products with long field lives, keep VEX history per released firmware version, since you will answer questions about old builds for years.

Frequently Asked Questions

What is reachability analysis in SCA?

Reachability analysis in software composition analysis tests whether your application can actually execute the vulnerable code in a dependency. Instead of flagging every package with a known CVE, the tool builds a call graph from your entry points and looks for a path to the vulnerable function. Findings with no path can be documented as not exploitable in your context, usually via a VEX statement, which cuts triage load while preserving an audit trail of why.

How accurate is call graph analysis for finding vulnerable code paths?

Accuracy depends on the language and algorithm. Static analysis over-approximates dynamic calls (false positives) or misses them entirely when it cannot model reflection, dependency injection, or dynamic dispatch (false negatives). Go’s official tooling documents these limits explicitly: interface and function pointer calls are conservative, while reflect calls and unsafe can cause missed findings. Expect high precision in Java and Go, lower in JavaScript and Python, and always verify with a sample audit.

Does reachability replace CVSS, EPSS, or CISA KEV?

No. They answer different questions. CVSS describes intrinsic severity, EPSS estimates the probability of exploitation activity in the next 30 days, KEV records observed active exploitation, and reachability describes your own code. A good prioritization uses all of them as separate signals: reachability decides whether the flaw can matter to you, while EPSS and KEV decide how urgently attackers are likely to care.

Which VEX status and justification should I use for an unreachable vulnerability?

Use the not_affected status with the justification vulnerable_code_not_in_execute_path when your analysis found no call path to the vulnerable function. Use component_not_present or vulnerable_code_not_present when the component or symbol is absent from the build. Add an impact statement naming the method, entry points, and known blind spots, because the justification alone does not tell a reader how strong the evidence is.

Can dynamic or runtime reachability prove a vulnerability is not exploitable?

No. Runtime data can show that a vulnerable function was executed, which is strong evidence to escalate. It cannot prove a function never runs, because absence depends on workload coverage, and vulnerable parsers often sit on rare paths such as error handling or crafted input. Use runtime evidence to confirm and prioritize, and combine it with static analysis, rather than treating a quiet trace as proof of safety.

What does govulncheck do and how does it differ from a package-level scanner?

govulncheck is the Go project’s vulnerability scanner. Backed by the Go vulnerability database, it uses static analysis to report only vulnerabilities whose affected functions your code can call, and shows call stacks on request. A package-level scanner reports any dependency version in an affected range. Its binary mode reads symbol tables without source but gives no call stacks and may report unreachable code, and it can emit OpenVEX output.

Further Reading

By Riju — about

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *