CVE Tracking
Track CVEs that affect your dependencies, map them to your exposure, and verify fixes land in your lockfile.
Why
A CVE in a dependency is a vulnerability in your software — you ship that code, you run it with your privileges, and you're responsible for the fix. But you can't fix what you don't track, and the firehose of CVEs (thousands per year) makes manual tracking impossible. Automated tracking — OSV-Scanner / Snyk / GitHub Dependabot alerts in CI — converts the firehose into a triaged list: which CVEs affect your exact lockfile, which are exploitable in your config, which are already fixed in your installed version. The key is mapping each CVE to your actual exposure, because most CVEs in your tree don't affect you (wrong code path, mitigated by your config, already patched transitively). Tracking + exposure mapping is what makes CVE response manageable.
When
Continuously in CI — scan every PR and every daily build. Triage new alerts within 48h.
How
1. Automate scanning in CI: OSV-Scanner or Snyk on every PR + a daily scheduled run 2. For each alert, map to exposure: is the vulnerable function called in your code? is the exploit condition met by your config? 3. Triage: exploitable -> P0/P1 (fix now) | not-exploitable-but-fix-available -> P2 (fix next release) | not-applicable -> dismiss with a written reason 4. Fix by upgrading the dep to a patched version; verify the fix is in the lockfile (npm ci uses the new version) 5. If no fix is available yet: apply a workaround (e.g., disable the affected feature) or pin to a safe transitive version 6. Track a CVE dashboard: CVE, dep, severity, exploitable-in-our-config?, fix version, status 7. Review dismissed CVEs quarterly — a "not applicable" can become applicable when your config changes
Example
# CI CVE scan (GitHub Actions, on every PR + daily)
on: [pull_request, schedule: [{ cron: '0 6 * * *' }]]
jobs:
cve-scan:
steps:
- run: npx @google/osv-scanner --lockfile package-lock.json --format json > osv.json
- run: node scripts/triage-cves.js osv.json # maps to exposure, assigns priority
# triage-cves.js (exposure mapping)
for (const vuln of osvResults) {
const isCalled = await grepCodebase(vuln.affectedFunction);
const isExploitable = isCalled && configMeets(vuln.exploitCondition);
vuln.priority = isExploitable ? 'P0' : 'P2';
vuln.reason = isExploitable ? 'exploitable' : `not called / mitigated by ${config}`;
}
# CVE dashboard
| CVE | Dep | CVSS | Called? | Exploitable? | Fix ver | Status |
|----------------|-----------|------|---------|--------------|---------|--------|
| CVE-2024-1234 | lodash | 7.5 | yes | yes | 4.17.21 | fixed |
| CVE-2024-5678 | minimist | 5.3 | no | no | 1.2.6 | dismiss|
| CVE-2024-9999 | express | 9.8 | yes | yes | (none) | workaround|
# Fix (upgrade the dep)
npm install lodash@4.17.21
# verify the lockfile has the patched version
npm ls lodash # -> 4.17.21Common Problems
- ⚠Scanning but not mapping to exposure — you fix 47 CVEs, 40 of which don't affect your code path, wasting effort and shipping unneeded upgrades.
- ⚠Dismissing a CVE as "not applicable" without a written reason — when the config changes, the CVE becomes applicable and nobody remembers why it was dismissed.
- ⚠Fixing the dep in package.json but the lockfile still has the old version — npm install didn't run, so the fix never reaches production.
Best Practices
- ✓Map every CVE to your actual exposure (is the function called? is the exploit condition met?) before fixing — most CVEs in your tree don't affect you.
- ✓Record a written reason for every dismissed CVE — "not applicable because X" lets a future reviewer re-check when the config changes.
- ✓Verify the fix is in the lockfile (npm ls <dep>) after upgrading — a package.json change without a lockfile update never reaches production.