Dependency Vulnerabilities
Package CVE management. Lockfile hygiene. Dependabot vs Renovate. Patch fatigue.
Dependency Vulnerabilities
A modern Node app installs roughly 1,000 transitive packages on npm install. A modern Python app, a few hundred. A modern Go app, dozens. Each one can have a published CVE that affects you. Each one's CVE rating is approximate, often inflated, and rarely matches your actual exposure. This is the dependency-vulnerability problem — and most teams are doing it badly.
The shape of the problem
You add express to your app. express depends on body-parser, cookie, qs, etc. Each of those depends on more packages. Within a year, one of those transitive packages has a CVE. You probably never knew it was in your tree.
The default workflow for handling this:
- Get a CVE alert from Dependabot / Renovate / Snyk / npm audit.
- Look at the severity number.
- Either: ignore it (because it's "just a dev dep" or "we don't use that part" or "we have other priorities"); or merge the auto-PR; or panic-patch.
- Repeat 2-5 times a week, forever.
This produces patch fatigue — the team becomes desensitized to CVE alerts, real ones get missed, and the inbox of pending Dependabot PRs grows to hundreds. Most large engineering orgs are in this state. The good news: the fix is process, not technology.
Lockfiles, again
Before any CVE management, you need reproducible builds. That means a committed lockfile:
package-lock.json(npm),yarn.lock,pnpm-lock.yamlPipfile.lock,poetry.lockCargo.lock,go.sum
The lockfile pins exact versions of every dependency, transitive included, with content hashes. CI installs from the lockfile only:
npm ci # not npm install
yarn install --frozen-lockfile
pnpm install --frozen-lockfile
pip install --no-deps -r requirements-locked.txt
Without this, your builds are non-deterministic and CVE attribution is impossible. Whoever ran npm install last got a slightly different tree.
The pinning spectrum
A common debate:
- Pin everything to exact versions (
"express": "4.18.2"): builds are deterministic, but you accumulate security debt; old versions stop receiving CVE fixes; you're always behind. - Float everything to latest (
"express": "*"or^4.0.0): you get the latest fixes, but builds can break overnight when a new minor releases with subtle bugs.
Neither alone is right. The real answer:
- package.json: float to caret ranges (
"express": "^4.18.2") so npm picks the latest compatible. - Lockfile: pinned exact versions, deterministic.
- Dependabot/Renovate: bumps the lockfile periodically, opens PRs, runs CI, lets you review.
That's the pattern. Range in package.json, exact in lockfile, automated bumps through CI.
Dependabot vs Renovate
Both do the same job — open PRs that bump dependencies — but with different opinions:
| Feature | Dependabot | Renovate |
|---|---|---|
| Hosting | GitHub native | Self-host or Mend.io hosted |
| Config | .github/dependabot.yml |
renovate.json |
| Grouping | Limited (groups) | Granular (packageRules) |
| Scheduling | Coarse | Per-rule, time-windowed |
| Auto-merge | GitHub Auto-merge feature | Built-in, configurable per rule |
| Languages | Most major ecosystems | More |
| Custom rules | Limited | Extensive |
Pick one. Dependabot is fine if you want simple and GitHub-native. Renovate is better if you want to group all patch bumps into one PR, defer minors to a "renovation Tuesday," or auto-merge specific package patterns.
A reasonable starting Dependabot config:
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule: { interval: "weekly" }
open-pull-requests-limit: 10
groups:
patches:
update-types: ["patch"]
minors:
update-types: ["minor"]
This produces 2-3 PRs a week (one for patches, one for minors, occasional majors), runs your CI, and lets you human-review them.
Triaging by reachability — the patch-fatigue cure
Most "critical" CVEs in your dependency tree don't actually affect you. The CVE was for a code path you don't call, or in a sub-library only used in dev mode, or in a test fixture. Severity alone is a noisy signal.
Reachability analysis asks: does the CVE-affected function actually get called from my application code? Tools that do this:
- Snyk Code — call-graph analysis across your project + dependencies.
- Endor Labs — similar; positions itself as "dependency-CVE noise reduction."
- Socket.dev — behaviour-based: flags packages with newly-introduced network calls, install scripts, etc.
Without reachability, you have 200 open Dependabot PRs and no idea which 3 actually matter. With reachability, you have ~5 PRs marked "actually exploitable" and the rest are "fix on the next regular update cycle."
Even without paid tooling, you can do basic reachability manually for the top ~10 alerts: read the CVE, find the affected function, grep your codebase for usage. Most of the time it's a 5-minute "we don't call that" check.
Auto-merge — what to automate, what not to
A reasonable default policy:
PATCH versions of trusted packages → auto-merge after CI passes
MINOR versions → auto-merge if no new security advisories; otherwise human-review
MAJOR versions → human review, always
SECURITY advisories → auto-merge after CI; alert on-call if CI fails
For "trusted packages": packages your team has installed and used for >6 months without incident. New packages get a 30-day cooldown — let the broader community find the regression first.
Renovate makes this trivially configurable. Dependabot needs a small GitHub Actions workflow plus the automerge setting on the PR.
What about transitive vulnerabilities?
When a CVE lands in a deeply-transitive dep, you can't always update it directly. Options:
- Wait for the parent to update — open an issue upstream.
- Override the version in your own package.json:
{
"dependencies": { "express": "^4.18.2" },
"overrides": {
"express": {
"vulnerable-transitive": "^2.0.5"
}
}
}
Or resolutions in yarn classic, pnpm.overrides in pnpm. Use sparingly — overrides drift from upstream over time.
- Replace the dependency entirely if it's chronically problematic. moment.js → date-fns / dayjs. request → undici / axios. node-sass → sass.
What "right" looks like
Operational baseline:
☐ Lockfile committed for every project
☐ CI uses lockfile-only install (npm ci / yarn --frozen-lockfile)
☐ Dependabot or Renovate enabled
☐ Auto-merge policy: patch automatic, minor reviewed, major manual
☐ Patch SLA: critical → 24h, high → 72h, medium → 1 week, low → next sprint
☐ SBOM generated per release
☐ npm audit / equivalent runs in CI; non-zero exit blocks merge for criticals
☐ Reachability tool in place (Snyk / Endor / manual triage of top alerts)
Cultural baseline:
☐ "PR is not done until CI is green" enforced
☐ Quarterly dependency-aging review for outdated transitives
☐ Annual "kill the dependency" exercise for chronically problematic packages
Get this in place once and most of A06 (Vulnerable & Outdated Components) goes from "ongoing fire" to "background noise we manage."
What goes wrong (real)
- Equifax 2017 ($1.4B settlement): unpatched Apache Struts CVE-2017-5638 with a fix available for 2 months before exploitation. The team had patches in their backlog; nobody prioritized them.
- Tens of npm worm incidents 2017-2025: malicious typosquats and account-takeovers using old/abandoned packages.
- log4shell (Dec 2021): CVE-2021-44228 in log4j. Teams without SBOMs spent days grepping build logs to find affected services. Teams with SBOMs found and patched in hours.
The unifying theme: it's not the alerts you read that get you, it's the ones you didn't read. Automate the boring updates so the alerts that come through represent actual decisions worth making.
A note on pinning fragility
"express": "^4.18.2" allows 4.18.3, 4.19.0, ...4.99.0 — anything below 5.0.0. That's your security update path. If you pin to exact "express": "4.18.2", you don't get 4.18.3's security fix until you manually bump.
Caret ranges in package.json + lockfile pinning is the right shape. Don't pin exactly in package.json — that's the road to security-debt accumulation.
Closing thought
Modern software security is mostly other people's security. Your own code matters; the 1,000 packages you depend on matter more. Treat the dependency tree as part of your attack surface, automate the boring updates, triage the rest by reachability, and stop debating exact-vs-floating versions — the answer is "both, in their proper places."
Tools in the wild
6 tools- serviceGitHub Dependabotfree tier
Automated CVE-aware dependency PRs; native to GitHub.
- serviceRenovatefree tier
More-flexible dependency updater; groupable, schedulable, auto-merge for patches.
- service
CVE scanner + reachability analysis to cut false positives.
- clinpm audit / pnpm audit / yarn auditfree tier
Built-in vulnerability scanner driven by the package manager.
- serviceOSV.devfree tier
Open vulnerability database aggregator (npm, PyPI, Go, Rust, etc).
- serviceGitHub Security Advisoriesfree tier
Centralized advisory list integrated with Dependabot.