Per-CVE Reachability: Doing the Triage Without the Headcount

Attackers have stopped needing headcount to scale capability. Cheap, capable open-weight models let a small crew generate, adapt, and fire exploits at a rate that used to require a team — so a defense that scales by hiring more triage engineers is bringing a linear cost curve to an exponential fight, and losing. The only durable answer is to decouple security outcomes from headcount: automate the work end to end, with AI that is controlled and measurable — where every decision is bounded, on the record, and cheaper than the human hour it replaces.

Dependency triage is one of the clearest places to prove that, because it is where an enormous amount of skilled human effort currently goes to die. A security engineer we spoke with told us about a moment that captures it. Their scanner had done something genuinely useful: it grouped every CVE affecting a single package version into one finding, instead of firing a dozen separate alerts for the same upgrade. Good. Then they looked closer. The finding was labeled Informational, but one of the CVEs bundled inside it had a relatively high EPSS score — a real signal that exploitation is being observed in the wild. The label and the CVE seemed to flatly contradict each other. Was the tool broken? Was it hiding something?

Neither. The finding was labeled that way because that specific high-EPSS CVE was not reachable — the application never enters the code path that touches the vulnerable function. But nothing in the finding said so until someone from our team explained it. That gap — between "here is a package with vulnerabilities" and "here is what your code actually exposes" — is where most triage time goes to die, and it is exactly the kind of skilled, repetitive judgment that should be automated rather than staffed.

The bundling problem, and the rollup problem hiding inside it

Modern SCA tools have mostly figured out that firing one alert per CVE per package is noise. So they bundle: all the CVEs against some-package@1.24.1 collapse into a single finding with a single suggested upgrade. That is the right call for the queue. But it creates a second, subtler problem in how severity gets computed.

Take one finding we worked through: a Go 1.24.1 finding that bundles 32 CVEs in one package. Each of those CVEs typically affects a different function inside Go's standard library. The application calls a small subset of those functions. But the finding's severity label is the max across all 32 CVEs. So the finding pages someone on Critical — even when every Critical CVE in the bundle is for an encoding/asn1 code path the app never enters, and the only vulnerable function the app actually reaches is a Medium-severity bug in net/http.

The engineer who picks up that ticket does the work anyway. They read the advisory, they trace the imports, they figure out that the Critical is irrelevant and the real exposure is Medium. That is real, careful analysis — and the tool made them redo it by hand for a conclusion the tool could have reached itself. Multiply that across every bundled stdlib finding in every repo and you have the reason AppSec queues are where good engineers burn out.

The point is not that max-severity rollup is stupid. Absent any reachability data, taking the max is the only safe thing to do — you cannot under-report a Critical you have not ruled out. The problem is that the tool never tries to rule it out. As one of our engineers put it: the triage agent had been making the same worst-case call for every CVE under a finding, because with no scope or reachability data it had to assume worst-case. The result was a noisy queue, engineers triaging false positives, and autofix budget burned on vulnerabilities that were never shipping.

What "reachable" actually means, and why it needs two layers

Reachability is not one question. It is two, and they cost very different amounts to answer.

  • Is the package even imported? This is cheap and deterministic. You can parse the source tree and see whether import or require statements reference the package at all.
  • Is the specific vulnerable function called? This is the actual reachability analysis, and it is hard. It requires reading the advisory text, identifying which symbol is vulnerable, and walking the code with judgment about dynamic dispatch, callback registration, and framework-mediated calls.

We build the answer in that order, and the split is deliberate — it is the core of keeping the AI controlled and cheap. The deterministic layer does the filtering; the expensive AI layer only runs when the deterministic layer leaves something genuinely ambiguous.

For the first layer, we extract imported packages straight from the source AST using tree-sitter. The extractor walks every .js/.ts/.jsx/.tsx file — skipping vendored and test paths — and pulls the npm package names out of import, export, require, and dynamic import() nodes, with regex fallbacks because grammars model the import keyword inconsistently. It normalizes correctly, too: node: scheme imports and #-prefixed subpath imports (which always resolve intra-package) get stripped, scoped names like @aws-sdk/client-s3 are preserved, and lodash/fp collapses to lodash. There is even a fix for imports that resolve through tsconfig.json path aliases, because those point at internal source, and leaving them in would cause silent false-negative reachability. The same machinery exists for Rust, filtering stdlib crates and workspace-local references.

