Chaîne d’approvisionnement logicielle : combien de composants dans vos propres outils ?

En mars 2026, lors de la keynote de SANS à RSAC, Joshua Wright a ouvert l’utilitaire 7-Zip et trouvé, sous son installeur minimal, 300 dépendances uniques. Nous avons posé la même question à nos propres outils : 19 inventaires, 4 543 composants, 568 failles distinctes dont une seule figure au catalogue des failles exploitées. Voici la méthode, un script testé de bout en bout et ce que cette mesure a révélé.

Ce qu’il faut retenir

  • Un outil, ce sont des centaines de composants que personne n’a choisis. Sur nos 19 outils : de 2 à 992 composants. Une application web en Node.js dépasse souvent 500 composants pour une trentaine de dépendances déclarées (515 installés pour 34 directes dans notre liste de souhaits).
  • Le fichier de verrouillage n’est pas ce qui tourne. Notre liste de souhaits : 1 179 composants dans le fichier, 515 installés. À l’inverse, 53 des 147 composants de notre application de recettes sont des outils de développement installés en production.
  • Peu de composants sont touchés, mais leurs failles s’additionnent : il faut trier. 134 composants touchés sur 4 543 (3 %), 568 failles distinctes, une seule dans le catalogue KEV de la CISA, aucune au-dessus de 10 % de probabilité d’exploitation (EPSS, maximum 7,1 %).
  • Les bases de connaissance arrivent après le paquet piégé. Version malveillante d’axios publiée le 31 mars 2026 à 00 h 21 UTC ; avis dans OSV à 03 h 15 : 2 h 55 d’exposition que seul un délai de carence aurait évitée.
  • Installer, c’est exécuter. Un setup.py, un script postinstall ou un fichier .pth (même dans une roue) s’exécutent chez vous. Nous l’avons démontré, et c’est ce que contenait la version 1.82.8 de LiteLLM le 24 mars 2026.

Ce que disent SANS et les autres

La keynote « Five Most Dangerous New Attack Techniques » (RSAC, 24 mars 2026) classe la chaîne d’approvisionnement au deuxième rang. Joshua Wright y résume le problème ainsi (traduction libre) : le risque, ce n’est pas le logiciel que l’on choisit, c’est le logiciel du fournisseur et celui de ses fournisseurs. Les chiffres du secteur vont dans le même sens :

ConstatSource
300 dépendances uniques sous l’installeur de 7-ZipSANS, communiqué RSAC 2026 (J. Wright)
454 648 nouveaux paquets malveillants publiés en 2025, soit +75 % ; 1,233 million cumulésSonatype, State of the Software Supply Chain 2026
+73 % de paquets malveillants détectés ; npm concentre près de 90 % des détections ; le ver Shai-Hulud a compromis plus de 1 000 paquets npm et exposé environ 25 000 dépôts GitHubReversingLabs, rapport 2026
Part des brèches impliquant un tiers : de 15 % à 30 % en un anVerizon DBIR 2025 (via Help Net Security)

Nous n’avons pas pu reproduire le chiffre de 7-Zip : le communiqué ne dit pas ce qui est compté (exécution, compilation, outils de construction). Peu importe : l’enseignement est de faire la mesure chez soi, avec une méthode que l’on peut relire. C’est l’objet du reste de l’article.

Vocabulaire minimal

TermeCe que c’est
Dépendance directe / transitiveDirecte : vous l’avez déclarée. Transitive : elle vient d’une autre dépendance. Les secondes sont de loin les plus nombreuses.
Fichier de verrouillagepackage-lock.json, pnpm-lock.yaml, uv.lock, composer.lock, Gemfile.lock… : la liste exacte (versions, empreintes) que le gestionnaire de paquets a résolue.
SBOM« Software Bill of Materials » : l’inventaire des composants, dans un format standard (ici CycloneDX, avec des identifiants purl).
AvisDescription publique d’une faille ou d’un paquet malveillant : CVE, GHSA (GitHub), PYSEC, GO-…, MAL-… (paquet publié pour nuire). La base OSV.dev les agrège et les relie à des versions précises.
KEV et EPSSKEV : catalogue CISA des failles exploitées dans la nature. EPSS : probabilité d’exploitation à 30 jours publiée par FIRST. Deux signaux pour trier.

Notre inventaire : 19 outils, 4 543 composants

Nous avons inventorié ce qui tourne réellement dans notre laboratoire, en lisant l’état installé quand il est lisible (environnement Python, node_modules, vendor/composer/installed.json, binaires Go) et le fichier de verrouillage sinon. Les applications accessibles depuis Internet sont décrites par leur fonction, pas par leur nom : un inventaire de versions publié est une aide à l’attaquant.

Graphique : composants par outil, de 2 (renseignement CTI en Python) à 992 (partage de secrets, verrou npm), avec le nombre de failles distinctes
Outil (fonction · écosystème)Source lueComposantsTouchésFaillesKEV
Gestion de recettes · Pythonenvironnement installé147261451
Cartographie réseau · Pythonenvironnement installé601140
Cartographie réseau · interface npmnode_modules (état installé)73212390
Listes de souhaits · npm (pnpm)état installé de pnpm51516690
Partage de secrets · gems RubyGemfile.lock221110
Partage de secrets · npmétat installé de pnpm (identique au verrou)99220500
Suivi de prix · PHP (Composer)installed.json13419490
Reverse proxy · API npmyarn.lock3737140
Reverse proxy · interface npmyarn.lock455230
Suivi des vulnérabilités · Pythonenvironnement installé50160
Renseignement (CTI) · Pythonenvironnement installé2000
Outils de contrôle de code · Pythonenvironnement installé8160
Forge Git · Gobinaire2263190
Prometheus · Gobinaire2404210
node_exporter · Gobinaire474310
blackbox_exporter · Gobinaire413190
Pushgateway · Gobinaire343190
CrowdSec · Gobinaire2176390
DNS (AdGuard Home) · Gobinaire495240
Total4 5431345681

