Modèle de sécurité des runners hébergés
Les runners hébergés de fremforge exécutent les workflows Forgejo Actions des clients sur des machines virtuelles ECS T Cloud Public dédiées à chaque commit, dans la région EU-DE. Un job en file d’attente sans VM inactive pour son commit provisionne une nouvelle VM avec Docker intégré. Cette VM n’exécute que des jobs du même commit du même dépôt, un à la fois, attend jusqu’à 60 secondes un autre job, puis s’éteint et est détruite. Il n’existe aucun pool de runners persistant, et aucune VM n’exécute jamais du code issu de deux commits, de deux dépôts ou de deux clients.
Cette page décrit les garanties d’isolation que donne fremforge, les limites par tenant qui s’appliquent, et le modèle de menace : ce que le code client peut faire, ce qu’il ne peut pas faire, et les choix de conception qu’un auteur de workflow doit faire pour protéger ses secrets.
Où s’exécutent les workflows
| Composant | Mise en œuvre |
|---|---|
| Calcul | Machines virtuelles T Cloud Public ECS (Elastic Cloud Server), région eu-de, trois zones de disponibilité |
| Cycle de vie | Une VM éphémère par commit. La plateforme crée une nouvelle VM ECS lorsque le premier job d’un commit est mis en file d’attente et la détruit une fois les jobs de ce commit terminés. Jamais deux clients, ni deux commits, ne partagent un noyau — comparable aux runners hébergés par GitHub et par GitLab en ce qui concerne la séparation entre tenants. Les jobs d’un même commit peuvent partager la VM (par exemple build puis deploy) ; c’est pourquoi un pipeline doit être traité comme un seul domaine de confiance — voir Modèle de menace. Les workflows clients ne partagent jamais une VM avec des charges de travail de la plateforme. Il n’y a pas de pool préchauffé (cible 0, par choix de coût) ; chaque commit paie donc les ~40 s de démarrage ECS. |
| Placement réseau | Chaque VM reçoit sa propre ENI dans un sous-réseau dédié aux runners, avec un Security Group par VM qui contrôle la sortie. Les VM se trouvent dans un sous-réseau VPC dédié réparti sur trois zones de disponibilité |
| Runtime de la VM | Une VM par commit ; provisionnée lorsque le premier job de ce commit est mis en file d’attente, détruite lorsque ses jobs sont terminés. Le plafond de durée réelle par job (60 min par défaut, plafond strict de 360 min) est appliqué sur le runner |
| Nettoyeur de secours | Un nettoyeur distinct, hors bande, supprime les VM orphelines — runner jamais enregistré, runner terminé mais suppression manquée, âge de la VM au-delà de son échéance de nettoyage, ou filet de sécurité absolu de 6 h 10 min. |
| Binaire du runner | forgejo-runner (act_runner) amont v13.2.0, sans fork. S’enregistre de façon éphémère avec --ephemeral, de sorte qu’un enregistrement est consommé par une seule tâche ; l’agent se réenregistre pour chaque tâche suivante du même commit, et la plateforme détruit la VM une fois les jobs de ce commit terminés |
| Image de la VM | L’image des runners hébergés — base debian 13 (trixie), avec des toolchains intégrées à partir de binaires mis en cache dans l’UE (aucun téléchargement direct depuis l’amont au démarrage). L’image est le disque de démarrage de la VM ; le démon Docker est préinstallé et s’exécute nativement sur la VM (pas de sidecar séparé). Le démarrage à froid prend ~40 s à chaque commit — la cible du pool préchauffé est 0 par choix de coût, aucune VM n’est donc provisionnée à l’avance |
Garanties d’isolation
Isolation réseau (par VM)
Chaque VM de runner reçoit sa propre ENI liée à un Security Group propre à la VM. Par défaut, le SG refuse tout trafic sauf :
- le DNS (53/UDP, 53/TCP) vers le résolveur DNS interne de la plateforme
- la sortie via le proxy de sortie des runners — le seul chemin HTTP sortant
Le code client ne peut pas atteindre :
- l’API fremforge ni sa base de données privée
- l’endpoint interne de Forgejo (les VM de runner parlent à Forgejo via l’apex public derrière Bunny, avec une portée propre aux runners ; l’endpoint interne est inaccessible)
- les services de la plateforme T Cloud Public (RDS, DCS, OBS, SFS-Turbo, IAM, KMS)
- les VM de runner des autres tenants
- l’IP de métadonnées du cluster (
169.254.169.254) — explicitement bloquée par la protection SSRF - les plages privées RFC1918 ni le loopback
Le Security Group est appliqué au provisionnement de la VM et ne peut pas être modifié depuis l’intérieur de la VM — la VM n’a aucune agence IAM attachée, les API de configuration réseau sont donc inaccessibles via l’identité dérivée des métadonnées.
Sortie réseau
Les étapes de workflow client qui ont besoin de l’internet public (cloner un dépôt public, télécharger un outil, appeler une API SaaS) passent par le proxy de sortie des runners via HTTPS_PROXY, injecté dans la VM. Ce proxy :
- accepte toute IP unicast publique, y compris le Carrier-Grade NAT (même comportement que les runners hébergés par GitHub — nécessaire pour les propres services de T Cloud Public, comme SWR, qui se trouvent dans le CGNAT)
- bloque les IP privées RFC1918, link-local, loopback, multicast, réservées, ainsi que les IP explicites de métadonnées cloud (
169.254.169.254,100.100.100.200de T Cloud Public) - consigne chaque tentative de tunnel CONNECT avec le nom d’hôte + l’IP résolue, pour la conservation à des fins d’audit
Le proxy est délibérément permissif, au sens des runners hébergés par GitHub : les workflows clients peuvent atteindre tout hôte public dont ils ont besoin. La frontière se situe au niveau du SSRF + de la plateforme interne, pas de listes d’autorisation de fournisseurs.
Caches de paquets (souverains, résidant dans l’UE)
Avant qu’un workflow n’atteigne l’internet public, les installations de paquets et les tirages d’images passent par le proxy de dépendances propre à votre organisation, résidant dans l’UE, dans la même région que les VM de runner. Le miroir Docker Hub partagé et les caches de paquets internes au VPC ont été retirés en octobre 2026. Le proxy de chaque organisation applique sa propre politique de paquets, ses identifiants amont, son journal d’accès et son plafond de stockage ; un fichier public tiré par plusieurs organisations n’est stocké qu’une fois, et un contenu récupéré avec les identifiants propres d’une organisation n’est jamais servi à une autre. Avec le pull-through des runners activé (la valeur par défaut pour chaque organisation qui n’a pas enregistré ses propres paramètres de proxy de dépendances), chaque job est dirigé vers le proxy à son démarrage :
| Trafic | Injecté sous la forme |
|---|---|
| npm, pnpm, pip, uv | le npmrc du job, PIP_INDEX_URL, UV_DEFAULT_INDEX |
Go, Maven, NuGet, base de vulnérabilités Trivy (chacun selon la liste de runner_pull_through_ecosystems, tous par défaut) | GOPROXY, un miroir Maven de Central, un NuGet.Config à l’échelle de la machine, TRIVY_DB_REPOSITORY et TRIVY_JAVA_DB_REPOSITORY |
Images de conteneurs depuis ghcr.io, quay.io, mcr.microsoft.com, registry.k8s.io, public.ecr.aws, registry.access.redhat.com ; et docker.io, registry.redhat.io, registry.connect.redhat.com uniquement avec l’identifiant propre de l’organisation, jamais pour les pull requests issues de forks | la configuration de registre du démon Docker de la VM |
Le job s’authentifie auprès du proxy avec des identifiants limités à sa propre organisation et à son exécution ; les installations de paquets passent donc par la politique de paquets et les vérifications antimalware de l’organisation, et apparaissent dans son journal d’accès. Pour les images de conteneurs sur ce chemin, le proxy est un cache, pas une barrière : un tirage qu’il refuse se rabat sur le registre. Le proxy est vérifié au début de chaque job ; s’il est injoignable, le job installe et tire depuis les registres publics via le proxy de sortie, et ce repli est consigné. Voir Runners hébergés.
Dans le cas courant (dépendances publiques), c’est transparent : rien ne change dans votre workflow.
Si vous utilisez un index ou un registre privé, notez que la valeur injectée est la valeur par défaut ; elle prend donc le pas sur une valeur par défaut configurée dans un fichier de configuration versionné (pip.conf, .npmrc, go env). Désignez explicitement votre source privée pour qu’elle l’emporte ; la marche à suivre pour chaque outil figure ci-dessous.
Utilisez un seul index par installation : le proxy, ou un index privé qui met lui-même en miroir le registre public. N’ajoutez jamais un second index à côté du premier (--extra-index-url, PIP_EXTRA_INDEX_URL, UV_EXTRA_INDEX_URL) : l’installeur prend alors un nom de paquet dans n’importe quel index qui le propose, ce qui est précisément le principe des attaques par confusion de dépendances. Voir les notes PyPI du proxy de dépendances.
- pip :
pip install --index-url <your-private> ...(un--index-urlen ligne de commande remplace lePIP_INDEX_URLinjecté), ou définissez-le dans leenv:de l’étape du workflow, lorsque cet index privé met en miroir les paquets publics dont vous avez besoin. Pour installer vos propres paquets depuis le registre de votre organisation tandis que les paquets publics passent par le proxy, utilisez deux installations distinctes, chacune avec son propre index unique : d’abord les dépendances publiques, puis vos paquets avec--index-url https://frem.sh/api/packages/<org>/pypi/simple/ --no-deps. - uv : la même règle. Épinglez vos propres paquets sur votre registre par leur nom, avec un index marqué
explicit = trueet une entrée[tool.uv.sources]par paquet, afin qu’aucun nom ne puisse se résoudre depuis les deux. - npm : définissez
NPM_CONFIG_REGISTRY(et, pour pnpm 11,PNPM_CONFIG_REGISTRY) dans leenv:de l’étape sur un registre unique qui met en miroir le registre public, ou conservez le proxy et routez uniquement votre scope vers votre registre (@<your-scope>:registry=…), ce qui épingle chaque nom de ce scope sur un seul registre. Voir Caches de paquets. - Go : définissez
GOPRIVATE(par ex.GOPRIVATE=git.example.com/*) pour que la toolchain contourne le proxy + la base de sommes de contrôle pour vos chemins de modules privés.
Isolation au niveau de la VM
| Surface | Application |
|---|---|
| Processus | Les étapes de workflow s’exécutent par défaut sous l’utilisateur runner de la VM ; sudo est disponible afin que les workflows puissent faire apt install, exécuter docker, configurer des outils système, etc. (conforme aux attentes des runners hébergés par GitHub). La frontière est l’hyperviseur sous la VM, pas les espaces de noms utilisateur |
| Espace de travail | Espace temporaire local à la VM dans /workspace (et /tmp) — effacé entre les jobs et détruit avec la VM. Voir Jobs qui partagent une VM pour ce qui peut subsister entre les jobs d’un même commit |
| Identité | La VM n’a aucune agence IAM attachée. Les requêtes au service de métadonnées ne renvoient que l’identité réseau nécessaire au démarrage ; aucun accès aux API de la plateforme |
| Limites de ressources | Le type ECS de la VM fixe un plafond strict de CPU + de mémoire. Un client incontrôlé ne peut pas épuiser la capacité au-delà des limites de sa propre VM. L’emballement global est borné par la limite de simultanéité par tenant + la capacité de calcul de la plateforme |
| Runtime de conteneurs | Docker (natif) — le démon s’exécute sur l’hôte VM, pas comme sidecar. docker build, docker compose, kind, testcontainers fonctionnent directement, sans la complexité de DinD. Pour les workflows qui ont besoin de capacités supplémentaires du runtime de conteneurs, le label dind-privileged route vers un type de VM avec la virtualisation imbriquée KVM activée |
| Hyperviseur | L’hyperviseur ECS de T Cloud Public, basé sur KVM — la même frontière de VM multi-tenant que celle utilisée pour chaque client commercial de T Cloud Public exécutant ECS dans la région |
Isolation du noyau entre tenants + au sein d’un tenant
C’est l’affirmation porteuse du modèle des runners hébergés. Chaque VM est liée à un commit d’un dépôt — un noyau Linux invité s’exécutant sur l’hyperviseur de T Cloud Public. Elle exécute les jobs de ce commit un à la fois, et la plateforme la détruit une fois qu’ils sont terminés. Un job d’un autre commit, d’un autre dépôt ou d’une autre organisation obtient toujours une VM différente, avec un noyau différent.
| Mécanisme | Comment |
|---|---|
| VM par commit | La plateforme crée une VM pour un job en attente lorsqu’aucune VM de ce commit n’est libre. Une évasion au niveau du noyau (par ex. CVE-2024-1086 nf_tables) ne compromet QUE cette VM jetable et les jobs du même commit, pas les tenants voisins |
| Arrêt rapide à la fin | Après son dernier job, la VM attend jusqu’à 60 secondes un autre job du même commit, puis s’éteint et est supprimée. Un nettoyeur distinct, hors bande, rattrape les orphelines (runner jamais enregistré, suppression manquée) dans un filet de sécurité absolu de 6 h 10 min |
| ENI par VM + SG par VM | Isolation réseau indépendante de la frontière du noyau. La VM du tenant A ne peut pas atteindre la VM du tenant B par le réseau — ENI séparées, Security Groups séparés |
| Cycle de vie limité à un commit | Chaque job s’exécute dans un nouveau conteneur de job. Une VM n’est jamais réutilisée pour un autre commit, un autre dépôt ou un autre tenant ; elle ne contient donc jamais les secrets d’un tenant précédent |
| Jetons OIDC | Chaque VM reçoit un nouveau jeton OIDC signé par l’émetteur runner-OIDC avec aud=fremforge et sub=repo:<owner>/<repo>:ref:<refname>. Les politiques de confiance cloud du client s’appuient sur cette audience + ce sub pour assume_role_with_web_identity |
Jobs qui partagent une VM
Une VM n’est réutilisée que pour des jobs du même commit dans le même dépôt (et donc de la même organisation) : d’autres workflows, exécutions ou réexécutions de ce commit, ou un déploiement après son build. Entre ces jobs, fremforge réinitialise tout ce dont il sait que cela peut contenir un identifiant : le conteneur du job, /workspace et /tmp, la connexion Docker et la configuration npm, ainsi que les fichiers d’identifiants habituels. Si cette réinitialisation ne peut pas être confirmée, la VM ne prend plus aucun job, et le job suivant obtient une nouvelle VM.
La réinitialisation n’équivaut pas à une nouvelle VM. Les jobs peuvent utiliser le démon Docker de la VM, qui est root sur la VM ; un job peut donc laisser quelque chose derrière lui pour un job ultérieur du même commit. Les images tirées avec des identifiants, le cache d’outils et le cache de build Go restent aussi sur la VM. Traitez tous les jobs d’un même commit comme un seul domaine de confiance :
- N’exécutez pas de code non fiable (fixtures fournies par des contributeurs, mises à jour de dépendances non relues) dans le pipeline du même commit qu’un job qui détient des secrets de production.
- Placez les déploiements derrière une approbation d’environnement (voir Séparer les jobs
validateetapply), et préférez des identifiants OIDC de courte durée à des clés stockées. - Si un déploiement ne doit jamais partager une machine avec un build, exécutez-le sur un runner fourni par vous.
Limites par tenant
| Limite | Par défaut | Dérogation |
|---|---|---|
| Jobs simultanés par tenant | 2 garantis par utilisateur, au moins 10 et au plus 60 (essai : 2). Au-delà, un tenant payant peut utiliser la capacité inutilisée jusqu’à 60, mais seulement tant que 20 emplacements de la plateforme restent libres, afin que la pointe d’un tenant ne retarde jamais les jobs garantis d’un autre. À titre de comparaison, hébergé par GitHub : Free 20, Pro 40, Team 60, Enterprise 500 | Enterprise-on-Demand : une dérogation par tenant remplace la part garantie |
| Jobs simultanés globaux, tous tenants confondus | Bornés par la capacité de calcul de la plateforme dans eu-de | — |
| Plafond de débit de jobs | 7 démarrages de jobs par fenêtre glissante de 5 minutes et par emplacement du plafond de pointe du tenant : 420 pour un tenant payant, 14 pour un essai. Il croît avec une limite de simultanéité relevée ; c’est donc un filet de sécurité contre les abus, jamais la limite qu’un pipeline normal rencontre en premier. GitHub ne publie aucune limite de débit de démarrage de jobs | Dérogation par tenant |
| Durée réelle par job | 3600 s (60 min) | Configurable par étape de workflow, mais plafonnée à 6 h |
| Réserve de minutes de runner | seat_cap × 1000 minutes/month (ou un plafond explicite par tenant) | Configuration de l’opérateur ; correspond à la tarification publique |
| Type de VM | c9.large.2 (2 vCPU / 4 Gio de RAM) par défaut ; c9.xlarge.4 (4 vCPU / 16 Gio) pour runs-on: [fremforge, large] | Le label runs-on: de l’étape de workflow route vers d’autres types |
Lorsqu’un tenant dépasse sa réserve de minutes de runner et que le dépassement n’est pas activé (ou est gelé), la plateforme refuse le dispatch : le job en file d’attente du workflow passe à Cancelled dans Forgejo. Les tenants avec dépassement activé continuent de s’exécuter et sont facturés pour l’excédent.
Lorsqu’un tenant atteint sa limite de simultanéité, les jobs supplémentaires en file d’attente patientent et sont dispatchés dès qu’un emplacement se libère.
Ce que le code client ne peut PAS faire
- Atteindre l’API fremforge, l’endpoint interne de Forgejo ou tout autre service de la plateforme (l’API n’est disponible que via l’apex public Bunny, avec une authentification en bonne et due forme, comme pour tout appelant externe)
- Lire la VM de runner d’un autre tenant (ENI séparée ; trafic entre VM explicitement refusé par le Security Group)
- Lire les secrets d’un autre tenant (les secrets de workflow ne sont transmis qu’au runner qui exécute le workflow auquel ils appartiennent ; la destruction de la VM efface les identifiants en mémoire)
- Conserver un état au-delà de son propre commit (la VM est détruite une fois les jobs de ce commit terminés, et aucun disque n’est partagé entre VM)
- Atteindre directement l’API OTC avec une portée privilégiée (aucune agence IAM attachée ; l’identité dérivée des métadonnées n’a aucun rôle)
- Atteindre les endpoints de métadonnées cloud (
169.254.169.254,100.100.100.200— tous deux explicitement refusés par la protection SSRF) - Épuiser le pool de runners (limite de simultanéité par tenant + plafond de calcul global de la plateforme)
- Provoquer une famine de la file d’attente entre tenants (limite de simultanéité par tenant)
- S’échapper de l’hyperviseur (la VM est la frontière ; l’hôte en dessous appartient à T Cloud Public et est isolé par le même VMM qui sépare chaque tenant ECS commercial de la région)
Formes de workflow recommandées
Séparer les jobs validate et apply
Exécutez le code non fiable (builds de PR, analyses de dépendances, fixtures de test fournies par des contributeurs) dans un job validate sans identifiants de production, et placez le job apply derrière une approbation d’environnement. Le job validate ne voit jamais les secrets du job apply. Les deux jobs appartiennent toutefois au même commit ; ils peuvent donc s’exécuter sur la même VM l’un après l’autre : voir Jobs qui partagent une VM.
Épingler les références uses: par SHA quand la chaîne d’approvisionnement compte
uses: actions/checkout@v4 se résout en frem.sh/actions/checkout (le miroir pull natif Forgejo de fremforge de github.com/actions/checkout, synchronisé toutes les 8 heures). Le miroir suit les tags amont, y compris les force-pushs ; épingler par SHA est donc le seul moyen de garantir une stabilité à l’octet près :
# Bytewise-stable across maintainer force-pushes
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.0
# Picks up upstream's current HEAD at last mirror sync — including
# any maintainer-credential-compromise force-push
- uses: actions/checkout@v4Utiliser OIDC pour l’authentification cloud, pas des secrets de longue durée
Chaque VM de runner reçoit un nouveau jeton OIDC avec aud=fremforge. Configurez la politique de confiance de votre fournisseur cloud pour qu’elle accepte ce jeton et l’échange contre des identifiants de courte durée. Évitez de stocker des clés d’accès AWS de longue durée ou des JSON de compte de service GCP dans les secrets de workflow, où ils se retrouvent dans l’environnement des processus.
Plafonner la durée des étapes de workflow
Définissez timeout-minutes: sur les étapes individuelles pour borner la durée pendant laquelle une étape bloquée peut s’exécuter. Le plafond de durée réelle au niveau de la VM (activeDeadlineSeconds=3600) est le plancher de la plateforme ; les délais au niveau des étapes donnent un retour plus rapide.
Synthèse du modèle de menace
| Menace | Atténuation |
|---|---|
| Le workflow client contient un identifiant | La protection du push (Gitleaks) bloque le commit avant qu’il n’entre dans l’historique |
| Le workflow client tente de lire la plateforme fremforge | ENI + SG par VM refusent ; aucune agence IAM attachée à la VM ; API joignable uniquement par le chemin public authentifié |
| Le job d’un tenant tente de lire la VM d’un autre | ENI par VM sur des IP séparées ; SG en refus par défaut dans tout le sous-réseau des runners ; et ils sont sur des VM différentes — des noyaux différents |
| Évasion au niveau du noyau (root dans la VM → noyau de l’hôte via une CVE) | Ne compromet que la VM jetable qui exécute les jobs de ce commit. Les tenants voisins ne le sont PAS — ils sont sur leur propre VM. La VM est détruite environ une minute après le dernier job de ce commit, quoi qu’il arrive |
| Le workflow client tente d’exfiltrer via le DNS | Le DNS est autorisé (les workflows en ont légitimement besoin), mais le proxy de sortie consigne chaque CONNECT avec le nom d’hôte complet pour l’audit |
act_runner amont compromis | Binaire amont testé (nous ne forkons pas). Mis en miroir via le cache européen, avec des contrôles Trivy sur l’image du runner. Les correctifs de CVE arrivent via la reconstruction hebdomadaire |
| Le workflow client tente d’atteindre les IP de métadonnées | Refus explicite par la protection SSRF : 169.254.169.254 (style AWS) et 100.100.100.200 (T Cloud Public) |
| Un client monopolise la capacité | Part garantie par tenant (2 par utilisateur, de 10 à 60) avec une pointe uniquement dans la capacité inutilisée qui laisse 20 emplacements libres, plafond de débit de jobs (7 démarrages par emplacement de pointe par 5 min), plafond de minutes de la réserve |
| Une VM de runner périmée survit à sa tâche | Le plafond de durée réelle par job arrête de force à l’échéance ; la plateforme supprime la VM à la sortie du runner ; un nettoyeur distinct, hors bande, rattrape les orphelines avec un filet de sécurité absolu de 6 h 10 min |
Auditabilité
Chaque dispatch est consigné à trois endroits :
- Table
runner_jobs— tenant_id, repo, forgejo_task_id, started_at, finished_at, duration_seconds, vm_id. Source de l’agrégation de facturation et de l’onglet Infrastructure de la console de l’opérateur - Table
metering_events— lignescode=runner_minutedérivées derunner_jobs.duration_seconds. Alimente les lignes de facture et le panneau « Runner minutes used » du tenant - Flux de journaux de l’opérateur — journaux structurés de création de VM pour l’opérateur, avec des alertes continues sur les refus de dispatch, les VM bloquées, les échéances dépassées et l’épuisement de la capacité
Les tenants voient leur propre utilisation dans Administration de l’organisation → Build et déploiement → Utilisation de la CI, y compris un nombre de minutes par mois et un tableau des durées par job.