guest@6admin.io:~$

Un pin bloquant, quatre CVE : la rustine qui gagne est celle qui disparaît

La situation de départ : une image Docker trop sage

Je maintiens une stack Foreman dockerisée : quatre images reproducibles, construites depuis les tarballs officiels pinnés en sha256, avec patches, scan Trivy, signatures cosign et une CI GitLab qui vérifie tout. Bref, la configuration “paranoïaque” classique : rien ne bouge sans que je le veuille.

Le problème avec les versions pinnées, c’est que le monde, lui, bouge. J’ai donc lancé un audit sécurité de l’image, genre “montre-moi tes CVEs, mon gros”. Trivy sort sa liste, je trie, je choque sur deux points :

  • des fixes existent dans la branche develop de Foreman, mais pas dans la release 5.0.0 que je pinnais ;
  • des fixes CVE de concurrent-ruby sont bloqués par un pin de dépendance transitive (dynflow exige ~> 1.1).

Je peux patcher chaque CVE en local, encore et encore, en portant des Rustines de plus en plus créatives. Ou je remonte la chaîne et je contribue directement en amont. Devant ce choix cornélien entre “répéter le même effort à l’infini” et “un seul effort utile à tout le monde”, même un lazy dev succombe.

La chasse au trésor, version bug tracker

Premier réflexe : quel est l’état réel des bugs de sécurité dans Foreman ? Réponse : ~4800 issues ouvertes, dont 298 candidats “sécurité” après tri par mots-clés (auth, token, session, csrf, injection…). Croisées avec les PRs GitHub, leur destination finale est hilarante : 19 ont une PR, 279 n’ont aucune.

Après filtrage du bruit (les features déguisées en bug, les issues de 2013 qu’on ne réveillera plus), il reste une shortlist de ~132 bugs réels, récents, impactants et orphelins de toute PR. Autrement dit : un bac à sable infini pour qui aime les problèmes de sécurité réels.

Les PRs et la règle d’or

Le process est strict et il est imposé autant par leur CI que par le bon sens : un seul commit par PR (leur check “Assert Single Commit” refuse le reste), tests écrits en rouge avant d’être verts, reproduction locale dans un Docker postgres:13 + ruby:3.3 avant de toucher au code.

Résultat de la campagne :

  • foreman #11256 : les champs sensibles (mots de passe, tokens) sont audités “pas du tout” au lieu de “en clair dans les logs”. Le genre de fix où le test de régression vaut de l’or.
  • foreman #11279 : les cookies _ForemanSelected* traînent après le logout. Hygiène de session basique, mais personne ne s’y était collé.
  • Dynflow #473 : le fameux pin bloquant, dont on va parler.
  • foreman #11228 : le pin puma, dont on va parler aussi.
  • jenkins #27432 : de la dead code dans CrumbFilter qui prétendait défendre contre un CSRF de 2014 qui n’a jamais fonctionné.

La leçon est gravée dans le marbre : avant d’ouvrir une PR, chercher les PRs ouvertes ET fermées, par numéro d’issue et par fichiers touchés. Les issues ouvertes depuis 2014 attirent les contributeurs comme les lampes attirent les moustiques.

L’histoire qui vaut le détour : le pin qui bloquait les CVE

Voici le schéma classique du cauchemar supply-chain :

Fix CVE concurrent-ruby (1.3.x)  →  bloqué par
dynflow ~> 1.1                   →  en attente de
la merge d'une PR de relaxation

J’ouvre donc Dynflow #473 : relax du pin (concurrent-ruby >= 1.1.3, < 2.0), avec le minimum de choses qui en découlent. Sauf que CI coté dynflow explose : le test “pg goes away for good” dépasse la fenêtre de 60 secondes. En traçant les logs, je découvre que le monde termine bien, mais la séquence stalle dans start_termination : le watchdog de timeout et son callback de complétion sont planifiés sur le même executor IO global, qu’on vient de saturer. Le détecteur de mort est mort.