Quatre lectures de ce tableau :

  • La taille ne prédit pas le risque. L’application de recettes compte 147 composants et 145 failles ; l’interface du reverse proxy en compte 455 et 3. Ce qui compte, c’est la fraîcheur des versions, pas leur nombre.
  • Un binaire Go n’est pas « simple ». Prometheus embarque 239 modules (240 composants avec la bibliothèque standard du compilateur), Forgejo 225, CrowdSec 216, tous lisibles dans le binaire lui-même (voir plus bas).
  • Le compilateur fait partie de la chaîne. Chaque binaire Go contient la bibliothèque standard du compilateur qui l’a produit : notre forge Git compilée avec Go 1.26.8 cumule 13 avis sur la bibliothèque standard, tous corrigés en 1.26.9. On ne corrige pas cela en mettant à jour une dépendance : il faut que l’éditeur recompile.
  • Le reste de la chaîne n’est pas dans ce tableau : paquets du système (suivis par notre SIEM), images de conteneurs, bibliothèques JavaScript copiées dans des pages, binaires sans information de build (celui de notre outil de tâches : ni l’outil officiel de Go ni notre script n’y trouvent quoi que ce soit).

Le fichier de verrouillage n’est pas ce qui tourne

Premier piège de la mesure : lire le fichier de verrouillage donne ce qui peut être installé, pas ce qui l’est. Nous avons comparé les deux là où l’état installé est lisible :

OutilDans le verrouRéellement installéÉcart et cause
Listes de souhaits (pnpm)1 179515−664 : 112 variantes propres à d’autres systèmes (macOS, Windows, ARM…) et surtout des outils de construction (Babel par exemple), absents après la compilation
Cartographie réseau, interface (npm)805732−73 : 66 variantes propres à d’autres plateformes (esbuild pour AIX, Android, macOS…)
Suivi de prix (Composer)190134−56 : exactement les paquets de développement, exclus par composer install --no-dev (dev-package-names : 0 paquet de développement installé)
Gestion de recettes (uv)1461470 : tout est installé, y compris les 53 composants de développement (36 % du total), plus l’application elle-même

Le dernier cas est le plus instructif. La procédure d’installation par défaut de uv sync inclut le groupe de développement ; notre environnement de production contient donc pytest, mypy, pylint, ruff, coverage, pre-commit, mkdocs-material… Six de ces outils de développement portent des avis (filelock, mkdocs-material, pygments, pymdown-extensions, pytest, virtualenv) : des failles qui n’existeraient pas avec uv sync --no-dev. Le suivi de prix, lui, est installé comme il faut. Même logique ailleurs : composer install --no-dev, pnpm install --prod, npm ci --omit=dev.

Règle pratique : scanner l’état installé quand on le peut, et le fichier de verrouillage en plus (c’est lui que l’on propage dans l’intégration continue, et lui que l’on peut analyser avant de déployer).

Le script depscan.py

depscan.py (bibliothèque standard de Python 3.11 ou plus, 330 lignes) inventorie un projet sans rien exécuter : il lit des fichiers, il n’importe ni ne lance jamais le code du projet. Il comprend huit sources :

  • un dossier site-packages ou un environnement virtuel Python (métadonnées .dist-info, avec la distinction « de premier niveau » ou transitif) ;
  • requirements.txt et uv.lock (racine, dépendances directes, groupes de développement) ;
  • package-lock.json (v1 à v3, y compris l’état installé node_modules/.package-lock.json), yarn.lock (v1 et Berry), pnpm-lock.yaml (v6 et v9, y compris node_modules/.pnpm/lock.yaml) ;
  • composer.lock et vendor/composer/installed.json, Gemfile.lock ;
  • un binaire Go : les informations de build y sont intégrées (format documenté), le script les lit directement dans le fichier, sans l’exécuter et sans installer Go.
python3 depscan.py --kev --epss /opt/app/.venv/lib/python3.13/site-packages   # inventaire + avis OSV + KEV + EPSS
python3 depscan.py --no-net --sbom sbom.cdx.json package-lock.json        # inventaire et SBOM CycloneDX 1.5, sans réseau
python3 depscan.py /usr/local/bin/prometheus                             # un binaire Go
python3 -m unittest -v test_depscan                                      # 29 tests hors ligne (+3 avec DEPSCAN_ONLINE=1)

Il interroge OSV.dev par lots de 500, suit la pagination, regroupe les avis qui décrivent la même faille (un même défaut apparaît souvent sous un identifiant GHSA, un PYSEC et une CVE : sans regroupement, notre application de recettes affichait 284 « avis » au lieu de 145 failles), calcule pour chaque composant la plus petite version qui corrige tout, et classe : paquet malveillant d’abord, puis KEV, gravité, EPSS. Extrait réel (application de recettes, noms de fonctions à la place des noms d’outils) :

