Triage dependency vulnerability alerts by reachability, not CVSS alone
For developers drowning in dependency alerts, this guide shows how to decide what to fix first using reachability and exploitability instead of severity alone. You’ll get a concrete workflow, CI commands, policy examples, and the edge cases that matter in real codebases.
TL;DR — A critical CVE in a dependency is not automatically your top priority. Triage alerts by asking two concrete questions: can your code actually reach the vulnerable function, and is there a realistic path for an attacker to exploit it in your runtime and deployment context? Start by failing CI only for vulnerabilities that are both reachable and exploitable, while tracking the rest with time-boxed exceptions. Reading time: ~7 min
What it is and where it sits
This is a dependency-vulnerability triage approach for CI/CD and release engineering. Instead of sorting alerts by CVSS severity alone, you add two filters:
- Reachability: does your application actually call, import, instantiate, or otherwise execute the vulnerable code path?
- Exploitability: given your runtime, configuration, network exposure, privileges, and input paths, can an attacker realistically trigger it?
In a typical pipeline, this sits between your dependency scanner and your policy gate. It does not replace SBOM generation, SCA scanning, or patching. It replaces the simplistic rule of "block every high/critical" with a more useful rule like "block reachable vulnerabilities exposed to untrusted input; ticket the rest."
What talks to it:
- package managers and lockfiles:
package-lock.json,pnpm-lock.yaml,poetry.lock,pom.xml,go.sum - SCA scanners:
npm audit,pip-audit,osv-scanner, Trivy, Grype, Dependabot-style alerts - build artifacts: JARs, wheels, containers, bundles
- static analysis / call graph tools: language-specific or scanner-provided reachability analysis
- policy engine in CI: shell scripts, GitHub Actions, GitLab CI, Jenkins, OPA/Conftest, custom scripts
What it replaces:
- blanket fail-on-severity thresholds like "fail if CVSS >= 7"
- manual triage based only on alert count and package popularity
- endless patch churn for dev-only or unreachable dependencies while exposed issues wait
Where it lives in the flow:
View as text
[Developer] ---- 1. push code ----> [CI Pipeline] [CI Pipeline] ---- 2. build SBOM/lockfile ----> [SCA Scanner] [SCA Scanner] ---- 3. CVEs + package paths ----> [Reachability Check] [Reachability Check] ---- 4. reachable findings ----> [Exploitability Policy] [Exploitability Policy] ---- 5. block or allow ----> [Merge Gate] [Merge Gate] ---- 6. pass build ----> [Artifact Registry] [Exploitability Policy] ---- 7. create ticket/waiver ----> [Issue Tracker]
The architecture point: severity is metadata from the vulnerability database. Reachability and exploitability are properties of your code and environment. That is why they belong in your pipeline, not only in the scanner’s default output.
How it actually works
Use one realistic example: a Node.js API service gets an alert for a vulnerable transitive dependency used by a JSON schema validator. The scanner marks it critical because the package has a prototype pollution or ReDoS CVE. Your job is to determine whether this service can actually hit the vulnerable function with attacker-controlled input.
End-to-end example
Assume your repo has this dependency tree:
npm ls --all vulnerable-lib
Example output shape:
orders-api@1.18.0 /workspace/orders-api
└─┬ ajv@8.17.1
└── vulnerable-lib@2.4.0
Your scanner reports something like:
osv-scanner --lockfile=package-lock.json --format=json > osv.json
jq '.results[0].packages[] | select(.name=="vulnerable-lib")' osv.json
Output shape:
{
"name": "vulnerable-lib",
"version": "2.4.0",
"vulnerabilities": [
{
"id": "GHSA-xxxx-yyyy-zzzz",
"summary": "Prototype pollution via unsafe merge",
"severity": [{"type":"CVSS_V3","score":"9.8"}]
}
]
}
At this point, severity alone says "drop everything." Don’t stop there.
Step 1: identify the exact vulnerable symbol or behavior
Read the advisory and patch diff. You are looking for the concrete entry point: function name, class, parser mode, CLI flag, deserialization path, or regex pattern.
Typical notes you want to extract into your triage comment:
- vulnerable function:
merge(target, source) - fixed in:
>=2.4.3 - exploit preconditions: attacker controls object keys including
__proto__ - affected usage: only when called on untrusted JSON body before validation
If the advisory does not name the symbol, inspect the patch:
git clone https://github.com/upstream/vulnerable-lib.git
cd vulnerable-lib
git log --oneline --grep='GHSA-xxxx-yyyy-zzzz'
git show <fix-commit>
Step 2: test reachability in your codebase
Search for the package and the vulnerable API usage, not just the package name.
rg "from 'ajv'|require\('ajv'\)|merge\(" src test
Suppose you find:
import Ajv from "ajv";
const ajv = new Ajv({allErrors: true});
app.post("/orders/search", async (req, res) => {
const valid = ajv.validate(searchSchema, req.body);
if (!valid) return res.status(400).json(ajv.errors);
return res.json(await searchOrders(req.body));
});
This tells you the dependency chain is loaded on a public route with attacker-controlled JSON. That is already stronger evidence than a CVSS score.
Now verify whether the vulnerable helper is actually in the runtime path. In JS, precise static reachability is hard because of dynamic dispatch, but you can still gather useful evidence:
- package is in the production dependency tree, not only
devDependencies - route imports the parent package in production code
- the vulnerable code path is exercised by schema validation on request bodies
You can confirm production installation shape with:
npm ci --omit=dev
npm ls --omit=dev vulnerable-lib
If output is empty under --omit=dev, the finding is likely not reachable in production builds.
Step 3: test exploitability in your runtime context
Now ask whether an attacker can satisfy the preconditions.
For this example:
- Is the route internet-exposed or only internal?
- Does it accept arbitrary JSON from users?
- Is there a WAF/body filter that strips dangerous keys? Usually no.
- Does the process run with privileges that make impact meaningful?
- Is the vulnerable behavior blocked by your own validation before the library call? Here, no: the library call is the validator.
Run a proof-oriented test against a local environment. Don’t jump straight to production.
curl -i http://localhost:3000/orders/search \
-H 'Content-Type: application/json' \
--data '{"__proto__":{"polluted":"yes"},"status":"open"}'
Possible output shape if validation path is hit:
HTTP/1.1 500 Internal Server Error
Content-Type: application/json; charset=utf-8
Content-Length: 54
{"error":"Cannot read properties of undefined (...)"}
Or a successful exploit test in a unit/integration harness may show polluted objects after request handling. If you can trigger the vulnerable behavior with untrusted input on a reachable route, this finding is both reachable and exploitable. Block the merge and patch or mitigate immediately.
Step 4: classify and act
A practical classification model:
- Reachable + exploitable: fail CI now
- Reachable + not currently exploitable: patch soon, but waiver allowed with documented control
- Not reachable in production: do not fail release; create backlog item or suppress with expiry
- Unknown: treat as reachable until proven otherwise for internet-facing apps
A CI gate often boils down to checking scanner output plus a reviewed allowlist. Example failure message shape you want in logs:
Policy check failed:
- GHSA-xxxx-yyyy-zzzz in vulnerable-lib@2.4.0
severity: CRITICAL (9.8)
reachability: reachable via src/routes/orders.js -> ajv.validate()
exploitability: attacker-controlled POST /orders/search JSON body
action: BLOCK
That message is actionable. "Found 37 high vulnerabilities" is not.
When to use it (and when not to)
Use this when alert volume is high enough that severity-only gating creates noise, patch churn, and developer distrust.
| Scenario | Recommendation |
|---|---|
| Internet-facing app with many transitive dependencies and frequent alerts | Use reachability + exploitability gating in CI. Keep a short SLA for reachable findings. |
| Internal service with strong network isolation but lots of scanner noise | Use it, but document the network assumptions in the waiver. Re-check on topology changes. |
| Regulated environment where policy mandates patching all known vulns | Still use reachability for prioritization, but not as the sole reason to suppress remediation. |
| Small app with 10 direct deps and rare alerts | You probably don’t need a full triage system; patch directly and move on. |
| Library/package published for others to consume | Be careful: your code may not reach the vuln, but your consumers might. Patch more aggressively. |
| Safety-critical or high-assurance systems | Do not rely on reachability alone; combine with patching, sandboxing, and compensating controls. |
You probably don’t need this if your dependency graph is tiny, your releases are infrequent, and your team can patch every alert within days without disrupting delivery.
Trade-offs
Every benefit costs something.
-
Benefit: fewer false-priority incidents
Cost: you need engineering time to inspect advisories, code paths, and runtime assumptions. -
Benefit: CI failures become meaningful
Cost: you must maintain policy files, suppression expiries, and evidence for waivers. -
Benefit: developers trust security gates more
Cost: the process is more complex than--severity-threshold=highand harder to explain to auditors unless documented well. -
Benefit: focus patching on actually exposed risk
Cost: reachability analysis is imperfect in dynamic languages, reflection-heavy Java, plugin systems, code generation, and optional imports. -
Benefit: less emergency upgrading of irrelevant transitive deps
Cost: you may accept latent risk if your assumptions are wrong, especially after config drift, new routes, feature flags, or network exposure changes. -
Benefit: better release throughput
Cost: some teams become overconfident and suppress too much. Put expiries on exceptions and re-evaluate after dependency or architecture changes.
In practice
Example 1: CI script that blocks only reviewed reachable findings
#!/usr/bin/env bash
set -euo pipefail
osv-scanner --lockfile=package-lock.json --format=json > osv.json
jq -r '
.results[].packages[]?
| . as $pkg
| .vulnerabilities[]?
| [ .id, $pkg.name, $pkg.version ] | @tsv
' osv.json > findings.tsv
blocked=0
while IFS=$'\t' read -r vuln_id pkg ver; do
if jq -e --arg id "$vuln_id" '.reachable[$id] == true' triage.json >/dev/null; then
if ! jq -e --arg id "$vuln_id" '.waived[$id].expires > now | not' triage.json >/dev/null; then
echo "BLOCK $vuln_id $pkg@$ver is reachable and has no valid waiver"
blocked=1
fi
fi
done < findings.tsv
exit "$blocked"
This scans a lockfile, reads a local triage.json, and exits non-zero only for findings marked reachable without a valid waiver. Gotcha: jq date handling is easy to get wrong; store waiver expiries as Unix epoch seconds or compare ISO timestamps consistently in a separate script.
A matching triage.json shape:
{
"reachable": {
"GHSA-xxxx-yyyy-zzzz": true,
"GHSA-abcd-efgh-ijkl": false
},
"waived": {
"GHSA-xxxx-yyyy-zzzz": {
"reason": "Patch scheduled in next sprint after framework compatibility test",
"expires": 1796083200,
"owner": "orders-team"
}
}
}
Example 2: npm commands to separate prod-only from dev/test-only findings
npm ci
npm audit --json > audit-all.json
npm ci --omit=dev
npm audit --omit=dev --json > audit-prod.json
jq '.metadata.vulnerabilities' audit-all.json
jq '.metadata.vulnerabilities' audit-prod.json
This compares the full dependency tree with the production-only tree so you can stop treating test tooling issues as release blockers. Gotcha: some build systems bundle dev-time code into production artifacts indirectly; if you transpile, bundle, or copy assets in custom scripts, verify the final artifact rather than trusting dependency scopes blindly.
Example 3: container image scan after build, not just lockfile scan
docker build -t orders-api:ci .
trivy image --severity HIGH,CRITICAL --format json orders-api:ci > trivy-image.json
jq '.Results[] | {Target, Type, Vulnerabilities: [.Vulnerabilities[]? | {VulnerabilityID, PkgName, InstalledVersion} ]}' trivy-image.json
This catches OS package vulnerabilities and runtime-installed libraries that lockfile-only scanning misses. Gotcha: image scans report packages that may be present but unused; apply the same reachability/exploitability thinking, especially for shell tools and dormant system libraries in base images.
⚠️ If you add automatic suppressions, do not create permanent waivers by default. Use expiries, require an owner, and re-run triage after dependency upgrades, route changes, auth changes, or network exposure changes. A stale waiver can hide a newly exploitable path.
Further reading
- OWASP Dependency-Check documentation
- OSV-Scanner documentation
- NIST SP 800-204D
- OWASP Vulnerable Dependency Management Cheat Sheet
- The "Software Composition Analysis" sections of vendor-neutral DevSecOps platform docs
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI