Compiler ses dépendances depuis les sources : bilan

La règle, et la question qu'elle ne pose pas

Le principe est simple à énoncer : dans une image FROM scratch, rien de ce qui est embarqué ne doit provenir d'un paquet de la distribution. Tout se compile depuis les sources, signatures vérifiées, versions épinglées dans le versions.json du dépôt.

Appliqué à l'image DNS du homelab, ça donne huit bibliothèques portées une à une : jemalloc, zlib, userspace-rcu, libcap, libuv, json-c, puis OpenSSL. L'image est passée de 19,6 à 17 Mo. Le principe est respecté, les commits sont verts.

Reste la question que le principe ne pose jamais : qu'est-ce que ça a rapporté, exactement ? Pas en intentions, en octets et en propriétés ELF. La réponse mérite d'être écrite parce qu'elle contredit une partie de ce que je croyais en commençant.

Le protocole de mesure

La comparaison se fait dans la même image, sur la même architecture, entre deux exemplaires de la même bibliothèque : celui compilé depuis les sources et celui du paquet Alpine équivalent.

# extraire le systeme de fichiers publie, sans jamais executer l'image
cid=$(docker create ghcr.io/jbsky/bind9-hardened:latest)
docker export "$cid" | tar -x -C rootfs/

# comparer avec le paquet, dans un conteneur Alpine de meme version
apk add --no-cache binutils zlib jemalloc libuv json-c libcap libcrypto3 libssl3
readelf -lW  libfoo.so | grep GNU_RELRO      # Full RELRO
readelf -dW  libfoo.so | grep BIND_NOW       # liaison immediate
readelf -sW --dyn-syms libfoo.so | grep __stack_chk_fail   # canari de pile
readelf -lW  libfoo.so | grep GNU_STACK      # pile non executable

Cinq propriétés, huit bibliothèques, deux origines. Aucune interprétation : le binaire répond ou ne répond pas.

Résultat 1 : le durcissement était déjà là

C'est le résultat qui fait le plus mal, parce que c'était l'argument principal.

Propriété Compilée ici Paquet Alpine
Full RELRO oui oui
BIND_NOW oui oui
Canari de pile oui oui
Pile non exécutable oui oui
FORTIFY identique identique

Aucun écart, sur aucune des huit bibliothèques. Alpine compile ses paquets avec -fstack-protector-strong, -fstack-clash-protection, -D_FORTIFY_SOURCE=2 et lie en relro,now par défaut depuis des années : c'est le comportement standard de son outil de build. Recompiler « pour appliquer nos flags de durcissement » revient à réappliquer des flags déjà appliqués.

Attention à la portée de cette phrase : elle vaut pour les dépendances, pas pour le service. Le binaire principal, lui, n'existe pas en paquet dans la configuration qu'on veut — j'y reviens.

Résultat 2 : recompiler alourdit

Deuxième surprise, dans l'autre sens que celui attendu.

Bibliothèque Compilée ici Paquet Alpine Delta
libcrypto 6 044 K 4 868 K +1 175 K
libssl 1 043 K 819 K +224 K
jemalloc 798 K 394 K +403 K
libuv, json-c, libcap, urcu, zlib 543 K 499 K +44 K
Total 8 428 K 6 580 K +1 848 K

Presque deux mégaoctets de plus. L'essentiel vient d'un choix assumé : -O2 là où Alpine compile en -Os. Sur de la cryptographie appelée à chaque requête signée, c'est défendable — mais c'est un arbitrage performance contre taille, pas un gain de sécurité, et il faut savoir qu'on le fait.

Résultat 3 : le gain venait des suppressions

Si recompiler alourdit de 1,8 Mo et que l'image a quand même maigri de 2,6 Mo, c'est qu'autre chose a payé. Ce sont les bibliothèques retirées, avec leurs chaînes transitives.

Retiré Poids Ce qui l'a permis
libstdc++ 2 738 K jemalloc compilé avec --disable-cxx
libgcc_s 169 K même origine
libxml2 1 046 K option --without-libxml2 du service
liblzma 221 K plus personne ne la liait

4 174 K supprimés contre 1 848 K ajoutés. Voilà l'arithmétique réelle de la migration.

Et c'est ici que la distinction devient intéressante. Sur ces quatre suppressions, une seule exigeait la compilation depuis les sources : le paquet jemalloc d'Alpine dépend de libstdc++.so.6 et de libgcc_s.so.1, on ne s'en débarrasse pas sans reconstruire la bibliothèque avec le support C++ désactivé. Les 1 267 K de libxml2 et liblzma, eux, ne demandaient aucune recompilation : le service était déjà compilé depuis les sources, et l'option existait depuis le premier jour. Il suffisait de la passer.

Autrement dit : sur huit portages, un seul se justifie techniquement. Les sept autres ont coûté 1 445 K et n'ont rien apporté de mesurable.

Le bonus qu'on n'avait pas cherché

La suppression de libxml2 a entraîné celle de liblzma, qui n'était liée que par elle. C'est la bibliothèque de la porte dérobée de 2024 — celle qui, via une archive de test piégée dans les sources, ouvrait un accès distant sur les serveurs qui la chargeaient indirectement.

Un serveur DNS n'a aucune raison de décompresser du XZ. La voir quitter l'espace d'adressage d'un démon exposé au réseau vaut plus que les 221 K qu'elle occupait. Et ce gain-là ne devait rien à la compilation : il tenait à une question qu'on ne s'était pas posée avant, celle de savoir pourquoi elle était là.

