Aller au contenu
English
English
CI/CD & Automatisation medium

GitLab CI : durcir vos pipelines en 10 mesures

35 min de lecture

Sécuriser un pipeline GitLab CI ne se réduit pas à masquer les variables. Le durcissement couvre toute la chaîne : les permissions du token de job, l'isolation des runners, l'épinglage des dépendances, la restriction des includes et la configuration au niveau instance. Ce guide détaille 10 mesures concrètes, classées de la plus simple à la plus structurante, avec les commandes pour vérifier chaque point sur votre instance KVM.

  • Restreindre les permissions du CI_JOB_TOKEN au strict nécessaire
  • Isoler les runners par niveau de confiance (shared, group, project)
  • Désactiver le mode privileged et configurer les executors de manière sécurisée
  • Épingler les images Docker et les includes par hash ou version exacte
  • Restreindre les Actions autorisées au niveau instance et groupe
  • Auditer la configuration avec plumber et des scripts API

Vous avez une instance GitLab CE auto-hébergée sur KVM avec des runners enregistrés. Vos pipelines fonctionnent, les secrets sont protégés (guide précédent), mais vous n'avez pas encore restreint les permissions par défaut. Un développeur peut encore déclencher un pipeline qui fait du Docker-in-Docker en mode privileged, référencer n'importe quelle image sans vérification, ou inclure un fichier YAML distant non audité.

Le CI_JOB_TOKEN est un token éphémère créé automatiquement pour chaque job. La documentation GitLab précise deux choses qui gouvernent tout le reste : « The token receives the same access level as the user that triggered the pipeline » et « The token is valid only while the job is running ».

Ce que le token peut atteindre par défaut, mesuré

Section intitulée « Ce que le token peut atteindre par défaut, mesuré »

Sur une instance récente, un job ne peut pas cloner un projet privé voisin, mais il clone sans difficulté un projet interne. C'est la nuance qui décide du risque réel, et elle ne se devine pas.

Mesuré le 20 septembre 2026 sur un GitLab CE 19.4.0 fraîchement installé, avec trois projets d'un même groupe et un runner à exécuteur shell :

Projet cibleVisibilitéRésultat du git clone avec CI_JOB_TOKEN
cible-priveeprivaterefusé, HTTP 403
cible-interneinternalréussi

Le refus porte un message sans ambiguïté, qu'il faut savoir reconnaître dans une trace de job :

remote: Authentication by CI/CD job token not allowed from appelant to project #2.
fatal: unable to access 'http://…/equipe-audit/cible-privee.git/':
The requested URL returned error: 403

Deux réglages expliquent ce refus, tous deux relevés sur l'instance neuve :

  • la liste d'autorisation entrante d'un projet nouvellement créé contient une seule entrée, le projet lui-même, et non son groupe ;
  • au niveau de l'instance, enforce_ci_inbound_job_token_scope_enabled vaut true.

Le scénario de mouvement latéral reste donc entier pour les projets internes. Sur une instance d'entreprise, la visibilité interne est courante, précisément parce qu'elle paraît inoffensive : elle n'expose rien à Internet. Elle expose pourtant le dépôt à tout job de l'instance.

.gitlab-ci.yml : ce qu'un job compromis tente en premier
mouvement-lateral:
script:
# ❌ Contre-exemple : réussit encore aujourd'hui si la cible est « interne »
- git clone "http://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.example.com/org/autre-projet.git"

La restriction se configure dans le projet cible, pas dans le projet qui appelle : c'est le dépôt à protéger qui décide qui a le droit de l'atteindre avec un token de job.

  1. Vérifier la liste d'autorisation entrante du projet cible

    Le bon point d'entrée n'est pas l'objet projet, c'est l'endpoint dédié. Un projet sain n'y montre que lui-même :

    Lire la liste d'autorisation entrante
    curl --header "PRIVATE-TOKEN: $GITLAB_API_TOKEN" \
    "https://gitlab.example.com/api/v4/projects/$PROJECT_ID/job_token_scope/allowlist" \
    | jq -r '.[].path_with_namespace'

    Dans l'interface, la même liste se lit sous Settings > CI/CD > Job token permissions.

  2. Traiter le cas des projets internes, qui échappent à cette liste

    Un projet interne reste clonable sans figurer sur aucune liste. Deux parades, selon ce qu'on veut préserver : passer le projet en privé, ou restreindre la seule fonctionnalité dépôt aux membres.

    Restreindre le dépôt aux membres, en gardant le projet interne
    curl --header "PRIVATE-TOKEN: $GITLAB_API_TOKEN" \
    --header "Content-Type: application/json" \
    --request PUT "https://gitlab.example.com/api/v4/projects/$PROJECT_ID" \
    --data '{"repository_access_level":"private",
    "merge_requests_access_level":"private",
    "builds_access_level":"private"}'

    Les trois valeurs descendent ensemble, et ce n'est pas un excès de zèle : l'API refuse la modification isolée avec cannot have higher visibility level than repository access level. Les demandes de fusion et les pipelines ne peuvent pas être plus visibles que le dépôt dont ils dépendent.

    Après application, le même job échoue avec un message différent du premier, ce qui permet de distinguer les deux causes dans une trace :

    remote: You are not allowed to download code from this project.
  3. Réduire ce que le token peut faire, et pas seulement où il peut aller

    Depuis GitLab 18.3, les permissions fines des tokens de job sont disponibles en version stable, après une expérimentation en 17.10 et une bêta en 18.0. Elles autorisent un projet de la liste à n'appeler qu'un sous-ensemble d'endpoints de l'API REST, au lieu de lui ouvrir tout ce que le token sait faire.

    Elles s'activent par projet, puis se posent entrée par entrée dans Settings > CI/CD > Job token permissions > CI/CD job token allowlist. Il faut le rôle Maintainer ou Owner. Les permissions se nomment par ressource et par niveau :

    NiveauExemples de noms
    LectureREAD_JOBS, READ_PACKAGES, READ_BADGES
    Lecture et écritureADMIN_PACKAGES, ADMIN_DEPLOYMENTS, ADMIN_BADGES

    Un projet qui ne fait que télécharger un paquet n'a besoin que de READ_PACKAGES : lui laisser ADMIN_PACKAGES lui permet aussi d'en publier, donc de remplacer une dépendance que d'autres pipelines consomment.

  4. Sortir du token générique quand la destination est un tiers

    Pour parler à un service externe, un jeton d'identité à destination unique vaut mieux qu'un token qui ouvre l'API GitLab :

    .gitlab-ci.yml : un jeton par destination
    default:
    id_tokens:
    VAULT_TOKEN:
    aud: https://vault.example.com

Mesure 2, Désactiver le mode privileged sur les runners

Section intitulée « Mesure 2, Désactiver le mode privileged sur les runners »

Un runner Docker en mode privileged donne un accès root complet à l'hôte. C'est le vecteur d'évasion de conteneur le plus direct.

config.toml : AVANT (dangereux)
[[runners]]
name = "shared-runner"
[runners.docker]
privileged = true
config.toml : APRÈS (sécurisé)
[[runners]]
name = "shared-runner"
[runners.docker]
privileged = false
cap_drop = ["ALL"]
security_opt = ["no-new-privileges:true"]

Mesure 3, Séparer les runners par niveau de confiance

Section intitulée « Mesure 3, Séparer les runners par niveau de confiance »

Ne partagez pas les mêmes runners entre des projets publics/fork et des projets internes qui déploient en production.

NiveauRunnerTagsUsage
Public/ForkRunner dédié, non protégépublic, untrustedMR externes, CI de contribution
InterneRunner de groupeinternalBuild, tests, qualité
DéploiementRunner de projet, protégédeploy, protectedDéploiement staging/production
config.toml : Runner de déploiement isolé
[[runners]]
name = "deploy-runner"
limit = 1
[runners.docker]
privileged = false
allowed_images = ["registry.example.com/deploy/*"]
allowed_services = []
  1. Enregistrer un runner protégé dédié au déploiement

    Enregistrer un runner protégé
    gitlab-runner register \
    --non-interactive \
    --url "https://gitlab.example.com" \
    --token "glrt-xxxxxxxxxxxx" \
    --executor docker \
    --docker-image "alpine:3.21@sha256:ce64758a109eb420d874a118f87920e625e12d3634e03b4a5573fd9f6e5d3507" \
    --tag-list "deploy,protected" \
    --run-untagged=false

    L'image par défaut du runner s'épingle aussi, par le même digest d'index que la mesure 4 : c'est elle qui sert à tout job qui ne déclare pas d'image:, donc celle qu'on oublie de vérifier. --run-untagged=false complète la restriction : sans lui, ce runner de déploiement ramasserait n'importe quel job sans tag.

  2. Marquer le runner comme protégé dans l'UI

    Administration > Runners → sélectionner le runner → cocher Protected. Un runner protégé n'exécute que les jobs sur des branches/tags protégés.

  3. Forcer les tags dans les jobs de déploiement

    .gitlab-ci.yml : Job restreint au runner protégé
    deploy:production:
    tags:
    - deploy
    - protected
    rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    script:
    - ansible-playbook deploy-prod.yml

Référencer une image par tag (python:3.12) expose au tag poisoning, le même vecteur qui a compromis Trivy et KICS sur GitHub Actions.

.gitlab-ci.yml : AVANT (tag mutable)
test:
# ❌ Contre-exemple : un tag seul, que le registre peut redéplacer à tout moment
image: python:3.12-slim
.gitlab-ci.yml : APRÈS (digest immuable)
test:
image: python:3.12-slim@sha256:2f17fc044b579bab302c2e8054d3a686e2cb9a83de48e70534b94cd8ebbe06a9

Quel digest épingler : celui de l'index, pas celui d'une plateforme

Section intitulée « Quel digest épingler : celui de l'index, pas celui d'une plateforme »

Une image populaire est un index qui pointe vers un manifeste par architecture, et les deux ont des empreintes différentes. Épingler la mauvaise fige votre pipeline sur une seule architecture, silencieusement.

La commande qu'on voit partout rend la mauvaise :

Fenêtre de terminal
# ❌ Rend le digest du PREMIER manifeste de l'index, donc une plateforme
docker manifest inspect python:3.12-slim | jq -r '.manifests[0].digest'

Mesuré le 20 septembre 2026 sur python:3.12-slim, les deux valeurs diffèrent :

Ce qu'on obtientValeurCe que cela fige
.manifests[0].digestsha256:44ff437b…linux/amd64 seulement
Digest de l'indexsha256:2f17fc04…le tag, toutes architectures

La bonne commande lit l'index lui-même :

Obtenir le digest d'index, celui que le tag référence
docker buildx imagetools inspect python:3.12-slim | awk '/^Digest:/ {print $2}'

Le premier digest n'est pas faux, il est plus étroit : un runner arm64 qui tire cette référence obtient l'image amd64, et l'erreur n'apparaît qu'à l'exécution du premier binaire. Sur une flotte homogène, personne ne le voit pendant des mois ; le jour où un runner arm64 arrive, le pipeline casse sans que le fichier ait changé.

Un include: remote: charge un fichier YAML depuis une URL externe à chaque exécution du pipeline. Si cette URL est compromise, tous vos pipelines le sont.

.gitlab-ci.yml : AVANT (include non vérifié)
include:
# ❌ Contre-exemple : l'URL est relue à chaque pipeline, sans aucune vérification
- remote: https://example.com/templates/deploy.yml
.gitlab-ci.yml : APRÈS (include depuis un projet, épinglé sur un commit)
include:
- project: 'my-org/ci-templates'
ref: '787123b47f14b552955ca2786bc9542ae66fee5b' # v2.1.0
file: '/templates/deploy.yml'

Épinglez sur le SHA du commit, pas sur le tag. ref accepte les trois formes, et elles n'offrent pas la même garantie :

Valeur de refCe qui peut changer sous vos pieds
OmisLe pire cas. La documentation est explicite : « Defaults to the HEAD of the project when not specified ». Chaque git push sur la branche par défaut modifie ce que votre pipeline exécute.
ref: mainIdem, à chaque commit poussé sur cette branche
ref: 'v2.1.0'Un tag se redéplace : git tag -f suivi d'un push --force change le contenu sans changer la référence
ref: '787123b4…'Rien. Un SHA de commit est immuable

C'est la même règle que ce site applique aux actions GitHub et aux images de conteneurs, et pour la même raison : une référence mutable annule l'épinglage qu'elle est censée fournir. Le tag garde sa place en commentaire, pour que le lecteur sache de quelle version il s'agit.

Depuis GitLab 17.9, un include: remote: peut porter l'empreinte du fichier attendu, et le pipeline échoue si le contenu a changé. La documentation est directe : « If integrity does not match the actual content, the remote file is not processed and the pipeline fails ». C'est la réponse au cas où l'URL externe est imposée et qu'on ne peut pas rapatrier le fichier.

.gitlab-ci.yml : include distant verrouille par empreinte
include:
- remote: 'https://gitlab.com/exemple/ci-templates/-/raw/v2.1.0/deploy.yml'
integrity: 'sha256-L3/GAoKaw0Arw6hDCKeKQlV1QPEgHYxGBHsH4zG1IY8='

Le format n'est pas celui auquel on pense : c'est un SHA-256 encodé en base64, préfixé de sha256-, et non la représentation hexadécimale que rend sha256sum. La conversion se fait en une commande :

Calculer l'empreinte attendue par integrity
curl -fsSL https://gitlab.com/exemple/ci-templates/-/raw/v2.1.0/deploy.yml \
| openssl dgst -sha256 -binary | openssl base64 -A \
| sed 's/^/sha256-/'

Cette protection ne dispense pas d'auditer le contenu : elle garantit que le fichier n'a pas changé depuis que vous l'avez lu, pas qu'il était sain ce jour-là.

Mesure 6, Restreindre les images autorisées au niveau runner

Section intitulée « Mesure 6, Restreindre les images autorisées au niveau runner »

Le config.toml du runner permet de limiter les images que les pipelines peuvent utiliser :

config.toml : Whitelist d'images
[[runners]]
[runners.docker]
allowed_images = [
"registry.example.com/*",
"python:3.12-*",
"node:20-*",
"alpine:3.*"
]
allowed_services = [
"postgres:16-*",
"redis:7-*"
]

Toute image non listée provoquera un échec du job avec image is not allowed. C'est la défense la plus efficace contre l'utilisation d'images compromises.

Le défaut est le pire des cas, et il est silencieux. La documentation du runner est explicite : « If not present, all images are allowed (equivalent to ["**"]) ». Une clé absente n'est donc pas une clé neutre, c'est une autorisation générale. Le contrôle ne consiste pas à vérifier que la valeur est bonne, mais que la clé existe.

Quand un runner doit vraiment tourner en mode privilégié, par exemple pour du Docker-in-Docker, deux clés permettent de limiter qui obtient ce privilège au lieu de l'accorder à toute image autorisée :

config.toml : le privilège réservé à une seule image
[[runners]]
[runners.docker]
privileged = true
allowed_images = ["registry.example.com/*", "docker:28.*"]
allowed_privileged_images = ["docker:28.*"]
allowed_services = ["docker:28.*-dind"]
allowed_privileged_services = ["docker:28.*-dind"]

allowed_privileged_images est un sous-ensemble de allowed_images : une image absente de la première liste s'exécute quand même, mais sans privilège. Le résultat est un runner qui accepte le Docker-in-Docker pour l'image qui en a besoin et refuse le privilège à toutes les autres, alors que privileged = true seul l'accorde à chacune.