Je documente ça dans la PR, je garde mon périmètre (relax du pin, rien de plus) et jakduch intervient avec le vrai portage (#475) : le watchdog passe sur un thread dédié à durée de vie courte. Sa reproduction mesure 80 secondes de termination - d’où la fenêtre élargie à 180s dans ma PR, marge CI oblige. Deux PRs, deux responsabilités, zéro compétition.

Merge le 25/09, approuvée par un mainteneur, merci poli. Reste à attendre la release du gem dynflow sur RubyGems pour que tout Foreman (Redmine #39353 en tête) puisse débloquer son lock. C’est déjà mergé ; c’est le temps de publication qui tourne.

Le pin puma ou comment upstreamer sa propre Rustine

Autre pépin du même genre : la contrainte puma de Foreman (>= 5.1, '< 7') conservait toutes les versions vulnérables dans la plage autorisée (CVE-2026-47736/47737, injection de requête via PROXY protocol) et bloquait les releases corrigées - la première version fixée au-dessus de 6.6.1 étant 7.2.1.

Mon image Docker portait déjà la contorsion : un sed qui réécrit la contrainte au build. Plutôt que de la garder jusqu’à la fin des temps, j’ai fait la PR #11228 : monter le plafond de '< 7' à '< 9', pour que le packaging avance à son rythme. Mergée le 11/09 par ehelms. En 5.1, mon sed part à la poubelle et j’en serai ravi.

La boucle qui se referme

Le résumé de la partie :

  • mergées : foreman #11228 (puma), Dynflow #473 (concurrent-ruby), jenkins #27432 ;
  • en review : foreman #11256 (audits), #11279 (cookies), CI 100 % verte ;
  • en attente de publication : le gem dynflow 2.0.2+.

Et quand Foreman 5.1 sortira, mes deux Rustines docker (le sed puma et le pin concurrent-ruby) disparaissent du dépôt. C’est exactement le but : upstreamer la vraie correction, puis supprimer son propre contournement. Un patch local qui disparaît quand l’upstream l’absorbe, c’est un patch qui a gagné.

Trois patchs plus loin

  1. Chercher les PRs existantes avant d’écrire du code. Chercher par issue ET par fichiers touchés, ouvertes ET fermées.
  2. Un périmètre minimal, diagnostic documenté. Sur Dynflow, mon PR ne fait qu’une chose (le pin) et le diagnostic du stall a permis au vrai portage d’atterrir ailleurs. Si j’avais insisté pour tout faire, j’aurais fait plus lent, moins bien et en concurrence.
  3. La CI flaky ne se contourne pas, elle se relance proprement. Amend vide + force-push pour retrigger, un commit conservé et on avance.

La morale, pour finir : un audit de sécurité n’est jamais qu’un rapport. Chaque CVE dans une liste est soit une rustine à poser, soit un upstream à convaincre. Ce jour-là, j’ai choisi la deuxième case et ça a mergé.

Épilogue, trois semaines plus tard

Publié le 26 septembre avec le gem dynflow encore en attente de publication. Le temps a fini par tourner : dynflow 2.0.2 est sorti sur RubyGems et le 11 octobre je l’ai consommé de bout en bout. Locks des deux tiers déverrouillés (le core foreman et le proxy via smart_proxy_dynflow qui acceptait déjà >= 1.6, < 3.0) : concurrent-ruby 1.3.8 et edge 0.7.2. Les deux entrées .trivyignore correspondantes sont passées à la poubelle, les findings documentés tombent de huit à six et les images sont sorties dans la foulée, cosign-signées. La boucle côté concurrent-ruby est refermée ; le sed puma, lui, attend encore 5.1.

Une correction au scoreboard, aussi : #11279 (cookies) a mergé le 23 septembre. Le tableau plus haut est daté du jour de la publication ; la review est arrivée plus vite que ma plume. Restent en course : #11256 (audits), toujours en review et #11252 / #945, les deux PRs qui, en mergant, absorberont mes deux dernières rustines. Redmine #39353, le lockfile upstream, est passé en “Ready For Testing”. Pour le reste, les mainteneurs tiennent le chrono.

La morale tient toujours : la rustine qui a gagné est celle qui disparaît. Le pin concurrent-ruby est déjà parti. Le prochain, c’est le sed.