Reprendre le contrôle de vos dépendances NPM
Je vois souvent des gens ne pas maitriser l'usage de NPM au quotidien. Des choses basiques, mais importantes, surtout quand il s'agit de se débarrasser d'une faille de sécurité dans une dépendance transitive. Je ne suis pas expert NPM, mais après quasi 10 ans à l'utiliser au quotidien, j'ai appris quelques trucs qui servent bien, et j'en apprends encore !
C'est quoi une dépendance ?🔗
En général, on répondra "tout ce qui est listé dans le package.json". Et ce n'est pas faux en soi. Mais c'est incomplet en théorie.
Déjà si vous faites du Node, vous avez au strict minimum deux dépendances : Node et npm (ou peut-être yarn ou pnpm, mais au fond vous avez quand même npm sur votre machine quoi qu'il arrive, et je ne vais pas couvrir les spécificités de yarn (que je ne maitrise pas assez) et pnpm (qui est trop intéressant pour juste faire un paragraphe dessus)). Sans ces deux outils, vous ne pouvez pas travailler au sens compiler / exécuter votre application. Donc vous dépendez de ces deux outils. Je ne vais pas rentrer plus dans le détail mais juste : pensez à mettre à jour votre version Node régulièrement, particulièrement avec le changement de cycle de release.
L'autre aspect qui manque c'est la notion de dépendance transitive. On qualifie de dépendance transitive une dépendance d'une de nos dépendances. Prenons un exemple simple : vous avez un projet SolidJS avec Vite, vous installez donc vite-plugin-solid. Vous avez donc dans votre package.json solid-js, vite et vite-plugin-solid qui sont vos dépendances directes, mais vite-plugin-solid dépend de @babel/core, babel-preset-solid, solid-js, solid-refresh, vite et vitefu, et si vous creusez encore vous verrez que babel-preset-solid dépend de @babel/core, babel-plugin-jsx-dom-expressions et solid-js, et on pourrait creuser un peu toutes les dépendances comme ça. Chaque dépendance pouvant dépendre d'une version différente d'une même dépendance (vous pouvez dépendance de solid-js@1.9.12, babel-preset-solid dépendre de solid-js@1.9.11, et babel-preset-solid dépendre de solid-js@1.9.10), ce ne serait pas forcément très stable / recommandé mais c'est possible du point de vue de NPM.
Vous avez donc des dépendances que vous maitrisez et d'autres que vous ne maitrisez pas directement. À priori.
Visualisé vos dépendances🔗
Commençons par notre package.json :
{
...
"dependencies": {
"solid-js": "^1.9.5"
},
"devDependencies": {
"typescript": "~5.8.3",
"vite": "^6.3.5",
"vite-plugin-solid": "^2.11.6"
}
}
On voit donc 4 dépendances ici.
Maintenant, installons nos dépendances (npm ci si vous avez un package-lock.json, npm i sinon, j'y reviendrais plus tard).
❯ npm ci # si vous n'avez pas encore de package-lock.json, utilisez npm i ou npm install
added 73 packages in 1m
13 packages are looking for funding
run `npm fund` for details
Et là NPM nous dit avoir installé 73 packages. On est donc bien loin des 4 qu'on voyait plus haut…
Ensuite on va utiliser npm ls.
npm-deps-override@0.0.0 /home/anthony_p/tmp/npm-deps-override
├── solid-js@1.9.12
├── typescript@5.8.3
├── vite-plugin-solid@2.11.12
└── vite@6.4.2
C'est logique, on retrouve les dépendances de notre package.json. Allons regarder plus en profondeur :
❯ npm ls --depth 1
npm-deps-override@0.0.0 /home/anthony_p/tmp/npm-deps-override
├─┬ solid-js@1.9.12
│ ├── csstype@3.2.3
│ ├── seroval-plugins@1.5.2
│ └── seroval@1.5.2
├── typescript@5.8.3
├─┬ vite-plugin-solid@2.11.12
│ ├── @babel/core@7.29.0
│ ├── UNMET OPTIONAL DEPENDENCY @testing-library/jest-dom@^5.16.6 || ^5.17.0 || ^6.*
│ ├── @types/babel__core@7.20.5
│ ├── babel-preset-solid@1.9.12
│ ├── merge-anything@5.1.7
│ ├── solid-js@1.9.12 deduped
│ ├── solid-refresh@0.6.3
│ ├── vite@6.4.2 deduped
│ └── vitefu@1.1.3
└─┬ vite@6.4.2
├── UNMET OPTIONAL DEPENDENCY @types/node@^18.0.0 || ^20.0.0 || >=22.0.0
├── esbuild@0.25.12
├── fdir@6.5.0
├── UNMET OPTIONAL DEPENDENCY fsevents@~2.3.3
├── UNMET OPTIONAL DEPENDENCY jiti@>=1.21.0
├── UNMET OPTIONAL DEPENDENCY less@*
├── UNMET OPTIONAL DEPENDENCY lightningcss@^1.21.0
├── picomatch@4.0.4
├── postcss@8.5.9
├── rollup@4.60.1
├── UNMET OPTIONAL DEPENDENCY sass-embedded@*
├── UNMET OPTIONAL DEPENDENCY sass@*
├── UNMET OPTIONAL DEPENDENCY stylus@*
├── UNMET OPTIONAL DEPENDENCY sugarss@*
├── UNMET OPTIONAL DEPENDENCY terser@^5.16.0
├── tinyglobby@0.2.16
├── UNMET OPTIONAL DEPENDENCY tsx@^4.8.1
└── UNMET OPTIONAL DEPENDENCY yaml@^2.4.2
Le fonctionnement de vite étant d'essayer de s'auto-configurer au maximum, on retrouve beaucoup de dépendances optionnelles. Épurons ces dépendances qui ne sont pas installées pour rendre l'arbre plus lisible
❯ npm ls --depth 1 | grep -v 'UNMET OPTIONAL DEPENDENCY'
npm-deps-override@0.0.0 /home/anthony_p/tmp/npm-deps-override
├─┬ solid-js@1.9.12
│ ├── csstype@3.2.3
│ ├── seroval-plugins@1.5.2
│ └── seroval@1.5.2
├── typescript@5.8.3
├─┬ vite-plugin-solid@2.11.12
│ ├── @babel/core@7.29.0
│ ├── @types/babel__core@7.20.5
│ ├── babel-preset-solid@1.9.12
│ ├── merge-anything@5.1.7
│ ├── solid-js@1.9.12 deduped
│ ├── solid-refresh@0.6.3
│ ├── vite@6.4.2 deduped
│ └── vitefu@1.1.3
└─┬ vite@6.4.2
├── esbuild@0.25.12
├── fdir@6.5.0
├── picomatch@4.0.4
├── postcss@8.5.9
├── rollup@4.60.1
├── tinyglobby@0.2.16
Note :
greppermet de filtrer pour ne garder que les lignes qui contiennent un certain motif, mais si on ajoute l'option-v, on garde les lignes qui ne contiennent pas le motif.
Rien qu'en ajoutant un niveau de profondeur on est déjà à 20 dépendances, avec solid-js@1.9.12 et vite@6.4.2 qui taggé comme deduped, c'est à dire identifiées comme déjà résolue et donc n'ont plus besoin d'être résolus (car le package est tiré plus haut dans l'arbre et avec la même version ou une version compatible).
On peut continuer comme ça en augmentant la profondeur affichée. On peut afficher tout l'arbre directement en passant --all. On peut aussi cibler un package précis.
❯ npm ls solid-js
npm-deps-override@0.0.0 /home/anthony_p/tmp/npm-deps-override
├── solid-js@1.9.12
└─┬ vite-plugin-solid@2.11.12
├─┬ babel-preset-solid@1.9.12
│ └── solid-js@1.9.12 deduped
├── solid-js@1.9.12 deduped
└─┬ solid-refresh@0.6.3
└── solid-js@1.9.12 deduped
Ici on voit où la dépendance solid-js est tirée dans l'arbre, la version attendue, si elle est bien dédupliquée comme on s'y attend (ici c'est le cas).
En choisissant solid-js dans un projet SolidJS, on s'attend à le voir. Si vous demande pour esbuild ?
❯ npm ls esbuild
npm-deps-override@0.0.0 /home/anthony_p/tmp/npm-deps-override
└─┬ vite@6.4.2
└── esbuild@0.25.12
Il n'est pas dans notre package.json pourtant il fait bien partie de l'arbre. Une faille sur esbuild peut donc nous impacter. Et certains me diront qu'esbuild sert uniquement à la compilation donc ne part pas en production. Ce n'est pas aussi simple. Déjà esbuild sera installé sur chaque runner de notre CI, donc à défaut d'infecter nos utilisateurs, on peut impacter notre SI via notre CI ce qui n'est déjà pas terrible. Ensuite on peut se retrouver dans un cas où la faille n'est pas dans esbuild mais dans le code qu'il produit, en effet les outils de compilation vont modifier nos sources et peuvent donc injecter du code non sûr et là ça peut retomber chez les utilisateurs.
Comment qu'on dit ce qu'on veut comme version ?🔗
Déjà il faut comprendre ce qu'on appelle le SemVer ou Semantic Versioning.
Pour faire très simple : on va définir une version sur trois nombre X.Y.Z. On va appeler X la version majeure, Y la mineure et Z le patch. L'idée va être de se dire qu'une modification qui vient corriger va juste incrémenter le patch, ensuite une nouvelle fonctionnalité qui ne casse rien pour les utilisateurs actuels incrémente uniquement le mineur, et si on casse quelque chose on incrémente le majeur.
Note : je vous laisse aller voir sur Internet pour tous les détails du SemVer, je n'irai pas plus loin ici. Il reste pas mal de chose à dire : les versions alpha/beta/RC, les versions à quatre nombres, les versionnings alternatifs compatible ou non avec SemVer, etc.
À partir de là, on se dit qu'on veut parfois dire "je veux strictement cette version", parfois "je veux bien prendre un nouveau patch mais pas de changement mineur" et de temps en temps "je veux bien tout mettre à jour sauf la majeure".
Par défaut, si vous faite npm install lodash (pareil si vous faite npm install lodash@latest), NPM va ajouter à votre package.json dans dependencies "lodash": "^4.18.1" (la dernière version sortie à date). Sauf que là vous avez peut-être mis un doigt dans un système que vous ne vouliez pas avec le ^ devant la version. En effet ^4.18.1 veut dire "n'importe quelle version 4.x.x qui est supérieur à 4.18.1". Donc si vous refaite un npm i dans quelques jours/semaines/mois vous aurez peut-être la version 4.18.1, peut-être une 4.18.2 ou peut-être une 4.45.0. Est-ce que vous vouliez prendre en compte tous ces changements simplement en installant vos dépendances ? Pas sûr (même si, je vous l'accorde : avec lodash on peut normalement mettre à jour les yeux fermés).
Si vous voulez "toujours" tirer la version 4.18.1, il faut retirer le ^ et refaire un npm i (pour mettre à jour le package-lock.json) ou simplement faire directement npm install lodash --save-exact qui va ajouter au package.json "lodash": "4.18.1".
Si vous voulez bien les mises à jours, mais seulement de patch, vous pouvez indiquer "~4.18.1".
Sauf que là ça ne concerne que les versions qu'on tire directement via notre package.json. Si je veux forcer la version 0.27.7 d'esbuild je ne peux pas fonctionner comme ça.
En effet si je fais npm i esbuild@0.27.7 --save-exact (la dernière version à date) puis que je regarde l'arbre de dépendance :
❯ npm ls esbuild
npm-deps-override@0.0.0 /home/anthony_p/tmp/npm-deps-override
├── esbuild@0.27.7
└─┬ vite@6.4.2
└── esbuild@0.25.12
On voit bien que j'ai version 0.27.7 à la racine de l'arbre, mais sous vite j'ai toujours la version 0.25.12. Donc comment on fait ? Déjà on supprime esbuild qui est inutile à la racine du projet npm uninstall esbuild. Puis on va ajouter une section overrides à notre package.json pour forcer partout dans l'arbre une certaine version d'esbuild :
{
...
"overrides": {
"esbuild": "0.27.7"
}
}
Là on fait npm install pour mettre à jour nos dépendances et on regarde notre arbre de dépendance :
❯ npm ls esbuild
npm-deps-override@0.0.0 /home/anthony_p/tmp/npm-deps-override
└─┬ vite@6.4.2
└── esbuild@0.27.7 overridden
Là on voit bien que la version n'est plus la même et c'est indiqué qu'elle est overridden montrant bien que ce n'est pas la version que NPM aurait choisi normalement.
À Noter qu'on peut aller assez loin avec cette mécanique, par exemple :
{
...
"overrides": {
"esbuild": "^0.27.0",
"vite": {
"esbuild": "^0.27.5"
}
}
}
Permet d'indiquer qu'on veut surcharger la version d'esbuild en autorisant n'importe quelle version compatible avec ^0.27.0 partout dans l'arbre de dépendance sauf dans le sous-arbre de vite où on va viser ~0.26.0 (en l’occurrence, la branche 0.26.x ne contient que la version 0.26.0 donc NPM n'a pas beaucoup de choix de version). Ici ça ne sert pas à grand-chose mais dans certains cas, ça peut être utile. // TODO
❯ npm ls esbuild
npm-deps-override@0.0.0 /home/anthony_p/tmp/npm-deps-override
└─┬ vite@6.4.2
└── esbuild@0.26.0
Ce n'est pas une erreur de ma part, ici on ne voit plus l'indication overridden alors que la version l'est bien mais passons là-dessus. Par contre on voit bien que notre version est surchargée.
Et si maintenant on ajoute ça ?
{
...
"overrides": {
...
"vite-plugin-solid": {
"solid-js": "1.9.4"
}
}
}
Normalement au moment de l'installation vous aurez une erreur du type :
❯ npm i
npm warn ERESOLVE overriding peer dependency
npm warn While resolving: solid-refresh@0.6.3
npm warn Found: solid-js@1.9.12
npm warn node_modules/solid-js
npm warn solid-js@"^1.9.5" from the root project
npm warn
npm warn Could not resolve dependency:
npm warn peer overridden solid-js@"1.9.4" (was "^1.3") from solid-refresh@0.6.3
npm warn node_modules/solid-refresh
npm warn solid-refresh@"^0.6.3" from vite-plugin-solid@2.11.12
npm warn node_modules/vite-plugin-solid
npm warn
npm warn Conflicting peer dependency: solid-js@1.9.4
npm warn node_modules/solid-js
npm warn peer overridden solid-js@"1.9.4" (was "^1.3") from solid-refresh@0.6.3
npm warn node_modules/solid-refresh
npm warn solid-refresh@"^0.6.3" from vite-plugin-solid@2.11.12
npm warn node_modules/vite-plugin-solid
npm error code ERESOLVE
npm error ERESOLVE could not resolve
npm error
npm error While resolving: vite-plugin-solid@2.11.12
npm error Found: solid-js@1.9.12
npm error node_modules/solid-js
npm error solid-js@"^1.9.5" from the root project
npm error
npm error Could not resolve dependency:
npm error peer overridden solid-js@"1.9.4" (was "^1.7.2") from vite-plugin-solid@2.11.12
npm error node_modules/vite-plugin-solid
npm error dev vite-plugin-solid@"^2.11.6" from the root project
npm error
npm error Conflicting peer dependency: solid-js@1.9.4
npm error node_modules/solid-js
npm error peer overridden solid-js@"1.9.4" (was "^1.7.2") from vite-plugin-solid@2.11.12
npm error node_modules/vite-plugin-solid
npm error dev vite-plugin-solid@"^2.11.6" from the root project
npm error
npm error Fix the upstream dependency conflict, or retry
npm error this command with --force or --legacy-peer-deps
npm error to accept an incorrect (and potentially broken) dependency resolution.
npm error
npm error
npm error For a full report see:
npm error /home/anthony_p/.npm/_logs/2026-04-15T05_25_31_667Z-eresolve-report.txt
npm error A complete log of this run can be found in: /home/anthony_p/.npm/_logs/2026-04-15T05_25_31_667Z-debug-0.log
Et là beaucoup vous diront "passe --force et c'est bon" ou "--legacy-peer-deps c'est mieux !". Moi je vous dirais : à moins que vous soyez absolument sûr de vous ou que vous n'ayez pas du tout le choix temporairement : ne le faites pas !
Dans mon exemple j'ai indiqué à NPM que je voulais qu'il tire une version "1.9.4" de solid-js pour vite-plugin-solid ce qui implique qu'on casse le fonctionnement des peer dependancies (dépendance qu'on attend qui soit tirée dans l'arbre mais pas forcément par notre package). Passer --force ou --legacy-peer-deps ne va pas fonctionner ici de part le fonctionnement des peer dependancies (vous aurez la 1.9.12 qui sera prise partout dans l'arbre quand même) mais passer --force ou --legacy-peer-deps va transformer les erreurs en warnings, masquant au passage (car personne ne lit les warnings) des futurs problèmes et donc vous rendant beaucoup plus difficile la montée de version de vos dépendances dans le futur…
Donc si vous arrivez dans le cas où NPM vous propose --force ou --legacy-peer-deps corriger la source du problème, mais n'utilisez pas ces arguments !
npm i, npm ci, npm audit et package-lock.json🔗
Je vois souvent des gens utiliser sans distinction npm i et npm ci. Les deux sont fondamentalement différents dans l'usage :
npm iva lire votrepackage.jsonpuis va interroger le registry NPM (par défaut https://npmjs.com/) pour résoudre chaque dépendance (directe et transitive), voir s'il y a une nouvelle version à tirer qui correspond au pattern qu'on a indiqué, puis une opération de dédoublonnement va rentrer en œuvre, pour enfin créer votre dossiernode_moduleset créer (ou mettre à jour) lepackage-lock.jsonqui va être une image de votre dossiernode_modulesavec tout l'arbre, les checksums pour valider que les packages n'ont pas changé (et donc n'ont pas été infectés depuis la dernière fois où vous les avez téléchargés) ;npm ciquant à lui va lire lepackage-lock.json, vérifier qu'il est bien synchronisé avec lepackage.json(une section au début dupackage-lock.jsonreprend le contenu desdependenciesetdevDependenciesdupackage.json), puis va télécharger en vérifiant les checksums les versions indiquées dans lepackage-lock.json, sans plus de traitement, pour créer le dossiernode_modules;
Donc autrement dit : npm i est là pour modifier / mettre à jour vos dépendances, npm ci pour les installer. Si vous utilisez continuellement npm i vous allez continuellement mettre à jour vos dépendances (en plus de largement perdre du temps sur cette étape), alors qu'avec npm ci vous allez installer les mêmes versions que toute l'équipe et que votre CI donc vous êtes certain que vous n'aurez pas d'écart à l'exécution. C'est encore plus important si vous exécutez du Node.js côté serveur, parce qu'avec des npm i vous n'avez aucune garantie que l'exécution se fera avec les mêmes versions de dépendances en production qu'en local.
Et npm audit qu'on voit régulièrement quand on fait des npm i ? C'est une commande qui va inspecter vos dépendances et va vous indiquer si c'est possible de mettre à jour des packages dans l'arbre de dépendance pour corriger des failles de sécurité. npm audit vous donnera les infos, npm audit fix va appliquer tout ce qui est sans risque d'un point de vue SemVer. Dans la plupart des cas, npm audit fix va essentiellement modifier le package-lock.json pour changer une version d'une dépendance transitive, donc vous ne voyez rien côté package.json.
Donc votre package-lock.json de facto n'est pas une simple image de votre package.json qui est généré et jetable sans questionnement. C'est un fichier qui a une vraiment importance en termes de sécurité et de garantie de fonctionnement dans le temps.
Autant que possible, aujourd'hui, évitez de supprimer le package-lock.json.
Note : c'est un peu opaque de prime abord mais si vous utilisez la fonctionnalité d'installation des dépendances via IntelliJ (ou autre IDE de la suite de Jetbrains), il lancera la commande
npm install, pasnpm ci. J'imagine qu'ils ont fait ça pour éviter d'avoir à tester si le package-lock.json est synchronisé avant de lancer la commandenpm ciounpm i, mais c'est une erreur fondamentale de fonctionner comme ça…
Renovate / Dependabot🔗
Il existe des outils pour vous aider à maintenir vos dépendances à jour sans trop d'effort, particulièrement si vous avez un bon pipeline qui s'exécute sur vos MR et qui valide assez largement votre application !
Quelques astuces :
- Mettez toutes vos versions en mode exacte (ne pas mettre de
~ou^avant la version) ; - Forcez-vous à mettre de l'auto-merge sur les patchs (normalement c'est censé être gratuit), sauf sur les dépendances internes à votre entreprise (j'ai rarement vu une librairie interne qui suivait entièrement le SemVer et j'ai toujours eu des soucis, donc dans le doute…) ;
- définissez un rôle tournant dans votre équipe de mise à jour des dépendances (essentiellement : regarder si le pipeline passe sur les branches de Renovate / Dependabot et cliquer sur "merge") ;
Note : si on vous bloque pour une quelconque raison l'usage de Renovate / Dependabot, NPM fourni la commande
npm outdatedqui va vous indiquer toutes les mises à jours que vous pouvez faire. À défaut d'un outil qui travail à votre place, l'exécuter une fois par semaine vous permettra de rester plutôt à jour à moindre effort. Et si un jour vous trouveznpm outdatedtrop lent, vous pouvez regarder du côté detazequi est plus rapide et permet de mettre à jour par lot les dépendances dans lepackage.json.
Astuce : SemVer Calculator🔗
Si vous avez dû mal à identifier quelles versions seront tirées pour un certain pattern, vous pouvez utiliser le npm SemVer Calculator. C'est un outil en ligne fourni par NPM pour visualiser les versions compatibles avec un certain pattern.
Par défaut c'est lodash qui est pointé, mais vous pouvez cibler un autre package, vous pouvez tester n'importe quel pattern de version. C'est vraiment pratique pour valider un pattern très vite, et comprendre comment tout ça fonctionne par la pratique.
Conclusion🔗
Les dépendances avec NPM c'est parfois un peu compliqué. Surtout parce qu'en général on ne prend pas le temps de se projeter sur le comment ça fonctionne vraiment avant d'avoir un problème et se battre avec. C'est souvent un moment où on s'est embourbé avec des mois de --force ou --legacy-peer-deps, où on a tout cassé et là on dit "NPM c'est de la m**de", alors que juste on s'est loupé.
NPM fait partie de ses outils avec des défauts historiques mais qui ont tendances à nous laisser décider pas mal de choses et de facto demande de comprendre un minimum ce qu'on fait pour que ça fonctionne dans le temps.
En tout cas, j'espère que cet article vous aidera à mieux gérer vos dépendances NPM ! 🤓
Sources :
Crédit photo : Générée via Mistral AI avec le prompt suivant :
Un style graphique inspiré de l'anime japonais Studio Ghibli, avec une ambiance chaleureuse, immersive, poétique et onirique. Une bibliothèque futuriste en bois sculpté, avec des escaliers en colimaçon, des échelles mobiles, et des étagères remplies de livres flottants. Chaque livre représente une version de package NPM (ex: lodash@4.18.1, esbuild@0.27.7) avec des titres lumineux et des couvertures colorées. Au centre, un grand hologramme central montre un arbre de dépendances NPM avec npm ls --all, avec des lignes lumineuses reliant les livres entre eux pour symboliser les relations entre les dépendances. En bas à droite, un panda roux anthropomorphe (style Ghibli, vêtements amples) est assis sur un tapis en tissu, un livre ouvert sur ses genoux (titre visible : "SemVer pour les nuls"), avec une tasse de thé fumant à côté. L'arrière-plan est rempli de détails organiques : plantes grimpantes, feuilles d'érable rouges/oranges/jaunes tombant doucement, et une lumière dorée tamisée évoquant un après-midi d'automne. Les couleurs dominantes sont des tons doux de beige, bleu pâle, orange chaud, et des accents de vert et de doré pour les éléments technologiques et naturels. L'image doit être détaillée, immersive, avec des textures riches et une atmosphère à la fois cosy et magique, mêlant technologie et nature de manière harmonieuse.