Les Merge Requests depuis des forks peuvent exécuter du code arbitraire sur vos runners. Par défaut, GitLab exécute les pipelines de fork dans le projet parent.

  1. Désactiver les pipelines de fork sur les projets sensibles

    Settings > CI/CD > General pipelines → décocher Run pipelines in the parent project for merge requests from forks.

  2. Ou exiger une approbation manuelle

    Settings > CI/CD > General pipelines → cocher Require approval for all fork pipelines.

  3. Vérifier via l'API

    Vérifier la configuration des pipelines de fork
    curl --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}'

Mesure 8, Activer la protection des branches par défaut

Section intitulée « Mesure 8, Activer la protection des branches par défaut »

Les branches protégées sont le mécanisme central de GitLab pour contrôler qui peut pousser, merger et déclencher des pipelines sur les branches critiques.

Protéger la branche main via l'API
curl --request POST \
--header "PRIVATE-TOKEN: $GITLAB_API_TOKEN" \
--data "name=main&push_access_level=40&merge_access_level=30&allow_force_push=false" \
"https://gitlab.example.com/api/v4/projects/$PROJECT_ID/protected_branches"
Niveau d'accèsValeurQui peut agir
No access0Personne
Developer30Développeurs et au-dessus
Maintainer40Maintainers et au-dessus
Admin60Administrateurs uniquement

Mesure 9, Forcer les merge requests avec approbation

Section intitulée « Mesure 9, Forcer les merge requests avec approbation »

Un pipeline de déploiement ne doit jamais se déclencher par un push direct. Exigez une MR avec au moins une approbation.

.gitlab-ci.yml : Deploy uniquement via MR mergée
deploy:production:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
when: never
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- ansible-playbook deploy-prod.yml

Ce que vous appliquez dépend donc de votre édition :

ÉditionCe qui bloque réellement une fusion non revue
Free / CELes branches protégées de la mesure 8, qui décident qui peut fusionner, plus le réglage « Pipelines must succeed », disponible en Free. L'approbation reste un signal social, pas un verrou.
Premium / UltimateLes règles d'approbation : nombre requis, exclusion de l'auteur et des committers, règles par chemin de fichier.

En CE, la protection réelle vient donc de la combinaison des mesures 8 et 9 : seuls les Maintainers fusionnent sur main, le pipeline doit passer, et le job de déploiement ne se déclenche que sur la branche par défaut. Compter sur l'approbation seule y donnerait une fausse impression de contrôle.

plumber est un outil open source qui analyse la configuration de sécurité de vos projets et pipelines, sur GitLab comme sur GitHub. Le code est publié sur github.com/getplumber/plumber.

Analyser un projet GitLab avec plumber
docker run --rm \
-e GITLAB_TOKEN="$GITLAB_API_TOKEN" \
getplumber/plumber:0.4.12@sha256:86ae3d2a9a4b2e8f6f81929b37b9b254c9a0565b407dc1ba15341b1e7f6bd032 analyze \
--gitlab-url "https://gitlab.example.com" \
--project "my-org/my-project"
Résultat type
[WARN] Unprotected branch: main (push allowed for Developers)
[WARN] Fork pipelines enabled without approval
[WARN] CI_JOB_TOKEN scope not restricted
[WARN] Runner using privileged mode
[INFO] 4 findings, 0 critical, 4 warnings

Intégrez plumber dans votre pipeline CI pour un audit continu :

.gitlab-ci.yml : Audit continu avec plumber
security-audit:
stage: test
image: getplumber/plumber:0.4.12@sha256:86ae3d2a9a4b2e8f6f81929b37b9b254c9a0565b407dc1ba15341b1e7f6bd032
variables:
GITLAB_TOKEN: $GITLAB_API_TOKEN
script:
- plumber analyze --gitlab-url $CI_SERVER_URL --project $CI_PROJECT_PATH
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"

Chaque ligne renvoie à un endroit précis où l'état réel se vérifie, dans l'interface, dans le config.toml du runner ou par une recherche dans le dépôt. Une mesure que vous ne pouvez pas contrôler de cette façon n'est pas appliquée, elle est seulement supposée. Reprenez cette liste après chaque ajout de runner ou de projet.