$ python3 depscan.py --kev --epss --top 5 .../site-packages
.../site-packages : 147 composants, 21 directs, ? de développement, 26 touchés par 145 failles distinctes, 1 dans KEV
  KEV HIGH      starlette 0.49.1      5 faille(s)  corrigées toutes en 1.3.1  EPSS max 0.071
      CRITICAL  nltk 3.9.2           43 faille(s)  corrigées toutes en 3.10.3  EPSS max 0.015
      CRITICAL  pyjwt 2.12.1         15 faille(s)  au moins une sans correctif publié  EPSS max 0.004
      CRITICAL  anyio 4.11.0          2 faille(s)  corrigées toutes en 4.14.2  EPSS max 0.003
      HIGH      urllib3 2.5.0         6 faille(s)  corrigées toutes en 2.8.0  EPSS max 0.029

Les tests : 32 cas et cinq vérifications croisées

Le fichier test_depscan.py compte 32 tests (29 sans réseau, 3 contre la vraie API) ; il n’est pas reproduit ici pour ne pas doubler la longueur de l’article. Il couvre : chaque lecteur avec ses cas limites (noms de paquets à portée @scope/nom, versions de pairs 1.2.3(react@18), en-têtes yarn à plusieurs plages, versions de plateforme des gems, alias npm, liens et entrées sans version), un binaire Go fabriqué de toutes pièces (dont un en-tête mal aligné, qui doit être ignoré), le traitement par lots (1 203 composants en trois lots de 500, 500 et 203, un doublon écarté), la pagination suivie jusqu’au bout, la comparaison numérique des versions (4.10.0 est plus récent que 4.9.0, alors qu’il est plus petit en tri alphabétique), le regroupement des avis, la priorité aux paquets malveillants, les codes de retour et les fichiers corrompus.

Des tests écrits par la même personne que le code valident surtout ses propres hypothèses. Nous avons donc comparé le script à des références indépendantes :

VérificationRéférenceRésultat
7 binaires Go (de 33 à 239 modules)go version -m, Go 1.24.4 (installé le temps du test, retiré ensuite)7 sur 7 identiques : mêmes modules, mêmes versions, même version du compilateur. Le 8e binaire : l’outil officiel échoue aussi (« not a Go executable »)
Environnement Python (8 paquets)pip list et pip list --not-requiredmêmes paquets et versions ; mêmes paquets de premier niveau
Verrou npm (224 composants)npm ls --package-lock-only : 227écart de 3 expliqué : des alias (string-width-cjs pointe vers string-width 4.2.3). npm ls les liste sous l’alias ; nous, sous le vrai nom, le seul que connaisse le registre (interroger l’alias donnerait zéro avis, à tort). Test ajouté
SBOM CycloneDX (4 documents, de 134 à 515 composants)schéma JSON officiel CycloneDX 1.54 sur 4 valides ; un document volontairement cassé (type invalide, version absente) : 2 erreurs détectées
Paquets réellement malveillantsOSV, base des paquets malveillantsaxios 1.14.1 → MAL-2026-2307, plain-crypto-js 4.2.1 → MAL-2026-2306 ; axios 1.14.0 (propre) : aucun avis « MAL »

Ce travail a aussi produit une erreur de test, de notre fait : nous attendions pkg:npm/@babel/core@7.0.0 dans le SBOM ; la spécification purl impose %40 pour la portée npm. Le code avait raison, le test avait tort.

Un paquet piégé, mesuré : axios, 31 mars 2026

Le 31 mars 2026, deux versions d’axios (1.14.1 et 0.30.4), publiées depuis le compte compromis d’un mainteneur, ont installé un cheval de Troie d’accès à distance via une dépendance injectée (plain-crypto-js@4.2.1). Selon la post-mortem du mainteneur, elles sont restées en ligne environ trois heures, de 00 h 21 à 03 h 15 UTC. Nous avons passé ces versions réelles dans depscan.py :

package-lock.json : 3 composants, 2 directs, 0 de développement, 3 touchés par 70 failles distinctes
      MALWARE   axios 1.14.1             35 faille(s)  au moins une sans correctif publié
      MALWARE   plain-crypto-js 4.2.1     1 faille(s)  au moins une sans correctif publié
      HIGH      axios 1.14.0             34 faille(s)  corrigées toutes en 1.20.0

L’outil les repère, et classe le paquet malveillant avant toute faille ordinaire (la version propre 1.14.0 porte pourtant 34 avis, d’où l’intérêt du tri). Mais la mesure qui compte est celle du délai, lue dans OSV :

Événement (31 mars 2026, UTC)HeureÉcart
axios@1.14.1 publié sur npm (post-mortem)00 h 210
avis MAL-2026-2306 (plain-crypto-js) publié dans OSV02 h 07+1 h 47
avis MAL-2026-2307 (axios) publié dans OSV03 h 15+2 h 55

Pendant près de trois heures, aucun scanner fondé sur une base de connaissance n’aurait rien signalé. C’est l’argument du délai de carence : refuser d’installer une version publiée depuis moins de quelques jours. Le 24 mars, les versions 1.82.7 et 1.82.8 de LiteLLM (PyPI) sont restées en ligne environ 40 minutes avant mise en quarantaine ; la seconde contenait un fichier litellm_init.pth, et toutes deux une charge conçue pour voler des identifiants cloud (billet de LiteLLM).

Du bruit au signal : trier 568 failles

