CVE, SBOM & chaîne d'approvisionnementintermédiaire5 min de lecture

Scanner package-lock.json pour les vulnérabilités connues : npm audit, OSV.dev, et les lacunes des deux

npm audit est gratuit et intégré, et pourtant des équipes ratent des vulnérabilités avec — ou se noient dans 400 signalements sur des dépendances de développement. Voici le pipeline que nous utilisons nous-mêmes : parser le lockfile en liste exacte, interroger OSV.dev par lots, séparer dépendances de dev et de production, et conserver la sortie par semaine pour pouvoir dire depuis quand une CVE est ouverte.

Sommaire
  1. Étape 1 : ce que npm audit fait et ne fait pas
  2. Étape 2 : parser le lockfile en liste exacte
  3. Étape 3 : interroger OSV.dev par lots de 900
  4. Étape 4 : de l'advisory à la CVE, la sévérité et la version corrigée
  5. Étape 5 : ce qui compte vraiment — atteignabilité et EPSS
  6. Étape 6 : hebdomadaire, avec historique
  7. Pièges
  8. Ce qu'il vous manque encore
  9. Comment monsys fait
  10. FAQ

Un projet Next.js de taille moyenne a de 500 à 1 500 paquets dans son package-lock.json, dont vingt que vous avez choisis vous-même. Les mille autres sont des dépendances transitives que personne n'a jamais regardées. C'est là que sont les vulnérabilités — et c'est là aussi qu'est le bruit, car la moitié ne tourne que pendant npm run build et ne touche jamais un serveur de production. Cet article construit un scan qui connaît la différence.

Étape 1 : ce que npm audit fait et ne fait pas

cd /srv/app
npm audit --omit=dev            # dépendances de production uniquement
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 interroge la GitHub Advisory Database (via le registre npm) et convient comme premier contrôle. Trois limites :

  • Il signale au niveau advisory (GHSA), pas toujours avec un identifiant CVE, et la sévérité est celle de l'advisory — pas pondérée pour votre usage.
  • Il ne voit que ce qui est dans votre lockfile. Un paquet qui arrive via une image Docker, ou que npx récupère à la volée, échappe.
  • fixAvailable: false signifie qu'il n'y a pas de mise à niveau compatible semver ; c'est généralement là que le travail commence, pas là qu'il s'arrête.

Pour une seconde source avec identifiants CVE, versions corrigées et une API stable : OSV.dev.

Étape 2 : parser le lockfile en liste exacte

Le lockfile v2/v3 (npm ≥ 7) a un objet packages avec la version exacte par chemin installé et un drapeau dev. C'est tout ce dont vous avez besoin :

# Dépendances de production : nom 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
# Dépendances de dev à part (outillage de build : pertinent pour votre runner CI, pas pour la 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

Le sub("^.*node_modules/"; "") ramène les chemins imbriqués (node_modules/a/node_modules/b) au nom du paquet ; sort -u déduplique la même version imbriquée plusieurs fois. Les versions différentes d'un même paquet restent séparées — à juste titre, car elles sont vulnérables séparément.

Étape 3 : interroger OSV.dev par lots de 900

Le même point de terminaison que pour les paquets OS, avec l'écosystème npm :