La leçon : ce qui compte, c'est ce qu'on éteint

Le service, lui, gagne réellement à être compilé depuis les sources — mais pour une raison différente de celle qu'on avance d'habitude. Pas les flags : la configuration.

./configure --without-libxml2 --without-gssapi --without-libidn2 
            --without-readline --disable-doh --disable-static

Chacune de ces options retire du code du binaire final : un analyseur XML, une pile d'authentification Kerberos, une bibliothèque de noms internationalisés, une pile HTTPS complète. Aucun paquet générique ne peut faire ces choix à votre place — il est construit pour servir tout le monde, donc il active tout.

La valeur de la compilation depuis les sources, c'est ce qu'elle permet d'éteindre, pas les drapeaux qu'elle permet d'allumer. Formulé ainsi, le critère devient utilisable : une bibliothèque ne mérite d'être recompilée que si le paquet impose quelque chose dont on ne veut pas.

Le plancher : la libc reste un paquet

Le principe a une limite, et elle se démontre plutôt qu'elle ne se décrète.

En voulant porter musl, la dernière bibliothèque encore issue d'un paquet, la lecture de la recette Alpine a révélé deux correctifs rétroportés : une corruption de tas dans qsort et un défaut de l'analyseur iconv. Or la dernière version publiée en amont ne contient ni l'un ni l'autre : les correctifs vivent dans le dépôt git du projet, en attente d'une publication qui arrive environ une fois par an.

Compiler « la même version » depuis les sources livrerait donc une corruption de tas connue, dans une fonction qu'un démon réseau appelle en permanence, pour gagner une ligne de principe. C'est le cas général, poussé à son extrême : les distributions rétroportent des correctifs de sécurité, et reprendre le seul numéro de version régresse sur tout ce qui n'est pas ce numéro.

Le réflexe qui en découle tient en une commande, à passer avant tout portage :

curl -s "https://gitlab.alpinelinux.org/alpine/aports/-/raw/master/main/PKG/APKBUILD" 
  | sed -n '/^source=/,/"/p'

Tout fichier .patch de cette liste est un correctif que la compilation amont n'aura pas.

La conséquence pratique n'est pas de renoncer, mais de changer de posture : la libc reste un paquet, et la dépendance subie devient une dépendance surveillée. Un contrôle automatisé épingle la liste des correctifs appliqués par la distribution et signale toute dérive dans les deux sens. Un correctif qui apparaît est hérité gratuitement mais doit être tracé ; un correctif qui disparaît signifie qu'il est passé en amont, et rouvre la question du portage. Le critère de réouverture se note d'ailleurs en identifiants de commits, jamais en numéro de version : un numéro ne dit rien par lui-même.

Ce que la compilation achète quand même

Il reste deux bénéfices réels, qu'il serait malhonnête de balayer.

La provenance. Chaque archive est vérifiée contre une signature du mainteneur amont, empreinte épinglée dans le dépôt. Avec un paquet, on fait confiance à la clé de la distribution et à sa ferme de compilation. C'est un modèle de menace différent — mais partiel, puisque le compilateur, la libc et tout l'outillage du stage de build restent des paquets. Déplacer la racine de confiance d'un cran n'est pas la supprimer.

Le contrôle de configuration. C'est le vrai levier, et le cas jemalloc prouve qu'il n'est pas théorique.

En face, deux coûts à assumer sans détour. Le suivi des vulnérabilités de huit bibliothèques passe de l'équipe sécurité de la distribution à un fichier de versions qu'il faut soi-même tenir à jour — l'affaire des correctifs rétroportés montre que ce n'est pas une formalité. Et le temps de construction sur architecture émulée passe de 25 minutes à une heure et demie, ce qui change la nature de chaque erreur : un correctif d'une ligne coûte désormais une matinée.

L'arbre de décision

flowchart TD
    A["Bibliotheque liee par le service"] --> B{"Le service en a-t-il besoin ?"}
    B -->|"non"| C["Supprimer par une option de configure"]
    B -->|"oui"| D{"Le paquet impose-t-il une dependance non voulue ?"}
    D -->|"oui"| E["Compiler depuis les sources"]
    D -->|"non"| F{"La distribution retroporte-t-elle des correctifs ?"}
    F -->|"oui"| G["Garder le paquet et surveiller ses patches"]
    F -->|"non"| H["Compiler si la provenance amont compte"]
    C --> I["Gain maximal : la chaine transitive part aussi"]

Lu dans cet ordre, l'arbre place la question rentable en premier. Sur l'image DNS, la branche de gauche a rapporté 1 267 K sans écrire une ligne de Dockerfile, et la branche « compiler » 2 907 K pour un seul portage sur huit.

Ce que je ferais différemment

Le prochain chantier concerne une image qui embarque soixante et onze bibliothèques tierces. La tentation est de dérouler la même méthode, une par une, pendant des semaines.

Ce serait reproduire l'erreur à plus grande échelle. La bonne première question n'est pas « comment compiler ces soixante et onze bibliothèques » mais « lesquelles le service charge-t-il réellement ». C'est là que se trouvent les mégaoctets et la surface d'attaque. La migration depuis les sources aura surtout eu le mérite de forcer cet inventaire — on peut le faire directement, et beaucoup plus vite.


Liens

Articles connexes


Commentaires

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur la façon dont les données de vos commentaires sont traitées.