Hardening systemd : passer un service Python de 9.6 à 1.7

TL;DR
systemd-analyze security est un outil sous-utilisé. Il scanne tes unit files et calcule un score d’exposition de 0.0 (UNSAFE) à 10.0 (PERFECT). Les services Python custom sortent souvent autour de 9.6 par défaut — c’est mauvais.
J’ai fait passer mon service hugo-mcp (FastAPI exposant 7 tools MCP) de 9.6 → 1.7 sans casser la moindre fonctionnalité. Voici les directives qui comptent vraiment, et celles qui sont des pièges.
Le score initial
$ sudo systemd-analyze security hugo-mcp
→ Overall exposure level for hugo-mcp.service: 9.6 UNSAFE 😨9.6 sur 10. Le service tourne en root équivalent, peut faire setuid(), peut allouer de la mémoire RWX, voit tous les processus du système, peut écrire partout, peut faire des syscalls arbitraires.
Pour un MCP qui édite du contenu Markdown et déclenche un build Hugo, c’est démesuré.
Les directives qui comptent vraiment
1. Isolation système
[Service]
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
ProtectProc=invisible
ProcSubset=pidImpact : le service ne voit plus que /proc/<son-pid>/, ne peut plus toucher au kernel, ni aux cgroups, ni à l’horloge système.
2. Capabilities Linux
CapabilityBoundingSet=
AmbientCapabilities=
NoNewPrivileges=trueLe service tourne sans aucune capability. Pas de CAP_NET_BIND_SERVICE, pas de CAP_SYS_ADMIN. Si tu écoutes sur un port < 1024, utilise socket activation ou setcap sur le binaire — pas une capability ambient.
3. Filesystem write whitelist
ReadWritePaths=/home/jm/hugo-mcp /home/jm/hugo-site
ReadOnlyPaths=/etc
PrivateMounts=trueLe service ne peut écrire QUE dans 2 dossiers précis. Toute autre tentative open(O_WRONLY) retourne EROFS. Même si quelqu’un trouve une RCE dans FastAPI, l’attaquant ne peut pas modifier /etc/passwd ou /usr/local/bin/.
4. Network egress whitelist
IPAddressDeny=any
IPAddressAllow=127.0.0.0/8
IPAddressAllow=192.168.122.1/32
# Cloudflare API (purge cache)
IPAddressAllow=104.16.0.0/12
IPAddressAllow=2606:4700::/32C’est la directive la plus puissante et la plus piégeuse. Le service ne peut sortir que vers son loopback, le bridge libvirt, et les ranges Cloudflare.
Piège #1 : si tu oublies les ranges externes que ton service appelle (Cloudflare API, GitHub, ton broker de log), tu auras des timeouts silencieux difficiles à debug. J’ai passé ~2h à comprendre pourquoi purge_cache timeoutait à 5s alors que SSH manuel marchait : IPAddressDeny=any était activé sans whitelist Cloudflare.
Piège #2 : IPAddressAllow ne supporte que les ranges IPv4/IPv6, pas les hostnames. Donc si Cloudflare change ses ranges, faut mettre à jour le unit file.
5. Syscall filter
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
SystemCallArchitectures=native@system-service est un set préfini de syscalls “raisonnables” pour un service. Bloque tout ce qui est niche (mount, swapon, reboot…).
6. Bonus
LockPersonality=true # bloque setpersonality()
RestrictRealtime=true # pas de scheduling temps réel
RestrictSUIDSGID=true # pas de fichiers setuid créés
RemoveIPC=true # cleanup IPC à l'arrêt
RestrictNamespaces=true # pas de unshare(CLONE_NEWNS)
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
MemoryDenyWriteExecute=true # bloque mmap PROT_WRITE|PROT_EXEC
UMask=0077 # fichiers créés sans group/worldMemoryDenyWriteExecute=true est intéressant : il bloque les mmap RWX, ce qui casse la plupart des shellcodes JIT-style. Pour un service Python pur (pas de JIT), aucun impact fonctionnel.
Le piège UMask=0077
Cette directive crée TOUS les fichiers en 0600 (lecture/écriture uniquement pour le user). Innocent en apparence.
Sauf que mon service hugo-mcp écrit dans public/ (le dossier de build Hugo), et nginx (qui sert ces fichiers) tourne sous un autre user. Résultat :
$ ls -la public/
-rw------- 1 hugo-mcp hugo-mcp 4823 May 9 14:23 index.htmlnginx ne peut plus lire index.html. Le site renvoie 403 Forbidden sur tout après chaque rebuild.
Fix : ajouter chmod -R o+rX public/ dans deploy.sh après chaque build :
hugo --minify
chmod -R o+rX public/ # ← essentiel après UMask=0077
rsync -a public/ /var/www/hugo/Score final
$ sudo systemd-analyze security hugo-mcp
→ Overall exposure level for hugo-mcp.service: 1.7 EXPOSED 😱De 9.6 UNSAFE à 1.7 EXPOSED. Le label “EXPOSED” reste menaçant, mais c’est juste que systemd-analyze considère qu’un service qui écoute sur un port reste exposé par nature. Pour aller plus bas, il faudrait passer en socket activation + User= non-privilégié + isolation namespace complète, ce qui n’a plus de sens pour un service réseau actif.
Ce que systemd-analyze ne dit pas
Le score systemd ne couvre que l’isolation kernel. Il ne dit rien sur :
- La sécurité applicative (auth, validation des inputs, rate limiting…)
- Les secrets dans
/etc/secrets/(lisibles par root via une autre voie) - Les logs qui pourraient leak des tokens
- Les dépendances Python (CVE dans une lib en cache)
C’est juste une couche dans une stratégie defense-in-depth. Mais c’est une couche bon marché qui durcit énormément la posture par défaut.
Recette appliquable à n’importe quel service Python
Si tu as un service Python systemd, voici un drop-in que tu peux adapter en 5 minutes :
sudo systemctl edit mon-service[Service]
# Isolation
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
ProtectClock=true
ProtectProc=invisible
ProcSubset=pid
# Capabilities
CapabilityBoundingSet=
AmbientCapabilities=
# Filesystem
ReadWritePaths=/home/user/data
PrivateMounts=true
# Network
IPAddressDeny=any
IPAddressAllow=127.0.0.0/8
# Ajouter les ranges externes nécessaires
# Syscalls
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
# Bonus
LockPersonality=true
RestrictRealtime=true
RestrictSUIDSGID=true
RemoveIPC=true
RestrictNamespaces=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
MemoryDenyWriteExecute=true
UMask=0077Ensuite :
sudo systemctl daemon-reload
sudo systemctl restart mon-service
sudo systemd-analyze security mon-serviceSi le service refuse de démarrer, regarde journalctl -u mon-service -n 50 — souvent c’est IPAddressDeny=any qui bloque un appel API externe oublié.
Conclusion
Le hardening systemd est une discipline ops sous-cotée. 30 minutes d’investissement pour passer de 9.6 à 1.7 sur un service. C’est presque toujours rentable.
Pour le sprint sécurité MCP en cours, c’est juste la fondation. Restent à faire : rate limiting applicatif, audit logs structurés JSON, validation Pydantic stricte, rotation des tokens, TLS interne. Mais aucune de ces couches n’aurait de sens sur un service en 9.6 UNSAFE.
On commence par durcir l’OS. Le code vient ensuite.