Lab : Sécurisation des Pipelines GitLab CI — Variables Protégées, Runners et Environnements

Aperçu

GitLab CI est la deuxième plateforme CI/CD la plus utilisée dans l’industrie, alimentant des millions de pipelines au sein d’organisations de toutes tailles. Son intégration étroite avec le contrôle de version la rend exceptionnellement pratique — mais cette même intégration crée une surface d’attaque étendue si les pipelines ne sont pas délibérément renforcés.

Dans ce lab pratique, vous parcourrez six exercices qui sécurisent progressivement un pipeline GitLab CI. Vous commencerez avec une configuration intentionnellement non sécurisée où chaque variable est visible depuis toutes les branches, les runners partagés gèrent tous les jobs, et il n’y a aucune porte d’approbation sur les environnements. À la fin, vous disposerez d’un pipeline qui applique un accès aux variables selon le principe du moindre privilège, des runners à portée limitée, des environnements protégés avec approbations de déploiement, un accès restreint au CI_JOB_TOKEN, des pipelines de merge request sécurisés, ainsi que des contrôles de renforcement supplémentaires incluant la détection de secrets.

Chaque commande, extrait YAML et chemin d’interface dans ce lab est basé sur GitLab 16.x / 17.x et fonctionne sur le plan gratuit de GitLab.com.

Prérequis

  • Un compte GitLab — le plan gratuit sur gitlab.com est suffisant pour chaque exercice.
  • Un projet de test contenant une application simple (même un simple fichier index.html suffit) et un fichier .gitlab-ci.yml à la racine du dépôt.
  • Une familiarité de base avec la syntaxe GitLab CI : stages, jobs, scripts et rules.
  • (Optionnel) Une machine Linux ou macOS si vous prévoyez d’enregistrer votre propre GitLab Runner dans l’Exercice 2.

Mise en Place de l’Environnement

Étape 1 — Créer un Nouveau Projet GitLab

  1. Naviguez vers GitLab > New Project > Create blank project.
  2. Nommez-le secure-pipeline-lab, définissez la visibilité sur Private et initialisez avec un README.
  3. Sous Settings > Repository > Protected branches, confirmez que main est listée comme branche protégée (c’est le paramètre par défaut).

Étape 2 — Ajouter une Application Simple

Créez index.html à la racine du dépôt :

<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Secure Pipeline Lab</title></head>
<body><h1>Hello, GitLab CI!</h1></body>
</html>

Étape 3 — Créer le Pipeline Initial (Non Sécurisé)

Ajoutez le fichier .gitlab-ci.yml suivant. Il est délibérément non sécurisé — c’est le point de départ que nous renforcerons tout au long du lab :

# .gitlab-ci.yml — Point de départ NON SÉCURISÉ
stages:
  - build
  - test
  - deploy

build-job:
  stage: build
  script:
    - echo "Building the application..."
    - echo "DB_PASSWORD is $DB_PASSWORD"   # Variable affichée dans les logs !

test-job:
  stage: test
  script:
    - echo "Running tests..."
    - echo "API_KEY is $API_KEY"            # Variable affichée dans les logs !

deploy-job:
  stage: deploy
  script:
    - echo "Deploying to production..."
    - echo "DEPLOY_TOKEN is $DEPLOY_TOKEN" # Variable affichée dans les logs !

Ce pipeline présente plusieurs problèmes :

  • Toutes les variables CI/CD sont accessibles depuis toutes les branches, y compris celles créées par des contributeurs externes.
  • Les variables sont affichées directement dans les logs des jobs — toute personne ayant accès aux logs peut les lire.
  • Les jobs s’exécutent sur des runners partagés sans garantie d’isolation.
  • Il n’y a aucune porte d’approbation sur les environnements — le job de déploiement s’exécute automatiquement à chaque push.

Committez ce fichier sur main et vérifiez que le pipeline s’exécute. Maintenant, corrigeons chacun de ces problèmes.

Exercice 1 : Variables Protégées et Masquées

