Des articles approfondis sur les technologies qui façonnent l'avenir.

Attaque supply chain PyPI : la leçon LiteLLM

La compromission de LiteLLM sur PyPI a livré un voleur d'identifiants dans chaque wheel. Ce que le hash pinning et les lockfiles auraient bloqué, ou non.

Un tapis roulant de caisses scellées, dont une caisse falsifiée laisse fuir des fils vers les autres.
Un seul paquet compromis sur le tapis contamine discrètement tout ce qui se trouve en aval.

Trois fichiers. C'est toute l'empreinte d'une des attaques supply chain Python les plus vicieuses que nous ayons vues : un cook.pth de 12 octets, un cook.py de 22 Ko et un sitecustomize.py de 25 Ko. Quand LiteLLM 1.107.0 est arrivé sur PyPI, ces trois fichiers étaient embarqués dans chacune de ses sept wheels. Et comme le démarrage de Python fonctionne ainsi, ils s'exécutaient dans chaque processus Python d'une machine infectée : votre application, votre runner CI, pip lui-même, voire le serveur de langage de votre éditeur. Pas besoin d'importer litellm. Pas besoin de l'exécuter. Il suffisait de lancer un interpréteur Python.

L'équipe LiteLLM a confirmé la compromission et PyPI a retiré la version en quelques heures, mais la fenêtre était suffisamment longue. Les images Docker malveillantes, y compris le tag latest, ont été disponibles pendant environ deux heures et demie. Si votre CI a tiré pendant cette fenêtre, le voleur avait déjà récupéré clés SSH, identifiants cloud, mots de passe de navigateur, secrets CI/CD et jetons d'outils d'IA, les avait chiffrés et envoyés vers un domaine contrôlé par l'attaquant. Ce n'était ni un typosquatting ni un paquet au nom ressemblant. C'était le vrai paquet, construit par le vrai pipeline, signé par le vrai processus de publication. Et ça devrait changer votre façon de penser la confiance envers les dépendances.

Comment trois fichiers ont détourné le pipeline de publication officiel

L'attaquant n'a pas piraté PyPI, et il n'a pas glissé un commit dans le dépôt public : la source GitHub en amont est propre. D'après le rapport d'incident de LiteLLM, l'attaquant détenait les identifiants GitHub CLI de trois développeurs, apparemment récupérés dans les logs d'un déploiement interne de LiteLLM exposé sur Internet. Avec le jeton d'un propriétaire d'organisation, il a contourné la protection de branche et redirigé le workflow de publication privé vers un dépôt au nom ressemblant, Berri-AI/litellm au lieu de BerriAI/litellm. Les identifiants d'un second développeur ont satisfait la validation. Le pipeline officiel a alors construit, signé et publié les artefacts de l'attaquant.

L'injection proprement dite était d'une élégante simplicité. Une seule ligne ajoutée dans le pyproject.toml de la sdist :

[tool.maturin]
python-source = "."
data = "cook"            # <-- injected, copies cook/ into the wheel
python-packages = ["litellm_cli"]   # legitimate, present upstream

Cette directive data = "cook" indique à maturin d'inclure un répertoire cook/ dans le chemin purelib de la wheel, et pip l'installe docilement dans site-packages. La sdist elle-même omettait le dossier : un relecteur qui survolerait la release ne verrait qu'une seule ligne cryptique et rien d'autre. La charge utile se cachait dans les artefacts de build, pas dans les sources. Relire l'historique git, ce que la plupart d'entre nous considèrent comme la vérité de référence, ne vous aurait rien montré du tout.

Pourquoi les fichiers .pth et sitecustomize.py sont le rêve des malwares

Deux mécanismes de la séquence de démarrage de Python rendaient cette attaque particulièrement puissante, et honnêtement, ce sont des fonctionnalités auxquelles la plupart des développeurs Python n'ont jamais réfléchi. Premièrement : tout fichier .pth présent dans site-packages est traité au démarrage de l'interpréteur, et les lignes commençant par import sont exécutées. Le cook.pth de 12 octets ne contenait qu'une seule ligne, import cook, et cela suffisait pour lancer le voleur dans chaque processus Python de la machine. Deuxièmement : Python importe automatiquement un module nommé sitecustomize au démarrage s'il le trouve dans le chemin. Les attaquants ont déposé le leur, qui lançait un démon détaché, lequel contactait toutes les heures un fichier texte hébergé sur GitHub, téléchargeait le code désigné par l'attaquant et l'exécutait avec exec().

