---
title: "Hardening systemd : passer un service Python de 9.6 à 1.7"
description: "Comment durcir un service Python systemd et passer le score systemd-analyze de 9.6 (UNSAFE) à 1.7 (EXPOSED) sans casser les fonctionnalités. Trade-offs IPAddressDeny + IPAddressAllow, piège UMask=0077, recette réutilisable."
url: "https://www.arleo.eu/posts/hardening-systemd-mcp/"
language: "fr"
datePublished: "2026-05-09T12:40:00+02:00"
dateModified: "2026-05-15T23:51:25+02:00"
tags: ["systemd","hardening","security","linux","infrastructure"]
categories: ["infrastructure"]
contentSignal: "ai-train=no, search=yes, ai-input=yes"
---

# Hardening systemd : passer un service Python de 9.6 à 1.7
Comment durcir un service Python systemd et passer le score systemd-analyze de 9.6 (UNSAFE) à 1.7 (EXPOSED) sans casser les fonctionnalités. Trade-offs IPAddressDeny + IPAddressAllow, piège UMask=0077, recette réutilisable.



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

```bash
$ 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é.

<!--more-->
<!-- mermaid-source:Z3JhcGggTFIKICAgIEFbImh1Z28tbWNwLnNlcnZpY2U8YnIvPlNjb3JlJm5ic3A7OiZuYnNwOzkuNiBVTlNBRkUiXSAtLT4gQlsiUHJvdGVjdFN5c3RlbT1zdHJpY3Q8YnIvPlByaXZhdGVUbXAgwrcgUHJpdmF0ZURldmljZXMiXQogICAgQSAtLT4gQ1siQ2FwYWJpbGl0eUJvdW5kaW5nU2V0PeKIhTxici8+Tm9OZXdQcml2aWxlZ2VzPXRydWUiXQogICAgQSAtLT4gRFsiSVBBZGRyZXNzRGVueT1hbnk8YnIvPisgd2hpdGVsaXN0IENGIl0KICAgIEEgLS0+IEVbIlN5c3RlbUNhbGxGaWx0ZXI9QHN5c3RlbS1zZXJ2aWNlPGJyLz5NZW1vcnlEZW55V3JpdGVFeGVjdXRlPXRydWUiXQogICAgQiAmIEMgJiBEICYgRSAtLT4gRlsiU2NvcmUmbmJzcDs6Jm5ic3A7MS43IEVYUE9TRUQiXQogICAgc3R5bGUgQSBmaWxsOiM5MjJiMjEsY29sb3I6I2ZmZgogICAgc3R5bGUgRiBmaWxsOiMxZTg0NDksY29sb3I6I2ZmZg== -->
{{< mermaid-svg hash="3559cfe3" >}}

## Les directives qui comptent vraiment

### 1. Isolation système

```ini
[Service]
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
ProtectProc=invisible
ProcSubset=pid
```

Impact : 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

```ini
CapabilityBoundingSet=
AmbientCapabilities=
NoNewPrivileges=true
```

Le 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

```ini
ReadWritePaths=/home/jm/hugo-mcp /home/jm/hugo-site
ReadOnlyPaths=/etc
PrivateMounts=true
```

Le 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

```ini
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::/32
```

C'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

```ini
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

```ini
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/world
```

`MemoryDenyWriteExecute=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.html
```

nginx 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 :

```bash
hugo --minify
chmod -R o+rX public/   # ← essentiel après UMask=0077
rsync -a public/ /var/www/hugo/
```

## Score final

```bash
$ 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 :

```bash
sudo systemctl edit mon-service
```

```ini
[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=0077
```

Ensuite :

```bash
sudo systemctl daemon-reload
sudo systemctl restart mon-service
sudo systemd-analyze security mon-service
```

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



## Tags

- systemd
- hardening
- security
- linux
- infrastructure

## Categories

- infrastructure