FiltreFailles restantesRemarque
Failles distinctes, 134 composants56813 critiques, 180 hautes, 176 modérées, 31 faibles, 168 sans gravité renseignée
Paquet malveillant (avis « MAL »)0aucun dans l’inventaire
Catalogue KEV (exploitée dans la nature)1Starlette, CVE-2026-48710, ajoutée à KEV le 2 septembre 2026
EPSS supérieur ou égal à 10 %0maximum 7,1 % ; médiane 0,36 %
Sans correctif publié18à surveiller ou à contourner : il n’y a rien à installer

Étude de cas : la seule faille KEV

La faille KEV est dans Starlette, la couche web de FastAPI, dans notre application de recettes (version 0.49.1 ; corrigée en 1.0.1, et en 1.3.1 pour l’ensemble de ses cinq avis). L’avis décrit un en-tête Host non validé : request.url.path peut différer du chemin réellement demandé, ce qui contourne les contrôles d’accès fondés sur ce chemin. Il précise que les applications derrière un proxy sont protégées si le proxy rejette l’en-tête mal formé. Plutôt que de supposer, nous avons vérifié trois choses :

  1. Le code de l’application n’utilise pas ce motif pour contrôler l’accès : grep sur request.url ne trouve que de la journalisation et des requêtes sortantes.
  2. Le reverse proxy rejette l’en-tête piégé : Host: nom.exemple/abc?x= reçoit un 400, un autre nom d’hôte un 421 (seule la requête normale passe, en 200).
  3. Le port de l’application, atteignable depuis le réseau local, accepte l’en-tête mal formé (200). L’exposition réelle se limite donc au réseau local, elle n’est pas nulle.

Décision : ce n’est pas une urgence à minuit, mais une faille KEV ne se laisse pas en place. Nous avons mesuré ce que donnerait la mise à jour : le fichier de verrouillage publié avec la dernière version de l’application embarque Starlette 1.6.0 et ne laisse que 10 failles distinctes sur 5 composants (contre 145 sur 26), aucune dans KEV. Une seule mise à jour, à planifier avec sauvegarde préalable, fait donc plus que tous les correctifs un par un. Remarque : nous n’avons modifié aucun des outils mesurés pour écrire cet article.

Ce qui protège vraiment : quatre protections testées

1. Compter avant d’installer

pip install --dry-run --report - résout les dépendances et affiche ce qui serait installé, sans rien télécharger d’exécutable (avec --only-binary=:all:, aucun code de construction n’est lancé). Résultats du 11 octobre 2026 :

Paquet demandéPaquets installés
django3
pandas4
requests5
flask7
fastapi11
streamlit36
jupyterlab90

Une petite application Streamlit s’installe avec 36 paquets, un environnement JupyterLab avec 90 : chaque pip install est un petit arbre de dépendances.

2. Épingler avec des empreintes

Le mode --require-hashes de pip (documentation) refuse tout fichier dont l’empreinte diffère de celle du fichier de dépendances. Nous l’avons testé avec six 1.17.0 et quatre fichiers :

Fichier de dépendancesRésultat de pip
six==1.17.0 --hash=sha256:4721…3274 (empreinte publiée par PyPI)téléchargé
même ligne, premier caractère de l’empreinte modifiérefusé : « THESE PACKAGES DO NOT MATCH THE HASHES », avec l’empreinte attendue et l’empreinte reçue
six==1.17.0 sans empreinterefusé : « Hashes are required in –require-hashes mode »
six>=1.16 --hash=… (version non épinglée)refusé : « all requirements must have their versions pinned with == »

Limite : l’empreinte protège contre une substitution du fichier, pas contre un paquet piégé dès l’origine, dont vous auriez épinglé l’empreinte vous-même.

3. Savoir ce qui s’exécute à l’installation

Nous avons fabriqué des paquets inoffensifs qui écrivent un fichier-témoin, et regardé quand ce fichier apparaît :

CasFichier-témoin créé ?
pip, paquet disponible uniquement en source (setup.py)oui, pendant l’installation
même cas avec --only-binary=:all:non : « No matching distribution found », refusé avant toute exécution
pip, installation d’une roue déjà construitenon : installer une roue n’exécute pas le code du paquet
…mais la roue contient un fichier .pth ; on démarre simplement Python, sans rien importeroui, au démarrage de l’interpréteur
npm, paquet avec un script postinstalloui, pendant npm install
npm avec --ignore-scriptsnon (option documentée)

Deux enseignements. D’abord, « roues seulement » ne suffit pas : Python exécute les lignes d’un fichier .pth qui commencent par import à chaque démarrage (documentation du module site), c’est le mécanisme de la version 1.82.8 de LiteLLM. Ensuite, un piège que nous avons rencontré : --only-binary ne s’applique pas à un chemin de fichier donné explicitement (notre première tentative a quand même exécuté le code) ; il agit sur ce qui est résolu depuis un index.

4. Le délai de carence

Nous avons mesuré ci-dessus l’exposition d’axios : 2 h 55 avant l’avis. Les gestionnaires de paquets proposent maintenant de refuser les versions trop récentes (tour d’horizon d’Andrew Nesbitt) : pnpm (minimumReleaseAge), npm (min-release-age), uv (exclude-newer), pip (--uploaded-prior-to). Nous avons testé le dernier :

$ pip --version
pip 26.2.1
$ pip download --no-deps --only-binary=:all: requests                              -> requests-2.34.2
$ pip download --no-deps --only-binary=:all: --uploaded-prior-to 2024-01-01 requests -> requests-2.31.0
$ pip download --no-deps --only-binary=:all: --uploaded-prior-to P7D requests        -> requests-2.34.2  (publiée il y a plus de 7 jours)

Les options de pnpm, npm et uv sont citées d’après leur documentation et ce tour d’horizon ; nous ne les avons pas testées ici. Contrepartie : un délai de sept jours retarde aussi les correctifs. Prévoyez une exception explicite pour les failles KEV, dont le correctif ne doit pas attendre.

Un plan d’action

  1. Inventorier : un SBOM par outil (depscan.py --sbom), état installé et fichier de verrouillage.
  2. Retirer ce qui ne sert pas en production : --no-dev / --prod / --omit=dev. C’est la réduction de surface la moins chère.
  3. Trier avec KEV, EPSS, exposition et correctif disponible, dans cet ordre ; traiter tout avis « MAL » comme un incident (machine compromise, identifiants à renouveler).
  4. Épingler et vérifier : fichiers de verrouillage versionnés, empreintes (--require-hashes), scripts d’installation désactivés (--ignore-scripts) là où l’outil n’en a pas besoin.
  5. Ajouter un délai de carence d’une semaine par défaut, avec une exception documentée pour les correctifs de failles exploitées.
  6. Rejouer le scan à intervalle régulier (nous le faisons chaque semaine) et alerter seulement sur KEV et paquets malveillants, sinon on s’habitue au bruit.

Ce que nous avons vérifié, et ses limites

Les chiffres de SANS, Sonatype, ReversingLabs et du DBIR sont ceux des communiqués et articles cités, relus à la source ; le détail d’axios vient de la post-mortem du mainteneur, celui de LiteLLM du billet de l’éditeur, les horaires des avis de la base OSV elle-même. Le script et ses 32 tests ont été exécutés, comparés à go version -m, pip list, npm ls et au schéma CycloneDX, et lancés sur l’inventaire réel du laboratoire le 11 octobre 2026. Les démonstrations d’exécution à l’installation (pip, npm, .pth), du mode d’empreintes et de --uploaded-prior-to ont été exécutées avec des paquets inoffensifs que nous avons fabriqués.

  • « Touché » ne veut pas dire « exploitable ». OSV rapproche des versions, pas des chemins d’appel : une faille peut concerner une fonction que le programme n’appelle jamais. Pour Go, l’outil officiel govulncheck sait en tenir compte ; nous ne l’avons pas lancé ici.
  • La plus petite version « qui corrige tout » est calculée par comparaison numérique des versions, pas avec toutes les règles de SemVer ou de PEP 440 (versions préliminaires notamment).
  • L’indicateur « développement » est exact pour uv.lock, package-lock.json et Composer ; pour yarn et pnpm, il n’est connu que pour les dépendances directes (affiché « ? » sinon).
  • Non couvert : paquets du système, couches d’images de conteneurs, bibliothèques JavaScript copiées dans des pages, binaires sans information de build, outils Rust.
  • EPSS est une probabilité, pas une mesure d’exposition : une faille à 0,3 % sur un service exposé et non protégé peut passer avant une faille à 7 % sur un service isolé.

Transparence : la recherche, la rédaction et, lorsqu’il y en a, les tests de cet article ont été réalisés avec l’aide d’un assistant IA (Claude) ; l’éditeur du site relit et valide chaque article avant publication. Les mesures portent sur notre propre laboratoire ; nous n’y avons modifié aucun outil pour cet article.

Le script complet

Le code complet de depscan.py (bibliothèque standard de Python 3.11 ou plus).

#!/usr/bin/env python3
"""depscan.py [options] CHEMIN... : inventaire des dépendances d'un projet et avis connus (OSV.dev), sans rien exécuter.

CHEMIN : dossier site-packages ou venv Python, requirements.txt, uv.lock, package-lock.json, yarn.lock, pnpm-lock.yaml,
composer.lock, Gemfile.lock, ou binaire Go (les informations de build sont intégrées au binaire).
  --json FICHIER  inventaire + avis    --sbom FICHIER  SBOM CycloneDX 1.5    --no-net  inventaire seul
  --kev  marque les CVE du catalogue CISA KEV    --epss  ajoute le score EPSS (FIRST)    --top N  avis affichés (10)
Code retour : 0 aucun avis, 1 au moins un avis, 2 erreur de lecture."""
import argparse, email.parser, json, mmap, re, sys, tomllib, urllib.request, uuid
from collections import namedtuple
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import quote

Comp = namedtuple("Comp", "eco name ver direct dev")
OSV, KEV = "https://api.osv.dev/v1", "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"
norm = lambda n: re.sub(r"[-_.]+", "-", n).lower()
vkey = lambda v: [int(x) for x in re.findall(r"\d+", v)]


def manifest(path, kind):
    """Noms directs (prod, dev) d'un package.json / composer.json voisin, sinon None."""
    try:
        m = json.loads(Path(path).read_text())
    except (OSError, ValueError):
        return None, None
    a, b = ("dependencies", "devDependencies") if kind == "npm" else ("require", "require-dev")
    prod = {k for k in {**m.get(a, {}), **m.get("optionalDependencies", {})} if k != "php" and not k.startswith("ext-")}
    return prod, set(m.get(b, {}))


def parse_venv(d):
    pk, need = {}, set()
    for f in Path(d).glob("*.dist-info/METADATA"):
        h = email.parser.Parser().parsestr(f.read_text(errors="ignore"), headersonly=True)
        pk[norm(h["Name"])] = h["Version"]
        for r in h.get_all("Requires-Dist") or []:
            if not re.search(r"extra\s*==", r):
                need.add(norm(re.match(r"[A-Za-z0-9][A-Za-z0-9._-]*", r).group()))
    return [Comp("PyPI", n, v, n not in need, False) for n, v in pk.items()]


def parse_requirements(text):
    rx = re.compile(r"^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?\s*==\s*([^\s;\\#]+)")
    return [Comp("PyPI", norm(m[1]), m[2], None, None) for m in map(rx.match, (l.strip() for l in text.splitlines())) if m]


def parse_uv_lock(text):
    data = tomllib.loads(text)
    pk = {p["name"]: p for p in data["package"]}
    root = next((p for p in data["package"] if set(p.get("source", {})) & {"virtual", "editable"}), {})
    names = lambda lst: [d["name"] for d in lst]
    direct = set(names(root.get("dependencies", [])))
    for grp in (*root.get("optional-dependencies", {}).values(), *root.get("dev-dependencies", {}).values()):
        direct |= set(names(grp))
    prod, todo = set(), names(root.get("dependencies", []))
    while todo:
        n = todo.pop()
        if n not in prod and n in pk:
            prod.add(n); todo += names(pk[n].get("dependencies", []))
    return [Comp("PyPI", norm(n), p["version"], n in direct, n not in prod) for n, p in pk.items() if p is not root and "version" in p]


def _npm_flags(out, man):
    """Complète direct/dev d'après le package.json voisin (directs seulement : yarn et pnpm ne donnent pas le reste)."""
    prod, dev = man
    if prod is None:
        return out
    res = []
    for c in out:
        d = False if c.name in prod else True if c.name in dev else c.dev
        res.append(c._replace(direct=c.name in prod | dev, dev=d))
    return res


def parse_package_lock(text, man=(None, None)):
    lock, out = json.loads(text), {}
    root = lock.get("packages", {}).get("")
    direct = None if root is None else set(root.get("dependencies", {})) | set(root.get("devDependencies", {})) | set(root.get("optionalDependencies", {}))
    for path, p in lock.get("packages", {}).items():
        if path and not p.get("link") and "version" in p:
            n = p.get("name") or path.split("node_modules/")[-1]
            out[(n, p["version"])] = Comp("npm", n, p["version"], None if direct is None else n in direct, bool(p.get("dev")))
    def walk(deps):  # lockfileVersion 1 : arbre imbriqué
        for n, p in deps.items():
            out[(n, p["version"])] = Comp("npm", n, p["version"], None, bool(p.get("dev"))); walk(p.get("dependencies", {}))
    if "packages" not in lock:
        walk(lock.get("dependencies", {}))
    return _npm_flags(list(out.values()), man) if direct is None else list(out.values())


def parse_yarn_lock(text, man=(None, None)):
    out = {}
    for blk in re.split(r"\n\s*\n", text):
        lines = [l for l in blk.splitlines() if l.strip() and not l.startswith("#")]
        if not lines or lines[0].startswith(("__metadata", " ")):
            continue
        v = re.search(r"^\s+version:?\s+\"?([^\"\n]+)\"?", blk, re.M)
        if v:
            name = lines[0].rstrip(":").split(",")[0].strip().strip('"').rsplit("@", 1)[0]
            out[(name, v[1])] = Comp("npm", name, v[1], None, None)
    return _npm_flags(list(out.values()), man)


def parse_pnpm_lock(text, man=(None, None)):
    out, sec, cur = {}, None, None
    rx = re.compile(r"^  ['\"]?/?((?:@[^/@'\"]+/)?[^@/'\"\s]+)@([^\s'\"(:]+)")
    for ln in text.splitlines():
        if re.match(r"^\S.*:\s*$", ln):
            sec = ln.strip(": ")
        elif sec == "packages":
            m = rx.match(ln)
            if m:
                cur = (m[1], m[2]); out[cur] = Comp("npm", m[1], m[2], None, None)
            elif cur and ln.strip() == "dev: true":
                out[cur] = out[cur]._replace(dev=True)
    return _npm_flags(list(out.values()), man)


def parse_composer_lock(text, man=(None, None)):
    lock, prod, dev = json.loads(text), man[0], man[1] or set()
    out = []
    for key, is_dev in (("packages", False), ("packages-dev", True)):
        for p in lock.get(key, []):
            out.append(Comp("Packagist", p["name"], p["version"].lstrip("v"), None if prod is None else p["name"] in prod | dev, is_dev))
    return out


def parse_composer_installed(text):
    """vendor/composer/installed.json : ce qui est réellement installé (les paquets de développement y sont signalés)."""
    d = json.loads(text)
    pk = d["packages"] if isinstance(d, dict) else d
    devs = set(d.get("dev-package-names", [])) if isinstance(d, dict) else set()
    return [Comp("Packagist", p["name"], p["version"].lstrip("v"), None, p["name"] in devs) for p in pk]


def parse_gemfile_lock(text):
    gems = re.findall(r"^    ([^\s(]+) \(([^)]+)\)$", text.split("\nDEPENDENCIES")[0], re.M)
    direct = set(re.findall(r"^  ([^\s(!]+)", text.split("\nDEPENDENCIES")[1].split("\n\n")[0], re.M)) if "\nDEPENDENCIES" in text else set()
    return [Comp("RubyGems", n, v, n in direct, None) for n, v in sorted({(n, v.split("-")[0]) for n, v in gems})]


def parse_go_binary(path):
    """Lit les informations de build d'un binaire Go (format 1.18+ : chaînes intégrées après l'en-tête de 32 octets)."""
    with open(path, "rb") as f, mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as m:
        off = m.find(b"\xff Go buildinf:")
        while off >= 0 and off % 16:
            off = m.find(b"\xff Go buildinf:", off + 1)
        if off < 0 or not m[off + 15] & 2:
            raise ValueError("pas d'informations de build Go (format 1.18+ attendu)")
        pos, strings = off + 32, []
        for _ in range(2):  # version du compilateur, puis modules
            n, shift = 0, 0
            while True:
                b = m[pos]; pos += 1; n |= (b & 0x7F) << shift; shift += 7
                if b < 0x80:
                    break
            strings.append(bytes(m[pos:pos + n]).decode("utf-8", "replace")); pos += n
    comps, last = [], None
    mod = strings[1][16:-16] if len(strings[1]) >= 32 else ""
    for ln in mod.splitlines():
        f = ln.split("\t")
        if f[0] == "dep" and len(f) >= 3:
            last = Comp("Go", f[1], f[2].lstrip("v"), None, False); comps.append(last)
        elif f[0] == "=>" and last and len(f) >= 3 and f[2]:  # remplacement : on garde la version réellement compilée
            comps[-1] = last._replace(name=f[1], ver=f[2].lstrip("v"))
    gv = re.search(r"go(\d+\.\d+(?:\.\d+)?)", strings[0])
    return ([Comp("Go", "stdlib", gv[1], None, False)] if gv else []) + comps


def load(path):
    p = Path(path)
    if p.is_dir():
        return parse_venv(p)
    head, name = p.read_bytes()[:4], p.name
    if head[:4] in (b"\x7fELF", b"\xcf\xfa\xed\xfe") or head[:2] == b"MZ":
        return parse_go_binary(p)
    text = p.read_text(errors="ignore")
    if name == "uv.lock":
        return parse_uv_lock(text)
    if name == "Gemfile.lock":
        return parse_gemfile_lock(text)
    if name.startswith("requirements") and name.endswith(".txt"):
        return parse_requirements(text)
    if name in ("package-lock.json", ".package-lock.json", "yarn.lock", "pnpm-lock.yaml", "lock.yaml"):
        parser = {"yarn.lock": parse_yarn_lock, "pnpm-lock.yaml": parse_pnpm_lock, "lock.yaml": parse_pnpm_lock}.get(name, parse_package_lock)
        return parser(text, manifest(p.with_name("package.json"), "npm"))
    if name == "installed.json":
        return parse_composer_installed(text)
    if name == "composer.lock":
        return parse_composer_lock(text, manifest(p.with_name("composer.json"), "composer"))
    raise ValueError(f"format non reconnu : {name}")


def purl(c):
    n = {"PyPI": "pypi", "npm": "npm", "Packagist": "composer", "RubyGems": "gem", "Go": "golang"}[c.eco]
    v = "v" + c.ver if c.eco == "Go" and c.name != "stdlib" and c.ver[:1].isdigit() else c.ver
    return f"pkg:{n}/{quote(c.name, safe='/')}@{quote(v)}"


def jget(url, body=None):
    req = urllib.request.Request(url, data=json.dumps(body).encode() if body else None, headers={"Content-Type": "application/json"})
    return json.load(urllib.request.urlopen(req, timeout=30))  # nosec B310 (URL https fixes)


def osv_ids(comps, fetch=jget):
    uniq, ids = sorted({(c.eco, c.name, c.ver) for c in comps}), {}
    for i in range(0, len(uniq), 500):
        lot = uniq[i:i + 500]
        qs = [{"package": {"name": n, "ecosystem": e}, "version": v} for e, n, v in lot]
        for key, q, r in zip(lot, qs, fetch(f"{OSV}/querybatch", {"queries": qs})["results"]):
            ids[key] = [v["id"] for v in r.get("vulns", [])]
            while r.get("next_page_token"):  # un composant très touché est paginé
                r = fetch(f"{OSV}/query", {**q, "page_token": r["next_page_token"]})
                ids[key] += [v["id"] for v in r.get("vulns", [])]
    return ids


def fixed_in(detail, c):
    best = None
    for a in detail.get("affected", []):
        p = a.get("package", {})
        if p.get("ecosystem") == c.eco and norm(p.get("name", "")) == norm(c.name):
            for r in a.get("ranges", []):
                for e in r.get("events", []):
                    f = e.get("fixed")
                    if f and vkey(f) > vkey(c.ver) and (best is None or vkey(f) < vkey(best)):
                        best = f
    return best


RANG = {"MALWARE": 5, "CRITICAL": 4, "HIGH": 3, "MODERATE": 2, "MEDIUM": 2, "LOW": 1}


def regrouper(ids, det):
    """Union-find : deux avis qui se citent (alias) ou partagent une CVE décrivent la même faille."""
    chef = {i: i for i in ids}
    def racine(i):
        while chef[i] != i:
            chef[i] = chef[chef[i]]; i = chef[i]
        return i
    vu = {}
    for i in ids:
        for a in [i, *det[i].get("aliases", [])]:
            if a in chef:
                chef[racine(a)] = racine(i)
            elif a.startswith("CVE-"):
                chef[racine(vu.setdefault(a, i))] = racine(i)
    groupes = {}
    for i in ids:
        groupes.setdefault(racine(i), []).append(i)
    return list(groupes.values())


def advisories(comps, fetch=jget, kev=False, epss=False):
    ids = osv_ids(comps, fetch)
    tous = sorted({i for v in ids.values() for i in v})
    with ThreadPoolExecutor(8) as ex:
        det = dict(zip(tous, ex.map(lambda i: fetch(f"{OSV}/vulns/{i}"), tous)))
    known = {v["cveID"] for v in fetch(KEV)["vulnerabilities"]} if kev else set()
    out = []
    for c in sorted(set(comps)):
        for g in regrouper(ids.get((c.eco, c.name, c.ver), []), det):
            cves = sorted({a for i in g for a in det[i].get("aliases", []) + [i] if a.startswith("CVE-")})
            pref = next((i for i in g if i.startswith("GHSA-")), g[0])
            fixes = [f for f in (fixed_in(det[i], c) for i in g) if f]
            sev = max((det[i].get("database_specific", {}).get("severity", "?") for i in g), key=lambda x: RANG.get(x, 0))
            out.append({"id": pref, "ids": sorted(g), "composant": f"{c.name} {c.ver}", "eco": c.eco, "cve": cves,
                        "gravite": "MALWARE" if any(i.startswith("MAL-") for i in g) else sev,  # paquet publié pour nuire : priorité absolue
                        "corrige_en": min(fixes, key=vkey) if fixes else None, "kev": bool(set(cves) & known), "epss": None,
                        "resume": det[pref].get("summary", "")[:90]})
    if epss:
        allc = sorted({c for a in out for c in a["cve"]})
        sc = {r["cve"]: float(r["epss"]) for k in range(0, len(allc), 50) for r in fetch("https://api.first.org/data/v1/epss?cve=" + ",".join(allc[k:k + 50]))["data"]}
        for a in out:
            a["epss"] = max((sc.get(c, 0) for c in a["cve"]), default=None)
    return out


def par_composant(adv):
    """Une ligne par composant touché : nombre de failles, gravité maximale, version qui les corrige toutes (None si l'une n'a pas de correctif)."""
    res = {}
    for a in adv:
        r = res.setdefault(a["composant"], {"composant": a["composant"], "failles": 0, "gravite": "?", "kev": False, "epss": 0, "fixes": []})
        r["failles"] += 1; r["kev"] |= a["kev"]; r["epss"] = max(r["epss"], a["epss"] or 0); r["fixes"].append(a["corrige_en"])
        r["gravite"] = max(r["gravite"], a["gravite"], key=lambda x: RANG.get(x, 0))
    for r in res.values():
        r["mise_a_niveau"] = None if None in r["fixes"] else max(r["fixes"], key=vkey)
        del r["fixes"]
    return sorted(res.values(), key=lambda r: (not r["kev"], -RANG.get(r["gravite"], 0), -r["epss"], r["composant"]))


def sbom(label, comps):
    return {"bomFormat": "CycloneDX", "specVersion": "1.5", "serialNumber": f"urn:uuid:{uuid.uuid4()}", "version": 1,
            "metadata": {"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "tools": [{"name": "depscan.py"}],
                         "component": {"type": "application", "name": label}},
            "components": [{"type": "library", "name": c.name, "version": c.ver, "purl": purl(c), "bom-ref": purl(c),
                            "scope": "optional" if c.dev else "required"} for c in sorted(set(comps))]}


def main(argv=None):
    ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    ap.add_argument("chemins", nargs="+"); ap.add_argument("--json"); ap.add_argument("--sbom")
    ap.add_argument("--no-net", action="store_true"); ap.add_argument("--kev", action="store_true"); ap.add_argument("--epss", action="store_true")
    ap.add_argument("--top", type=int, default=10)
    a, rc, report = ap.parse_args(argv), 0, []
    for path in a.chemins:
        try:
            comps = load(path)
        except (OSError, ValueError, KeyError) as e:
            print(f"{path} : erreur de lecture : {e}", file=sys.stderr); rc = 2; continue
        adv = [] if a.no_net else advisories(comps, kev=a.kev, epss=a.epss)
        lignes = par_composant(adv)
        known = lambda attr: any(getattr(c, attr) is not None for c in comps)
        print(f"{path} : {len(comps)} composants, {sum(1 for c in comps if c.direct) if known('direct') else '?'} directs, "
              f"{sum(1 for c in comps if c.dev) if known('dev') else '?'} de développement, "
              f"{len(lignes)} touchés par {len(adv)} failles distinctes" + (f", {sum(x['kev'] for x in adv)} dans KEV" if a.kev else ""))
        for r in lignes[:a.top]:
            print(f"  {'KEV ' if r['kev'] else '    '}{r['gravite']:<9} {r['composant']:<40} {r['failles']:>2} faille(s)  "
                  f"{'corrigées toutes en ' + r['mise_a_niveau'] if r['mise_a_niveau'] else 'au moins une sans correctif publié'}"
                  + (f"  EPSS max {r['epss']:.3f}" if a.epss else ""))
        report.append({"chemin": path, "composants": [c._asdict() for c in comps], "avis": adv, "par_composant": lignes})
        if a.sbom:
            Path(a.sbom if len(a.chemins) == 1 else f"{a.sbom}.{Path(path).name}.json").write_text(json.dumps(sbom(str(path), comps), indent=1))
        rc = rc or (1 if adv else 0)
    if a.json:
        Path(a.json).write_text(json.dumps(report, indent=1, ensure_ascii=False))
    return rc


if __name__ == "__main__":
    sys.exit(main())

Sources