Aller au contenu
CI/CD & Automatisation medium

Sécuriser pull_request_target

20 min de lecture

L'événement pull_request_target est l'un des plus dangereux de GitHub Actions. Il exécute du code avec accès aux secrets, même pour les PR de forks. Mal utilisé, il ouvre la porte à l'exfiltration de vos credentials.

  • Distinguer pull_request et pull_request_target et leur accès aux secrets
  • Reconnaître l'attaque classique : pull_request_target + checkout du fork
  • Choisir la bonne garde anti-fork, de la plus forte à la plus faible
  • Éviter le piège du faux garde github.repository ==
  • Appliquer le pattern en deux workflows relié par workflow_run
  • Détecter les workflows vulnérables et vérifier si vous avez déjà été exploité

Différence entre pull_request et pull_request_target

Section intitulée « Différence entre pull_request et pull_request_target »

Ces deux déclencheurs réagissent à une pull request, mais leur modèle de sécurité est opposé. Comprendre cette différence est la base de tout le reste.

ÉvénementAccès aux secretsCode exécutéContexte
pull_request❌ Non (forks)Code de la PRFork
pull_request_target✅ OuiCode de la branche cibleBase repo

Avec pull_request, le workflow s'exécute dans le contexte du fork : il voit le code de la PR mais n'a aucun accès aux secrets. C'est le comportement sûr par défaut.

on:
pull_request:
types: [opened, synchronize]
jobs:
test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
# Le code vient de la PR (fork potentiel)
# Pas d'accès aux secrets pour les forks = sûr
- run: npm test

Avec pull_request_target, le workflow s'exécute dans le contexte du dépôt cible : il a accès aux secrets. Tant qu'il ne checkout pas le code du fork, cela reste maîtrisé.

on:
pull_request_target:
types: [opened, synchronize]
jobs:
test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
# Le code vient de la branche cible (main)
# Accès aux secrets = OK si on ne checkout pas le code du fork
- run: npm test

Le danger survient quand vous combinez pull_request_target avec un checkout du code de la PR :

# ❌ DANGEREUX : accès aux secrets + code du fork
on:
pull_request_target:
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }} # Code du fork !
# Ce script vient du fork et a accès aux secrets
- run: npm test
env:
API_TOKEN: ${{ secrets.API_TOKEN }} # Exfiltrable !

Scénario d'attaque :

  1. L'attaquant fork votre repo
  2. Il modifie package.json pour ajouter un script malveillant dans test
  3. Il ouvre une PR
  4. Votre workflow pull_request_target s'exécute
  5. Le script malveillant envoie $API_TOKEN vers un serveur externe

Une fuite de secret est déjà grave. Trois facteurs la transforment en incident bien plus large.

  • Runner self-hosted : un pull_request_target vulnérable sur un runner self-hosted ne fuite pas seulement un secret, il donne une exécution de code arbitraire sur votre infrastructure. L'attaquant peut persister sur la machine, pivoter vers le réseau interne et contaminer les runs suivants. C'est de la RCE, pas une simple fuite.
  • OIDC (id-token: write) : si le job dispose de id-token: write sans garde, l'attaquant récupère un jeton OIDC vers votre cloud. C'est pire qu'un secret de dépôt : il obtient les rôles IAM associés et pivote directement dans votre compte cloud.
  • Action tierce non épinglée : dans le job privilégié, une action référencée par tag (@v4) ou une image par tag mutable est la deuxième marche de l'escalade. Épinglez chaque action par SHA (uses: org/action@<sha>) et chaque image par digest (image@sha256:...).

Le déclencheur n'est pas à bannir : il a des usages légitimes, tous caractérisés par une même propriété, ils n'exécutent jamais le code du fork.

Étiqueter une PR selon les fichiers modifiés ne nécessite que l'API GitHub, jamais le code du fork.

# ✅ Sûr : pas de checkout du code du fork
on:
pull_request_target:
types: [opened]
permissions: {}
jobs:
label:
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}

Poster un message de bienvenue passe par actions/github-script, qui appelle l'API GitHub sans rien cloner.

