Un hook d'installation s'exécutait avant la première ligne du projet

Le ver « ChainDrop » publié sur npm le 4 août au matin partait d'un hook
`preinstall` sur `keyv`, `cacheable`, `flat-cache` et `file-entry-cache`, volait
les jetons npm et GitHub, les clés SSH et les chaînes de connexion, puis se
republiait avec ce qu'il venait de prendre — plus de 1 300 versions en une
matinée.

fokan n'a pas été touché, et il faut dire pourquoi : `node_modules` et le lock
dataient du 29/07, six jours avant la fenêtre, et `npm ci` résout depuis le lock.
Les trois paquets sont pourtant bien là, en transitif sous ESLint, chacun tiré
par un `^`. Un seul `npm install` ce jour-là aurait suffi. `ignore-scripts=true`
rend désormais un paquet compromis inerte tant que rien ne l'importe ; les trois
qui ont réellement besoin de leur binaire sont reliés nommément par `npm rebuild`
dans la CI et dans les deux étages du Dockerfile.

Séparément, le `COPY . .` du builder emportait tout le répertoire de travail dans
une couche de l'image : le `.env` de développement — gitignoré, donc bien présent
sur le disque — devenait lisible par n'importe quel script de build, et le
`node_modules` de l'hôte écrasait celui que `npm ci` venait d'installer à la ligne
précédente. Un `.dockerignore` ferme les deux.

Au passage, les actions GitHub sont épinglées au commit et non au tag — un tag est
mutable, c'est le `^` de npm sous un autre nom — et le jeton du runner passe en
lecture seule.

D-047.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Gautier Stefanini 2026-08-05 21:03:57 +00:00
parent 7d9c200018
commit df613bb99c
5 changed files with 74 additions and 4 deletions

26
.dockerignore Normal file
View file

@ -0,0 +1,26 @@
# Le `COPY . .` du builder emportait tout le répertoire de travail dans une couche de l'image
# (D-047, 5 août 2026). Deux effets : le `.env` de développement — gitignoré, donc bien présent
# sur le disque — devenait lisible par n'importe quel script de build, et le `node_modules` de
# l'hôte écrasait celui que `npm ci` venait d'installer à la ligne précédente.
# Secrets — jamais dans une image, y compris l'exemple qui n'y sert à rien.
.env
.env.*
# Réinstallé dans l'image par `npm ci`, jamais copié depuis l'hôte.
node_modules
# Artefacts de build et caches locaux.
.next
dist
*.tsbuildinfo
*.log
coverage
# Sans usage à l'exécution : `docs/` n'est cité que dans des commentaires, jamais lu par le code.
.git
.github
.claude
docs
README.md
CLAUDE.md

View file

@ -5,19 +5,32 @@ on:
branches: [main]
pull_request:
# Le jeton du runner ne sert qu'à lire le dépôt (D-047). Les droits par défaut sont en écriture,
# et un runner est précisément ce qu'un ver de chaîne d'approvisionnement vient piller pour se
# republier ailleurs.
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Actions épinglées au commit : un tag est mutable, c'est le `^` de npm sous un autre nom.
# Commentaire = version lisible, SHA = ce qui est réellement exécuté.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
- run: npm ci
# `.npmrc` coupe les scripts d'installation (D-047) : `tsx` et `vitest` passent par esbuild,
# ESLint par le résolveur d'imports. Les deux seuls à relier, nommément.
- name: Binaires des outils de build
run: npm rebuild esbuild unrs-resolver
- name: Typecheck
run: npm run typecheck

7
.npmrc
View file

@ -1 +1,8 @@
legacy-peer-deps=true
# Aucun script d'installation n'est exécuté (D-047, 5 août 2026). Le ver ChainDrop du 04/08 volait
# les secrets depuis un hook `preinstall`, avant même que la moindre ligne du projet ne s'exécute.
# 7 paquets sur 687 ont un script ici ; les trois qui en ont besoin sont relancés nommément par
# `npm rebuild` dans la CI et le Dockerfile. Un paquet compromis reste alors du code inerte tant
# que rien ne l'importe — et `keyv`, la tête de pont du 04/08, n'est importé par rien.
ignore-scripts=true

View file

@ -1,7 +1,9 @@
FROM node:22-bookworm-slim AS builder
WORKDIR /app
COPY package*.json .npmrc ./
RUN npm ci
# `.npmrc` coupe tous les scripts d'installation (D-047) : on relance nommément les deux qui
# servent au build. `next build` lance ESLint, donc le résolveur d'imports doit être lié.
RUN npm ci && npm rebuild esbuild unrs-resolver
COPY . .
RUN npm run build
@ -10,7 +12,9 @@ RUN npm run build
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package*.json .npmrc ./
RUN npm ci --omit=dev
# `sharp` est le seul paquet de production à avoir un script d'installation (optimisation des
# images de Next.js) ; il est relié à son binaire ici, les scripts étant coupés par défaut (D-047).
RUN npm ci --omit=dev && npm rebuild sharp
FROM node:22-bookworm-slim
WORKDIR /app

View file

@ -1,5 +1,25 @@
# Journal des décisions post-cadrage
## D-047 — 5 août 2026 · Les scripts d'installation npm sont coupés par défaut ; le `.env` sort de l'image
**Contexte** : ver de chaîne d'approvisionnement « ChainDrop » publié sur npm le 4 août 2026 vers 09:35 UTC. Point d'entrée `keyv`, `cacheable`, `flat-cache`, `file-entry-cache`, puis plus de 1 300 versions en une matinée par auto-propagation : un hook `preinstall` vole les jetons npm, GitHub, AWS, les clés SSH et les chaînes de connexion, et republie avec les jetons volés.
**fokan n'a pas été touché**, et il faut dire pourquoi, parce que ce n'est pas de la robustesse : `node_modules` datait du 29/07 et `package-lock.json` du 29/07 également, six jours avant la fenêtre. `npm ci` en CI comme dans les deux étages du Dockerfile a fait le reste — la résolution vient du lock, pas des plages. Les trois paquets concernés sont pourtant bien là, en transitif sous ESLint : `file-entry-cache@8.0.0``flat-cache@4.0.1``keyv@4.5.4`, chacun tiré par un `^`. Un seul `npm install` ce jour-là aurait suffi.
**Ce qui change :**
1. **`ignore-scripts=true` dans `.npmrc`.** C'est la parade au vecteur lui-même : sans script d'installation, un paquet compromis est du code inerte tant que rien ne l'importe — et `keyv` n'est importé par rien ici, il dort au fond de l'arbre d'ESLint. Le coût est borné et mesuré : **7 paquets sur 687 ont un script d'installation**, dont un seul en production. `esbuild` (×4 versions), `unrs-resolver` et `fsevents` (optionnel, macOS) sont en dev ; `sharp` est en prod, optionnel. Les trois utiles sont relancés nommément par `npm rebuild` — dans la CI (esbuild pour `tsx` et `vitest`, unrs-resolver pour ESLint) et dans le Dockerfile (les deux mêmes au build, `sharp` à l'étage des dépendances de production). On fait confiance à trois paquets désignés au lieu de 687 par défaut.
2. **`.dockerignore`, qui n'existait pas.** Le `COPY . .` du builder emportait le `.env` de développement dans une couche de l'image — gitignoré, donc bien présent sur le disque, et lisible par n'importe quel script de build : exactement ce que le payload du 04/08 allait chercher. Il écrasait au passage le `node_modules` fraîchement installé par le `npm ci` de la ligne précédente, avec celui de l'hôte.
3. **`permissions: contents: read` sur la CI**, dont le jeton avait jusqu'ici les droits par défaut du dépôt, et **actions épinglées au SHA** (`actions/checkout`, `actions/setup-node`) : un tag est mutable, c'est le `^` de npm sous un autre nom.
**Ce qui n'a délibérément pas été fait** : aucune rotation de secret. Rien n'a été installé après le 29/07 sur cette machine ni sur les autres projets (`video-studio` 07/07, `kankwa/frontend` 30/05, tous deux sans ces paquets ; `openclaw.bak` en liste deux dans son lock mais n'a aucun `node_modules`), aucun `preinstall` n'existe dans l'arbre, aucun fichier de `node_modules` n'est postérieur au 04/08.
**Ce qui n'aurait servi à rien, et qu'il vaut mieux savoir avant la prochaine fois** : les versions malveillantes portaient une **provenance GitHub Actions valide** — l'attaquant poussait sur `main` puis publiait une release. La provenance atteste le dépôt d'origine, jamais le contenu ; `npm audit signatures` aurait répondu que tout allait bien. Le 2FA mainteneur non plus : le ver republiait avec des jetons d'automatisation volés.
**Le point faible qui reste**, et qu'aucun fichier ne couvre : `npm install` (y compris `npm i <paquet>`) re-résout les `^` et peut remonter sur une version empoisonnée le jour où elle est en ligne. La règle est temporelle, et c'est la seule qui aurait protégé quelle que soit la technique : **on n'installe pas une version publiée il y a moins de sept jours**`npm install --before=$(date -d '7 days ago' -I)` en attendant une politique outillée. Le ver a été repéré dans l'heure ; sept jours de quarantaine le rendent inoffensif par construction. Corollaire de discipline : **le diff de `package-lock.json` se lit**, ligne à ligne, sur tout ajout de dépendance.
## D-046 — 5 août 2026 · La frise des douze mois glisse au lieu de suivre l'année civile
**Contexte** : demandé par le porteur de projet pour la frise de fin de quiz et de la page foyer — qui sont le même écran, le quiz atterrissant sur `/foyer/[id]`.