Fuite de secrets GitHub Actions : enquête et exfiltration
Comment les secrets GitHub Actions fuient malgré le masquage *** : workflows piégés, nouvelles branches, sorties encodées, actions tierces. Les preuves à lire.
En bref. Un workflow peut lire tous les secrets de sa portée, et le masquage ne cache que la valeur exacte : echo "$KEY" | base64 | base64 l'affiche en clair. Les attaquants y arrivent en poussant un workflow modifié sur une nouvelle branche, en abusant de pull_request_target, ou en compromettant une action tierce (tj-actions/changed-files, 2025). Les preuves : workflows.created_workflow_run sur une branche jamais vue dans le journal d'audit, plus l'archive de logs de l'exécution, où l'on voit à la fois le script de l'étape et le blob encodé. Téléchargez les archives avant que quelqu'un ne les supprime, puis renouvelez tous les secrets que le workflow pouvait lire.
GitHub Actions, c'est l'endroit où le code source devient un accès au cloud. Les workflows qui déploient en production détiennent des clés AWS, des jetons de registre et des clés de signature sous forme de secrets. Pour un attaquant qui dispose d'un jeton avec le scope workflow, ou d'une prise dans une action dont vous dépendez, exfiltrer ces secrets est souvent le véritable objectif.
Comment les secrets arrivent chez l'attaquant
| Voie | Ce qui se passe | Où cela se voit |
|---|---|---|
| Workflow poussé sur une nouvelle branche | L'attaquant pousse un .github/workflows/*.yml modifié sur une branche neuve ; un déclencheur push l'exécute avec les secrets du dépôt | git.push + workflows.created_workflow_run avec un head_branch inédit |
Mauvais usage de pull_request_target / workflow_run | Du code de PR non fiable s'exécute dans un contexte privilégié | Exécutions déclenchées par des PR venant de forks, voir GitHub Security Lab sur les « pwn requests » |
| Action tierce compromise | Un tag que vous utilisez pointe désormais vers du code malveillant | Toutes les exécutions qui ont utilisé ce tag, avec la même sortie étrange |
| Runner auto-hébergé | Secrets et jetons lus sur un hôte persistant | Voir la sécurité des runners auto-hébergés |
| Protection d'environnement supprimée | Relecteurs obligatoires ou politiques de branche supprimés : les secrets d'environnement deviennent accessibles | environment.remove_protection_rule, environment.delete |
ATT&CK dispose désormais d'une technique pour cette famille, T1677 Poisoned Pipeline Execution. L'entrée de glossaire sur l'exécution de pipeline empoisonné explique les variantes directe et indirecte.
Un exemple public
En mars 2025, la CISA a publié une alerte sur la compromission de tj-actions/changed-files (CVE-2025-30066) et de reviewdog/action-setup (CVE-2025-30154), qui lui était liée. Les tags de version de l'action avaient été modifiés pour pointer vers du code malveillant qui déversait les secrets de CI dans les logs des workflows. Dans les dépôts publics, n'importe qui peut lire ces logs. Chaque organisation qui utilisait un tag déplacé a dû vérifier ses logs d'exécution sur la période concernée. La question de détection était : lesquelles de nos exécutions ont affiché un blob encodé dans cette étape ?
Pourquoi le masquage n'est pas un contrôle
GitHub remplace par *** les valeurs des secrets enregistrés dans les logs. La référence d'utilisation sécurisée est explicite : le masquage n'est pas garanti, et un secret transformé (Base64, encodage URL) doit être enregistré séparément pour être masqué. Ainsi :
echo "$AWS_ACCESS_KEY_ID:$AWS_SECRET_ACCESS_KEY" | base64 -w0 | base64 -w0
affiche une longue chaîne qu'aucun masque ne reconnaît. Décodez-la deux fois et vous avez la paire de clés. Les variantes passent par xxd, rev, od, le découpage de la valeur, toJSON(secrets), ou l'envoi direct de la valeur avec curl -d vers un service de capture de requêtes. L'entrée de glossaire sur le masquage des secrets résume ce que le masquage couvre.
Les preuves, par ordre de valeur
1. L'archive de logs de l'exécution
Dépôt → Actions → exécution → ⋯ → Download log archive. Le ZIP contient un log combiné par job et un fichier par étape. On y trouve le script de l'étape (le bloc ##[group]Run … reprend les commandes) et sa sortie. C'est le seul endroit où l'on voit ce que le workflow a fait d'un secret. Par défaut, les logs sont conservés 90 jours, et toute personne ayant un accès en écriture peut les supprimer : téléchargez-les en premier.
L'analyseur lit ces ZIP directement. Deux règles s'y appliquent :
actions-secret-exfil(critique) : une ligne de script qui mentionne un secret, un jeton, une clé, l'environnement ou une variable$et qui la fait passer par un encodeur (base64,xxd,rev,od), vide l'environnement (printenv,env |,toJSON(secrets)) ou envoie des données vers un point de terminaison externe (curl -d,wget --post,/dev/tcp/, services courants de capture de requêtes et de tunnel). Le simple décodage (base64 -d) sans appel réseau est exclu pour limiter les faux positifs. Si la ligne nomme des identifiants AWS, Google Cloud ou Azure, le constat affiche l'étape suivante côté cloud.actions-encoded-output(moyenne) : une ligne de sortie qui contient un jeton base64 ou hexadécimal d'un seul tenant de 60 caractères ou plus. C'est exactement l'allure d'une clé doublement encodée.
Chaque ligne de log apparaît deux fois dans une archive (fichier combiné et fichier par étape). L'analyseur déduplique : une étape malveillante donne un seul constat.
2. Les événements d'exécution de workflow dans le journal d'audit
workflows.created_workflow_run et workflows.completed_workflow_run portent repo, name (le workflow), event (push, pull_request, workflow_dispatch…), head_branch, head_sha, workflow_run_id et l'acteur. La règle workflow-new-branch (faible) signale un workflow qui s'exécute sur une branche où il ne s'est jamais exécuté, pour les déclencheurs push et workflow_dispatch. Seule, elle est bruyante, puisque chaque branche de fonctionnalité la déclenche. Elle devient significative quand l'acteur ou le jeton a d'autres constats, ou quand le nom de branche a l'air d'une maintenance (ci/cache-fix, dependabot-fix) et que la branche a été supprimée ensuite.
3. Les événements Git
Un git.push juste avant l'exécution, depuis le même jeton et la même IP, relie la modification du workflow à l'identifiant. Sans événements Git, il vous reste head_sha, que vous pouvez retrouver dans le dépôt si la branche existe encore.
4. Les changements de métadonnées des secrets
repo.create_actions_secret, org.update_actions_secret et environment.create_actions_secret (règle actions-secret-created, faible) relèvent du travail de CI courant. Ils comptent quand l'acteur est suspect : un attaquant peut remplacer un secret de déploiement pour détourner des artefacts. Notez ce que le journal d'audit n'enregistre pas : la lecture d'un secret par un workflow n'est pas un événement.
Délimiter et remédier
- Listez toutes les exécutions du workflow modifié, et de tout workflow sur les branches de l'attaquant, avec les valeurs
workflow_run_iddu journal d'audit. - Renouvelez tous les secrets que le workflow pouvait lire : secrets du dépôt, de l'organisation (si le dépôt y a accès) et d'environnement. Y compris ceux qui n'ont pas été affichés : un script qui exécute
envenvoie tout. - Supprimez les logs des exécutions qui ont affiché des secrets, après les avoir archivés pour le dossier, pour que les blobs encodés ne soient plus lisibles dans l'interface.
- Supprimez les branches de l'attaquant et relisez
.github/workflowssur toutes les branches restantes. - Suivez les clés jusque dans AWS, Google Cloud ou Azure : voir les clés cloud fuitées.
Le durcissement qui réduit vraiment l'exposition
- Épinglez les actions tierces sur un SHA de commit complet. Pour GitHub, c'est actuellement le seul moyen d'utiliser une action comme une version immuable.
- Réglez la permission par défaut de
GITHUB_TOKENen lecture seule et accordez l'écriture job par job. - Protégez les secrets de déploiement avec des environnements qui exigent des relecteurs et restreignent les branches. L'analyseur signale la suppression de cette protection (
environment-protection-removed, élevée). - Remplacez les clés cloud stockées par la fédération OIDC : un jeton à courte durée de vie, limité à un dépôt et à une branche, vaut beaucoup moins pour un attaquant.
- Exigez une relecture CODEOWNERS sur
.github/workflows/et protégez ce dossier avec un ruleset de dépôt. - Utilisez les contrôles d'OpenSSF Scorecard (permissions des jetons, dépendances épinglées, workflows dangereux) pour repérer les motifs à risque.
FAQ
Les secrets GitHub Actions peuvent-ils fuir alors qu'ils sont masqués par *** ?
Oui. Le masquage remplace la valeur exacte du secret dans les logs. Un script qui l'encode en base64, l'inverse ou le découpe affiche une chaîne qui ne correspond plus : elle apparaît en clair et se décode. La documentation de GitHub prévient elle-même que le masquage n'est pas garanti pour les secrets transformés.
Le journal d'audit indique-t-il quand un workflow lit un secret ?
Non. Le journal d'audit enregistre la création, la modification et la suppression des secrets, ainsi que la création et la fin des exécutions de workflow. La lecture d'un secret pendant une exécution n'est pas un événement d'audit : le log d'exécution est la seule preuve de ce qu'une étape en a fait.
À lire aussi
- Clés cloud fuitées dans GitHub Actions : le rebond vers le cloud
- Protection de branche désactivée ? Auditer les rulesets GitHub
- Le cas fictif northwind-labs : une clé AWS encodée deux fois en base64 dans un log d'exécution
- OWASP Top 10 CI/CD Security Risks