# ✅ Sûr : pas d'exécution de code du fork
on:
pull_request_target:
types: [opened]
permissions: {}
jobs:
welcome:
runs-on: ubuntu-24.04
permissions:
pull-requests: write
steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: 'Merci pour votre contribution !'
})

Quand un pull_request_target doit vraiment tourner sur des PR entrantes, une garde décide quelles PR ont le droit de s'exécuter avec les secrets. Toutes ne se valent pas. Voici les cinq gardes, de la plus forte à la plus faible.

GardeRobustesseCondition (if:)
Vérifier l'absence de forkForte, sans humaingithub.event.pull_request.head.repo.fork == false
Job amont de permissionForteneeds.gate.outputs.allowed == 'true'
environment + reviewersForte, approbation manuelle(pas de if, environment: protégé)
Acteur de confianceCas des botsgithub.actor == 'dependabot[bot]'
Label maintainer-onlyLa plus faiblecontains(github.event.pull_request.labels.*.name, 'safe-to-test')

1. Vérifier que la PR ne vient pas d'un fork (forte)

Section intitulée « 1. Vérifier que la PR ne vient pas d'un fork (forte) »

Ne rien exécuter de privilégié si la PR provient d'un fork. C'est la garde la plus nette, sans intervention humaine.

jobs:
privileged:
if: github.event.pull_request.head.repo.fork == false
runs-on: ubuntu-24.04

Quand l'utiliser : dès que le job privilégié n'a de sens que pour les PR internes (branches du dépôt lui-même).

2. Job amont de vérification de permission (forte)

Section intitulée « 2. Job amont de vérification de permission (forte) »

Un premier job vérifie que l'auteur a un rôle write, et le job privilégié en dépend (needs) en mode fail-closed : si la vérification échoue, le second ne tourne pas.

jobs:
gate:
runs-on: ubuntu-24.04
outputs:
allowed: ${{ steps.check.outputs.require-result }}
steps:
- uses: actions-cool/check-user-permission@c21884f3dda18dafc2f8b402fe807ccc9ec1aa5e # v2.4.0
id: check
with:
require: write
privileged:
needs: gate
if: needs.gate.outputs.allowed == 'true'
runs-on: ubuntu-24.04

Quand l'utiliser : quand des contributeurs de confiance (au-delà des bots) doivent pouvoir déclencher le job.

3. environment avec reviewers requis (forte, approbation manuelle)

Section intitulée « 3. environment avec reviewers requis (forte, approbation manuelle) »

Rattacher le job à un environment protégé par des required reviewers. GitHub met le job en pause jusqu'à une approbation humaine.

jobs:
privileged:
environment: fork-pr # protégé par des required reviewers
runs-on: ubuntu-24.04

Quand l'utiliser : quand un humain doit relire la PR avant toute exécution privilégiée.

N'autoriser qu'un acteur précis, typiquement un bot connu comme Dependabot.

jobs:
privileged:
if: github.actor == 'dependabot[bot]'
runs-on: ubuntu-24.04

Quand l'utiliser : pour des PR automatisées d'un bot identifié. À combiner avec une autre garde, github.actor seul reste une condition d'identité, pas de relecture.

N'exécuter que si un label a été posé sur la PR.

jobs:
privileged:
if: contains(github.event.pull_request.labels.*.name, 'safe-to-test')
runs-on: ubuntu-24.04

Quand l'utiliser : en complément, jamais seule. Un label n'est posable que par un compte write ou triage, mais poser un label n'est pas relire le code : le mainteneur peut labelliser sans avoir audité le diff, et le fork peut pousser un nouveau commit après le label.

Si vous devez exécuter du code du fork et avoir accès aux secrets, séparez le travail en deux workflows distincts.

Le premier workflow se déclenche sur pull_request : il build le code du fork mais sans aucun secret à voler.

.github/workflows/pr-build.yml
name: PR Build
on:
pull_request: # Pas de secrets, code du fork
permissions: {}
jobs:
build:
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: Récupérer le code de la PR
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Builder le projet
run: npm ci && npm run build
- name: Publier l'artefact de build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: build-output
path: dist/

