GitLab CI est l'une des chaînes CI/CD les plus utilisées en entreprise, mais sa configuration par défaut est permissive : runners partagés, tokens larges, secrets potentiellement exposés, jobs sans isolation. Ce guide détaille les contrôles à activer côté projet, côté groupe et côté runner pour durcir une chaîne GitLab CI à un niveau de maturité sérieux, avec les commandes et extraits .gitlab-ci.yml correspondants.
1. Modèle de menace GitLab CI
Avant de durcir, il faut identifier ce qu'on protège et contre qui.
1.1 Actifs critiques
- Code source : propriété intellectuelle, mais aussi vecteur d'introduction de portes dérobées.
- Secrets : credentials cloud, tokens API, certificats, clés privées de signature.
- Artefacts de build : images Docker, binaires signés, packages poussés en production.
- Infrastructure de runners : machines qui exécutent les jobs et ont accès à tout ce que tu y routes.
- Tokens GitLab :
CI_JOB_TOKEN, Personal Access Tokens, Deploy Tokens, Group Tokens.
1.2 Attaquants probables
| Profil | Motivation | Capacités typiques |
|---|---|---|
| Contributeur externe malveillant | Extraire secrets, injecter code | PR sur fork, MR sur projet public |
| Collaborateur interne compromis | Escalade, exfiltration | Push direct, modification de pipelines |
| Attaquant supply chain | Compromission durable | Dépendance tierce malveillante, image de base empoisonnée |
| Attaquant runner | Extraction secrets, pivot | Runner partagé mal isolé, registration token volé |
1.3 Surfaces d'attaque spécifiques à GitLab CI
- Pipeline injection : un attaquant modifie
.gitlab-ci.ymlpour exfiltrer des secrets. - Protected variables leak : mauvaise configuration qui expose des variables protégées sur des MR non protégées.
- Runner pivoting : compromission d'un runner partagé qui voit passer les secrets de dizaines de projets.
- Docker-in-Docker escape : escalade depuis un job conteneurisé vers le host.
- MR from fork : MR ouverte depuis un fork qui exécute du code attaquant avec les droits du projet.
- Dependency confusion : paquet privé interne imité sur un registre public.
2. Hardening du projet - réglages GitLab natifs
Avant de toucher à .gitlab-ci.yml, une série de réglages projet et groupe doivent être activés.
2.1 Branches protégées
Settings > Repository > Protected branches :
main,release/*,productiondoivent être protégées.- Allowed to push :
No one(ou équipe restreinte seulement). - Allowed to merge :
Maintainersminimum. - Require approval from code owners : activé.
- Code owner approval : requis pour les chemins sensibles (
/ci/,/terraform/,/.gitlab-ci.yml).
2.2 CODEOWNERS
Fichier à la racine du repo :
# Tout changement CI nécessite review de l'équipe plateforme
.gitlab-ci.yml @groupe/plateforme
/ci/ @groupe/plateforme
/terraform/ @groupe/sre
/k8s/ @groupe/sre
# Dépendances = review sécurité
package.json @groupe/appsec
package-lock.json @groupe/appsec
go.mod @groupe/appsec
go.sum @groupe/appsec
requirements.txt @groupe/appsec
Dockerfile @groupe/appsec2.3 Merge request approvals
Settings > General > Merge request approvals :
Prevent approval by author: activé.Prevent approvals by users who add commits: activé (évite qu'un attaquant s'auto-valide).Require new approvals when commits are added: activé surmain.Require approval from selected users or groups: au moins 2 approbateurs sur les branches critiques.
2.4 Variables protégées et masquées
Settings > CI/CD > Variables :
- Protected : la variable n'est exposée qu'aux jobs exécutés sur branches/tags protégées. Obligatoire pour tout credential de production.
- Masked : la valeur est masquée dans les logs. Obligatoire pour tokens, passwords, clés.
- Expanded : désactivé pour les variables sensibles (évite la substitution récursive).
- File : utiliser ce type pour fichiers de configuration (kubeconfig, keystore, JSON de service account).
- Environments scope : scope par environnement (production, staging) pour limiter l'exposition.
2.5 Désactiver l'exécution automatique depuis des forks
Settings > CI/CD > General pipelines :
Run pipelines for merge requests from forks: désactivé par défaut.- Si nécessaire pour un projet open source, activer avec limites : pas d'accès aux variables protégées (toujours le cas), pas de runner partagé sensible, review obligatoire avant chaque run.
2.6 Restrictions de push et de tag
- Push rules (Premium/Ultimate) : bloquer les pushes qui contiennent des secrets (regex), qui créent des fichiers > 100 MB, qui ont des noms de commit non conformes, ou qui poussent sans signature GPG.
- Signed commits obligatoires sur branches protégées : empêche un attaquant avec accès au token d'un dev de pusher sans la clé GPG associée.
3. Hardening du .gitlab-ci.yml
Le fichier pipeline est lui-même un artefact à sécuriser.
3.1 Principes directeurs
- Principe de moindre privilège : chaque job reçoit seulement les variables et tokens dont il a besoin.
- Pas de secrets en clair : jamais de
VAR: "token_abc"dans le YAML. - Lisibilité maximale : YAML plat et court beat YAML ingénieux ; le YAML intelligent cache des CVEs.
- Pinning de tout : images avec SHA256, templates inclus avec
refexplicite, versions d'outils figées.
3.2 Exemple de pipeline durci
default:
image:
name: registry.example.com/base/alpine@sha256:abc123
pull_policy: always
interruptible: true
tags:
- private-runner
variables:
GIT_DEPTH: 10
GIT_SUBMODULE_STRATEGY: none
SECURE_LOG_LEVEL: info
FF_ENABLE_BASH_EXIT_CODE_CHECK: "true"
stages:
- validate
- test
- security
- build
- deploy
include:
- project: groupe/ci-templates
ref: v3.2.1
file:
- /sast.yml
- /container-scan.yml
lint:yaml:
stage: validate
image: cytopia/yamllint@sha256:def456
script:
- yamllint -s .gitlab-ci.yml
rules:
- if: $CI_MERGE_REQUEST_IID
sast:
stage: security
variables:
SEMGREP_RULES: p/owasp-top-ten p/ci
script:
- semgrep ci --config "$SEMGREP_RULES"
artifacts:
reports:
sast: gl-sast-report.json
expire_in: 1 week
deploy:production:
stage: deploy
environment:
name: production
url: https://app.example.com
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
resource_group: production3.3 Règles à retenir
interruptible: true: les nouveaux commits annulent les pipelines en cours, évite les deploys concurrents.resource_group: production: sérialise les deploys vers un environnement critique (un seul à la fois).when: manualsur production : humain dans la boucle.rules:avecif:: plus lisible et plus sûr queonly:/except:dépréciés.tags:: force l'exécution sur un runner spécifique (pas le shared runner par défaut).
3.4 Include avec ref épinglé
Inclusions dangereuses :
include:
- project: groupe/ci-templates
file: /sast.yml # PAS de ref : utilise HEAD, vulnérable à compromissionInclusions sûres :
include:
- project: groupe/ci-templates
ref: v3.2.1 # Tag immuable
file: /sast.yml
# ou mieux :
- project: groupe/ci-templates
ref: 8a7b3f2e9c # SHA précis, totalement immuable
file: /sast.yml3.5 Images Docker épinglées par digest
# Risqué : l'image peut changer
image: node:20
# Mieux : tag patch
image: node:20.11.1
# Idéal : digest immuable
image: node@sha256:123abc...def456Un attaquant qui compromet un registre peut re-tag node:20 vers une image malveillante. Un digest SHA256 est cryptographiquement immuable.
4. Runner hardening
Le runner est le point sensible : il exécute le code et a accès aux secrets injectés.
4.1 Ne jamais utiliser les shared runners pour le privé sensible
Les shared runners GitLab.com sont pratiques mais :
- Mutualisés avec tous les autres utilisateurs de la plateforme.
- Pas de contrôle sur l'OS, les versions d'outils, les packages préinstallés.
- Impossible d'auditer.
Pour tout projet qui manipule des secrets de production : runners dédiés.
4.2 Executor Docker - configuration durcie
config.toml du runner :
[[runners]]
name = "prod-docker-runner"
url = "https://gitlab.example.com"
token = "GLRUNNER-xyz"
executor = "docker"
[runners.docker]
image = "alpine:3.19"
privileged = false
disable_cache = true
disable_entrypoint_overwrite = true
oom_kill_disable = false
oom_score_adjust = 100
security_opt = ["no-new-privileges:true", "seccomp=default"]
cap_drop = ["ALL"]
cap_add = []
read_only = true
tmpfs = { "/tmp" = "rw,noexec,nosuid,size=100m" }
shm_size = 0
network_mode = "bridge"
extra_hosts = []
volumes = []
pull_policy = "always"
[runners.cache]
Type = "s3"
Shared = falsePoints clés :
privileged = false: jamais true pour des runners partagés ou exposés à des MR externes.cap_drop = ["ALL"]: retire toutes les capabilities Linux.security_optavecno-new-privilegesetseccomp: empêche l'escalade et applique un profil seccomp.read_only = true+tmpfs: système de fichiers en lecture seule avec uniquement/tmpaccessible en écriture.disable_cache: évite le partage de cache entre jobs potentiellement hostiles.
4.3 Executor Kubernetes - Pod Security
Pour runners Kubernetes, contraindre les pods via Pod Security Admission :
# Namespace gitlab-runner
apiVersion: v1
kind: Namespace
metadata:
name: gitlab-runner
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/warn: restrictedConfiguration runner :
[runners.kubernetes]
namespace = "gitlab-runner"
cpu_limit = "2"
memory_limit = "4Gi"
service_cpu_limit = "1"
service_memory_limit = "2Gi"
[runners.kubernetes.pod_security_context]
run_as_non_root = true
run_as_user = 10000
fs_group = 10000
seccomp_profile_type = "RuntimeDefault"4.4 Docker-in-Docker - l'exception
DinD est souvent requis pour construire des images Docker dans les jobs. Risques : nécessite privileged: true, donne accès quasi-root au host.
Alternatives plus sûres :
- Kaniko (Google) : build d'images Docker sans daemon ni privilèges.
- Buildah : build rootless côté Linux.
- BuildKit rootless : mode sans privilèges.
Exemple Kaniko en GitLab CI :
build:image:
stage: build
image:
name: gcr.io/kaniko-project/executor:v1.20.0-debug
entrypoint: [""]
script:
- /kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/Dockerfile"
--destination "${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHA}"
--cache=true
--snapshot-mode=redo4.5 Registration tokens - rotation
Les tokens d'enregistrement de runners sont critiques. Avec GitLab 16+ :
- Authentication tokens remplacent les registration tokens (déprécié).
- Token scope : runner spécifique, groupe, ou instance.
- Rotation manuelle via UI ou API (
POST /runners/:id/reset_authentication_token). - Révoquer immédiatement toute fuite suspectée.
5. Gestion des secrets
5.1 Hiérarchie recommandée
- Vault ou solution équivalente : source de vérité.
- GitLab CI/CD Variables : pour cas simples (variable non partagée entre projets).
- Variables de groupe : credentials partagés à plusieurs projets d'un même groupe.
- Jamais dans le YAML ou dans le code.
5.2 Intégration Vault native
GitLab 13.4+ supporte l'authentification JWT OIDC vers HashiCorp Vault sans secret partagé :
variables:
VAULT_SERVER_URL: https://vault.example.com
VAULT_AUTH_ROLE: ci-role
deploy:
id_tokens:
VAULT_ID_TOKEN:
aud: https://vault.example.com
script:
- export VAULT_TOKEN="$(vault write -field=token auth/jwt/login
role=$VAULT_AUTH_ROLE jwt=$VAULT_ID_TOKEN)"
- DB_PASSWORD=$(vault kv get -field=password kv/prod/db)
- ./deploy.shAvantages :
- Aucun secret Vault stocké dans GitLab.
- Token courte durée (TTL configurable côté Vault, typiquement 5-15 min).
- Révocable en un clic côté Vault sans toucher à GitLab.
- Audit complet des accès côté Vault.
5.3 OIDC vers AWS/GCP/Azure
Même principe : GitLab émet un JWT signé, AWS/GCP/Azure acceptent ce JWT comme identité pour assumer un rôle, sans credential long-lived stocké dans GitLab.
deploy:aws:
id_tokens:
AWS_TOKEN:
aud: https://sts.amazonaws.com
variables:
AWS_ROLE_ARN: arn:aws:iam::123456789012:role/gitlab-deploy
script:
- >
export $(aws sts assume-role-with-web-identity
--role-arn "$AWS_ROLE_ARN"
--role-session-name "gitlab-ci-$CI_JOB_ID"
--web-identity-token "$AWS_TOKEN"
--duration-seconds 3600
--query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]'
--output text | awk '{print "AWS_ACCESS_KEY_ID="$1"\nAWS_SECRET_ACCESS_KEY="$2"\nAWS_SESSION_TOKEN="$3}')
- terraform apply5.4 Secret Detection activée
GitLab propose un détecteur de secrets intégré (basé sur Gitleaks) :
include:
- template: Security/Secret-Detection.gitlab-ci.yml- Scanne les commits des MR.
- Détecte AWS keys, GitHub tokens, clés SSH, Slack webhooks, etc.
- Résultats remontés dans l'onglet Security MR.
- Extensible avec règles custom (
.gitleaks.toml).
6. Scanners de sécurité intégrés (GitLab Ultimate)
GitLab fournit nativement plusieurs scanners activables par include template.
6.1 SAST - Static Application Security Testing
include:
- template: Security/SAST.gitlab-ci.yml
variables:
SAST_EXCLUDED_PATHS: "tests/, vendor/"
SAST_EXCLUDED_ANALYZERS: ""Détecte automatiquement les langages du projet et lance les analyseurs correspondants (Semgrep, Brakeman pour Ruby, gosec pour Go, etc.). Résultats exposés dans l'onglet Security des MR avec remediation advice.
6.2 Dependency Scanning
include:
- template: Security/Dependency-Scanning.gitlab-ci.ymlScanne les dépendances pour CVE connues. Supporte npm, pip, Maven, Gradle, Go modules, Composer, NuGet, etc.
6.3 Container Scanning
include:
- template: Security/Container-Scanning.gitlab-ci.yml
variables:
CS_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHAUtilise Trivy sous le capot. Scanne les images Docker poussées au registre pour CVE dans l'OS et les libs applicatives.
6.4 DAST
include:
- template: Security/DAST.gitlab-ci.yml
variables:
DAST_WEBSITE: https://staging.example.com
DAST_AUTH_URL: https://staging.example.com/loginBasé sur OWASP ZAP. À lancer sur un environnement de préproduction, jamais sur production directement.
6.5 License Compliance
include:
- template: Security/License-Scanning.gitlab-ci.ymlVérifie les licences des dépendances. Permet de bloquer l'introduction de GPL dans un projet propriétaire par exemple.
6.6 IaC Scanning
include:
- template: Security/IaC-SAST.gitlab-ci.ymlScanne Terraform, CloudFormation, Kubernetes manifests, Ansible avec Checkov et KICS.
7. Supply chain et attestation
7.1 Signed commits
Branches protégées avec signatures obligatoires :
- Chaque développeur publie sa clé GPG dans son profil GitLab.
- Configuration git locale :
git config commit.gpgsign true. - Push rule
Reject unsigned commitsactivée surmain.
Limite : pas de garantie sur l'auteur réel (un attaquant qui vole la clé GPG reste indiscernable), mais empêche les pushes via token volé sans clé.
7.2 Cosign et signature d'artefacts
sign:image:
stage: deploy
image: gcr.io/projectsigstore/cosign:v2.2.3
id_tokens:
SIGSTORE_ID_TOKEN:
aud: sigstore
script:
- cosign sign --yes "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
rules:
- if: $CI_COMMIT_BRANCH == "main"Cosign en mode keyless utilise l'OIDC GitLab : la signature est attestée par Sigstore (Rekor transparency log), sans clé privée stockée nulle part.
7.3 SBOM automatique
include:
- template: Security/Dependency-Scanning.gitlab-ci.yml
sbom:
stage: build
image: anchore/syft:v0.100.0
script:
- syft "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" -o cyclonedx-json > sbom.json
artifacts:
paths:
- sbom.json
expire_in: 1 yearGitLab stocke nativement les SBOM CycloneDX depuis la version 15.7, avec matching automatique contre les CVE publiées.
7.4 Attestation SLSA
GitLab 16.0+ propose la génération automatique d'attestations SLSA Level 3 :
variables:
RUNNER_GENERATE_ARTIFACTS_METADATA: "true"
build:
stage: build
script:
- build-artefact.sh
artifacts:
paths:
- dist/Métadonnées générées : digest des artefacts, commit SHA, runner tag, pipeline ID, paramètres de build. Permet à un consommateur downstream de vérifier que l'artefact a bien été produit par le pipeline attendu.
8. Isolation des jobs et moindre privilège
8.1 CI_JOB_TOKEN - portée limitée
Chaque job reçoit un CI_JOB_TOKEN pour s'authentifier au registry GitLab et aux APIs internes. Depuis GitLab 15.9 :
Settings > CI/CD > Token Access :
Limit CI_JOB_TOKEN access: activé.- Allowlist des projets autorisés à utiliser le token de ce projet.
Par défaut, CI_JOB_TOKEN du projet A peut appeler l'API du projet B si le projet B est public. Avec l'allowlist, on restreint à une liste explicite.
8.2 Deploy tokens avec scope minimal
Settings > Repository > Deploy tokens :
- Un token par usage (registry pull, package registry read, etc.).
- Scope minimal :
read_registryseul si le job n'a qu'à tirer une image. - Expiration définie (90 jours max).
- Nom explicite pour traçabilité.
8.3 Environments protégés
Settings > CI/CD > Protected environments :
- L'environnement
productionne peut être déployé que par des groupes autorisés. Required approvals: humain obligatoire avant chaque deploy vers production.- Toute variable scoped
productionn'est exposée qu'aux jobs autorisés sur cet environnement.
8.4 Éviter before_script global bavard
Un before_script: global qui s'exécute avant tous les jobs dilue la surface d'attaque. Chaque job hérite des variables, des clones, des configurations. Préférer des before_script locaux, minimum nécessaire.
9. Audit et observabilité
9.1 Audit events
Admin > Monitoring > Audit events (self-managed Ultimate) :
- Toute action sensible loguée : changement de permission, création de token, modification de variable CI/CD, push vers branche protégée.
- Export vers SIEM via Elasticsearch ou webhook.
9.2 Logs de jobs
- Activation de la rétention longue sur branches protégées.
- Export vers stockage externe (S3, GCS) si obligations réglementaires.
- Masquage des secrets vérifié par échantillonnage.
9.3 Monitoring runners
- Métriques Prometheus exposées par le runner (
listen_addressdansconfig.toml). - Alertes sur : taux d'échec anormal, jobs qui dépassent la durée moyenne, consommation CPU/RAM atypique (signal d'exfiltration).
- Vérification périodique des tags runners (un runner non tagué prend tous les jobs qui ne précisent pas de tag).
9.4 Compliance framework
GitLab Ultimate permet de définir des compliance frameworks au niveau groupe : pipelines forcés d'inclure certains templates, check automatique que ces pipelines ont bien tourné avant merge.
Exemple : tout projet taggé framework: pci-dss doit inclure obligatoirement /compliance/sast.yml, /compliance/container-scan.yml, /compliance/sbom.yml.
10. Patterns dangereux à éviter
| Anti-pattern | Pourquoi c'est dangereux | Remplacer par |
|---|---|---|
image: docker:latest | Tag mouvant, pas de pinning | Digest SHA256 ou tag patch figé |
tags absent sur jobs sensibles | Exécution sur shared runner GitLab.com | Tag explicite vers runner dédié |
| Secret en clair dans YAML | Exposé à tout reviewer et dans l'historique Git | Variable protégée ou Vault |
privileged: true sur runner Docker | Escape container trivial | Kaniko, BuildKit rootless |
CI_JOB_TOKEN utilisé partout | Portée trop large, pivot facile | Allowlist + Deploy token scoped |
include sans ref | Vulnérable à compromission du template | ref: v1.2.3 ou SHA précis |
Script qui curl | bash | Supply chain risk, pas d'intégrité | Pinning version + checksum |
Job sans rules: strictes | Exécution sur branches non voulues | rules: - if: $CI_COMMIT_BRANCH == "main" |
| Cache partagé entre projets | Fuite d'artefacts entre projets | Cache scoped projet + S3 chiffré |
| Logs verbeux sur credentials | Exfiltration via logs | Masking + audit post-mortem des logs |
11. Plan de hardening en 5 étapes
Pour une équipe qui part d'une configuration GitLab CI par défaut :
Étape 1 (semaine 1) - hygiène de base
- Audit des variables CI/CD : identifier les secrets non masked, non protected, ou orphelins.
- Protéger
main, interdire push direct. - Activer Secret Detection template.
- Rotation de tous les tokens historiques.
Étape 2 (semaines 2-3) - runners dédiés
- Déployer runners Docker ou Kubernetes dédiés pour les projets sensibles.
- Configurer
config.tomldurci (privileged off, cap_drop, seccomp). - Migrer les projets sensibles des shared runners vers les dédiés.
Étape 3 (mois 2) - scanners activés
- SAST, Dependency Scanning, Container Scanning inclus dans tous les projets.
- Compliance framework si GitLab Ultimate.
- Seuils d'alerte configurés sans bloquer les merges (phase observation).
Étape 4 (mois 3) - secrets modernes
- Intégration Vault ou OIDC cloud provider pour les projets production.
- Suppression progressive des credentials long-lived des variables CI/CD.
- Policy de rotation automatique pour ce qui reste.
Étape 5 (mois 4-6) - supply chain
- Signed commits sur branches protégées.
- Cosign pour signer les images avant push registry.
- SBOM automatique et matching CVE.
- Attestation SLSA Level 3.
12. Checklist de revue pipeline
Avant tout merge sur main qui touche .gitlab-ci.yml, vérifier :
- Images épinglées par digest ou tag patch figé
include:avecrefimmuable uniquement- Aucun secret en clair dans le YAML
tags:explicite pour runners dédiés sur jobs sensiblesrules:qui excluent les MR de forks sur jobs sensiblesinterruptible: truesur jobs non critiquesresource_groupsur jobs production- Jobs production avec
when: manualou approvals - Artefacts avec
expire_in:défini - Scanners security activés et à jour
- Pas de
privileged: truehors Kaniko/BuildKit justifié - Audit trail : qui a modifié quoi, quand, pourquoi (commit message)
13. Verdict et posture Zeroday
GitLab CI fournit un socle très complet, mais par défaut il vise la facilité d'usage plus que la sécurité. Un projet production sérieux doit investir une à trois semaines pour passer d'un pipeline démo à un pipeline durci.
Les deux chantiers qui rapportent le plus vite : runners dédiés (élimine 80 % du risque d'exfiltration croisée) et scanners activés en observation (révèle la dette sécurité cachée). Le reste (supply chain, SLSA, OIDC) est une maturation progressive sur 6 à 12 mois, justifiée par le niveau d'exposition réel du produit.
Pour approfondir côté rôle : voir les ressources métier DevSecOps et roadmap DevSecOps. Côté technique pur, SAST vs DAST complète les scanners décrits ci-dessus.