When the deterministic substrate gives a confident negative — the package simply is not imported anywhere — the AI subagent is skipped entirely. Every CVE under that finding gets an UNREACHED / HIGH-confidence / package-only verdict, at zero LLM cost. That is the discipline in miniature: the least AI token spend for the same correct answer, with the model reserved for the cases where its judgment actually earns its cost.

Being honest about what a "no" is worth

Here is the part that most reachability marketing skips. A positive signal and a negative signal are not equally trustworthy, and pretending they are is how you silently bury real vulnerabilities.

If tree-sitter finds Called=true, that proves an import edge exists in the source. HIGH confidence is appropriate. But Called=false is ambiguous. It might mean "we scanned the code and confirmed no static import." Or it might mean the scanner missed a dynamic import — require(varName), importlib.import_module, a plugin loader, framework auto-wiring, generated or transpiled code. Static analysis cannot see those.

So we deliberately downgrade negatives to MEDIUM confidence. A false is never trusted enough on its own to crush a finding's severity; the triage path still has to investigate before it can act on a "no." This asymmetry runs through the whole system. It is the difference between a tool that reduces your queue and a tool that quietly deletes findings you needed to see.

Where the AI spends its budget — and where it refuses to

For the CVEs that survive the cheap filter — package is imported, so the vulnerable symbol might be reached — we run a per-CVE reachability subagent (Haiku 4.5). It emits a structured verdict per CVE: reached, unreached, or unknown; a confidence level; and a method that records how it decided, ranked by evidentiary strength — an LSP reference to the symbol, a textual grep match, package-only import, or pure AI judgment. Every verdict carries an evidence string.

Two design choices keep the token spend honest:

  • We batch. One LLM call per finding examines all of that finding's CVEs together, because a 32-CVE Go stdlib finding shares almost all of its context — only the affected symbol differs across CVEs. Batching saves roughly 30x the prompt cost versus calling per CVE.
  • We built the tools the agent needs. Purpose-built tools — one that classifies a dependency's usage (imported in N files, called in N files, scope, entrypoint-reachable) in a single call, one that fetches the upstream fix commit to name patched functions when the advisory lacks that metadata, one that finds in-app callers of a symbol with vendored and test callers already filtered out — replace 15–20 exploratory tool calls per CVE with a short, purposeful sequence. Rather than a single global cap, each tool carries its own hard ceiling that scales with the number of CVEs on the finding: one fix-commit fetch per CVE, roughly three caller checks per CVE, and a small fixed budget for the package-level and SBOM lookups that only need to run once. The dispatcher enforces each ceiling independently so a runaway loop on one step can't burn the whole cost budget, and the workflow timeout scales up with CVE count too — a big 32-CVE stdlib finding gets far more headroom than a single-CVE one. Fewer wasted rounds, higher-confidence answers, less spend.

And when there is no data to reason over at all — the scanner never produced a reachability dossier because the build was unsupported or population failed — the agent does not spend money guessing. Every CVE gets UNKNOWN / LOW-confidence, synthesized for free. Spending real LLM budget to produce the same "unknown" verdicts we can write down for nothing would be exactly the waste we are trying to eliminate.

The rules that stop the AI from being wrong in a dangerous direction

Recomputing severity is where a reachability system either earns trust or destroys it. Ours is built so that every failure mode fails toward more attention, never toward silently dropping a finding.

  • Unknowns count as reached. If the engine cannot decide — an LSP parse error, missing advisory metadata, an AI timeout — the CVE keeps its full severity. Not deciding must never under-prioritize a real exploit.
  • Confidence sets how far a "no" can move a CVE. Only a high-confidence, symbol-level unreached demotes a CVE all the way toward Informational. A medium-confidence "no" demotes by a single band, and a low-confidence one leaves the severity untouched.
  • A flaky tool run cannot bury a finding. A dedicated reconciliation step refuses to let a low-fidelity per-CVE unreached override the scanner's authoritative HIGH-confidence package-level "this dependency is used" signal. When the symbol analysis did not genuinely run and prove absence — an unbuildable repo, a missing lockfile, a gopls timeout — the unreached is demoted to unknown, which the severity math treats as fully reachable. Without this guardrail, one flaky language-server run would silently reclassify a reachable vulnerability as a false positive.
  • Known-exploited vulnerabilities have a floor. If a CVE is on CISA's Known Exploited Vulnerabilities list, static symbol reachability — which can miss reflection, dynamic dispatch, and build-time codegen — is never trusted enough to crush it to Informational. The strongest downgrade a KEV can receive is a single band, keeping it visible and actionable no matter what the reachability evidence says.
  • No CVE can disappear by accident. A deterministic backstop forces exactly one verdict for every CVE the finding actually contains. If the model omits a CVE, we synthesize an UNKNOWN for it. If the model hallucinates a CVE that does not belong to the finding, we drop the hallucination, not the real one.

We learned the importance of this the direct way. An internal audit of an earlier iteration caught the exact inverse failure — three genuinely vulnerable pinned dependencies were auto-marked false-positive because reachability was being treated as a suppression signal rather than a prioritization signal. We fixed it so that high-band CVEs are never auto-dismissed on reachability alone. Reachability retriages. It does not suppress.

Nothing is hidden — it is correctly ranked, and you can see why

This is the load-bearing distinction, so we will be blunt about it: per-CVE reachability does not remove findings. It reprioritizes them.

Every per-CVE verdict is persisted on the CVE row in Postgres — confidence, method, and evidence columns alongside the reachability flag — not held in transient agent state. The individual CVE record and its true CVSS severity are never deleted or altered. What changes is the finding-level rollup: instead of blindly taking the max across all 32 CVEs, the recomputed severity takes the max across the CVEs that are reached or unknown.

To make the rollup math concrete, here is how the 32 CVEs in a bundle like that sort into buckets:

  • 3 CVEs are confirmed reachable with high confidence — severity unchanged.
  • 21 CVEs are provably unreached with high confidence, symbol-level — demoted all the way to Informational for the rollup.
  • 6 CVEs are unreached with only medium confidence — demoted a single band, not to Informational, because a "no" we are not sure of does not get to crush a CVE.
  • 2 CVEs are genuinely unknown — left unchanged, counted as reached, conservatively.

So 21 of the 32 CVEs drop to Informational for the purposes of that rollup, 6 move down one band, and the rollup recomputes off the CVEs that were genuinely reached — the 3 reachable ones being the Medium-severity net/http bug, so the finding correctly lands at Medium rather than the original max-rollup Critical. We do not hide anything. We stop the irrelevant CVEs from drowning the real signal, and we let confidence, not optimism, decide how far each one moves.

And the recomputation is auditable end to end. For every CVE judged reachable, the system builds an evidence graph node for each distinct in-app call site, with a CAN_REACH edge to the finding and a human-readable explanation naming the vulnerable symbol and the CVE. You do not get a mysterious new number; you get the concrete file-and-line call sites that justify it, rendered in the platform alongside the rest of the code-context evidence. When we roll this out, we watch the recomputed severity and priority distributions against the pre-change baseline on the same tenant — so the effect of the feature is itself measured, not asserted.

The severity math is intentionally asymmetric in one more way that matters for trust. Triage-proven severity can rise above the classifier's original draft — if we prove a specific CVE is reachable at Critical, that is new ground truth the classifier did not have. But priority, which drives who gets paged, stays capped at the draft. We never let a reachability judgment silently page someone out of band.

The lever is yours

The whole pipeline is gated behind a feature flag that defaults off. It rolls out on a per-stack canary before going global, and can be flipped back without losing the reachability data already collected — so a team can turn the severity math off and keep the evidence, or turn it on once they trust it, without re-running anything. And because the AI only ever adjudicates the genuinely ambiguous CVEs — the deterministic substrate settles the clear-cut ones for free — a security engineer who wants to review a specific verdict has the full evidence chain in front of them, not a black box to argue with.

That is the shape of the whole thing, and it is the shape of the larger bet: outcomes decoupled from headcount. The default is autonomous efficiency — the least human triage time and the least AI token spend to get the queue correctly prioritized. The override is always available. And every decision the system made is on the record, with the call sites to back it up. A CVE that is present but unreachable is still tracked, still queryable, still on the row where you left it — it is just no longer the reason a good engineer spent an afternoon confirming what the tool already knew.

Book a demo