Adjudicating dependency-CVE reachability: affected, or just noisy?
A scanner that lists every CVE in your dependency tree is measuring your
package.json, not your exposure. Most flagged CVEs are unreachable: you never
call the vulnerable function, or you call it in a way the bug does not trigger, or
no attacker-controlled input gets there. Adjudicating reachability turns a wall of
red into the short list that actually matters, and gives you a defensible reason
for each one you set aside.
When to use
- An SCA tool, dependency bot, or advisory flagged a CVE in a library you use.
- You are drowning in "critical" dependency alerts and need to triage by exposure.
- You must justify a "not affected" status (for an auditor, a VEX statement, a
customer) with evidence, not a guess.
- You are deciding whether an urgent patch is actually urgent for you.
Scope check
Authorized source only (your own application and the dependencies it ships). If
you can't name the authorization, stop.
The loop
Pin the vulnerable symbol. From the advisory and, crucially, the fix
commit, identify the exact vulnerable function(s) or sink in the dependency
and the affected version range. "The library is vulnerable" is not actionable;
"parse_untrusted() before v2.3 is vulnerable when called with X" is. Reading
the patch tells you precisely what is unsafe and under what condition.
Test call-path reachability. Does any path from your own entry points reach
that vulnerable function, directly or transitively? If nothing in your code (or
the code you actually invoke) calls it, you are not reachable through the static
graph. Record that, but note the blind spots below before you call it clean.
Check the trigger preconditions. Reaching the function is not enough. The
CVE usually needs a specific argument shape, a config flag enabled, a feature
in use, or a size or option the safe callers never pass. Confirm whether your
call sites actually satisfy the condition the fix now rejects. Many "reachable"
CVEs die here because you always call the safe overload or pass the safe flag.
Trace attacker control to the trigger. Reachable plus triggerable still
needs an attacker in the loop. Trace from your untrusted entry points to the
vulnerable argument. If only trusted, developer-supplied constants reach it,
the exposure is theoretical. If untrusted input reaches the dangerous argument
and satisfies the precondition, you have a real, reachable exposure.
Decide and record. Reachable + triggerable + attacker-controlled =
confirmed affected; set severity from real impact in your context, not the
advisory's generic score. Any leg failing = killed / not-affected, with the
exact reason (symbol never called / precondition never met / input not
attacker-controlled). Keep the not-affected record; the same CVE will be
re-flagged next scan and you should not re-triage from scratch.
Blind spots that turn "unreachable" into "unknown"
- Dynamic dispatch and reflection. A call made through configuration, a
plugin loader, dependency injection,
getattr/eval, or a virtual/interface
dispatch may not appear as a static edge. "No path" through one of these is
inconclusive; confirm by reading how the call is wired.
- Transitive and vendored copies. The vulnerable code may arrive through a
second dependency, a bundled/vendored copy, or a lockfile pin the manifest
hides. Resolve what you actually ship, not what the top-level manifest lists.
- Build-time vs run-time. A dev/build-only dependency has a different exposure
than one on the request path. Say which you are judging.
Worked example (a kill and a confirm)
Kill. CVE in a YAML library: full_load deserializes arbitrary objects.
Pinning the symbol and searching your call sites shows you only ever call
safe_load; full_load is never reached from your code. Killed /
not-affected, kill_reason = "vulnerable symbol full_load not on any call
path; only safe_load is used."
Confirm. CVE in an image-decoding library: a crafted file triggers a heap
overflow in decode_frame. Your avatar-upload handler passes user-uploaded
bytes straight into the library's decode entry, which reaches decode_frame,
and the affected version is the one you ship. Attacker controls the file.
Confirmed affected, high, remediation = upgrade past the fixed version or
gate uploads through a safe re-encoder.
Rationalizations to reject
- "It's rated critical, patch it now." → Critical to someone with a different
call graph. Adjudicate your reachability before you triage priority.
- "We import the library, so we're affected." → Importing is not calling. The
vulnerable symbol may never be on a path you execute.
- "It's reachable, so it's exploitable." → Not without the trigger precondition
and attacker-controlled input. Check both.
- "No static path, so we're clean." → Only if the call could not be made through
reflection, config, or a plugin. Confirm the blind spots.
Executing this in practice
You need to read the dependency's fix to pin the vulnerable symbol and condition,
a call graph that spans your code into the dependency (including transitive and
vendored code) to test reachability, and a way to trace untrusted input to the
trigger argument. A code property graph built over your app plus its resolved
dependencies answers all three; without one, you read the call sites and the
patch by hand. The verdict is yours; the graph only shows the paths.
Related
extracting-nday-from-a-patch - reading the dependency's fix to pin exactly
what is vulnerable and how it triggers.
adjudicating-taint-paths - tracing attacker control from your entry points to
the trigger argument.
- FINDING-SCHEMA.md - the shape every affected and
not-affected decision takes.
1---2name: adjudicating-dependency-cve-reachability3description: Decide whether a CVE in a dependency actually exposes your application before you scramble to patch: is the vulnerable function on a real call path from your code, do the trigger preconditions hold, and can an attacker control the input that reaches it. Use when an advisory, SCA scan, or dependency bot flags a CVE and you must separate a genuine exposure from unreachable noise, or justify why you are or are not affected. Covers pinning the vulnerable symbol, call-path reachability, precondition checks, and taint from your entry points.4license: MIT5---67# Adjudicating dependency-CVE reachability: affected, or just noisy?89A scanner that lists every CVE in your dependency tree is measuring your10`package.json`, not your exposure. Most flagged CVEs are unreachable: you never11call the vulnerable function, or you call it in a way the bug does not trigger, or12no attacker-controlled input gets there. Adjudicating reachability turns a wall of13red into the short list that actually matters, and gives you a defensible reason14for each one you set aside.1516## When to use1718- An SCA tool, dependency bot, or advisory flagged a CVE in a library you use.19- You are drowning in "critical" dependency alerts and need to triage by exposure.20- You must justify a "not affected" status (for an auditor, a VEX statement, a21 customer) with evidence, not a guess.22- You are deciding whether an urgent patch is actually urgent for *you*.2324## Scope check2526Authorized source only (your own application and the dependencies it ships). If27you can't name the authorization, stop.2829## The loop30311. **Pin the vulnerable symbol.** From the advisory and, crucially, the *fix32 commit*, identify the exact vulnerable function(s) or sink in the dependency33 and the affected version range. "The library is vulnerable" is not actionable;34 "`parse_untrusted()` before v2.3 is vulnerable when called with X" is. Reading35 the patch tells you precisely what is unsafe and under what condition.36372. **Test call-path reachability.** Does any path from your own entry points reach38 that vulnerable function, directly or transitively? If nothing in your code (or39 the code you actually invoke) calls it, you are not reachable through the static40 graph. Record that, but note the blind spots below before you call it clean.41423. **Check the trigger preconditions.** Reaching the function is not enough. The43 CVE usually needs a specific argument shape, a config flag enabled, a feature44 in use, or a size or option the safe callers never pass. Confirm whether your45 call sites actually satisfy the condition the fix now rejects. Many "reachable"46 CVEs die here because you always call the safe overload or pass the safe flag.47484. **Trace attacker control to the trigger.** Reachable plus triggerable still49 needs an attacker in the loop. Trace from your untrusted entry points to the50 vulnerable argument. If only trusted, developer-supplied constants reach it,51 the exposure is theoretical. If untrusted input reaches the dangerous argument52 and satisfies the precondition, you have a real, reachable exposure.53545. **Decide and record.** Reachable + triggerable + attacker-controlled =55 `confirmed` affected; set severity from real impact in your context, not the56 advisory's generic score. Any leg failing = `killed` / not-affected, with the57 exact reason (symbol never called / precondition never met / input not58 attacker-controlled). Keep the not-affected record; the same CVE will be59 re-flagged next scan and you should not re-triage from scratch.6061## Blind spots that turn "unreachable" into "unknown"6263- **Dynamic dispatch and reflection.** A call made through configuration, a64 plugin loader, dependency injection, `getattr`/`eval`, or a virtual/interface65 dispatch may not appear as a static edge. "No path" through one of these is66 inconclusive; confirm by reading how the call is wired.67- **Transitive and vendored copies.** The vulnerable code may arrive through a68 second dependency, a bundled/vendored copy, or a lockfile pin the manifest69 hides. Resolve what you actually ship, not what the top-level manifest lists.70- **Build-time vs run-time.** A dev/build-only dependency has a different exposure71 than one on the request path. Say which you are judging.7273## Worked example (a kill and a confirm)7475> **Kill.** CVE in a YAML library: `full_load` deserializes arbitrary objects.76> Pinning the symbol and searching your call sites shows you only ever call77> `safe_load`; `full_load` is never reached from your code. **Killed** /78> not-affected, `kill_reason` = "vulnerable symbol `full_load` not on any call79> path; only `safe_load` is used."80>81> **Confirm.** CVE in an image-decoding library: a crafted file triggers a heap82> overflow in `decode_frame`. Your avatar-upload handler passes user-uploaded83> bytes straight into the library's decode entry, which reaches `decode_frame`,84> and the affected version is the one you ship. Attacker controls the file.85> **Confirmed** affected, `high`, remediation = upgrade past the fixed version or86> gate uploads through a safe re-encoder.8788## Rationalizations to reject8990- *"It's rated critical, patch it now."* → Critical to someone with a different91 call graph. Adjudicate *your* reachability before you triage priority.92- *"We import the library, so we're affected."* → Importing is not calling. The93 vulnerable symbol may never be on a path you execute.94- *"It's reachable, so it's exploitable."* → Not without the trigger precondition95 and attacker-controlled input. Check both.96- *"No static path, so we're clean."* → Only if the call could not be made through97 reflection, config, or a plugin. Confirm the blind spots.9899## Executing this in practice100101You need to read the dependency's fix to pin the vulnerable symbol and condition,102a call graph that spans your code into the dependency (including transitive and103vendored code) to test reachability, and a way to trace untrusted input to the104trigger argument. A code property graph built over your app plus its resolved105dependencies answers all three; without one, you read the call sites and the106patch by hand. The verdict is yours; the graph only shows the paths.107108## Related109110- `extracting-nday-from-a-patch` - reading the dependency's fix to pin exactly111 what is vulnerable and how it triggers.112- `adjudicating-taint-paths` - tracing attacker control from your entry points to113 the trigger argument.114- [FINDING-SCHEMA.md](../../FINDING-SCHEMA.md) - the shape every affected and115 not-affected decision takes.