Scanning package-lock.json for known vulnerabilities: npm audit, OSV.dev, and the gaps in both
npm audit is free and built in, and teams still miss vulnerabilities with it, or drown in 400 reports about dev dependencies. This is the pipeline we run ourselves: parse the lockfile into an exact list, batch-query OSV.dev, separate dev from production dependencies, and keep the output per week so you can say since when a CVE has been open.
Contents
- Step 1: what npm audit does and doesn't do
- Step 2: parse the lockfile into an exact list
- Step 3: query OSV.dev in batches of 900
- Step 4: from advisory to CVE, severity and fix version
- Step 5: what really counts, reachability and EPSS
- Step 6: weekly, with history
- Pitfalls
- What you still don't have
- How monsys does it
- FAQ
A medium-sized Next.js project has 500 to 1,500 packages in its package-lock.json, twenty of which you chose yourself. The other thousand are transitive dependencies nobody has ever looked at. That's where the vulnerabilities are, and that's also where the noise is, because half of them only run during npm run build and never touch a production server. This article builds a scan that knows the difference.
Step 1: what npm audit does and doesn't do
cd /srv/app
npm audit --omit=dev # production dependencies only
npm audit --omit=dev --json | jq '.metadata.vulnerabilities'
# { "info": 0, "low": 0, "moderate": 0, "high": 3, "critical": 1, "total": 4 }
npm audit --omit=dev --json | jq -r '.vulnerabilities | to_entries[] | "\(.value.severity)\t\(.key)\t\(.value.range)\tfixAvailable=\(.value.fixAvailable != false)"' | sort
npm audit queries the GitHub Advisory Database (via the npm registry) and is fine as a first check. Three limitations:
- It reports at advisory level (GHSA), not always with a CVE id, and the severity is the advisory's: not weighted for your usage.
- It only sees what's in your lockfile. A package that arrives via a Docker image, or that
npxfetches on the fly, falls outside. fixAvailable: falsemeans there's no semver-compatible upgrade; that's usually where the work starts, not where it stops.
For a second source with CVE ids, fix versions and a stable API: OSV.dev.
Step 2: parse the lockfile into an exact list
Lockfile v2/v3 (npm ≥ 7) has a packages object with the exact version per installed path and a dev flag. That's all you need:
# Production dependencies: name version
jq -r '.packages | to_entries[] | select(.key != "" and (.value.dev // false | not))
| "\(.key | sub("^.*node_modules/"; "")) \(.value.version)"' package-lock.json | sort -u > /tmp/prod.txt
# Dev dependencies separately (build tooling: relevant for your CI runner, not for production)
jq -r '.packages | to_entries[] | select(.key != "" and (.value.dev // false))
| "\(.key | sub("^.*node_modules/"; "")) \(.value.version)"' package-lock.json | sort -u > /tmp/dev.txt
wc -l /tmp/prod.txt /tmp/dev.txt
The sub("^.*node_modules/"; "") reduces nested paths (node_modules/a/node_modules/b) to the package name; sort -u deduplicates the same version nested multiple times. Different versions of the same package stay separate: rightly so, because they're vulnerable separately.
Step 3: query OSV.dev in batches of 900
The same endpoint as for OS packages, with ecosystem npm:
sudo apt install -y jq curl
scan() { # scan <list> <output.jsonl>
: > "$2"; split -l 900 "$1" /tmp/npmbatch.
for f in /tmp/npmbatch.*; do
jq -Rn '{queries: [inputs | split(" ") | {package: {name: .[0], ecosystem: "npm"}, version: .[1]}]}' "$f" \
| curl -s -m 60 -X POST https://api.osv.dev/v1/querybatch -H 'Content-Type: application/json' -d @- \
| jq -c --slurpfile q <(jq -Rn '[inputs | split(" ")]' "$f") '
.results | to_entries[] | select(.value.vulns != null)
| {pkg: $q[0][.key][0], version: $q[0][.key][1], vulns: [.value.vulns[].id]}' >> "$2"
done; rm -f /tmp/npmbatch.*
}
scan /tmp/prod.txt /tmp/osv-prod.jsonl
scan /tmp/dev.txt /tmp/osv-dev.jsonl
jq -r '"\(.pkg)@\(.version): \(.vulns|join(", "))"' /tmp/osv-prod.jsonl
# js-yaml@4.3.0: GHSA-2883-xcg3-v3hh, GHSA-5p4m-2wfm-xmqj
# nanoid@3.3.15: GHSA-28wg-ghj8-5hjv, GHSA-2v37-7h3g-55p8
Step 4: from advisory to CVE, severity and fix version
: > /tmp/osv-detail.jsonl
for id in $(jq -r '.vulns[]' /tmp/osv-prod.jsonl | sort -u); do
curl -s "https://api.osv.dev/v1/vulns/$id" | jq -c '{
id,
cve: ((.aliases // []) | map(select(startswith("CVE-"))) | first),
severity: (.database_specific.severity // "unknown"),
fixed: ([.affected[] | select(.package.ecosystem=="npm") | .ranges[]?.events[]? | .fixed? // empty] | first),
summary }' >> /tmp/osv-detail.jsonl
sleep 0.1
done
jq -r '[.id, (.cve // "-"), .severity, (.fixed // "no fix"), .summary[0:60]] | @tsv' /tmp/osv-detail.jsonl | column -t -s $'\t'
The fixed column is your work list. If there's a version: npm install pkg@^version or, for a transitive dependency, an overrides block in package.json:
{
"overrides": {
"js-yaml": ">=4.3.1",
"nanoid": ">=3.3.16"
}
}
Then npm install, npm test, and scan again. An override that breaks the build signals that the upgrade contains a breaking change: then it's a ticket, not a commit.
Step 5: what really counts, reachability and EPSS
A vulnerability in a package that's in your lockfile isn't automatically a vulnerability in your application. Two filters:
# 1. Is the package loaded at all? Look for imports in your own code.
for p in $(jq -r '.pkg' /tmp/osv-prod.jsonl | sort -u); do
n=$(grep -rlE "from ['\"]$p['\"/]|require\(['\"]$p['\"/]" src/ 2>/dev/null | wc -l)
printf '%-30s direct imports in src/: %s\n' "$p" "$n"
done
# 0 = transitive only; then the package importing it determines whether the vulnerable code is reachable
# 2. EPSS: probability of exploitation within 30 days (see the OS package guide for the batch version)
jq -r '.cve // empty' /tmp/osv-detail.jsonl | sort -u | head -100 | paste -sd, - \
| xargs -I{} curl -s "https://api.first.org/data/v1/epss?cve={}" | jq -r '.data[] | [.cve, .epss] | @tsv'
"Transitive, EPSS 0.001, no fix" is an accepted risk you document in one line. "Directly imported, EPSS 0.4, fix available" is this afternoon.
Step 6: weekly, with history
sudo tee /usr/local/sbin/npm-cve-scan.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
# npm-cve-scan.sh <project dir>: writes /var/lib/cve-scan/npm/<project>-<date>.tsv
set -euo pipefail
P=$1; N=$(basename "$P"); D=/var/lib/cve-scan/npm; mkdir -p "$D"
cd "$P"
jq -r '.packages | to_entries[] | select(.key != "" and (.value.dev // false | not)) | "\(.key | sub("^.*node_modules/"; "")) \(.value.version)"' package-lock.json | sort -u > /tmp/prod.$$
: > /tmp/osv.$$; split -l 900 /tmp/prod.$$ /tmp/b.$$.
for f in /tmp/b.$$.*; do
jq -Rn '{queries: [inputs | split(" ") | {package: {name: .[0], ecosystem: "npm"}, version: .[1]}]}' "$f" \
| curl -s -m 60 -X POST https://api.osv.dev/v1/querybatch -H 'Content-Type: application/json' -d @- \
| jq -r --slurpfile q <(jq -Rn '[inputs | split(" ")]' "$f") '.results | to_entries[] | select(.value.vulns != null) | "\($q[0][.key][0])\t\($q[0][.key][1])\t\([.value.vulns[].id]|join(","))"' >> /tmp/osv.$$
done
sort /tmp/osv.$$ > "$D/$N-$(date +%F).tsv"; rm -f /tmp/prod.$$ /tmp/osv.$$ /tmp/b.$$.*
prev=$(ls -1 "$D/$N-"*.tsv | grep -v "$(date +%F)" | tail -1 || true)
if [ -n "$prev" ]; then
new=$(comm -13 <(cut -f1,2 "$prev") <(cut -f1,2 "$D/$N-$(date +%F).tsv"))
[ -n "$new" ] && printf 'NEW vulnerable packages in %s:\n%s\n' "$N" "$new" | curl -s -H "Title: npm cve $N" -H "Priority: high" --data-binary @- https://ntfy.example.be/cve >/dev/null
fi
echo "$D/$N-$(date +%F).tsv: $(wc -l < "$D/$N-$(date +%F).tsv") vulnerable packages"
EOF
sudo chmod 0755 /usr/local/sbin/npm-cve-scan.sh
echo '30 6 * * 1 root /usr/local/sbin/npm-cve-scan.sh /srv/app' | sudo tee /etc/cron.d/npm-cve-scan
Pitfalls
- Lockfile v1. Projects with npm 6 have a
dependenciestree instead ofpackages.npm install --package-lock-onlywith npm ≥ 7 converts it. Without a lockfile a scan is pointless: then you don't know what's installed. - Scanning
package.jsoninstead of the lockfile."express": "^4.18.0"doesn't say which version runs. Always the lockfile, and always the lockfile on the server (or in the image), not the one in your git checkout from last month. - Bundlers. After
next buildorvite buildthe vulnerable code sits in a bundle; thenode_moduleson the server may not even be needed. Then scan the lockfile the bundle was built with (in CI, with a date). npm audit fix --force. Installs major upgrades without asking. Never in CI, never withoutnpm testafterwards.- Scopes.
@scope/pkgworks in the OSV query as expected;jq'ssub("^.*node_modules/"; "")keeps the scope intact. Check once withgrep '^@' /tmp/prod.txt. - Lifecycle scripts. A vulnerability is one thing; a package running a
postinstallscript onnpm installis a supply-chain vector in its own right.jq -r '.packages | to_entries[] | select(.value.hasInstallScript) | .key' package-lock.jsonlists them.
What you still don't have
- All projects on all hosts. One cron per project is doable for three projects; at thirty you're maintaining an inventory of where which lockfile lives.
- Other ecosystems.
requirements.txt,composer.lock,go.sum,Cargo.lock: each with its own parsing, the same OSV API. - The link to what's running. A lockfile on disk doesn't say whether the app using it still runs, or on which port. That's the step from "CVE in a file" to "CVE on an internet-facing host".
How monsys does it
The monsys agent finds lockfiles (package-lock.json, yarn.lock, pnpm-lock.yaml, requirements.txt, composer.lock, go.sum) on the host, parses them and ships the package list to the hub; the dependency scanner checks it against OSV.dev, enriches with EPSS and KEV, and links every lockfile to the application and the host it runs on, including hop distance to the internet. postinstall scripts are reported as separate supply-chain signals. For a vulnerability you deliberately don't patch, you record a VEX statement with date and reason; it appears in the audit pack next to the CVE.
FAQ
Is npm audit enough?
As a first filter yes. For a complete picture no: it doesn't give CVE ids for every advisory, doesn't weight severity by reachability, and only sees what's in your lockfile. OSV.dev as a second source plus a reachability check gives a list you can defend.
Should I scan dev dependencies too?
Yes, but separately. They run on your CI runner and on developers' laptops, not in production. A vulnerable esbuild is a risk for the build environment (supply chain), not for your customers, and that difference belongs in your prioritisation.
What do I do with a vulnerability without a fix?
Assess whether the vulnerable code is reachable in your usage, look at EPSS, and document the decision (patch when fixed, mitigate, or accept) with a date. That's exactly what a VEX statement is for.
Written by the monsys team: sysadmins who do this every day.
Done it by hand? Let monsys keep it running.
Everything in this guide runs in monsys as a continuous check, with history, alerts and audit evidence. 5 servers free, EU-hosted in Belgium, installed in 60 seconds.