Le second se déclenche sur workflow_run, une fois le build terminé : il a les secrets mais ne manipule que l'artefact, jamais le code source du fork.

.github/workflows/pr-deploy-preview.yml
name: Deploy Preview
on:
workflow_run:
workflows: ["PR Build"]
types: [completed]
permissions: {}
jobs:
deploy:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-24.04
permissions:
actions: read # Télécharger l'artefact de l'autre run
pull-requests: write # Commenter l'URL de preview
steps:
# Télécharger l'artefact (pas le code source)
- name: Récupérer l'artefact de build
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: build-output
path: dist/
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
# Déployer avec les secrets
- name: Déployer la preview
run: ./deploy-preview.sh
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Pourquoi c'est sûr :

  • Le premier workflow exécute le code du fork, mais sans accès aux secrets
  • Le second workflow a accès aux secrets, mais ne télécharge que l'artefact (pas le code source du fork)
  • L'artefact ne peut pas contenir de code exécutable (juste les fichiers buildés)

En résumé, la sécurité de pull_request_target tient en une règle, ne jamais exécuter le code du fork avec les secrets, et trois patterns sûrs.

Le combo mortel : checkout du code du fork et secrets exposés dans le même job.

on: pull_request_target
jobs:
build:
runs-on: ubuntu-24.04
steps:
# Checkout du code du fork
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
# Exécution de scripts du fork avec secrets
- run: ./scripts/build.sh
env:
SECRET: ${{ secrets.SECRET }}

Trois patterns couvrent tous les besoins réels sans jamais exécuter le code du fork avec les secrets.

  1. Pas de checkout du fork :
on: pull_request_target
jobs:
label:
runs-on: ubuntu-24.04
steps:
# Actions GitHub uniquement, pas de code du fork
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
  1. Checkout de la branche cible uniquement :
on: pull_request_target
jobs:
analyze:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
# Sans ref = checkout de la branche cible (main)
- run: ./trusted-script.sh # Script de main, pas du fork
  1. Séparation build/deploy via workflow_run :
on:
workflow_run:
workflows: ["Untrusted Build"]
types: [completed]

Bonne nouvelle de mi-2026 : actions/checkout@v7 ajoute enfin une protection par défaut contre la pwn request. Quand un workflow pull_request_target (ou certains workflow_run) tente de checkouter la ref du fork (la branche ou le SHA de la PR), checkout refuse désormais l'opération au lieu de tirer du code non fiable dans un runner aux permissions élevées.

Ce qu'il faut retenir :

  • Le pattern dangereux (ref: ${{ github.event.pull_request.head.sha }} sous pull_request_target) échoue par défaut. C'est exactement l'anti-pattern décrit plus haut, désormais bloqué nativement.
  • Un nouvel input allow-unsafe-pr-checkout réautorise explicitement l'ancien comportement. Traitez-le comme une exception sensible : à justifier, isoler et relire, jamais activé par confort.
  • Le changement est rétroporté le 16 juillet 2026 sur les versions majeures supportées ; les tags flottants (@v4, @v3) héritent automatiquement de la protection.

Source : l'analyse de Socket sur le blocage des checkouts pull_request_target.

Plutôt que d'auditer à l'œil, plusieurs scanners repèrent automatiquement les pull_request_target dangereux.

plumber, le scanner de pipelines CI/CD, signale ce pattern via le contrôle ISSUE-804 (checkout de code non fiable sous pull_request_target).

Fenêtre de terminal
plumber analyze --github-url https://github.com/votre-org/votre-repo

Le détail du contrôle et sa remédiation sont documentés ici : ISSUE-804 dans la doc plumber.

Là où plumber repère l'odeur (le pattern à risque dans le YAML), Backflow prouve le chemin : il suit le flux de données de l'entrée attaquant (la ref du fork checkoutée) jusqu'au secret exfiltré, déroulé pas à pas. La distinction est utile en pratique : plumber vous dit où regarder, Backflow confirme qu'un chemin exploitable existe réellement et permet de prioriser la remédiation sur les workflows qui aboutissent vraiment à un secret, plutôt que sur les faux positifs.

