Lab : Durcissement des Workflows GitHub Actions — Permissions, Pinning et Secrets

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 :

  1. Permissions minimales — restreindre le GITHUB_TOKEN aux seuls scopes dont chaque job a réellement besoin.
  2. Pinning SHA — référencer chaque action tierce par son SHA de commit immuable plutôt que par un tag mutable.
  3. 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 gh installé (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 :

  1. Reviewers requis — ajoutez au moins un membre de l’équipe qui doit approuver les déploiements.
  2. Délai d’attente — ajoutez éventuellement un délai (par ex. 5 minutes) pour donner du temps aux reviewers.
  3. Branches de déploiement — restreignez à main uniquement.

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_request pour la CI. Évitez pull_request_target sauf 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, pas pull_request_target, pour les workflows qui compilent ou testent le code des PR. Le trigger pull_request_target accorde l’accès aux secrets à du code potentiellement non fiable.
  • Ajoutez concurrency, timeout-minutes et 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 :