C'est bien plus grave qu'un paquet malveillant classique avec un setup.py piégé ou un mauvais import dans __init__.py. Ceux-ci s'exécutent à l'installation ou à l'import du paquet. Celui-ci s'exécutait dès que vous lanciez n'importe quoi en Python. Votre exécution de pytest en CI. Le pip list que vous tapez pour vérifier ce qui s'est passé. Le formateur black de votre IDE. Chaque invocation réarmait le piège.

Il vaut la peine de s'arrêter sur ce que le voleur collectait réellement, car la liste ressemble à une carte de la vie moderne du développeur : identifiants AWS, Azure et gcloud, kubeconfigs, jetons GitHub CLI, clés SSH et GPG, bases de mots de passe des navigateurs, historiques shell, secrets CI issus des runners GitLab et de Jenkins, identifiants Terraform, variables d'environnement d'autres processus lues via /proc/<pid>/environ, identifiants des métadonnées d'instance AWS et, avec une ironie cruelle, des jetons pour Claude Code, Cursor, Copilot et une douzaine d'autres outils de codage IA, ainsi que les clés des fournisseurs LLM stockées dans la base de données du proxy de LiteLLM. Un seul pip install, et l'attaquant détient en pratique toute votre identité d'ingénieur.

Pas besoin d'importer litellm. Il suffisait de lancer un interpréteur Python sur la machine pour exécuter le voleur.

Hash pinning et lockfiles : nécessaires, mais pas suffisants

Toute checklist de sécurité supply chain commence par les lockfiles et le hash pinning, et c'est normal. Si vous épinglez les hashes avec pip install --require-hashes ou utilisez un outil comme uv ou Poetry avec des hashes verrouillés, une wheel qui change silencieusement après avoir été vérifiée est rejetée. C'est une vraie protection contre toute une classe d'attaques : altération du registre, empoisonnement de miroir, remplacement d'artefacts.

Mais soyons honnêtes sur ce qu'elle fait ici : rien, du moins pour quiconque a mis à jour en premier. Le hash pinning garantit que l'artefact installé demain est identique octet pour octet à celui installé aujourd'hui. Dans cette attaque, les wheels malveillantes étaient les artefacts officiels : de première partie, construits par le pipeline, avec des hashes cohérents. Dès que quelqu'un a lancé sa mise à jour de routine, résolu la nouvelle version et committé le lockfile mis à jour, les hashes malveillants étaient épinglés dans le dépôt avec une vérification CI au vert. Le pinning vous protège du changement ; il ne dit rien de l'intention.

Là où le pinning brille vraiment dans une histoire comme celle-ci, c'est sur le rayon d'impact. Les équipes qui épinglent les hashes et suivent un rythme de mise à jour délibéré ne suivent pas automatiquement chaque release dès sa sortie. Si votre politique est « mettre à jour les dépendances via une PR hebdomadaire, avec une période de carence de quelques jours pour tout ce qui n'est pas un correctif critique », le retrait sur PyPI à 13 h 23 UTC a eu lieu avant même l'ouverture de votre fenêtre de mise à jour. Un processus ennuyeux bat plus souvent qu'on ne voudrait l'admettre un outillage astucieux.

Alors gardez vos lockfiles. Générez-les avec des hashes. Mais arrêtez de vous dire qu'ils défendent contre un pipeline de mainteneur compromis : ils protègent contre la dérive et l'altération, et servent de ralentisseur face aux mauvaises releases. C'est tout.

Une solide porte de coffre-fort avec une brèche béante dans le mur fragile à côté.
Le hash pinning verrouille la porte d'entrée ; cette attaque a traversé le mur, c'est-à-dire le pipeline de build lui-même.

L'attestation de provenance aurait détecté celle-ci

Voici le revers désagréable : cette attaque présentait une anomalie flagrante et détectable, et l'outillage de l'écosystème capable de la repérer existe déjà. Les wheels malveillantes n'ont pas été construites depuis le dépôt public. Elles ont été construites depuis un dépôt au nom ressemblant créé par l'attaquant. Toute étape de vérification demandant « cet artefact a-t-il été construit à partir du commit et du dépôt qu'il annonce ? » aurait échoué bruyamment.