Le check Dangerous-Workflow d'OpenSSF Scorecard signale précisément ce pattern.

Fenêtre de terminal
scorecard --local . --checks Dangerous-Workflow --show-details

Checkov couvre la même règle via l'identifiant CKV_GHA_1.

Fenêtre de terminal
checkov -d .github/workflows/ --check CKV_GHA_1

Pour un audit manuel rapide, deux grep suffisent à lister les workflows à inspecter de près.

Fenêtre de terminal
# Chercher les workflows potentiellement dangereux
grep -r "pull_request_target" .github/workflows/
grep -r "github.event.pull_request.head" .github/workflows/

Point crucial souvent ignoré : l'exploitation ne laisse aucune trace dans le dépôt. Un pull_request_target vulnérable s'exécute au moment de la PR, sans merge : la preuve est dans les runs Actions, pas dans l'historique Git. Chercher un commit suspect ne sert donc à rien.

La bonne question est : un fork a-t-il déjà déclenché mon workflow privilégié ? Sous pull_request_target, il suffit de comparer le dépôt source (head_repository) au dépôt de base (repository) de chaque run. L'API GitHub expose ces deux champs (contrairement à gh run list, qui ne les renvoie pas) :

Fenêtre de terminal
# Runs pull_request_target où la source diffère du dépôt de base = PR de fork
gh api "repos/VOTRE_ORG/VOTRE_REPO/actions/runs?event=pull_request_target&per_page=100" \
--jq '.workflow_runs[]
| select(.head_repository.full_name != .repository.full_name)
| {id, fork: .head_repository.full_name, date: .created_at, workflow: .name}'

Fork différent du dépôt de base égale code de fork exécuté avec vos secrets. Si un tel run porte sur un workflow qui checkoutait la ref du fork (head.sha), considérez les secrets comme compromis : rotez-les immédiatement, puis auditez les logs du run (connexions réseau sortantes, variables d'environnement affichées, commandes inhabituelles).

Vérifiez que l'essentiel de ce guide est acquis. Les questions portent uniquement sur ce qui vient d'être expliqué ici.

Contrôle de connaissances

Validez vos connaissances avec ce quiz interactif

6 questions
6 min.
70% requis

Informations

  • Le chronomètre démarre au clic sur Démarrer
  • Questions à choix multiples, vrai/faux et réponses courtes
  • Vous pouvez naviguer entre les questions
  • Les résultats détaillés sont affichés à la fin

Lance le quiz et démarre le chronomètre

  • pull_request s'exécute dans le contexte du fork sans secrets ; pull_request_target s'exécute dans le dépôt cible avec les secrets.
  • L'attaque classique combine pull_request_target et un checkout du code du fork (ref: ...head.sha), le code de l'attaquant tourne alors avec vos secrets.
  • Les usages légitimes de pull_request_target n'exécutent jamais le code du fork : labelling et commentaires passent par l'API GitHub seule.
  • Pour builder le code d'un fork et utiliser des secrets, séparez en deux workflows reliés par workflow_run, le second ne consomme qu'un artefact.
  • Auditez avec le check Dangerous-Workflow de Scorecard, la règle CKV_GHA_1 de Checkov, ou le contrôle ISSUE-804 de plumber.
  • Pour exécuter du privilégié sur des PR, préférez une garde forte (absence de fork, job de permission, environment + reviewers) ; le label est la plus faible et ne remplace pas une relecture.
  • github.repository == ne protège pas : sous pull_request_target, il vaut toujours le dépôt de base. Distinguez un fork avec head.repo.fork.
  • L'exploitation ne merge pas : la trace est dans les runs Actions (head_repository différent du dépôt de base). En cas de doute, rotez les secrets avant même d'analyser.

Pour le détail des vecteurs d'exploitation, voir l'analyse de référence du GitHub Security Lab : Keeping your GitHub Actions secure.

Ce site vous est utile ?

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

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn