npm audit affiche 0 vulnérabilité : les quatre causes, et le contrôle CI qui tient
npm audit ne lit pas une ligne de ton code. Il prend les noms et versions de ton package-lock.json, les envoie en POST sur /-/npm/v1/security/advisories/bulk au registre configuré, et compte les avis que le registre renvoie. Sur mon banc d'essai, cinq dépendances déclarées font 471 paquets dans l'arbre et 33 avis — dont 3 critiques — sur des versions figées.
Sommaire
- Vérifier que le registre répond, avant d'interpréter
- Le chiffre sort du lockfile, pas de node_modules
- Cause n°1 — npm audit a reçu une liste vide
- Cause n°2 — npm audit n'a pas audité le même périmètre
- Le seuil qui a du sens
- Cause n°3 — l'avis n'est pas encore dans la base
- Cause n°4 — ton code n'est pas dans le périmètre
- Ce que npm audit fix fait vraiment
- Sur des versions figées, il ne répare rien
- Ce que fixAvailable cache
- Le garde-fou qui tient
Quand le chiffre tombe à zéro, ça dit une seule chose : le registre n'a rien trouvé à redire sur l'arbre qu'on lui a envoyé. Il y a quatre façons d'arriver à ce zéro, et une seule est une bonne nouvelle.
Vérifier que le registre répond, avant d'interpréter
Deux commandes, dix secondes, à faire avant de croire un 0 :
npm config get registry
curl -s -X POST "$(npm config get registry)/-/npm/v1/security/advisories/bulk" \
-H 'Content-Type: application/json' \
-d '{"lodash":["4.17.15"]}'
Si la réponse est {}, tout ce que npm audit te dira ensuite vaut zéro information : il n'a pas de base. Si elle contient des objets avec severity et vulnerable_versions, le canal fonctionne et le chiffre mérite d'être discuté.
Le chiffre sort du lockfile, pas de node_modules
J'ai supprimé node_modules du projet puis relancé l'audit : 33 vulnérabilités, 471 nœuds, résultat identique au run précédent. npm audit travaille sur le lockfile, jamais sur les fichiers installés.
Conséquence directe : un audit propre ne prouve rien sur ce qui tourne en prod si le node_modules déployé vient d'un npm install qui a fait bouger les versions à l'intérieur des ranges. Sans lockfile, la commande n'affiche pas 0 — elle échoue en ENOLOCK. Et --no-package-lock reconstruit l'arbre à chaque exécution ; la doc npm prévient elle-même que le résultat varie d'un run à l'autre.
En CI : npm ci puis npm audit, sur le même lockfile que le build.
Cause n°1 — npm audit a reçu une liste vide
C'est le cas le plus vicieux, parce qu'il ressemble trait pour trait à un projet sain. J'ai monté un serveur HTTP local qui répond {} sur le endpoint d'avis, puis lancé l'audit contre lui :
npm audit --registry=http://127.0.0.1:8899 --json
Sortie : "vulnerabilities": {}, "total": 0, code de sortie 0. Le projet contenait toujours ses 33 avis.
En vrai, ça arrive avec un miroir privé (Verdaccio, Nexus, Artifactory) qui ne relaie pas /-/npm/v1/security/advisories/bulk, un .npmrc qui pointe sur un proxy d'entreprise, ou un runner isolé du réseau. La doc npm prévoit un repli vers l'endpoint Quick Audit, mais uniquement si le registre renvoie une erreur ou une réponse invalide. Un {} bien formé est une réponse valide. Le silence passe pour un certificat de bonne santé.
Cause n°2 — npm audit n'a pas audité le même périmètre
Sur le même projet, les deux commandes ne parlent pas du même produit :
- audit complet : 33 avis (4 low, 9 moderate, 17 high, 3 critical)
npm audit --omit=dev: 9 avis (3 low, 6 high, 0 critical)
Les 24 avis qui disparaissent viennent de mocha et webpack-dev-server. C'est légitime, ce code ne part pas en prod. La ligne suivante, en revanche, ne l'est pas :
npm audit --omit=dev --audit-level=critical # exit 0
Code de sortie 0 alors qu'il reste 6 vulnérabilités high. --audit-level ne filtre pas le rapport, il déplace seulement le seuil d'échec — c'est écrit noir sur blanc dans la doc, et c'est exactement le genre de détail qu'on découvre devant une PR verte qui ne devrait pas l'être.
Le seuil qui a du sens
Pour un SaaS, le périmètre est celui du process qui tourne : --omit=dev --audit-level=high. Les avis moderate remontent dans le rapport sans casser le build, les high et critical bloquent.
Cause n°3 — l'avis n'est pas encore dans la base
La base d'avis du registre est alimentée par les advisories, avec un délai d'ingestion. Un correctif publié dans un dépôt GitHub ne devient pas immédiatement lisible par un scanner. L'écart entre « le correctif existe » et « l'avis est interrogeable » est documenté sur le cas Next.js d'octobre 2026, et il se mesure : sur OSV, published donne la date de l'entrée et github_reviewed_at celle à laquelle les consommateurs d'avis ont pu la voir. C'est la seconde qui compte pour ton pipeline.
Un audit est une photo, pas un état. La doc npm le dit sans détour : la base peut changer à tout moment, l'audit doit donc tourner régulièrement et pas une seule fois au moment du npm init.
Cause n°4 — ton code n'est pas dans le périmètre
npm audit couvre les dépendances directes, les devDependencies, les bundled et les optional. Pas les peerDependencies. Et rien de ce que tu écris.
Une route /api/factures/:id qui renvoie la facture du voisin faute de vérification du tenant produit zéro finding, indéfiniment — c'est le schéma décrit dans la faille IDOR côté API. Idem pour une clé API restée dans le bundle ou une CSP absente. Trois articles différents parce que trois contrôles différents, et aucun des trois n'est dans npm audit. C'est la même famille de raisonnement que pour un CVE de middleware Next.js : la faille est dans la logique, pas dans une version de paquet.
Ce que npm audit fix fait vraiment
La doc est explicite : audit fix lance un npm install complet sous le capot. Il ne peut donc bouger que dans les ranges autorisés par ton package.json.
Sur des versions figées, il ne répare rien
Test sur express@4.17.1 + lodash@4.17.15 en versions exactes : 8 avis avant, 8 avis après, "changed": 0. Le package.json n'a pas bougé d'un caractère. npm audit fix n'a rien fait et le rapport ne le crie pas.
Ce que fixAvailable cache
fixAvailable ne veut pas dire « une version corrigée existe ». Sur ip (SSRF, CVSS 8.1), npm affiche "range": "*" et propose comme correctif webpack-dev-server@6.1.0 avec "isSemVerMajor": true — autrement dit supprimer le parent, pas patcher le paquet, qui n'a pas de version corrigée.
Pour les dépendances transitives, la réponse propre tient dans overrides :
{
"overrides": {
"qs": "6.16.1",
"body-parser": "1.20.9",
"path-to-regexp": "0.1.13",
"send": "0.19.0"
}
}
Mesuré sur le même projet : 8 avis avant, 3 après, 5 high ramenés à 1. Ça se relit en revue et ça se revert sans élargir un seul range. npm audit fix --force, lui, annonce « Will install webpack-dev-server@6.1.0, which is a breaking change ». Un major de CI/CD pour fermer un avis, ça se décide ; ça ne se subit pas en fin de sprint.
Le garde-fou qui tient
Trois étapes, dans cet ordre, dans le job CI :
- run: npm ci
- name: audit prod
run: |
npm audit --omit=dev --audit-level=high --json > audit.json || true
node -e '
const r = require("./audit.json");
const t = r.metadata?.dependencies?.total ?? 0;
if (t < 1) { console.error("arbre vide : aucun paquet audite"); process.exit(1); }
const v = r.metadata.vulnerabilities;
console.log(`prod: ${v.total} avis (${v.high} high, ${v.critical} critical)`);
process.exit(v.high + v.critical > 0 ? 1 : 0);
'
- run: npm audit signatures
Le premier contrôle est celui qu'on oublie et c'est le plus important : si l'arbre audité est vide, on échoue. C'est ce qui transforme le faux zéro en erreur visible au lieu d'une coche verte. Le deuxième fixe le seuil sur le périmètre prod. Le troisième vérifie l'intégrité de ce qui a été téléchargé — sur mon banc, 467 paquets avec signature vérifiée et 17 attestations de provenance.
Un 0 qui sort d'un arbre de 471 nœuds est une information exploitable. Un 0 qui sort d'un arbre vide est un bug de configuration déguisé en bonne nouvelle. La différence tient dans la ligne if (t < 1).
Si tu veux la même vérification en plus des dépendances — routes, accès aux données, secrets dans le bundle — c'est ce que rustine scanne sur l'app déployée.