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.