package-lock.json scannen op bekende kwetsbaarheden: npm audit, OSV.dev, en de gaten in allebei
npm audit is gratis en ingebouwd, en toch missen teams er kwetsbaarheden mee — of verdrinken ze in 400 meldingen over dev-dependencies. Dit is de pijplijn die we zelf draaien: de lockfile parsen tot een exacte lijst, OSV.dev batch-bevragen, dev- van productie-dependencies scheiden, en de output per week bewaren zodat je kunt zeggen sinds wanneer een CVE openstaat.
Inhoud
- Stap 1: wat npm audit wel en niet doet
- Stap 2: de lockfile parsen tot een exacte lijst
- Stap 3: OSV.dev bevragen, in batches van 900
- Stap 4: van advisory naar CVE, severity en fix-versie
- Stap 5: wat écht telt — bereikbaarheid en EPSS
- Stap 6: wekelijks, met historie
- Valkuilen
- Wat je hiermee nog niet hebt
- Zo doet monsys het
- FAQ
Een Next.js-project van gemiddelde grootte heeft 500 tot 1 500 packages in zijn package-lock.json, waarvan je er zelf twintig hebt gekozen. De andere duizend zijn transitieve dependencies die niemand ooit heeft bekeken. Daar zitten de kwetsbaarheden — en daar zit ook de ruis, want de helft draait alleen tijdens npm run build en raakt nooit een productieserver. Dit artikel bouwt een scan die het verschil kent.
Stap 1: wat npm audit wel en niet doet
cd /srv/app
npm audit --omit=dev # alleen productie-dependencies
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 vraagt de GitHub Advisory Database (via de npm-registry) en is prima als eerste check. Drie beperkingen:
- Het meldt op advisory-niveau (GHSA), niet altijd met CVE-id, en de severity is die van de advisory — niet gewogen naar jouw gebruik.
- Het ziet alleen wat in jouw lockfile staat. Een package dat je via een Docker-image binnenkrijgt, of dat
npxon-the-fly ophaalt, valt erbuiten. fixAvailable: falsebetekent dat er geen semver-compatibele upgrade is; dat is meestal waar het werk begint, niet waar het stopt.
Voor een tweede bron met CVE-id's, fix-versies en een stabiele API: OSV.dev.
Stap 2: de lockfile parsen tot een exacte lijst
Lockfile v2/v3 (npm ≥ 7) heeft een packages-object met per geïnstalleerd pad de exacte versie en een dev-vlag. Dat is alles wat je nodig hebt:
# Productie-dependencies: naam versie
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 apart (build-tooling: relevant voor je CI-runner, niet voor productie)
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
De sub("^.*node_modules/"; "") haalt geneste paden (node_modules/a/node_modules/b) terug naar de packagenaam; sort -u dedupliceert dezelfde versie die meerdere keren genest is. Verschillende versies van hetzelfde package blijven apart staan — terecht, want ze zijn apart kwetsbaar.
Stap 3: OSV.dev bevragen, in batches van 900
Dezelfde endpoint als voor OS-packages, met ecosystem npm:
sudo apt install -y jq curl
scan() { # scan <lijst> <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
Stap 4: van advisory naar CVE, severity en fix-versie
: > /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'
De kolom fixed is je werklijst. Staat er een versie: npm install pkg@^versie of, voor een transitieve dependency, een overrides-blok in package.json:
{
"overrides": {
"js-yaml": ">=4.3.1",
"nanoid": ">=3.3.16"
}
}
Daarna npm install, npm test, en de scan opnieuw. Een override die de build breekt, is een signaal dat de upgrade een breaking change bevat — dan is het een ticket, geen commit.
Stap 5: wat écht telt — bereikbaarheid en EPSS
Een kwetsbaarheid in een package dat in je lockfile staat, is niet automatisch een kwetsbaarheid in je applicatie. Twee filters:
# 1. Wordt het package überhaupt geladen? Zoek naar imports in je eigen 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 = alleen transitief; dan bepaalt het package dat het importeert of de kwetsbare code bereikbaar is
# 2. EPSS: kans op exploitatie in 30 dagen (zie de OS-package-how-to voor de batch-versie)
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'
"Transitief, EPSS 0,001, geen fix" is een geaccepteerd risico dat je in één regel documenteert. "Direct geïmporteerd, EPSS 0,4, fix beschikbaar" is vanmiddag.
Stap 6: wekelijks, met historie
sudo tee /usr/local/sbin/npm-cve-scan.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
# npm-cve-scan.sh <projectmap> — schrijft /var/lib/cve-scan/npm/<project>-<datum>.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
Valkuilen
- Lockfile v1. Projecten met npm 6 hebben een
dependencies-boom in plaats vanpackages.npm install --package-lock-onlymet npm ≥ 7 converteert hem. Zonder lockfile is een scan zinloos: dan weet je niet wat er geïnstalleerd is. package.jsonscannen in plaats van de lockfile."express": "^4.18.0"zegt niet welke versie draait. Altijd de lockfile, en altijd de lockfile die op de server staat (of de image), niet die in je git-checkout van vorige maand.- Bundlers. Na
next buildofvite buildzit de kwetsbare code in een bundle; denode_modulesop de server zijn misschien niet eens nodig. Scan dan de lockfile waarmee de bundle is gebouwd (in CI, met datum). npm audit fix --force. Installeert major-upgrades zonder te vragen. Nooit in CI, nooit zondernpm testerna.- Scopes.
@scope/pkgwerkt in de OSV-query zoals verwacht;jq'ssub("^.*node_modules/"; "")houdt de scope intact. Controleer één keer metgrep '^@' /tmp/prod.txt. - Lifecycle-scripts. Een kwetsbaarheid is één ding; een package dat bij
npm installeenpostinstall-script draait is een supply-chain-vector op zich.jq -r '.packages | to_entries[] | select(.value.hasInstallScript) | .key' package-lock.jsontoont ze.
Wat je hiermee nog niet hebt
- Alle projecten op alle hosts. Eén cron per project is te doen voor drie projecten; bij dertig ben je een inventaris aan het bijhouden van waar welke lockfile staat.
- Andere ecosystemen.
requirements.txt,composer.lock,go.sum,Cargo.lock— elk met eigen parsing, dezelfde OSV-API. - De koppeling met wat draait. Een lockfile op schijf zegt niet of de app die hem gebruikt nog draait, of op welke poort. Dat is de stap van "CVE in een bestand" naar "CVE op een internet-facing host".
Zo doet monsys het
De monsys-agent vindt lockfiles (package-lock.json, yarn.lock, pnpm-lock.yaml, requirements.txt, composer.lock, go.sum) op de host, parseert ze en stuurt de package-lijst naar de hub; de dependency-scanner legt die tegen OSV.dev, verrijkt met EPSS en KEV, en koppelt elk lockfile aan de applicatie en de host waar het draait — inclusief hop-afstand tot internet. postinstall-scripts worden als aparte supply-chain-signalen gemeld. Voor een kwetsbaarheid die je bewust niet patcht, leg je een VEX-statement vast met datum en reden; die verschijnt in het audit pack naast de CVE.
FAQ
Is npm audit genoeg?
Als eerste filter ja. Voor een compleet beeld niet: het geeft geen CVE-id's bij elke advisory, weegt severity niet naar bereikbaarheid, en ziet alleen wat in jouw lockfile staat. OSV.dev als tweede bron plus een bereikbaarheidscheck geeft een lijst die je kunt verdedigen.
Moet ik dev-dependencies ook scannen?
Ja, maar apart. Ze draaien op je CI-runner en op laptops van ontwikkelaars, niet op productie. Een kwetsbare esbuild is een risico voor de build-omgeving (supply chain), niet voor je klanten — en dat verschil hoort in je prioritering.
Wat doe ik met een kwetsbaarheid zonder fix?
Beoordeel of de kwetsbare code bereikbaar is in jouw gebruik, kijk naar EPSS, en documenteer de beslissing (patchen zodra fix, mitigeren, of accepteren) met datum. Dat is precies waar een VEX-statement voor dient.
Geschreven door het monsys-team — sysadmins die dit dagelijks doen.
Zelf gedaan? Laat monsys het bijhouden.
Alles uit deze how-to draait in monsys als doorlopende check, met historie, alerts en audit-bewijs. 5 servers gratis, EU-gehost in België, geïnstalleerd in 60 seconden.