Bienvenue à toutes et à tous ! Cet article est le premier de la série Tout ce qu’il faut savoir, une série consacrée au Software Engineering.
Dans cette série, je vais essayer de vous donner une compréhension solide et fondamentale des concepts de Software Engineering que je considère comme importants.
Tous les systèmes informatiques modernes incluent des outils qui automatisent le processus d’installation, de désinstallation et de mise à jour de logiciels.
Cette responsabilité est celle d’un gestionnaire de paquets, et plusieurs peuvent intervenir au sein d’un même système informatique.
Système d’exploitation
La majorité des systèmes d’exploitation basés sur Unix embarquent un gestionnaire de paquets en standard, fournissant très simplement une multitude de paquets différents.
Si vous avez déjà utilisé une distribution Linux comme Ubuntu ou Debian, vous avez probablement déjà utilisé un gestionnaire de paquets. Si je vous dis apt-get update, est-ce que ça vous parle ?
Cette commande indique à APT de mettre à jour toutes les versions des paquets installés. APT (Advanced Packaging Tool) est un gestionnaire de paquets embarqué de manière très répandue en standard sur les systèmes d’exploitation Linux. Pour installer un paquet, vous pouvez par exemple saisir la commande apt-get install <package>.
Langage de programmation
La plupart des langages de programmation peuvent embarquer leur propre gestionnaire de paquets, soit nativement, soit fourni au sein de leur écosystème respectif.
Prenons par exemple npm, le gestionnaire de paquets par défaut de Node.js. On peut aussi citer pip pour Python, NuGet pour C#, Composer pour PHP, etc. À l’instar d’APT, npm permet d’installer facilement des paquets via la commande npm install <package>.
Pour cet article, j’ai décidé de prendre npm comme exemple. npm constitue en effet un très bon support pour mettre en évidence les avantages, mais aussi les inconvénients qu’un gestionnaire de paquets peut avoir. Les avantages et inconvénients listés dans la partie suivante sont valables pour tous les gestionnaires de paquets.
npm est installé en même temps que Node.js. Pour reproduire ces exemples, [il vous suffit d’installer Node.js ici].
En quatre parties, nous verrons quelles sont les principales raisons d’une telle expansion des gestionnaires de paquets à toutes les couches d’un système informatique.
1. Facilité d’utilisation et de maintenance des paquets
L’intérêt principal d’un gestionnaire de paquets est évidemment de simplifier l’installation des dépendances externes à notre application. Avant l’essor de npm en janvier 2010, les dépendances d’une application JavaScript étaient le plus souvent installées manuellement. Par « installation manuelle », j’entends :
- télécharger une archive zip depuis un serveur distant
- décompresser l’archive dans le projet
- référencer manuellement la version installée, et ce à chaque mise à jour d’une dépendance.
Avec un gestionnaire de paquets comme npm, nous bénéficions donc :
- De l’installation simplifiée d’un paquet
npm install <package> - De la mise à jour simplifiée d’un paquet
npm update <package> - De la suppression simplifiée d’un paquet
npm uninstall <package>
Les paquets sont installés dans un dossier node_modules adjacent à l’application et entièrement géré par npm. Tous les paquets situés dans le dossier node_modules peuvent être directement importés depuis l’application.
En règle générale, chaque langage de programmation embarque nativement son propre mécanisme de gestion de la résolution des modules.
1.1. Installation
Pour qu’un paquet puisse être installé, il faut d’abord un nom qui, dans la plupart des cas, sert d’identifiant unique. Les conventions de nommage peuvent différer d’un écosystème à l’autre.
$ npm install rxjs
Avec cette commande, le gestionnaire de paquets va rechercher au sein de la registry un paquet portant le nom rxjs. Lorsque la version n’est pas spécifiée, le gestionnaire de paquets installe en général la dernière version disponible.
1.2. Utilisation
// ECMAScript Modules (ESM)
import { of } from "rxjs";
// CommonJS
const { of } = require("rxjs");
Les systèmes de modules intégrés aux langages de programmation permettent d’importer une bibliothèque installée localement, et parfois à distance (comme Go ou Deno par exemple). Dans ce cas avec Node.js, le paquet doit être installé localement dans un dossier node_modules. Avec Node.js, l’algorithme de résolution des modules permet à la dépendance de se trouver dans un dossier node_modules soit adjacent au code source, soit dans un dossier parent (ce qui conduit parfois à un comportement inattendu).
2. Gérer la cohérence des paquets installés
Maintenant, entrons un peu plus dans le détail sur un aspect très important que doit gérer un gestionnaire de paquets : la cohérence de l’état entre les paquets installés. Jusqu’ici, installer un paquet semble être une tâche triviale, qui consiste simplement à automatiser le téléchargement d’un paquet d’une certaine version et à le rendre disponible dans un dossier conventionnel auquel l’application a accès.
Cependant, cette gestion de la cohérence entre les paquets se révèle relativement difficile, et la manière de modéliser l’arbre de dépendances varie selon les écosystèmes. La plupart du temps, on parle d’arbre de dépendances, mais on peut aussi parler de graphe de dépendances, et en particulier de graphe orienté.
Si vous n’êtes pas familier avec le concept de graphes orientés, je vous invite à lire la série d’articles que j’ai écrite à ce sujet avec des exemples en JavaScript.
Les implémentations de ces structures de données peuvent être radicalement différentes selon l’écosystème d’un gestionnaire de paquets, mais aussi entre gestionnaires de paquets d’un même écosystème (npm, yarn, pnpm pour Node.js par exemple).
Comment garantir que tous les développeurs partagent les mêmes dépendances, et donc les mêmes versions de chaque bibliothèque sous-jacente ?
Toujours dans le contexte de npm, prenons par exemple une liste de dépendances très simple, exprimée sous forme d’objet dans le fichier package.json :
package.json
{
"dependencies": {
"myDependencyA": "<0.1.0"
}
}
Cet objet décrit une dépendance de notre projet à la bibliothèque myDependencyA, téléchargeable depuis la registry npm. Le Semantic Versioning contraint ici la version de la bibliothèque à installer (ici inférieure à 0.1.0).
La gestion sémantique des versions (communément appelée SemVer) est l’application d’une spécification très précise pour caractériser la version d’un logiciel. Pour plus d’informations à ce sujet, je vous invite à jeter un œil à la spécification officielle https://semver.org/lang/fr/
Dans notre cas, en restant sur le schéma classique <major>.<minor>.<patch>, on exprime la possibilité d’installer toutes les versions de myDependencyA de « 0.0.1 » à « 0.0.9 ». Cela signifie donc que n’importe quelle version de la dépendance qui respecte la plage est considérée comme valide. En revanche, cela signifie aussi que si un développeur A installe la dépendance à 14 h et qu’un développeur B l’installe à 17 h, ils risquent tous les deux de ne pas avoir le même arbre de dépendances si jamais une nouvelle version de myDependencyA sort entre-temps.
L’algorithme de résolution des dépendances de npm privilégiera par défaut l’installation de la dépendance la plus récente qui respecte la gestion sémantique décrite dans le package.json. En spécifiant npm install myDependencyA, la version la plus récente de myDependencyA sera installée en respectant la contrainte « <1.0.0 » (version strictement inférieure à « 1.0.0 »).
Le problème majeur de cette approche est le manque de stabilité et de reproductibilité de l’arbre de dépendances d’une machine à l’autre, par exemple entre développeurs ou même sur la machine utilisée en production. Imaginez que la version 0.0.9 de myDependencyA vienne d’être publiée avec un bug et que votre machine de production s’apprête à lancer un npm install un vendredi à 17 h 59…
Cet exemple très simple est souvent désigné sous le terme version drift. C’est pourquoi un unique fichier de description (ici package.json) ne peut pas suffire à garantir une représentation identique et reproductible d’un arbre de dépendances.
Parmi les autres raisons, on peut citer :
- l’utilisation d’une version différente du gestionnaire de paquets, dont l’algorithme d’installation des dépendances peut changer.
- la publication d’une nouvelle version d’une dépendance indirecte (les dépendances des dépendances que nous listons ici dans le package.json), ce qui aurait pour conséquence que la nouvelle version soit donc récupérée et mise à jour.
- l’utilisation d’une registry différente qui, pour une même version d’une dépendance, expose deux bibliothèques différentes à un instant T.
Les lockfiles à la rescousse
Pour assurer la reproductibilité d’un arbre de dépendances, nous avons donc besoin de plus d’informations qui décriraient idéalement l’état actuel de notre arbre de dépendances. C’est exactement ce que font les lockfiles. Ce sont des fichiers créés et mis à jour lorsque les dépendances d’un projet sont modifiées.
Un lockfile est généralement écrit au format JSON ou YAML pour simplifier la lisibilité et la compréhension de l’arbre de dépendances par un humain. Un lockfile permet de décrire l’arbre de dépendances de manière très précise et donc de le rendre déterministe et reproductible d’un environnement à l’autre. Il est donc important de committer ce fichier dans Git et de s’assurer que tout le monde partage le même lockfile.
package-lock.json
{
"name": "myProject",
"version": "1.0.0",
"dependencies": {
"myDependencyA": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/myDependencyA/-/myDependencyA-0.0.5.tgz",
"integrity": "sha512-DeAdb33F+",
"dependencies": {
"B": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/B/-/B-0.0.1.tgz",
"integrity": "sha512-DeAdb33F+",
"dependencies": {
// dépendances de B
}
}
}
}
}
}
Pour npm, le lockfile de base s’appelle package-lock.json. Dans l’extrait ci-dessus, on peut voir précisément plusieurs informations importantes :
- La version de myDependencyA est fixée à « 0.0.5 », donc même si une nouvelle version sort, npm installera « 0.0.5 » quoi qu’il arrive.
- Chaque dépendance indirecte décrit son ensemble de dépendances avec des versions qui décrivent elles aussi leurs propres contraintes de versioning.
- En plus de la version, le contenu des dépendances peut être vérifié grâce à la comparaison des hashs, qui peuvent varier selon les registries utilisées.
Un lockfile tente donc de décrire avec précision l’arbre de dépendances, ce qui lui permet de rester cohérent et reproductible dans le temps à chaque installation.
⚠️ Mais…
Les lockfiles ne résolvent pas tous les problèmes d’incohérence ! Les implémentations du graphe de dépendances par les gestionnaires de paquets peuvent parfois conduire à des incohérences. Pendant longtemps, l’implémentation de npm a introduit des Phantom Dependencies ainsi que des NPM doppelgangers, qui sont très bien expliqués sur le site de documentation de Rush.js (des sujets avancés qui sortent du cadre de cet article).
3. Mise à disposition de bases de données distribuées et transparentes via l’open-source
Registries distribuées
Un gestionnaire de paquets est un client qui agit comme une passerelle vers une base de données distribuée (souvent appelée registry). Cela permet notamment de partager un nombre infini de bibliothèques open-source à travers le monde. Il est également possible de définir des registries privées à l’échelle d’une entreprise dans un réseau sécurisé, au sein desquelles des bibliothèques seraient accessibles.
Verdaccio permet de mettre en place une registry proxy privée pour Node.js
La disponibilité des registries a grandement changé la manière dont les logiciels sont développés en facilitant l’accès à des millions de bibliothèques.
Accès transparent aux ressources
L’autre avantage des gestionnaires de paquets open-source est qu’ils exposent le plus souvent des plateformes ou des outils permettant de parcourir les paquets publiés. L’accès au code source et à la documentation a été banalisé et rendu très transparent. Il est donc possible pour chaque développeur d’avoir une vue d’ensemble, voire d’enquêter pleinement sur la base de code d’une bibliothèque publiée.
4. Sécurité et intégrité
Utiliser des registries open-source avec des millions de bibliothèques exposées publiquement est plutôt pratique, mais qu’en est-il de la sécurité ?
Il est vrai que les registries open-source représentent des cibles idéales pour les hackers : il suffit de prendre le contrôle d’une bibliothèque largement utilisée (téléchargée des millions de fois par semaine) et d’y injecter du code malveillant, et personne ne s’en rendra compte !
Dans cette partie, nous verrons les solutions mises en œuvre par les gestionnaires de paquets et les registries pour faire face à ces attaques et limiter les risques.
Garantie d’intégrité pour chaque paquet installé
Étant donné qu’un paquet peut être installé depuis n’importe quelle registry, il est important de mettre en place des mécanismes de vérification au niveau du contenu du paquet téléchargé, afin de s’assurer qu’aucun code malveillant n’a été injecté pendant le téléchargement, quelle que soit son origine.
Pour cela, des métadonnées d’intégrité sont associées à chaque paquet installé. Par exemple avec npm, une propriété integrity est associée à chaque paquet dans le lockfile. Cette propriété contient un hash cryptographique qui sert à représenter avec précision la ressource que l’utilisateur s’attend à recevoir. Cela permet à n’importe quel programme de vérifier que le contenu de la ressource correspond à ce qui a été téléchargé. Par exemple pour @babel/core, voici comment l’intégrité est représentée dans package-lock.json :
"@babel/core": {
"version": "7.16.10",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.16.10.tgz",
"integrity": "sha512 pbiIdZbCiMx/MM6toR+OfXarYix3uz0oVsnNtfdAGTcCTu3w/JGF8JhirevXLBJUu0WguSZI12qpKnx7EeMyLA=="
}
Regardons de plus près comment l’intégrité peut réduire drastiquement le risque d’injection de code malveillant par le hachage du code source.
Pour rappel :
On appelle fonction de hachage une fonction particulière qui, à partir d’une donnée fournie en entrée, calcule une empreinte numérique servant à identifier rapidement la donnée initiale, de la même manière qu’une signature permet d’identifier une personne. Wikipedia
Prenons par exemple un cas simple :
// my-library
function someJavaScriptCode() {
addUser();
}
Imaginons que ce code JavaScript représente une ressource qu’un utilisateur pourrait vouloir télécharger. En utilisant la fonction de hachage SHA1, on obtient le hash 7677152af4ef8ca57fcb50bf4f71f42c28c772be.
Si jamais du code malveillant est injecté, l’empreinte de la bibliothèque va par définition changer, car l’entrée (ici le code source) de la fonction de hachage aura changé :
// my-library
function someJavaScriptCode() {
processMaliciousCode(); // ceci est injecté, l'utilisateur ne s'y attend pas
addUser();
}
Après injection du code malveillant, toujours en utilisant la même fonction de hachage SHA1, on obtient 28d32d30caddaaaafbde0debfcd8b3300862cc24 comme empreinte numérique.
On obtient donc comme résultats :
- Code d’origine =
7677152af4ef8ca57fcb50bf4f71f42c28c772be - Code malveillant =
28d32d30caddaaaafbde0debfcd8b3300862cc24
Tous les gestionnaires de paquets implémentent des spécifications strictes autour de cette approche de l’intégrité. Par exemple, npm respecte la spécification « Subresource Integrity ou SRI » du W3C, qui décrit les mécanismes à mettre en œuvre pour réduire le risque d’injection de code malveillant. Vous pouvez vous rendre directement ici, sur le document de spécification, si vous souhaitez creuser le sujet.
Contraintes de sécurité au niveau des auteurs
Pour renforcer la sécurité au niveau des paquets open-source, de plus en plus de contraintes émergent du côté des auteurs et mainteneurs de projets. Récemment, GitHub, qui possède npm, a annoncé qu’il imposait l’authentification à deux facteurs (2FA) aux contributeurs des 100 paquets les plus populaires. L’idée principale autour de ces actions est de sécuriser les ressources en amont en limitant les accès en écriture aux paquets open-source et en identifiant les personnes de manière plus précise.
Il est important de mentionner aussi qu’il existe des outils permettant de réaliser automatiquement des scans et des audits en continu.
Outils intégrés
Afin d’automatiser la détection des vulnérabilités, de nombreux gestionnaires de paquets intègrent nativement des outils permettant de scanner les bibliothèques installées. En général, ces gestionnaires de paquets communiquent avec des bases de données qui recensent toutes les vulnérabilités connues et référencées. Par exemple, GitHub Advisory Database est une base de données open-source qui référence des milliers de vulnérabilités à travers plusieurs écosystèmes (Go, Rust, Maven, NuGet, etc.) ; la commande npm audit utilise par exemple cette base de données.
Outils tiers
Chez NodeSecure, nous construisons des outils open-source gratuits pour sécuriser l’écosystème Node.js & JavaScript. Notre plus grand domaine d’expertise est l’analyse de paquets et de code.
Voici quelques exemples des outils disponibles :
- @nodesecure/cli, une CLI qui vous permet d’analyser en profondeur l’arbre de dépendances d’un paquet donné ou d’un projet Node.js local
- @nodesecure/js-x-ray, un scanner SAST (un analyseur statique pour détecter les patterns malveillants les plus courants)
- @nodesecure/vulnera, un outil d’analyse de composants logiciels (SCA)
- @nodesecure/ci, un outil permettant d’exécuter des analyses SAST, SCA et bien d’autres en CI/CD ou dans un environnement local
Snyk est la solution tout-en-un la plus populaire pour sécuriser les applications ou les infrastructures cloud. Snyk propose une offre gratuite avec analyses SAST et SCA.
Pour assurer une détection continue des vulnérabilités, il est recommandé de lancer des scans à chaque fois que des paquets sont installés/modifiés.
Conclusion
Voilà, vous savez maintenant quels problèmes sont traités et résolus par les gestionnaires de paquets !
Les gestionnaires de paquets sont des outils complexes qui visent à nous faciliter la vie en tant que développeurs, mais qui peuvent rapidement devenir problématiques s’ils sont mal utilisés.
Il est donc important de comprendre les problématiques qu’ils traitent et les solutions qu’ils apportent afin de pouvoir mettre en perspective plusieurs gestionnaires de paquets d’un même écosystème. Au final, c’est un outil comme un autre et il doit mobiliser la réflexion de la même manière que lorsqu’on utilise des bibliothèques/frameworks/langages de programmation.
N’oubliez pas non plus de prendre en compte les enjeux de sécurité et d’utiliser des outils automatisés qui peuvent réduire drastiquement la surface d’attaque !