Les variables CI/CD de GitLab prennent en charge trois indicateurs de protection qui réduisent considérablement le rayon d’impact d’une branche ou d’un fork compromis.

Comprendre les Trois Indicateurs

Indicateur Effet
Protected La variable est uniquement injectée dans les pipelines s’exécutant sur des branches ou tags protégés. Un pipeline déclenché depuis une branche de fonctionnalité ou un fork ne verra jamais la valeur.
Masked GitLab masque la valeur de la variable dans les logs des jobs. Si un script affiche accidentellement la valeur, le log affiche [MASKED] à la place.
Hidden (GitLab 17+) La valeur de la variable ne peut pas être révélée dans l’interface après sa création — même par les mainteneurs du projet. Utile pour les secrets gérés par une équipe plateforme que les développeurs ne devraient jamais voir en texte clair.

Étape 1 — Créer les Variables

  1. Allez dans Settings > CI/CD > Variables > Expand > Add variable.
  2. Créez les variables suivantes :
Clé Valeur (exemple) Protected Masked Hidden
DEPLOY_TOKEN glpat-xxxxxxxxxxxxxxxxxxxx Oui Oui Non
DB_PASSWORD S3cur3P@ssw0rd!2024 Oui Oui Oui
API_KEY sk-test-abc123def456 Non Oui Non

Étape 2 — Mettre à Jour le Pipeline

# .gitlab-ci.yml — Exercice 1
stages:
  - build
  - test
  - deploy

build-job:
  stage: build
  script:
    - echo "Building the application..."
    - echo "API_KEY value length = ${#API_KEY}"  # Sûr : affiche la longueur, pas la valeur

test-job:
  stage: test
  script:
    - echo "Running tests..."
    # Tentative d'affichage d'une variable masquée :
    - echo "DB_PASSWORD is $DB_PASSWORD"
    # La sortie affichera : DB_PASSWORD is [MASKED]

deploy-job:
  stage: deploy
  script:
    - echo "Deploying with DEPLOY_TOKEN..."
    - echo "Token is $DEPLOY_TOKEN"
    # Sur main (protégée) : Token is [MASKED]
    # Sur une branche de fonctionnalité : Token is <vide — variable non injectée>
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

Étape 3 — Vérifier le Comportement de Protection

  1. Push sur main — le job de déploiement s’exécute et DEPLOY_TOKEN est injecté (le log affiche [MASKED]).
  2. Créez une branche feature/test-vars, poussez un commit — le job de déploiement ne s’exécute pas (les rules le restreignent à main). Même si vous modifiez les rules pour le laisser s’exécuter, DEPLOY_TOKEN et DB_PASSWORD sont vides car la branche n’est pas protégée.
  3. API_KEY, qui est masquée mais non protégée, est disponible sur les deux branches — sa valeur est masquée dans les logs.

Leçon clé : Marquez toujours les identifiants de déploiement comme Protected et Masked. Utilisez Hidden pour les secrets que les développeurs ne devraient jamais récupérer depuis l’interface.

Exercice 2 : Sécurité et Portée des Runners

Les runners sont les moteurs de calcul qui exécutent vos jobs CI. Choisir le bon type de runner — et définir correctement sa portée — est l’une des décisions de sécurité les plus impactantes que vous puissiez prendre.

Types de Runners

Type Portée Posture de Sécurité
Instance (partagé) Disponible pour chaque projet sur l’instance GitLab Multi-tenant. Les jobs d’autres projets peuvent s’exécuter sur la même machine. Risque de fuite de données via le système de fichiers partagé, le socket Docker ou les couches en cache.
Group Disponible pour chaque projet d’un groupe spécifique Meilleure isolation que les runners d’instance, mais toujours partagé entre les projets du groupe.
Project Disponible pour un seul projet uniquement Meilleure isolation. Vous contrôlez la machine, la configuration Docker et l’accès réseau.

Étape 1 — Enregistrer un Runner Spécifique au Projet

Sur une machine que vous contrôlez (une VM, un serveur disponible ou même un hôte Docker local), installez GitLab Runner et enregistrez-le :

