SBOM : 196 composants pour 5 dépendances, ce que le fichier ne dit pas
Un SBOM — Software Bill of Materials, ou nomenclature logicielle — est l'inventaire des composants d'un logiciel, et rien de plus. Sur un projet Node qui déclare cinq dépendances, npm sbom --sbom-format cyclonedx en sort 196, dans un fichier conforme CycloneDX 1.5 qui ne contient aucune vulnérabilité : ce n'est pas une limite du format, c'est sa définition. Un inventaire ne diagnostique pas. Il ne devient utile qu'avec trois contrôles : un périmètre explicite, une génération reproductible, un rapprochement avec une base d'avis. Sans eux, tu archives 229 Ko que personne n'ouvre, et le jour où une CVE tombe tu ne sais toujours pas si ton service est concerné.
Sommaire
196 composants pour cinq lignes de package.json
Banc de mesure : Node 22.23.2, npm 12.0.2, trois dépendances de production (express 4.17.1, axios 0.21.1, lodash 4.17.15) et deux de dev (mocha 10.2.0, eslint 8.57.0).
$ npm sbom --sbom-format cyclonedx > sbom.cdx.json
$ wc -c sbom.cdx.json
228878 sbom.cdx.json # 196 composants
$ npm sbom --sbom-format cyclonedx --omit=dev > sbom-prod.cdx.json
$ wc -c sbom-prod.cdx.json
51812 sbom-prod.cdx.json # 51 composants
npm install avait posé 202 paquets ; le lockfile contient 203 entrées réelles dont 144 marquées dev, et node_modules 181 dossiers. Le rapport 196 pour 5 est la seule information à retenir de cette section : à cette taille, un SBOM ne s'ouvre pas, il se filtre. Un fichier qu'on ne filtre pas finit en pièce jointe que le client ne lit pas non plus.
Le même projet en SPDX donne 197 paquets et 282 relations. La racine y est comptée comme un paquet, alors que CycloneDX la range dans metadata.component — d'où l'écart d'une unité. Les deux formats portent la même information : choisis celui qu'attend ton outil d'analyse, pas celui dont la sortie est la plus lisible.
Le champ scope ne dit pas ce qui part en production
Sur les 196 composants, scope vaut required 196 fois. Mocha, eslint et toute leur descendance sont annoncés « required » au même titre qu'express. Le marqueur de développement existe, mais ailleurs, dans properties :
{ "name": "mocha", "version": "10.2.0", "scope": "required",
"properties": [{ "name": "cdx:npm:package:development", "value": "true" }] }
Conséquence directe : un outil qui filtre sur scope == "required" — le premier réflexe quand on parcourt un SBOM CycloneDX — traite tes outils de build comme du code en production. Le fichier envoyé à un client liste alors tes versions d'eslint, celles de mocha et ton runner de tests, c'est-à-dire la chaîne de build complète, sans intérêt pour lui et datée pour toi.
Le filtre correct porte sur la propriété :
$ jq -c '[.components[] | select(([.properties[]?.name] | index("cdx:npm:package:development")) | not)] | length' sbom.cdx.json
53
53, contre 51 pour --omit=dev. Les deux paquets d'écart sont inherits@2.0.3 et safe-buffer@5.1.2 : ils sont tirés par les deux arbres, et npm les marque dev parce que la branche de développement a gagné la résolution. Filtrer après coup rate donc deux composants réellement présents en production. La règle qui en sort : le périmètre se décide à la génération (--omit=dev pour l'artefact livré), pas dans un jq exécuté plus tard par quelqu'un qui ne saura pas ce qu'il enlève.
Trois versions du même paquet dans un seul fichier
Sept noms de paquets apparaissent en plusieurs versions dans les 196 composants : ms quatre fois, debug trois fois, puis brace-expansion, glob-parent, js-yaml et minimatch deux fois. C'est le comportement normal de npm face à des intervalles de versions incompatibles entre dépendants, et c'est exactement ce qu'un inventaire doit montrer.
Toute analyse qui raisonne par nom de paquet passe à côté. Si ms est corrigé en tête d'arbre, les trois copies imbriquées restent en place, et une requête du type « ai-je ms en version vulnérable ? » répondra oui sans que tu saches laquelle. Le purl règle le problème parce qu'il embarque la version : pkg:npm/ms@2.1.3. C'est le seul identifiant sur lequel tu peux joindre une base d'avis sans ambiguïté. Les 196 composants du fichier ont tous un purl, un hashes et une licence : npm ne laisse pas de trou ici, autant s'appuyer dessus.
Sans node_modules, la commande échoue
$ mv node_modules /tmp/nm
$ npm sbom --sbom-format cyclonedx
npm error code ESBOMPROBLEMS
npm error missing: express@4.17.1, required by sbom-lab@1.0.0
npm error missing: axios@0.21.1, required by sbom-lab@1.0.0
Code de sortie 1, zéro octet sur la sortie standard. npm sbom lit l'arbre installé, pas le lockfile : il n'invente pas un inventaire à partir des intentions. Deux conséquences en CI. L'étape SBOM passe après npm ci, jamais avant. Et si ton npm ci tourne avec --omit=dev, le fichier ne décrira pas ta chaîne de build — acceptable pour l'artefact livré, insuffisant pour un audit de la supply chain côté outillage. Un node_modules resté en place depuis trois semaines produira un SBOM qui ne correspond plus au lockfile, et rien ne te le signalera.
Deux générations, deux fichiers différents
Deux exécutions à 400 ms d'intervalle sur le même arbre : serialNumber différent, timestamp différent, liste de composants identique (même empreinte SHA-256 une fois triée). Un git diff sur le fichier brut affiche donc une modification à chaque build, même quand rien n'a bougé.
import json, sys
d = json.load(open(sys.argv[1]))
d.pop("serialNumber", None)
d["metadata"].pop("timestamp", None)
d["components"].sort(key=lambda c: c["bom-ref"])
d["dependencies"].sort(key=lambda x: x["ref"])
print(json.dumps(d, sort_keys=True, indent=1))
Normalise avant de comparer, puis diff deux versions : tu obtiens la seule information que personne n'a le temps d'extraire à la main — quels composants ont bougé entre le déploiement de mardi et celui de vendredi, et lesquels sont arrivés sans que tu les aies demandés. Un SBOM par release, gardé, diffusable : c'est là qu'il commence à servir à quelque chose.
Un SBOM n'est pas un diagnostic
Le fichier généré n'a pas de clé vulnerabilities. CycloneDX l'autorise (c'est le rôle du VEX), npm ne la remplit pas. Le rapprochement se fait à l'extérieur, et l'API OSV accepte des purl en lot :
$ jq -c '[.components[].purl | {package: {purl: .}}]' sbom-prod.cdx.json > q.json
$ curl -s -X POST https://api.osv.dev/v1/querybatch \
-H 'Content-Type: application/json' \
-d "{\"queries\": $(cat q.json)}" | jq '[.results[] | (.vulns // []) | length] | add'
46
Sur les 51 composants de production de ce banc, 9 correspondent à au moins un avis, pour 46 avis distincts. axios@0.21.1 en concentre 24 à lui seul, lodash@4.17.15 six, qs@6.7.0 cinq, express@4.17.1 deux. Sur les quarante premiers avis consultés : 16 HIGH, 17 MODERATE, 7 LOW. Ces versions sont volontairement anciennes — c'est un banc de mesure, pas une application — mais le ratio est le sujet : 9 composants sur 51 demandent un tri, et une sévérité HIGH ne dit toujours pas si le code vulnérable est atteignable depuis chez toi.
C'est ici que npm audit intervient, avec son propre périmètre : il interroge le registre sur les couples nom/version du lockfile, ce qui est une autre question que celle du SBOM. Les deux se complètent : l'article sur les quatre causes d'un npm audit à zéro détaille les cas où il ne voit rien.
Ce que le SBOM ne dira jamais
L'atteignabilité d'abord : un composant vulnérable dont la fonction fautive n'est jamais appelée reste un composant vulnérable dans l'inventaire, et un correctif urgent dans ton backlog. Le décalage ensuite : l'image déployée et le SBOM généré en CI sont deux choses différentes dès qu'un hotfix passe à la main. Les secrets de ton application, enfin — c'est un autre sujet, et une clé exposée dans le bundle JavaScript ne sortira jamais d'un inventaire de dépendances.
Le runtime non plus. npm sbom décrit ton arbre npm, pas l'interpréteur qui l'exécute : sur php2shell et ses deux failles critiques PHP, la version qui compte est celle de PHP, et elle n'apparaît que si tu inventaries aussi l'image du conteneur — syft, par exemple, remonte les paquets système en plus du langage.
Reste la fraîcheur des avis, qui est le vrai coût du dispositif. Un composant vulnérable n'apparaît dans les bases qu'après publication et revue : sur la faille Next.js d'octobre 2026, huit jours ont séparé la publication du correctif de l'apparition de l'avis dans les flux que lisent les scanners. Un SBOM régénéré chaque semaine ne raccourcit pas ce délai, mais il te dit en une requête si la version concernée est dans ton arbre — à condition d'avoir le purl sous la main. C'est tout l'intérêt de garder un fichier par release : quand un avis tombe sur un middleware que tu utilises, la réponse « suis-je affecté ? » se joue en secondes au lieu d'une soirée.
Deux commandes, une étape de CI, un fichier par release. Le jour où ça compte, tu as la liste. Si tu ne veux pas tenir ce pipeline, c'est la partie que Rustine automatise : rapprocher les avis publiés des versions réellement installées, et te prévenir quand l'une d'elles est touchée.