Présentation
GitHub Actions est devenu la plateforme CI/CD la plus largement adoptée, aussi bien pour les logiciels open source que commerciaux. Cette popularité en fait la surface d’attaque numéro un dans le paysage CI/CD. Des workflows mal configurés divulguent régulièrement des secrets, accordent des permissions excessives et intègrent du code tiers pouvant être modifié de manière silencieuse.
Dans ce lab pratique, vous allez durcir un workflow GitHub Actions volontairement non sécurisé en utilisant les trois techniques les plus efficaces disponibles aujourd’hui :
- Permissions minimales — restreindre le
GITHUB_TOKENaux seuls scopes dont chaque job a réellement besoin. - Pinning SHA — référencer chaque action tierce par son SHA de commit immuable plutôt que par un tag mutable.
- Protection des secrets — limiter les secrets à des environments avec des portes d’approbation et empêcher les fuites via les pull requests provenant de forks.
À la fin de ce lab, vous disposerez d’un template de workflow de qualité production que vous pourrez intégrer dans n’importe quel dépôt.
Prérequis
- Un compte GitHub avec la permission de créer des dépôts.
- Une familiarité de base avec la syntaxe YAML de GitHub Actions (triggers, jobs, steps).
- Le CLI
ghinstallé (optionnel mais utile pour interroger les SHA des actions).
Mise en Place de l’Environnement
Créer un Dépôt de Test
Créez un nouveau dépôt public sur GitHub appelé gha-hardening-lab. Vous pouvez le faire via l’interface ou avec le CLI :
gh repo create gha-hardening-lab --public --clone
cd gha-hardening-lab
Initialisez un projet Node.js minimal pour que le workflow ait quelque chose à compiler :
npm init -y
cat <<'EOF' > index.js
console.log("Hello from the hardening lab");
EOF
git add -A && git commit -m "Initial commit" && git push
Le Workflow Initial (Non Sécurisé)
Créez le fichier .github/workflows/build.yml avec le contenu suivant. Ce workflow est volontairement non sécurisé — il n’a pas de bloc permissions, utilise des tags mutables et expose les secrets de manière trop large :
# .github/workflows/build.yml — Point de départ NON SÉCURISÉ
name: Build
on:
push:
pull_request_target:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install
- run: npm test
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
- uses: actions/upload-artifact@v4
with:
name: build-output
path: .
Commitez et poussez ce fichier. Il s’exécutera avec succès, mais il présente au moins cinq problèmes de sécurité que vous corrigerez dans les exercices ci-dessous.
Exercice 1 : Permissions Minimales
Le Problème des Permissions par Défaut
Lorsqu’un workflow ne déclare pas de bloc permissions, le GITHUB_TOKEN reçoit les permissions par défaut du dépôt. Pour la plupart des dépôts, cela signifie un accès en lecture et écriture à chaque scope — contents, packages, issues, pull requests, deployments, et plus encore. Si un attaquant compromet n’importe quelle étape de ce workflow, il hérite de toutes ces permissions.
Le principe du moindre privilège exige que vous n’accordiez que les permissions dont chaque job a réellement besoin, et rien de plus.
Étape 1 — Définir un Défaut Restrictif au Niveau Supérieur
Ajoutez une clé permissions de niveau supérieur immédiatement après le bloc on:. Cela définit la valeur par défaut pour chaque job du workflow :
permissions:
contents: read
Si vous souhaitez commencer avec la valeur par défaut la plus restrictive possible puis accorder des permissions par job, vous pouvez utiliser un map vide :
permissions: {}
Étape 2 — Ajouter des Permissions par Job
Chaque job peut surcharger la valeur par défaut du workflow. N’accordez que ce dont le job a besoin :
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read # checkout du code
actions: read # lecture des métadonnées du workflow
steps:
- uses: actions/checkout@v4
# ...
Si un second job doit téléverser un asset de release, vous lui accorderez contents: write sur ce job uniquement — jamais au niveau du workflow.
Avant et Après
Avant (non sécurisé) :
name: Build
on:
push:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm install
Après (durci) :
name: Build
on:
push:
branches: [main]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
steps:
- uses: actions/checkout@v4
- run: npm install
Vérifier les Permissions Effectives
Après l’exécution du workflow, ouvrez le job dans l’onglet Actions. Cliquez sur l’icône d’engrenage en haut à droite du journal du job et sélectionnez « Set up job ». Développez cette section pour voir les permissions exactes du GITHUB_TOKEN qui ont été accordées. Confirmez que seules contents: read et actions: read apparaissent.
Vous pouvez également interroger les permissions de manière programmatique dans une étape :
- name: Print token permissions
run: |
curl -s -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
https://api.github.com/repos/${{ github.repository }} \
| jq '.permissions'
Exercice 2 : Pinning des Actions par SHA
Pourquoi les Tags Sont Dangereux
Lorsque vous écrivez uses: actions/checkout@v4, vous référencez un tag Git. Les tags sont mutables — le mainteneur de l’action (ou un attaquant qui compromet son compte) peut supprimer et recréer le tag pointant vers un code entièrement différent. Votre workflow exécutera alors silencieusement le nouveau code lors de sa prochaine exécution. Le pinning SHA élimine ce risque car un SHA de commit est immuable.
Étape 1 — Trouver le SHA d’une Action
Utilisez le CLI gh pour résoudre un tag en son SHA de commit :
# Résoudre actions/checkout@v4 en un SHA de commit
gh api repos/actions/checkout/git/ref/tags/v4 --jq '.object.sha'
Si le tag est annoté (la plupart le sont), la commande ci-dessus retourne le SHA de l’objet tag. Vous devez le déréférencer vers le commit :
TAG_SHA=$(gh api repos/actions/checkout/git/ref/tags/v4 --jq '.object.sha')
gh api repos/actions/checkout/git/tags/$TAG_SHA --jq '.object.sha'
Alternativement, visitez le dépôt de l’action sur GitHub, cliquez sur le tag et copiez le SHA complet du commit depuis l’URL ou l’en-tête du commit.
Étape 2 — Pinner les Actions Courantes
Remplacez chaque tag mutable par le SHA complet de 40 caractères. Ajoutez toujours un commentaire en fin de ligne avec la version pour la lisibilité :
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: build-output
path: dist/
Étape 3 — Automatiser les Mises à Jour SHA avec Dependabot
Le pinning par SHA signifie que vous ne recevez plus les mises à jour automatiques basées sur les tags. Dependabot résout ce problème en ouvrant des pull requests chaque fois qu’une action pinnée publie une nouvelle version.
Créez le fichier .github/dependabot.yml :
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: "ci"
Après avoir poussé ce fichier, Dependabot analysera vos workflows chaque semaine et ouvrira des PR pour mettre à jour les SHA pinnés. Chaque PR affiche le diff du code de l’action, vous donnant l’occasion de relire avant de merger.
Si vous préférez Renovate à Dependabot, ajoutez un renovate.json à la racine du dépôt :
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"github-actions": {
"enabled": true
}
}
Exercice 3 : Protection des Secrets
Secrets de Dépôt vs. Secrets d’Environnement
GitHub propose deux niveaux de stockage des secrets :
- Secrets de dépôt — disponibles pour chaque workflow et chaque job du dépôt. Pratiques mais trop larges.
- Secrets d’environnement — disponibles uniquement pour les jobs qui déclarent explicitement
environment: <nom>. C’est l’approche recommandée pour les identifiants sensibles.
Étape 1 — Créer un Environnement avec des Règles de Protection
Dans votre dépôt, naviguez vers Settings → Environments et créez un environnement appelé production. Activez les règles de protection suivantes :
- Reviewers requis — ajoutez au moins un membre de l’équipe qui doit approuver les déploiements.
- Délai d’attente — ajoutez éventuellement un délai (par ex. 5 minutes) pour donner du temps aux reviewers.
- Branches de déploiement — restreignez à
mainuniquement.
Ajoutez maintenant votre DEPLOY_TOKEN en tant que secret à l’intérieur de cet environnement, et non au niveau du dépôt.
Étape 2 — Référencer l’Environnement dans Votre Workflow
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Deploy
run: ./deploy.sh
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
La déclaration environment: production signifie que ce job sera mis en pause et attendra l’approbation d’un reviewer avant l’exécution de toute étape. Le secret DEPLOY_TOKEN n’est disponible que dans cet environnement — il ne peut pas être accédé par d’autres jobs ou workflows qui ne déclarent pas cet environnement.
Étape 3 — Comprendre le Comportement des Forks
Les secrets ne sont pas disponibles pour les workflows déclenchés par des événements pull_request provenant de forks. Il s’agit d’une frontière de sécurité critique. Si vous créez un workflow qui dépend de secrets lors des vérifications de PR, il échouera pour les contributeurs externes :
# Cette étape échouera pour les PR provenant de forks car DEPLOY_TOKEN est vide
- name: Authenticated API call
run: |
curl -H "Authorization: Bearer $DEPLOY_TOKEN" https://api.example.com/health
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
C’est voulu — cela empêche les forks malveillants d’exfiltrer vos secrets.
Étape 4 — Le Danger de pull_request_target
Le trigger pull_request_target s’exécute dans le contexte du dépôt de base, ce qui signifie qu’il a accès aux secrets. C’est extrêmement dangereux si vous checkoutez également le code HEAD de la PR :
# DANGEREUX — NE FAITES PAS CECI
on:
pull_request_target:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }} # Checkout de code NON FIABLE
- run: npm install # Exécute du code contrôlé par l'attaquant avec accès aux secrets
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
Un attaquant peut modifier package.json pour inclure un script postinstall qui exfiltre le DEPLOY_TOKEN. Ne combinez jamais pull_request_target avec un checkout du HEAD de la PR, sauf si vous avez explicitement validé et sandboxé le code.
Alternative sûre : Utilisez le trigger standard pull_request pour les workflows de build et de test. Réservez pull_request_target uniquement pour les workflows d’étiquetage ou de commentaire qui n’exécutent jamais le code de la PR.
Résumé des Bonnes Pratiques
- Stockez les secrets sensibles dans des environments, pas au niveau du dépôt.
- Ajoutez des reviewers requis et des restrictions de branche à chaque environnement contenant des identifiants de production.
- Utilisez le trigger
pull_requestpour la CI. Évitezpull_request_targetsauf si vous comprenez pleinement les implications de confiance. - Concevez les workflows de sorte que les jobs nécessitant des secrets soient séparés des jobs exécutant du code non fiable.
Exercice 4 : Durcissement Supplémentaire
Empêcher les Exécutions Dupliquées avec la Concurrence
Sans politique de concurrence, pousser plusieurs commits en succession rapide génère plusieurs exécutions de workflow qui gaspillent des ressources et peuvent provoquer des conditions de concurrence lors du déploiement. Ajoutez un bloc concurrency au niveau du workflow :
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Cela annule toute exécution en cours pour le même workflow et la même branche lorsqu’un nouveau commit est poussé.
Définir des Limites de Timeout
Un job bloqué peut consommer des minutes de runner indéfiniment. Définissez toujours un timeout explicite :
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 15
Choisissez une valeur qui donne à votre build suffisamment de marge mais empêche les processus incontrôlés. Pour la plupart des builds Node.js ou Go, 10 à 20 minutes sont généreux.
Restreindre les Triggers de Workflow
Évitez les triggers génériques qui se déclenchent sur chaque branche :
# Trop large — s'exécute à chaque push sur chaque branche
on:
push:
Au lieu de cela, limitez les triggers aux branches qui comptent :
on:
push:
branches: [main]
pull_request:
branches: [main]
Cela réduit les exécutions inutiles et limite la surface d’attaque pour les attaques par injection basées sur les branches.
Exécution Conditionnelle pour les Étapes Sensibles
Utilisez les conditions if: pour empêcher les étapes sensibles de s’exécuter dans des contextes où elles ne le devraient pas :
- name: Deploy to production
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: ./deploy.sh
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
Cela garantit que l’étape de déploiement ne s’exécute que lors des pushs vers main, jamais sur les pull requests ou autres branches, même si le job lui-même est déclenché.
Le Workflow Durci Final
Voici le workflow durci complet aux côtés de l’original. Chaque amélioration de sécurité est annotée avec un commentaire.
Original (Non Sécurisé)
name: Build
on:
push:
pull_request_target:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install
- run: npm test
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
- uses: actions/upload-artifact@v4
with:
name: build-output
path: .
Durci
name: Build
# DURCI : Triggers limités — uniquement la branche main, trigger PR sûr
on:
push:
branches: [main]
pull_request:
branches: [main]
# DURCI : Permissions par défaut restrictives pour tous les jobs
permissions:
contents: read
# DURCI : Annulation des exécutions dupliquées
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
# DURCI : Timeout explicite
timeout-minutes: 15
# DURCI : Permissions par job (moindre privilège)
permissions:
contents: read
actions: read
steps:
# DURCI : Toutes les actions pinnées par SHA
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20
- run: npm install
- run: npm test
# DURCI : Aucun secret exposé dans le job de build/test
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: build-output
path: dist/
deploy:
needs: build
runs-on: ubuntu-latest
# DURCI : S'exécute uniquement lors d'un push vers main
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
# DURCI : Secrets protégés derrière un environnement avec reviewers requis
environment: production
timeout-minutes: 10
permissions:
contents: read
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Deploy
run: ./deploy.sh
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
Tests de Rupture (Échec Intentionnel)
Pour consolider votre compréhension, cassez délibérément le workflow durci et observez les conséquences.
Test 1 — Supprimer le Bloc Permissions
Supprimez la clé permissions: de niveau supérieur et les permissions par job. Poussez et exécutez le workflow. Il réussira toujours, mais si vous inspectez l’étape de configuration du job, vous verrez que le token a maintenant un accès en lecture et écriture à chaque scope. Une étape compromise pourrait pousser du code, supprimer des branches ou modifier des releases.
Test 2 — Utiliser une Action Non Pinnée
Remettez une action avec une référence par tag :
- uses: actions/checkout@v4
Le workflow s’exécute toujours. Mais si le tag v4 est un jour déplacé vers un commit malveillant, votre workflow exécutera ce code sans avertissement. Il n’y a pas de trace d’audit — le tag résout simplement vers un SHA différent. Re-pinnez-le au SHA après ce test.
Test 3 — Accéder aux Secrets de Production depuis une PR
Créez une branche de fonctionnalité et ouvrez une pull request. Le job deploy ne s’exécutera pas à cause de la condition if:. Même si vous supprimez la condition, le secret d’environnement DEPLOY_TOKEN est protégé par l’environnement production, qui restreint le déploiement à la branche main et requiert l’approbation d’un reviewer. La valeur du secret sera vide dans le contexte de la PR.
C’est exactement le comportement souhaité — les secrets ne sont jamais disponibles dans des contextes non fiables.
Nettoyage
Lorsque vous avez terminé le lab, supprimez le dépôt de test pour éviter d’encombrer votre compte :
gh repo delete gha-hardening-lab --yes
Si vous avez utilisé un fork d’un projet existant, vous pouvez le réinitialiser à la place :
git checkout main
git reset --hard origin/main
git push --force
Points Clés à Retenir
- Déclarez toujours un bloc
permissions. Définissez une valeur par défaut restrictive au niveau du workflow et n’accordez des scopes supplémentaires par job que si nécessaire. - Pinnez chaque action tierce par son SHA complet. Les tags sont mutables et peuvent être silencieusement redirigés vers du code malveillant.
- Utilisez Dependabot ou Renovate pour garder les SHA pinnés à jour automatiquement.
- Stockez les secrets sensibles dans des environments avec des reviewers requis et des restrictions de branche — jamais au niveau du dépôt.
- Utilisez
pull_request, paspull_request_target, pour les workflows qui compilent ou testent le code des PR. Le triggerpull_request_targetaccorde l’accès aux secrets à du code potentiellement non fiable. - Ajoutez
concurrency,timeout-minuteset des triggers limités aux branches pour réduire le gaspillage de ressources et réduire la surface d’attaque.
Prochaines Étapes
Poursuivez votre apprentissage de la sécurité CI/CD avec ces guides connexes :
- Modèles d’Exécution CI/CD et Hypothèses de Confiance — Comprenez comment les différentes plateformes CI/CD modélisent la confiance et où les frontières se brisent.
- Séparation des Responsabilités et Moindre Privilège dans les Pipelines CI/CD — Concevez des pipelines où aucun acteur ou identifiant unique n’a plus d’accès que nécessaire.