Chapitre 818 minIntermédiaire
Secrets et variables dans un pipeline
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- distinguer un secret d'une variable de configuration ;
- créer et utiliser des secrets sans les exposer dans les journaux ;
- régler les permissions du GITHUB_TOKEN et comprendre OIDC ;
- réagir dans le bon ordre à la fuite d'un secret.
4 exercices corrigés
Un secret de pipeline est une valeur sensible, comme un mot de passe, une clé SSH ou un jeton d'API, dont un job a besoin pour déployer ou publier : il est stocké chiffré par la forge, injecté au moment de l'exécution, masqué dans les journaux, et n'apparaît jamais dans le dépôt. Les valeurs non sensibles (une URL, un nom d'environnement) sont des variables de configuration. Ce chapitre explique comment créer et utiliser les deux sur GitHub et GitLab, comment fonctionne le jeton automatique du pipeline, comment se passer de secrets permanents grâce à OIDC, et quoi faire en cas de fuite.
Ce qui est un secret, et ce qui n'en est pas
| Valeur | Type | Pourquoi |
|---|---|---|
| Clé SSH privée de déploiement | Secret | Donne accès au serveur |
| Mot de passe de la base de production | Secret | Donne accès aux données |
| Jeton d'un registre d'images, d'un service de paiement, d'une API | Secret | Permet d'agir au nom de l'organisation |
| URL de déclenchement d'un déploiement (deploy hook) | Secret | Quiconque la connaît peut lancer un déploiement |
| Adresse du serveur de recette | Variable | Utile, mais pas dangereuse si elle est vue |
Empreinte publique du serveur SSH (known_hosts) | Variable | Information publique, qui protège contre l'usurpation du serveur |
| Version de Node.js, nom de l'image | Valeur en clair dans le workflow | Fait partie de la configuration versionnée |
| Mot de passe d'une base de test jetable créée dans le job | Valeur en clair dans le workflow | La base est vide et détruite à la fin du job |
Les secrets dans GitHub Actions
Un secret se crée dans Settings, Secrets and variables, Actions, New repository secret, ou en ligne de commande :
gh secret set DEPLOY_SSH_KEY < ~/.ssh/sorbier_deploy # contenu d'un fichier
gh secret set RECETTE_DB_PASSWORD # la valeur est demandée sans écho
gh secret list # liste les noms, jamais les valeurs
Il existe trois niveaux :
| Niveau | Visible par | Usage |
|---|---|---|
| Organisation | Les dépôts choisis de l'organisation | Un jeton partagé par plusieurs projets |
| Dépôt | Tous les workflows du dépôt | Le cas le plus courant |
| Environnement | Les seuls jobs qui déclarent cet environnement (environment: production) | Les secrets de production, protégés par une approbation (chapitre 10) |
Si le même nom existe à plusieurs niveaux, le plus précis l'emporte : environnement, puis dépôt, puis organisation. Un nom ne contient que des lettres, des chiffres et _, ne commence ni par un chiffre ni par GITHUB_, et un secret pèse au maximum 48 Ko.
Dans le workflow, on lit un secret avec ${{ secrets.NOM }}, de préférence en le passant à une variable d'environnement de l'étape qui en a besoin, pas de tout le job :
- name: Déclencher le déploiement
env:
DEPLOY_HOOK: ${{ secrets.RECETTE_DEPLOY_HOOK }}
run: curl --fail --silent --show-error -X POST "$DEPLOY_HOOK"
Les variables de configuration : vars
Les valeurs non sensibles se rangent dans l'onglet Variables du même écran, aux mêmes trois niveaux, et se lisent avec ${{ vars.NOM }} :
- name: Tester la recette
run: curl --fail --silent "${{ vars.URL_RECETTE }}/health"
Une variable n'est pas masquée dans les journaux. Son intérêt : changer une adresse ou un réglage sans modifier le workflow, et donner des valeurs différentes à chaque environnement.
Ce que GitHub fait pour protéger les secrets, et ses limites
- Chiffrement. La valeur est chiffrée dans ton navigateur avant l'envoi ; personne ne peut la relire dans l'interface après l'enregistrement, pas même un administrateur. On ne peut que la remplacer.
- Masquage. Si la valeur exacte apparaît dans un journal, elle est remplacée par
***. - Forks. Les workflows déclenchés par la pull request d'un fork ne reçoivent pas les secrets (sauf
GITHUB_TOKEN, en lecture seule).
Le masquage a des limites : il ne reconnaît que la valeur exacte. Une valeur transformée (encodée en base64, coupée, mise dans un JSON) s'affiche en clair. Si un job calcule une valeur sensible, il doit la déclarer comme telle avant de l'afficher ou de la transmettre :
- name: Obtenir un jeton temporaire
run: |
JETON=$(./scripts/obtenir-jeton.sh)
echo "::add-mask::$JETON"
echo "JETON=$JETON" >> "$GITHUB_ENV"
Utiliser une clé SSH de déploiement
Le cas le plus fréquent en BTS : le job se connecte en SSH au serveur de recette pour y lancer le déploiement. On crée une paire de clés dédiée au pipeline, sans phrase de passe, dont la clé publique est autorisée sur le serveur pour un compte de déploiement aux droits limités :
ssh-keygen -t ed25519 -C "deploiement sorbier-frais" -f sorbier_deploy -N ""
# sorbier_deploy : clé privée, à enregistrer comme secret DEPLOY_SSH_KEY
# sorbier_deploy.pub : clé publique, à ajouter dans ~/.ssh/authorized_keys du compte deploy sur le serveur
ssh-keyscan -t ed25519 recette.sorbier.example
# la sortie (empreinte du serveur) est à enregistrer comme variable DEPLOY_KNOWN_HOSTS
Dans le workflow :
- name: Préparer la connexion SSH
env:
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
KNOWN_HOSTS: ${{ vars.DEPLOY_KNOWN_HOSTS }}
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
- name: Déployer
run: ssh deploy@recette.sorbier.example "/opt/sorbier-frais/deployer.sh sha-${GITHUB_SHA::7}"
L'empreinte du serveur, enregistrée dans known_hosts, garantit que le job parle bien au vrai serveur. L'option StrictHostKeyChecking=no, souvent recopiée des forums, supprime cette vérification : un serveur usurpé recevrait alors la connexion.
Le jeton GITHUB_TOKEN
À chaque job, GitHub crée un jeton temporaire, disponible sous ${{ secrets.GITHUB_TOKEN }} (ou ${{ github.token }}). Il agit sur le dépôt en cours uniquement et expire à la fin du job. Ses droits se règlent avec permissions :
| Permission | Elle permet de | Exemple d'usage |
|---|---|---|
contents: read | Lire le code | Toute CI |
contents: write | Pousser des commits et des tags, créer une release | Publier une version (chapitre 13) |
packages: write | Publier dans le registre de GitHub (ghcr.io) | Pousser une image Docker (chapitre 9) |
pull-requests: write | Commenter une pull request | Poster un résumé des tests |
security-events: write | Publier des alertes d'analyse de code | CodeQL |
id-token: write | Demander un jeton OIDC | S'authentifier auprès d'un cloud sans secret (voir plus bas) |
Un détail utile : les événements provoqués par GITHUB_TOKEN (un push fait par le workflow, par exemple) ne déclenchent pas de nouveaux workflows, sauf workflow_dispatch et repository_dispatch. C'est une protection contre les boucles infinies. Si un workflow doit vraiment en déclencher un autre, on utilise un jeton d'application GitHub ou un jeton personnel à droits restreints (fine-grained personal access token), avec une date d'expiration.
Se passer de secrets permanents : OIDC
Un secret stocké dans GitHub est permanent : s'il fuit, il reste valable jusqu'à ce que quelqu'un s'en aperçoive et le révoque. Pour les déploiements dans un cloud (AWS, Azure, Google Cloud) ou la publication sur npm, la bonne pratique actuelle est OIDC (OpenID Connect) :
- Le job demande à GitHub un jeton d'identité signé, valable quelques minutes, qui dit : « je suis le workflow
deploy.ymldu dépôtsorbier/sorbier-frais, sur la branchemain, pour l'environnementproduction». - Le fournisseur cloud, configuré à l'avance pour faire confiance à GitHub sous ces conditions précises, vérifie la signature et les conditions.
- Il délivre en échange des accès temporaires, limités au rôle prévu.
Aucun secret n'est stocké dans GitHub. Dans le workflow, il suffit d'autoriser la demande de jeton, puis d'utiliser l'action officielle du fournisseur (aws-actions/configure-aws-credentials, azure/login, google-github-actions/auth) :
permissions:
contents: read
id-token: write # autorise le job à demander un jeton OIDC à GitHub
GitLab propose le même mécanisme avec le mot-clé id_tokens.
Les variables dans GitLab CI
Dans GitLab, secrets et variables se créent au même endroit : Settings, CI/CD, Variables. Chaque variable a des options qui en font, ou non, un secret :
| Option | Effet |
|---|---|
| Protect variable | La variable n'est transmise qu'aux pipelines des branches et tags protégés : une branche de travail ne peut pas la lire |
| Masked | La valeur est remplacée par [MASKED] dans les journaux ; elle doit tenir sur une ligne, sans espace, et faire au moins 8 caractères |
| Masked and hidden | En plus, la valeur ne peut plus être affichée dans l'interface après l'enregistrement |
| Type File | GitLab écrit la valeur dans un fichier temporaire, et la variable contient le chemin du fichier : idéal pour une clé SSH ou un fichier de configuration |
| Environment scope | La variable n'est transmise qu'aux jobs d'un environnement (production) |
Dans .gitlab-ci.yml, une variable se lit comme une variable d'environnement : $RECETTE_DB_PASSWORD. Avec le type File :
deploiement-recette:
stage: deploy
image: alpine:3.24
before_script:
- apk add --no-cache openssh-client
- install -m 700 -d ~/.ssh
- cp "$DEPLOY_SSH_KEY" ~/.ssh/id_ed25519 # DEPLOY_SSH_KEY contient le chemin du fichier
- chmod 600 ~/.ssh/id_ed25519
- echo "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
script:
- ssh deploy@recette.sorbier.example "/opt/sorbier-frais/deployer.sh $CI_COMMIT_SHORT_SHA"
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Les gestionnaires de secrets
Dans les grandes organisations, les secrets ne sont pas copiés dans chaque outil : ils vivent dans un coffre centralisé, qui trace chaque accès et peut délivrer des secrets temporaires.
| Outil | Type |
|---|---|
| HashiCorp Vault, OpenBao | Coffre auto-hébergé |
| AWS Secrets Manager, Azure Key Vault, Google Secret Manager | Coffre du fournisseur cloud |
| SOPS | Chiffrement de fichiers de configuration versionnés : seules les valeurs sont chiffrées, la structure reste lisible |
Le pipeline s'authentifie auprès du coffre, de préférence par OIDC, et récupère les secrets au moment du déploiement.
Les secrets côté application
Le pipeline n'est pas le seul à manipuler des secrets : l'application en production a besoin du mot de passe de sa base. Les règles :
- l'application lit sa configuration dans des variables d'environnement (
process.env.DB_PASSWORD), jamais en dur dans le code ; - le dépôt contient un
.env.exampleavec les noms des variables et des valeurs factices ; le vrai.envest dans le.gitignore; - sur le serveur, le fichier de secrets appartient au compte de service et n'est lisible que par lui (
chmod 600), ou les secrets sont fournis par l'orchestrateur (secrets Docker Compose, Kubernetes) ; - un secret n'est jamais copié dans une image Docker : toute personne qui récupère l'image peut lire ses couches (chapitre 9).
Rotation et réaction à une fuite
Changer régulièrement les secrets (la rotation) limite la durée d'utilité d'un secret volé : clés de déploiement chaque année, jetons à durée limitée, départ d'un membre de l'équipe.
En cas de fuite (secret commité, affiché dans un journal, poste volé), l'ordre est :
- révoquer immédiatement le secret chez son émetteur (supprimer la clé SSH du serveur, régénérer le jeton) ;
- créer un nouveau secret et le mettre à jour dans la forge ;
- chercher dans les journaux du serveur et de la forge (audit log) si le secret a été utilisé ;
- nettoyer l'historique Git si le secret y figure, en sachant que des copies peuvent déjà exister ;
- documenter l'incident et la cause, pour qu'elle ne se reproduise pas (réagir à une fuite de secret).
Exercices
Exercice 1 sur 4 Secret, variable ou valeur en clair ? Réussi À revoir
Range chaque valeur : secret, variable de configuration, ou valeur écrite en clair dans le workflow.
https://recette.sorbier.example- Le jeton d'accès au service d'envoi de courriels
node-version: 24- Le mot de passe de la base MySQL de recette
- L'empreinte publique du serveur SSH de production
- Le mot de passe
rootd'une base MySQL de test créée parservicesdans le job
Voir la réponseMasquer la réponse de l'exercice 1
- Variable : utile à configurer, pas dangereuse.
- Secret : il permet d'envoyer des courriels au nom de l'entreprise.
- En clair dans le workflow : c'est de la configuration versionnée.
- Secret, idéalement au niveau de l'environnement
recette. - Variable : c'est une information publique, qui sert à vérifier l'identité du serveur.
- En clair : la base est vide, jetable, inaccessible hors du job ; en faire un secret n'apporte rien.
Tu avais trouvé ?
Exercice 2 sur 4 Quatre erreurs Réussi À revoir
Trouve les quatre problèmes de sécurité de ce workflow de déploiement.
name: Déploiement
on:
push:
branches: [main]
permissions: write-all
jobs:
deploy:
runs-on: ubuntu-latest
env:
DB_PASSWORD: Sorbier2026!
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
steps:
- uses: actions/checkout@v7
- run: echo "Clé utilisée = $SSH_KEY"
- run: |
mkdir -p ~/.ssh
echo "$SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh -o StrictHostKeyChecking=no deploy@prod.sorbier.example "./deployer.sh"
Voir la réponseMasquer la réponse de l'exercice 2
DB_PASSWORD: Sorbier2026!: un mot de passe en clair dans un fichier versionné. Il faut le stocker comme secret (${{ secrets.DB_PASSWORD }}), et changer ce mot de passe, désormais dans l'historique.permissions: write-all: le jeton a tous les droits en écriture alors que le job n'a besoin que de lire le code. Écrirepermissions: contents: read.echo "Clé utilisée = $SSH_KEY"affiche le secret. GitHub le masquera probablement, mais c'est inutile et risqué (une clé sur plusieurs lignes, une transformation, un journal téléchargé). À supprimer.StrictHostKeyChecking=nosupprime la vérification de l'identité du serveur : un serveur usurpé recevrait la connexion. Enregistrer l'empreinte du serveur dans~/.ssh/known_hostsà partir d'une variable.
Améliorations bonus : passer SSH_KEY à la seule étape qui l'utilise plutôt qu'à tout le job, et rattacher le job à un environnement production protégé, qui détient le secret.
Tu avais trouvé ?
Exercice 3 sur 4 Un secret vide Réussi À revoir
La pull request ouverte par une contributrice extérieure, depuis son fork, échoue avec ce journal :
Run test -n "$API_GEO_KEY" || { echo "Secret absent"; exit 1; }
Secret absent
Error: Process completed with exit code 1.
Le même workflow réussit sur les pull requests de Lina et Hugo. Explique et propose une solution.
Voir la réponseMasquer la réponse de l'exercice 3
Les workflows déclenchés par une pull request venant d'un fork ne reçoivent pas les secrets du dépôt : API_GEO_KEY est vide. C'est une protection voulue : sinon, n'importe qui pourrait modifier le workflow dans sa pull request et lire les secrets. Lina et Hugo travaillent dans des branches du dépôt lui-même, d'où la différence.
Solution : les tests qui tournent sur les pull requests ne doivent pas dépendre d'un vrai service externe. On simule l'API de géolocalisation (mock) dans les tests ; les tests qui ont vraiment besoin de la clé tournent seulement après la fusion, sur main. Passer à pull_request_target pour obtenir le secret serait une faille (chapitre 7).
Tu avais trouvé ?
Exercice 4 sur 4 Régler une variable GitLab Réussi À revoir
Hugo enregistre dans GitLab la clé SSH privée qui permet de déployer en production. Quelles options choisit-il, et pourquoi ?
Voir la réponseMasquer la réponse de l'exercice 4
- Type File : une clé SSH tient sur plusieurs lignes ; GitLab l'écrit dans un fichier dont le chemin est donné par la variable.
- Protect variable : seuls les pipelines des branches et tags protégés (
main, les tags de version) la reçoivent ; une branche de travail ne peut pas la lire. - Environment scope :
production, pour qu'elle ne soit transmise qu'au job de déploiement en production. - Le masquage n'est pas possible pour une valeur sur plusieurs lignes ; c'est une raison de plus pour protéger la variable et contrôler qui peut modifier
.gitlab-ci.ymlsurmain.
Tu avais trouvé ?