sudo apt install -y jq curl
scan() {  # scan <liste> <sortie.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

Étape 4 : de l'advisory à la CVE, la sévérité et la version corrigée

: > /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'

La colonne fixed est votre liste de travail. S'il y a une version : npm install pkg@^version ou, pour une dépendance transitive, un bloc overrides dans package.json :

{
  "overrides": {
    "js-yaml": ">=4.3.1",
    "nanoid": ">=3.3.16"
  }
}

Ensuite npm install, npm test, et relancez le scan. Un override qui casse le build signale que la mise à niveau contient un changement incompatible — c'est alors un ticket, pas un commit.

Étape 5 : ce qui compte vraiment — atteignabilité et EPSS

Une vulnérabilité dans un paquet présent dans votre lockfile n'est pas automatiquement une vulnérabilité de votre application. Deux filtres :

# 1. Le paquet est-il seulement chargé ? Cherchez les imports dans votre propre 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 = transitif uniquement ; c'est alors le paquet qui l'importe qui détermine si le code vulnérable est atteignable

# 2. EPSS : probabilité d'exploitation sous 30 jours (voir le guide des paquets OS pour la version par lots)
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'

« Transitif, EPSS 0,001, pas de correctif » est un risque accepté que vous documentez en une ligne. « Importé directement, EPSS 0,4, correctif disponible » c'est cet après-midi.

Étape 6 : hebdomadaire, avec historique

sudo tee /usr/local/sbin/npm-cve-scan.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
# npm-cve-scan.sh <répertoire projet> — écrit /var/lib/cve-scan/npm/<projet>-<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

Pièges

  • Lockfile v1. Les projets avec npm 6 ont un arbre dependencies au lieu de packages. npm install --package-lock-only avec npm ≥ 7 le convertit. Sans lockfile, un scan n'a pas de sens : vous ne savez pas ce qui est installé.
  • Scanner package.json au lieu du lockfile. "express": "^4.18.0" ne dit pas quelle version tourne. Toujours le lockfile, et toujours celui qui est sur le serveur (ou dans l'image), pas celui de votre checkout git du mois dernier.
  • Bundlers. Après next build ou vite build, le code vulnérable est dans un bundle ; les node_modules sur le serveur ne sont peut-être même pas nécessaires. Scannez alors le lockfile avec lequel le bundle a été construit (en CI, daté).
  • npm audit fix --force. Installe des mises à niveau majeures sans demander. Jamais en CI, jamais sans npm test ensuite.
  • Scopes. @scope/pkg fonctionne comme attendu dans la requête OSV ; le sub("^.*node_modules/"; "") de jq conserve le scope. Vérifiez une fois avec grep '^@' /tmp/prod.txt.
  • Scripts de cycle de vie. Une vulnérabilité est une chose ; un paquet qui exécute un script postinstall lors de npm install est un vecteur de chaîne d'approvisionnement en soi. jq -r '.packages | to_entries[] | select(.value.hasInstallScript) | .key' package-lock.json les liste.

Ce qu'il vous manque encore

  • Tous les projets sur tous les hôtes. Un cron par projet est faisable pour trois projets ; à trente, vous tenez un inventaire de l'emplacement de chaque lockfile.
  • Les autres écosystèmes. requirements.txt, composer.lock, go.sum, Cargo.lock — chacun avec son parsing, la même API OSV.
  • Le lien avec ce qui tourne. Un lockfile sur disque ne dit pas si l'application qui l'utilise tourne encore, ni sur quel port. C'est le pas de « CVE dans un fichier » à « CVE sur un hôte exposé à internet ».

Comment monsys fait

L'agent monsys trouve les lockfiles (package-lock.json, yarn.lock, pnpm-lock.yaml, requirements.txt, composer.lock, go.sum) sur l'hôte, les parse et envoie la liste des paquets au hub ; le scanner de dépendances la confronte à OSV.dev, enrichit avec EPSS et KEV, et relie chaque lockfile à l'application et à l'hôte où il tourne — y compris la distance en sauts vers internet. Les scripts postinstall sont signalés comme signaux de chaîne d'approvisionnement séparés. Pour une vulnérabilité que vous ne corrigez délibérément pas, vous enregistrez une déclaration VEX avec date et raison ; elle apparaît dans l'audit pack à côté de la CVE.

FAQ

npm audit suffit-il ?

Comme premier filtre, oui. Pour une image complète, non : il ne donne pas d'identifiant CVE pour chaque advisory, ne pondère pas la sévérité selon l'atteignabilité, et ne voit que ce qui est dans votre lockfile. OSV.dev comme seconde source plus un contrôle d'atteignabilité donne une liste défendable.

Dois-je aussi scanner les dépendances de dev ?

Oui, mais séparément. Elles tournent sur votre runner CI et sur les portables des développeurs, pas en production. Un esbuild vulnérable est un risque pour l'environnement de build (chaîne d'approvisionnement), pas pour vos clients — et cette différence a sa place dans votre priorisation.

Que faire d'une vulnérabilité sans correctif ?

Évaluez si le code vulnérable est atteignable dans votre usage, regardez l'EPSS, et documentez la décision (corriger dès qu'un correctif existe, atténuer, ou accepter) avec une date. C'est exactement à cela que sert une déclaration VEX.

Rédigé par l'équipe monsys — des sysadmins qui font cela tous les jours.

Fait à la main ? Laissez monsys s'en charger en continu.

Tout ce que contient ce guide tourne dans monsys comme contrôle permanent, avec historique, alertes et preuves d'audit. 5 serveurs gratuits, hébergés en Belgique, installés en 60 secondes.