# Installer GitLab Runner (Linux amd64)
sudo curl -L --output /usr/local/bin/gitlab-runner \
  https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-amd64
sudo chmod +x /usr/local/bin/gitlab-runner
sudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner
sudo gitlab-runner start

# Enregistrer le runner
# Trouvez votre token d'enregistrement : Settings > CI/CD > Runners > Expand > New project runner
sudo gitlab-runner register \
  --non-interactive \
  --url https://gitlab.com/ \
  --token "$RUNNER_TOKEN" \
  --executor docker \
  --docker-image alpine:latest \
  --description "secure-deploy-runner" \
  --tag-list "secure-deploy" \
  --access-level ref_protected

L’indicateur critique est --access-level ref_protected. Il indique à GitLab que le runner n’acceptera que les jobs provenant de branches ou tags protégés. Un pipeline déclenché par une branche de fonctionnalité ou une merge request de fork ne sera jamais planifié sur ce runner.

Étape 2 — Désactiver les Runners Partagés pour les Jobs Sensibles

Allez dans Settings > CI/CD > Runners et basculez Enable shared runners for this project sur désactivé — ou laissez-les activés pour les étapes non sensibles et utilisez des tags pour diriger les jobs sensibles vers votre runner de projet.

Étape 3 — Mettre à Jour le Pipeline avec une Sélection de Runner par Tags

# .gitlab-ci.yml — Exercice 2
stages:
  - build
  - test
  - deploy

build-job:
  stage: build
  # S'exécute sur n'importe quel runner disponible (partagé convient pour les builds)
  script:
    - echo "Building the application..."

test-job:
  stage: test
  script:
    - echo "Running tests..."

deploy-job:
  stage: deploy
  tags:
    - secure-deploy            # S'exécute uniquement sur le(s) runner(s) avec ce tag
  script:
    - echo "Deploying with DEPLOY_TOKEN..."
    - |
      curl --fail --silent --header "PRIVATE-TOKEN: $DEPLOY_TOKEN" \
        https://gitlab.com/api/v4/projects/$CI_PROJECT_ID/releases
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

Parce que le runner secure-deploy est enregistré avec l’accès ref_protected, ce job de déploiement ne s’exécutera que sur le runner spécifique au projet et uniquement lorsque le pipeline provient d’une référence protégée.

Exercice 3 : Environnements Protégés et Approbations de Déploiement

Même avec des variables protégées et des runners à portée limitée, vous pouvez souhaiter une validation humaine avant que le code n’atteigne la production. Les environnements protégés de GitLab fournissent exactement cela.

Étape 1 — Créer les Environnements

  1. Naviguez vers Operate > Environments > New environment.
  2. Créez deux environnements : staging et production.

Étape 2 — Protéger l’Environnement de Production

  1. Allez dans Settings > CI/CD > Protected environments (disponible sur Premium/Ultimate, ou sur le plan gratuit en auto-hébergé).
  2. Sélectionnez production.
  3. Sous Allowed to deploy, restreignez aux Maintainers (ou à un utilisateur spécifique).
  4. Sous Required approvals, définissez à 1 (ou plus, selon votre politique).
  5. Ajoutez le(s) approbateur(s) désigné(s).

Étape 3 — Mettre à Jour le Pipeline avec les Définitions d’Environnements

# .gitlab-ci.yml — Exercice 3
stages:
  - build
  - test
  - deploy

build-job:
  stage: build
  script:
    - echo "Building the application..."

test-job:
  stage: test
  script:
    - echo "Running tests..."

deploy-staging:
  stage: deploy
  environment:
    name: staging
    url: https://staging.example.com
  script:
    - echo "Deploying to staging..."
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

deploy-production:
  stage: deploy
  tags:
    - secure-deploy
  environment:
    name: production
    url: https://prod.example.com
  script:
    - echo "Deploying to production..."
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual           # Nécessite un clic humain
  allow_failure: false        # Le pipeline reste bloqué jusqu'à approbation

Fonctionnement de l’Approbation

  1. Un push sur main déclenche le pipeline.
  2. deploy-staging s’exécute automatiquement.
  3. deploy-production affiche un bouton Play dans l’interface du pipeline.
  4. Cliquer sur Play ne lance pas immédiatement le job — GitLab vérifie les règles de protection de l’environnement et présente une boîte de dialogue d’approbation au(x) approbateur(s) désigné(s).
  5. Le job ne démarre qu’après avoir reçu le nombre d’approbations requis.

Cette double porte — when: manual plus approbation d’environnement — garantit qu’aucune personne seule ne peut pousser du code directement en production sans revue.

Exercice 4 : Portée du CI_JOB_TOKEN

Chaque job GitLab CI reçoit un token automatique dans la variable CI_JOB_TOKEN. Ce token authentifie les requêtes API et Git en tant que projet du pipeline. Par défaut, sa portée est dangereusement large.

Le Risque

Sans restrictions, un job dans le Projet A peut utiliser CI_JOB_TOKEN pour cloner ou appeler l’API de n’importe quel autre projet dans le même groupe (ou instance, selon les paramètres). Si un contributeur malveillant injecte un script dans un job CI, il peut exfiltrer du code depuis des dépôts non liés.

Étape 1 — Restreindre la Portée du Token

  1. Allez dans Settings > CI/CD > Token Access.
  2. Basculez Limit access to this project sur Enabled.
  3. Sous Allow CI job tokens from the following projects to access this project, ajoutez uniquement les projets qui ont réellement besoin d’accès (modèle de liste d’autorisation).
  4. Sous Limit CI_JOB_TOKEN access to the following projects (sortant), ajoutez uniquement les projets que votre pipeline doit atteindre.

Étape 2 — Tester l’Accès

# .gitlab-ci.yml — Exercice 4
stages:
  - test

test-token-allowed:
  stage: test
  script:
    - echo "Cloning an allowed project..."
    - git clone https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com/my-group/allowed-project.git
    - echo "Success — access permitted"

test-token-denied:
  stage: test
  script:
    - echo "Cloning a non-allowed project..."
    - git clone https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com/my-group/restricted-project.git
    # Sortie attendue : remote: HTTP Basic: Access denied
    # fatal: Authentication failed — 403 Forbidden
  allow_failure: true

Étape 3 — Vérifier

  1. Exécutez le pipeline. test-token-allowed réussit et clone le projet autorisé.
  2. test-token-denied échoue avec 403 Forbidden car restricted-project n’est pas dans la liste d’autorisation.

Leçon clé : Restreignez toujours le CI_JOB_TOKEN à l’ensemble minimal de projets dont votre pipeline a réellement besoin. Considérez la portée par défaut « ouverte » comme une mauvaise configuration.

Exercice 5 : Sécurité des Pipelines de Merge Request

Les pipelines de merge request (MR) s’exécutent lorsqu’un contributeur ouvre ou met à jour une merge request. Ils sont essentiels pour la qualité du code — mais ils peuvent aussi constituer un vecteur d’attaque s’ils ne sont pas configurés avec soin.

Le Risque

Lorsqu’un contributeur externe fork votre projet et ouvre une MR, GitLab peut exécuter un pipeline sur cette MR. Si le pipeline a accès à des variables protégées ou à des runners privilégiés, le code du contributeur pourrait exfiltrer des secrets.

Étape 1 — Configurer les Règles de Pipeline MR

# .gitlab-ci.yml — Exercice 5
stages:
  - validate
  - build
  - deploy

# --- Jobs sûrs pour les pipelines MR (aucun secret nécessaire) ---
lint:
  stage: validate
  script:
    - echo "Linting code..."
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"

unit-tests:
  stage: validate
  script:
    - echo "Running unit tests..."
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"

# --- Jobs nécessitant des secrets — ne jamais exécuter sur les pipelines MR ---
build-image:
  stage: build
  script:
    - echo "Building and pushing Docker image..."
    - echo "Using REGISTRY_TOKEN = $REGISTRY_TOKEN"  # Protected + Masked
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

deploy-production:
  stage: deploy
  tags:
    - secure-deploy
  environment:
    name: production
    url: https://prod.example.com
  script:
    - echo "Deploying to production..."
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual

Gestion des Pipelines MR de Forks par GitLab

  • Les pipelines déclenchés par merge_request_event depuis un fork s’exécutent automatiquement avec des permissions limitées.
  • Les variables protégées ne sont jamais injectées dans les pipelines MR de forks.
  • Le CI_JOB_TOKEN dans les pipelines de forks a une portée réduite — il ne peut accéder qu’au projet source (fork), pas au projet cible.

En séparant vos jobs en « sûrs pour les MR » (lint, test) et « nécessitant des secrets » (build, deploy), vous garantissez que les contributeurs peuvent valider leur code sans exposer les identifiants.

Bonnes Pratiques pour les Pipelines MR

  • N’utilisez jamais only/except — préférez rules: pour plus de clarté et de fiabilité.
  • Conditionnez les jobs dépendant de secrets avec if: $CI_COMMIT_BRANCH == "main" (ou une autre référence protégée).
  • Envisagez d’activer Pipelines must succeed sous Settings > Merge requests pour exiger que le pipeline MR réussisse avant la fusion.
  • Activez les Merged results pipelines pour tester le résultat de la fusion plutôt que la seule branche source — cela détecte les problèmes d’intégration plus tôt.

Exercice 6 : Renforcement Supplémentaire

Avec les variables, runners, environnements, tokens et pipelines MR sécurisés, plusieurs contrôles supplémentaires amènent votre pipeline à un niveau de sécurité prêt pour la production.

Délais d’Expiration des Jobs

Les jobs sans limite de temps peuvent être exploités pour le crypto-mining ou utilisés pour maintenir un accès persistant. Définissez des délais explicites :

deploy-production:
  stage: deploy
  timeout: 10 minutes
  script:
    - echo "Deploying..."

Pipelines Interruptibles

Évitez le gaspillage de ressources et limitez la fenêtre d’exploitation des jobs malveillants de longue durée en marquant les jobs non critiques comme interruptibles :

lint:
  stage: validate
  interruptible: true     # Annulé automatiquement si un pipeline plus récent démarre
  script:
    - echo "Linting..."

Règles de Push (Restreindre la Création de Pipelines)

