Principaux enseignements
- L'URL Git et les dépendances des archives tar distantes sont récupérées à partir de leur emplacement d'origine, jamais via votre registre.
- Sur les 53 707 versions de paquets que nous suivons, 811 spécifications de dépendances sont résolues en dehors du registre.
- Les références de branche, de balise et non épinglées constituent le véritable risque, car leur code peut évoluer sans qu'il y ait de version publiée.
- Une reconstruction octet par octet prouve la fidélité, et non la conformité ; ainsi, une séquence d'échappement fidèlement reproduite reste une séquence d'échappement.
La plupart des équipes qui exploitent un npm Le registre le décrit de la même manière. Tout passe par Nexus ou Artifactory, le registre public est situé derrière un proxy, et rien n'arrive sur l'ordinateur portable d'un développeur ou sur un serveur d'intégration continue sans passer par cette unique porte qu'ils contrôlent.
C'est vrai, en grande partie. C'est justement ce « en grande partie » qui mérite qu'on s'y attarde.
Lors du développement de SecureChain, le service de TuxCare qui recompile les paquets open source à partir du code source et les distribue avec une traçabilité, nous avons cherché à identifier les cas où ce n'était pas le cas. Nous avons passé au crible 53 707 versions du npm Dans le graphe des dépendances, nous avons recensé et signalé chaque dépendance qui indique npm pour éviter complètement le registre. Cet article présente nos conclusions, explique pourquoi la configuration de votre registre ne permet pas de rediriger ces dépendances, et indique les points à vérifier dans vos propres projets cette semaine.
Ce que nous entendons par « dépendance échappante »
Une dépendance « échappant au miroir » est une dépendance déclarée à l'aide d'une URL Git, d'une URL d'archive tar ou d'un autre emplacement distant, au lieu d'un nom de paquet et d'une plage de versions, de sorte que npm il le récupère directement à cet emplacement plutôt que de le résoudre via votre registre.
En voici un. Les noms ont été modifiés, mais la structure correspond exactement à ce que nous avons trouvé dans les dépendances d'exécution d'un paquet publié :
"dependencies": {
"lodash": "^4.15.0",
"some-markdown-lib": "git://github.com/some-user/some-markdown-lib.git"
}
La première ligne correspond au cas normal. npm demande au registre que vous avez configuré pour un lodash version qui satisfait ^4.15.0. La deuxième ligne ne comporte ni version, ni commit, ni registre. Elle indique un dépôt sur un compte GitHub personnel, et npm récupère tout ce qui se trouve sur sa branche par défaut au moment de l'installation.
Si c'est le cas git:// Si ce préfixe te semble obsolète, tu as l'œil. GitHub a désactivé le protocole Git sans authentification dès 2022. Cela ne rend toutefois pas cette dépendance inactive. npm normalise GitHub git:// Les URL sont en HTTPS ; ainsi, sur toute configuration autorisant les dépendances Git, elles sont toujours résolues et installées. Une spécification qui semble défectueuse lors d'une révision du code fonctionne très bien dans la pratique, ce qui est un excellent moyen pour qu'un problème passe inaperçu pendant des années.
Pourquoi les paramètres de votre registre ne peuvent pas y accéder
Le registry s'installer .npmrc répond à une question. Lorsqu'une dépendance est constituée d'un nom et d'une plage, où faut-il npm « chercher » ? Une URL Git ou un lien HTTPS vers une archive tar n'est pas ce genre de dépendance. Elle contient son propre emplacement, et npm y accède directement. Remplacements de registre par zone, jetons d'authentification, un référentiel proxy en amont de npmjs.org: tout cela n'entre pas en ligne de compte, car npm ne lance jamais de requête d'enregistrement pour cette dépendance.
La profondeur du répertoire n'aide pas non plus. Si un paquet situé trois niveaux plus bas dans votre arborescence en déclare un, votre installation tente d'accéder à GitHub en cours d'installation, depuis un ordinateur portable ou un runner dont vous pensiez qu'il ne communiquait qu'avec votre registre. Lorsque ces machines ne parviennent pas à accéder à GitHub, l'installation échoue, et c'est ainsi que de nombreuses équipes découvrent leur existence. Lorsqu'elles y parviennent, tout se passe bien et personne ne s'en rend compte.
Les fichiers de verrouillage ne sont pas aussi utiles qu'on pourrait le croire. npm Il enregistre effectivement le commit résolu pour une dépendance Git, de sorte que les installations répétées à partir du même fichier de verrouillage obtiennent le même code. Cependant, la première résolution utilise toujours ce que contenait la branche ce jour-là, et chaque opération de récupération s'effectue toujours sur GitHub plutôt que sur votre registre. Un fichier de verrouillage fixe le contenu. Il ne modifie pas la provenance de ce contenu.
Ce que nous ont appris ces 53 707 versions de paquets
Parmi ces 53 707 versions de paquets, nous avons recensé 811 spécifications de dépendances dont la résolution s'effectue en dehors du registre, réparties sur 682 versions de 303 paquets distincts. GitHub en compte 730. Le reste est un mélange hétéroclite de liens vers des CDN, des serveurs de téléchargement de fournisseurs, des hébergements personnels et un « gist » GitHub utilisé comme dépendance.
Sur les 811 spécifications, 636 sont des dépendances de développement. Les utilisateurs du paquet publié ne les installent généralement pas, bien qu’elles soient récupérées à chaque fois que le paquet lui-même est compilé, ce qui est important si vous accordez de l’importance à la reproductibilité des compilations.
Les 175 autres se trouvent dans les dépendances d’exécution ou optionnelles, c’est-à-dire les périmètres orientés utilisateur. Ce chiffre doit être accompagné d’un astérisque, et il vaut mieux le signaler dès maintenant plutôt que de laisser quelqu’un le faire remarquer plus tard. Sur ces 175, 155 proviennent d’un seul outil CLI abandonné depuis longtemps, qui compte des dizaines de versions publiées, mais dont aucun élément du graphe de dépendances que nous suivons ne dépend. Si vous le retirez, il ne reste plus que 20 spécifications d’échappement destinées aux utilisateurs, réparties dans 15 paquets.
Vingt, ça semble peu, et ça l’est. Il ne faut surtout pas y voir une épidémie. Mais 13 de ces 20 paquets renvoient vers une branche ou un dépôt vide, ce qui signifie que le code qui les sous-tend peut changer à tout moment. C’est justement grâce à ces petits chiffres que ce genre de problème passe inaperçu. Personne ne vérifie les 15 paquets dont on ignorait qu’ils étaient particuliers.
Toutes les tentatives d'évasion ne présentent pas le même degré de dangerosité
La forme de la lunette a bien plus d'importance que la direction vers laquelle elle est orientée.
Une dépendance épinglée à un SHA de commit complet est adressée par contenu. Le propriétaire d’un dépôt ne peut pas déplacer cette référence vers un autre commit comme il le ferait pour une branche ou une balise ; ainsi, même si le dépôt change de mains demain, vous obtiendrez ce qui s’y trouvait au moment où l’épinglage a été effectué. Il reste la question de la disponibilité, ainsi que le fait que cela contourne toujours votre registre. Il s’agit davantage d’un problème de confinement que d’injection de code.
Les URL de dépôts nues, les branches, les balises et les références Git résolues selon la convention SemVer doivent toutes être considérées comme modifiables. L'absence totale de référence signifie la branche par défaut. A #branch La référence suit la branche. A #semver: La résolution de la plage s'effectue par rapport aux balises ou aux références correspondantes au moment de l'installation. Et les balises méritent qu'on leur consacre une phrase à part, car les gens les traitent comme des versions. Or, ce n'en sont pas. Une balise est un pointeur, et le propriétaire du dépôt peut la déplacer vers un autre commit quand bon lui semble. Si ce n'est pas un SHA, partez du principe qu'elle peut changer.
Les URL des archives tar distantes devraient généralement être placées dans le compartiment « mutable » elles aussi, à moins qu’un élément ne garantisse leur intégrité de manière indépendante. Il est déjà arrivé que les archives source générées par GitHub voient leurs sommes de contrôle changer sans que personne n’ait modifié le dépôt ; par conséquent, même un lien qui semble fixe ne garantit pas que le contenu soit identique.
Dans notre recensement, la répartition était très inégale. Selon cette classification, 687 des 811 spécifications étaient modifiables et seules 124 étaient associées à un SHA. Nous n'avons pas compté file:, link:, workspace:, ni aucun chemin relatif. Il y en avait 1 061, et nous les avons exclus car ils n'accèdent jamais au réseau ; ils ne peuvent donc pas échapper à quoi que ce soit.
Voyons maintenant ce dont le cas « mutable » ne nécessite pas : ni prise de contrôle de compte, ni typosquatting, ni vol de jeton de publication. Le propriétaire de cette fourche peut effectuer un push vers sa propre branche par défaut un mardi après-midi, et la prochaine installation fraîche, où qu’elle se trouve, le récupérera. Il n’y a pas de changement de version, pas d’événement de publication, et rien de nouveau dans le registre qu’un scanner puisse détecter. Si l’URL pointe vers un domaine que quelqu’un a oublié de renouveler, vous n’avez même pas besoin du propriétaire. La prochaine personne qui enregistrera le domaine pourra obtenir un certificat valide pour celui-ci et décider ce que votre installation téléchargera.
Une construction écologique ne suffit pas
C'est ce qui nous a le plus dérangés.
Un pipeline de recompilation vérifie la fidélité. Nous compilons à partir du code source, créons l'archive tar et vérifions qu'elle correspond, octet par octet, à l'archive tar fournie par l'amont. Si c'est le cas, nous savons que notre recompilation n'a pas modifié un seul octet de ce que l'amont a fourni. C'est une garantie précieuse, et nous la défendrons devant qui que ce soit.
Ce n'est pas non plus la garantie que les gens ont tendance à croire. La fidélité signifie que nous avons reproduit fidèlement le code en amont. Si le manifeste du code en amont indique d'aller récupérer quelque chose depuis un fork personnel, une recompilation fidèle indique exactement la même chose. Une faille reproduite fidèlement reste une faille.
La provenance a la même délimitation. Une attestation SLSA indique comment un artefact a été construit, où et à partir de quelle source. Elle ne précise pas ce que cet artefact demandera au gestionnaire de paquets d’un client de récupérer plus tard, sur une autre machine, plusieurs semaines après la fin de la compilation. C’est là le champ d’application que la norme SLSA définit pour la provenance, et c’est un champ d’application raisonnable. La provenance atteste de la manière dont quelque chose a été créé, jamais du fait qu’il aurait dû l’être.
Vous pourriez plutôt surveiller le réseau pendant les compilations, et c’est exactement ce que font certaines opérations de recompilation. Cela permet de détecter des éléments qu’une analyse du manifeste ne repérera jamais, comme un postinstall un script qui télécharge un fichier binaire depuis un emplacement donné. Mais cela ne décrit que votre compilation. L'installation qui compte a lieu sur la machine de quelqu'un d'autre, plus tard, et votre proxy de compilation n'en a jamais connaissance. Chaque approche comble une lacune de l'autre.
La leçon que nous avons tirée de tout cela, c'est que la fidélité et le confinement sont deux questions distinctes qui nécessitent des contrôles différents. Si vous intégrez un contrôle de confinement à votre étape de vérification, un échec ne vous permettra plus de déterminer quelle question a échoué.
La solution qui a failli aggraver la situation
La solution évidente consiste à le dupliquer. Il suffit de copier la dépendance Git dans le registre sous la forme d'un paquet en bonne et due forme, de réécrire la spécification pour indiquer une plage de versions normale, puis de passer à autre chose.
Il y a un hic. Dès que vous modifiez package.json, votre archive ne correspond plus à celle de l'amont, et le contrôle de fidélité la rejette à juste titre. Vous devez alors choisir entre trois critères importants à vos yeux : la fidélité octet par octet, la compatibilité réelle et des paquets qui s'installent comme des remplacements directs sans que personne n'ait à modifier son code. Vous en obtenez deux.
Le fait de nommer la copie s'est avéré être un piège en soi. Dans un cas précis, la dépendance Git pointait vers un fork personnel datant de plusieurs années dont le package.json a déclaré un nom de package en majuscules et une version qui n'avait jamais été publiée nulle part. npm n'accepte pas les nouveaux noms de paquets comportant des lettres majuscules ; ce nom était donc exclu. Le nom en minuscules était déjà pris dans le registre public, par un autre fork, avec un numéro de version inférieur. Si nous publions notre version sous ce nom avec un numéro de version supérieur, un résolveur configuré pour comparer les candidats issus de sources privées et publiques pourrait privilégier la nôtre.
C'est pratiquement la recette type pour créer une confusion entre les dépendances. Nous aurions ainsi généré exactement le type de problème que nous cherchions à résoudre.
Dans un cas comme celui-ci, la solution la plus claire consiste à créer un nouveau nom dans un espace de noms distinct, qui ne risque pas d’entrer en conflit avec des éléments publics, et à réécrire le manifeste utilisateur pour qu’il pointe vers ce nouveau nom. Cela demande plus de travail et implique que le paquet cesse de suivre automatiquement la branche de quelqu’un d’autre. Nous en sommes venus à considérer que ce dernier point est en réalité une fonctionnalité. Le suivi silencieux de la branche par défaut d’un inconnu constituait une vulnérabilité, et non un service.
Ce qu'il faut vérifier dans vos propres projets
Commencez par les trouver. npm peut le faire pour vous grâce à sa commande de requête :
npm query ":type(git), :type(remote)"
Cette commande parcourt votre arborescence de dépendances installées et renvoie tous les éléments provenant d'un dépôt Git ou d'une URL distante, y compris les dépendances transitives. Lorsqu'elle trouve quelque chose, vous voudrez savoir quels résultats sont directs et lesquels se trouvent trois niveaux plus bas.
Si vous avez déjà déménagé à npm 12, vous en avez sans doute déjà fait l'expérience à vos dépens. Les dépendances Git et les URL distantes sont désormais bloquées par défaut, et une installation qui en rencontre une échouera tant que vous n'aurez pas activé cette option avec allow-git ou allow-remote. C'est un bon paramètre par défaut, qui aurait dû être adopté depuis longtemps. On serait tenté de définir allow-git=all Le pipeline redevient donc vert, mais cela vous ramène exactement à votre point de départ, sauf qu’il y a désormais une ligne de configuration qui donne l’impression qu’il s’agit d’un choix délibéré. Si les seules dépendances Git dont vous avez besoin se trouvent dans votre propre package.json, le root Cette valeur limite l'exception à ces cas-là et maintient le blocage des exceptions transitives. pnpm La version 11 offre globalement les mêmes fonctionnalités dès son installation. Son blockExoticSubdeps Ce paramètre est désormais défini par défaut sur true, qui bloque les spécifications Git et les archives tar dans les dépendances transitives tout en autorisant celles que vous déclarez vous-même.
Remarquez toutefois où se trouve ce contrôle. C'est le client qui refuse d'aller chercher ces dépendances, et non votre registre qui en contiendrait. Votre registre privé ne voit toujours pas ces dépendances. Le client refuse simplement d'aller les chercher.
Quelle que soit la nature du problème détecté, examinez d’abord la structure. Un SHA épinglé dans une dépendance de développement constitue généralement un problème de conteneur de moindre priorité. En revanche, une branche, une balise ou l’URL d’un dépôt « bare » dans une dépendance d’exécution doit être corrigée, soit en passant à une version publiée si elle existe, soit en intégrant une copie épinglée sous un nom à portée restreinte dont vous êtes propriétaire. Et si vos exécuteurs CI peuvent accéder à github.com Cela dit, on peut se demander s'ils en ont réellement besoin, puisque le contrôle des sorties détecte ces récupérations, quel que soit le contenu du manifeste.
Qu'en est-il alors du miroir ?
Cela ne rend pas pour autant les registres privés inutiles. Ils accomplissent un travail considérable. Ils répondent simplement à une question plus restreinte que celle que les gens leur posent. « Ce paquet provient-il d’un endroit que nous contrôlons ? » n’est pas la même question que « Tout ce dont ce paquet a besoin provient-il d’un endroit que nous contrôlons ? »
Cette deuxième question revenait sans cesse pendant que nous développions SecureChain, et elle a changé notre façon d'interpréter un package.json. Nous considérons désormais la structure de chaque spécification de dépendance comme un indicateur de sécurité, au même titre que la plage de versions.
Si votre équipe a npm les dépendances qu’il ne peut pas remplacer facilement : c’est là le problème sur lequel TuxCare travaille. SecureChain couvre les paquets activement maintenus, recompilés à partir du code source avec traçabilité, tandis qu’Endless Lifecycle Support prend en charge les frameworks en fin de vie pour lesquels les développeurs en amont ont cessé de fournir des correctifs, mais dont votre application dépend toujours.
Foire aux questions
Que signifie le fait qu'une dépendance échappe au registre ?
Cela signifie qu'un paquet déclare l'une de ses dépendances à l'aide d'une URL Git, d'une URL d'archive tar ou d'un autre emplacement distant, plutôt qu'à l'aide d'un nom et d'une plage de versions. npm résout cette dépendance directement à partir de l'emplacement indiqué dans la spécification, plutôt que via le registre configuré dans .npmrc, à moins que le client ne soit configuré pour bloquer ce type de source. Dans tous les cas, un registre privé ou un proxy n'a aucun contrôle là-dessus.
Un registre npm privé offre-t-il une protection contre les dépendances Git ?
Non. Le routage via le registre ne s'applique qu'aux dépendances déclarées par nom et plage de versions ; ainsi, une dépendance Git ou une URL distante ne transite jamais par votre registre privé. Ce qui peut l'empêcher, c'est le npm le client lui-même. npm 12 bloque par défaut les dépendances Git et les URL distantes via son allow-git et allow-remote les paramètres, qui constituent un élément de contrôle distinct du registre.
La version 12 de npm bloque-t-elle les dépendances Git ?
Oui, par défaut. npm 12 lots allow-git et allow-remote à none, de sorte que les dépendances Git et les URL distantes, y compris les dépendances transitives, ne s'installent pas, sauf si vous activez cette option. La configuration allow-git=root n'autorise que les dépendances Git déclarées dans votre propre package.json, tandis que all les autorise partout dans l'arborescence.
Une dépendance Git associée à un SHA de commit est-elle plus sûre ?
Oui, c'est bien plus sûr qu'une référence à une branche ou à une balise, car le propriétaire ne peut pas associer un hachage de commit à un code différent. Cela contourne toutefois votre registre et repose sur la disponibilité permanente du dépôt ; il convient donc de considérer cette méthode comme un élément à surveiller plutôt que comme quelque chose à ignorer.
La provenance SLSA garantit-elle qu'un paquet peut être installé en toute sécurité ?
Non. La provenance SLSA atteste de la manière dont un artefact a été créé, de l'endroit où il l'a été et de sa source. Elle ne garantit pas l'authenticité du code source lui-même et ne couvre pas les dépendances que le paquet récupère en dehors du registre lorsqu'un utilisateur l'installe par la suite.