C'est précisément ce que fournit l'attestation de provenance. PyPI prend en charge les attestations PEP 740 : lorsqu'un paquet est publié via des Trusted Publishers depuis GitHub Actions, le workflow peut générer une déclaration signée liant l'artefact au dépôt, au workflow et au commit exacts qui l'ont produit. Une vérification côté consommateur devient alors mécanique : vérifier l'attestation, confirmer que le dépôt source est BerriAI/litellm et non Berri-AI/litellm. Les bundles Sigstore, la provenance SLSA et les vérifications de type pip-audit appartiennent tous à cette famille.

Le retard actuel tient à l'adoption et à l'application. Les attestations sont facultatives pour les éditeurs, et presque personne ne les vérifie à l'installation, même quand elles existent. C'est la partie qui doit changer, et elle rejoint un point déjà soulevé ici sur une infrastructure de confiance qui ne fonctionne que si quelqu'un la contrôle réellement : une signature que personne ne vérifie n'est qu'une décoration. Si votre organisation gère son propre index ou proxy de paquets, et si vous êtes plus d'une poignée d'ingénieurs, vous devriez, « rejeter les artefacts sans provenance vérifiable depuis la source attendue » est une politique que vous pouvez appliquer de façon centralisée, une fois, pour chaque développeur.

Il y a aussi une leçon plus subtile. L'attaquant avait besoin de trois identifiants de développeurs : un pour rediriger le workflow, un pour valider, un pour couvrir le tout. C'est une défaillance systémique de l'hygiène des identifiants, pas une erreur isolée. Des jetons Trusted Publisher OIDC de courte durée sur PyPI auraient considérablement réduit la surface d'attaque : il n'y a pas de jeton longue durée traînant dans un fichier de log à voler. Si votre projet publie encore avec des jetons d'API stockés comme secrets CI, c'est le moment de s'en occuper.

Installations sandboxées et intérêt des périodes de carence

Supposons le pire : une release malveillante passe à travers vos lockfiles et vos vérifications de provenance, car un jour ou l'autre cela arrivera. Que reste-t-il ? Le confinement. La question n'est plus « comment empêcher les mauvais paquets ? » mais « quand un mauvais paquet s'exécute, à quoi peut-il accéder ? »

Pour la CI, la réponse est réellement atteignable. Les builds doivent s'exécuter dans des environnements éphémères, sans identifiants longue durée, avec des jetons restreints et un filtrage de sortie réseau. Le comportement du voleur LiteLLM fait beaucoup de bruit sur le réseau si l'on écoute : un POST vers un domaine inconnu avec un en-tête d'authentification personnalisé, une récupération depuis une URL GitHub brute en boucle horaire et, l'indice le plus flagrant, un processus Python interrogeant 169.254.169.254, le service de métadonnées d'instance AWS. Le code applicatif n'a presque jamais de raison légitime de contacter directement IMDS : le SDK s'en charge. Alertez sur les processus Python qui accèdent à ce point de terminaison et vous attraperez toute une famille de voleurs d'identifiants, pas seulement celui-ci. Nous avons déjà écrit sur les angles morts de la journalisation de l'authentification cloud : celui-ci en fait partie, et il est peu coûteux à combler.

Les postes des développeurs sont plus difficiles, et je ne vais pas prétendre le contraire. Un ordinateur portable où le même utilisateur fait tourner son éditeur, son navigateur, son agent SSH et des pip install arbitraires est un buffet pour voleur. Les mesures pragmatiques : faites vos installations exploratoires dans des conteneurs ou des devcontainers plutôt que dans l'environnement de votre machine hôte ; gardez vos installations de paquets dans le devcontainer du projet pour qu'un sitecustomize.py malveillant n'atterrisse jamais dans les site-packages de votre hôte ; et considérez ~/.aws/credentials comme un passif, car le SSO avec des jetons de courte durée (aws sso login, gcloud auth) signifie qu'il reste bien moins de choses sur le disque à voler. Rien de tout cela n'est exotique. C'est simplement de la friction, et la friction est justement le but.

  • Épinglez les versions et les hashes dans un lockfile, mais traitez les mises à jour comme des événements délibérés, pas comme du bruit de fond.
  • Imposez une période de carence de 48 à 72 heures avant que les mises à jour de dépendances non critiques n'atteignent les builds de production. La plupart des releases malveillantes sont retirées en quelques heures.
  • Vérifiez les attestations de provenance là où elles existent, et privilégiez les paquets publiés via des Trusted Publishers avec OIDC plutôt que des jetons d'API longue durée.
  • Exécutez les builds CI dans des environnements éphémères sans identifiants, avec filtrage de sortie réseau ; alertez sur tout processus Python qui accède aux points de terminaison de métadonnées d'instance.
  • Installez et exécutez le code tiers dans des conteneurs sur les postes des développeurs, et remplacez les fichiers d'identifiants cloud statiques par des sessions SSO de courte durée.
  • Auditez votre environnement à la recherche des IoC si vous avez tiré quoi que ce soit pendant la fenêtre : cook.pth, cook.py, ou un sitecustomize.py isolé dans site-packages, ainsi que /tmp/.pc_once_* ou /tmp/remote_code_loader.lock sur le disque.

Ce que cette attaque révèle sur l'endroit où se trouve le vrai risque

Remarquez ce que l'attaquant n'a pas pris la peine de faire. Il n'a exploité aucune vulnérabilité de code. Il n'a pas eu besoin de faille zero-day. Il a visé la cible la plus molle du logiciel moderne : les humains et les machines qui détiennent des identifiants permanents. Trois jetons GitHub CLI, apparemment extraits des logs d'un déploiement interne exposé sur Internet, ont suffi à prendre le contrôle d'un pipeline de publication servant des millions de téléchargements. C'est le même schéma que l'on voit se répéter dans l'industrie : des secrets qui prolifèrent dans les logs, les agents et les fichiers de configuration, chacun étant une clé universelle. Si vous faites tourner une instance interne de quoi que ce soit et que vous l'exposez à Internet, ses logs font désormais partie de votre modèle de menace. Cette phrase devrait mettre quelques personnes mal à l'aise.

Il y a aussi un problème de vitesse à souligner. LiteLLM est un projet qui évolue très vite, avec des dizaines de milliers de commits et des releases fréquentes, et il se trouve au centre de la pile d'outils IA, tiré par des frameworks et des SDK dont beaucoup d'équipes ignorent même qu'ils en dépendent. La portée transitive signifie que vous ne choisissez pas votre exposition en choisissant vos dépendances directes. Un SBOM cesse d'être de la paperasse de conformité dans ce contexte ; c'est ce qui vous permet de répondre à « sommes-nous touchés ? » en quelques minutes plutôt qu'en plusieurs jours. Les outils qui génèrent une nomenclature logicielle à partir des preuves réelles de build plutôt que de l'extraction des manifestes détecteront les dépendances que votre requirements.txt ne mentionne pas.

Une silhouette solitaire avec une lanterne inspectant une caisse parmi des rayonnages d'entrepôt sans fin.
Les arbres de dépendances modernes sont des entrepôts ; la plupart des équipes n'ont jamais inventorié ce qui se trouve sur leurs étagères.

Mon avis : la réponse de l'industrie aux attaques supply chain a trop misé sur le théâtre de la prévention, avec des scanners qui signalent après coup les versions connues comme dangereuses, et pas assez sur les contrôles peu glamours qui auraient changé cette histoire. Des identifiants de publication de courte durée. Une vérification de provenance au niveau du proxy. Des périodes de carence. Des alertes sur les points de terminaison de métadonnées. Des installations sandboxées. Aucun de ces points n'est un problème de recherche. Ce sont des décisions de configuration, disponibles aujourd'hui, et la plupart des équipes n'en ont mis en place aucune. Les attaquants de LiteLLM avaient besoin d'une mauvaise ligne dans une config de build et de trois jetons volés. Les défenses ci-dessus les auraient obligés à franchir quatre couches indépendantes au lieu d'une seule. C'est ce que signifie réellement la défense en profondeur : pas plus de scanners, mais davantage de manières indépendantes d'échouer. C'est la même philosophie que celle derrière la conception de systèmes qui résistent aux catastrophes, plutôt que ceux qui espèrent simplement le meilleur.

La prochaine release compromise est déjà en préparation quelque part. Vous ne l'empêcherez pas d'être publiée. Mais vous pouvez absolument décider si elle s'exécute avec vos clés AWS à portée de main, votre agent SSH déverrouillé et un chemin réseau clair vers un domaine contrôlé par l'attaquant, ou si elle meurt dans un conteneur éphémère sans identifiants et derrière un pare-feu. Cette décision se prend maintenant, dans votre configuration CI et votre setup de devcontainer, pas le jour où le rapport d'incident tombe.