Sous Settings > Repository > Push rules, vous pouvez :

  • Rejeter les commits non signés — garantit que chaque commit est signé GPG.
  • Restreindre les noms de branches — imposer une convention de nommage (ex. feature/*, bugfix/*).
  • Empêcher le push de secrets — la règle de push intégrée de GitLab peut bloquer les fichiers correspondant aux patterns de secrets courants.

Détection de Secrets avec GitLab SAST

Ajoutez le template intégré de Détection de Secrets de GitLab pour détecter les identifiants accidentellement committés :

include:
  - template: Security/Secret-Detection.gitlab-ci.yml

Cela ajoute un job secret_detection qui analyse chaque commit à la recherche de clés API, tokens, mots de passe et autres patterns de secrets. Les résultats apparaissent dans l’onglet Security des merge requests.

Le Pipeline Renforcé Final

Voici le fichier .gitlab-ci.yml complet combinant tous les contrôles de sécurité de ce lab. Chaque ligne liée à la sécurité est commentée.

# .gitlab-ci.yml — Pipeline GitLab CI Entièrement Renforcé

# Inclure le scanner de détection de secrets intégré de GitLab
include:
  - template: Security/Secret-Detection.gitlab-ci.yml  # Analyse les secrets divulgués

stages:
  - validate
  - build
  - deploy

# --- Paramètres par défaut appliqués à tous les jobs ---
default:
  timeout: 10 minutes        # Empêcher les jobs incontrôlés/exploités

# --- Sûr pour les pipelines de merge request (aucun secret nécessaire) ---
lint:
  stage: validate
  interruptible: true        # Annulé si un pipeline plus récent démarre
  script:
    - echo "Linting source code..."
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"  # Exécuter sur les MR
    - if: $CI_COMMIT_BRANCH == "main"                    # Exécuter sur main

unit-tests:
  stage: validate
  interruptible: true
  script:
    - echo "Running unit tests..."
    - echo "API_KEY length = ${#API_KEY}"  # Sûr : affiche uniquement la longueur
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"

# --- Nécessite des secrets — s'exécute uniquement sur la branche protégée ---
build-image:
  stage: build
  script:
    - echo "Building Docker image..."
    - echo "Authenticating to registry..."  # Utilise REGISTRY_TOKEN (Protected + Masked)
  rules:
    - if: $CI_COMMIT_BRANCH == "main"       # Uniquement sur la branche protégée

# --- Déploiement staging — automatique sur main ---
deploy-staging:
  stage: deploy
  environment:
    name: staging                            # Environnement suivi
    url: https://staging.example.com
  script:
    - echo "Deploying to staging..."
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

# --- Déploiement production — manuel + approbation requise ---
deploy-production:
  stage: deploy
  tags:
    - secure-deploy                          # S'exécute uniquement sur le runner spécifique au projet
  environment:
    name: production                         # Environnement protégé avec approbations
    url: https://prod.example.com
  script:
    - echo "Deploying to production..."
    - |
      curl --fail --silent \
        --header "PRIVATE-TOKEN: $DEPLOY_TOKEN" \   # Variable Protected + Masked
        --request POST \
        "https://gitlab.com/api/v4/projects/$CI_PROJECT_ID/deployments"
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual                           # Nécessite un déclenchement humain
  allow_failure: false                       # Le pipeline bloque jusqu'à approbation
  timeout: 5 minutes                         # Délai plus serré pour les déploiements

Nettoyage

Après avoir terminé le lab, nettoyez les ressources de test :

  1. Supprimer ou archiver le projet de test : Allez dans Settings > General > Advanced > Delete project.
  2. Supprimer les variables CI/CD : Si vous prévoyez de conserver le projet, allez dans Settings > CI/CD > Variables et supprimez les variables de test (DEPLOY_TOKEN, DB_PASSWORD, API_KEY).
  3. Désenregistrer le runner de test :
# Lister les runners enregistrés
sudo gitlab-runner list

# Désenregistrer le runner de test
sudo gitlab-runner unregister --name "secure-deploy-runner"

# Optionnellement, supprimer complètement GitLab Runner
sudo gitlab-runner stop
sudo gitlab-runner uninstall
sudo rm /usr/local/bin/gitlab-runner

Points Clés à Retenir

  • Protégez et masquez chaque variable secrète. Les variables protégées ne sont injectées que sur les branches protégées, et le masquage empêche l’exposition accidentelle dans les logs. Utilisez l’indicateur Hidden pour les secrets qui ne devraient jamais être lisibles dans l’interface.
  • Limitez la portée des runners au niveau de confiance minimum requis. Utilisez des runners spécifiques au projet avec l’accès ref_protected pour les jobs de déploiement. Réservez les runners partagés aux étapes non sensibles de build et de test.
  • Conditionnez les déploiements en production avec la protection d’environnement et les approbations. Combiner when: manual avec un environnement protégé et des approbateurs requis garantit qu’aucune personne seule ne peut pousser en production sans contrôle.
  • Restreignez le CI_JOB_TOKEN à une liste d’autorisation explicite. La portée par défaut est trop large. Limitez l’accès entrant et sortant aux seuls projets dont votre pipeline a réellement besoin.
  • Séparez les jobs de pipeline MR des jobs de déploiement. Les jobs de lint et de test sont sûrs pour les pipelines de merge request ; les jobs de build et de déploiement nécessitant des secrets ne doivent s’exécuter que sur les branches protégées.
  • Superposez les contrôles supplémentaires : délais d’expiration, jobs interruptibles, règles de push et détection de secrets. Chaque couche adresse un vecteur d’attaque différent et ensemble, elles créent une défense en profondeur.

Prochaines Étapes

Continuez à développer vos connaissances en sécurité CI/CD avec ces guides connexes :