#MesureVérifiable par
1Liste d'autorisation entrante réduite au projet, cibles internes traitéesGET /projects/:id/job_token_scope/allowlist
2Mode privileged désactivéconfig.toml du runner
3Runners séparés par confianceAdministration > Runners (tags + protected)
4Images épinglées par digestGrep @sha256: dans .gitlab-ci.yml
5Includes épinglés sur un SHA de commit, pas un taggrep -A2 'include:' .gitlab-ci.yml, un ref: de 40 caractères hexadécimaux
6Images autorisées restreintesconfig.toml → allowed_images
7Pipelines de fork bloqués ou approuvésSettings > CI/CD > General pipelines
8Branches critiques protégéesSettings > Repository > Protected branches
9Fusion contrôlée : branches protégées en CE, règles d'approbation en PremiumSettings > Repository > Protected branches, et Settings > Merge requests
10Audit plumber régulierPipeline schedule hebdomadaire

Le durcissement casse toujours quelque chose au premier passage, et c'est attendu : un pipeline qui échoue après restriction révèle une dépendance implicite que personne n'avait documentée. Les symptômes ci-dessous correspondent aux mesures de ce guide, dans l'ordre où ils apparaissent généralement.

SymptômeCause probableSolution
Job échoue avec image is not allowedImage non dans allowed_images du runnerAjouter l'image à la whitelist dans config.toml
Job de déploiement ne démarre pasRunner protégé + branche non protégéeVérifier que la branche est dans la liste protégée
Authentication by CI/CD job token not allowed from X to project #NLe projet appelant n'est pas sur la liste d'autorisation entrante de la cibleAjouter le projet appelant dans Settings > CI/CD > Job token permissions de la cible
You are not allowed to download code from this projectLa fonctionnalité dépôt de la cible est restreinte aux membresAjouter le compte qui déclenche le pipeline comme membre, ou rouvrir repository_access_level en connaissance de cause
Build Docker échoue après désactivation de privilegedDinD nécessite privileged: trueMigrer vers buildah, ou vers kaniko depuis un fork maintenu, pas depuis gcr.io
plumber ne se connecte pasToken insuffisantUtiliser un PAT avec scope api et read_repository
  • Le durcissement couvre toute la chaîne : token, runner, images, includes, branches, approbations
  • Le CI_JOB_TOKEN ne franchit pas la frontière d'un projet privé sur une instance récente, mais il clone un projet interne sans figurer sur aucune liste. C'est là que se joue le mouvement latéral
  • Un projet interne se protège en restreignant la fonctionnalité dépôt aux membres, ce qui oblige à descendre aussi les demandes de fusion et les pipelines
  • Le mode privileged est l'évasion de conteneur la plus simple. Migrez vers buildah ou kaniko, en tirant kaniko d'un fork maintenu : le dépôt d'origine est archivé et gcr.io/kaniko-project est figé à v1.24.0
  • L'épinglage par digest (pas par tag) est la seule protection contre le tag poisoning sur les images Docker
  • Un include: project: s'épingle sur un SHA de commit : ref: omis vaut HEAD, et un tag se redéplace. Quand l'URL externe est imposée, integrity verrouille le contenu à défaut de la source
  • En édition gratuite, l'approbation de MR est optionnelle et ne bloque rien : ce sont les branches protégées qui contrôlent réellement la fusion. Les règles d'approbation exigées sont Premium
  • plumber en pipeline schedule détecte les régressions de configuration

Les réglages GitLab évoluent d'une version majeure à l'autre, en particulier les libellés des écrans Settings. Vérifiez les pages ci-dessous pour la version de votre instance avant d'appliquer une mesure.

Dix questions sur les points les plus souvent mal configurés : portée du token de job, mode privileged, épinglage des images et includes distants.

Contrôle de connaissances

Validez vos connaissances avec ce quiz interactif

10 questions
5 min.
70% requis

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

Ce site vous est utile ?

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

Je maintiens ce site gratuitement, sans publicité, sans profilage et sans compte à créer. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn