13. Hardening Docker et sécurité des images#
Docker a révolutionné le déploiement applicatif, mais l’adoption massive des conteneurs a élargi la surface d’attaque des infrastructures modernes. Ce chapitre explore les mécanismes d’isolation sous-jacents, les vecteurs d’attaque spécifiques aux conteneurs et les techniques de hardening applicables à chaque niveau de la pile Docker.
Namespaces et cgroups : isolation et ses limites#
Docker repose sur deux primitives du noyau Linux pour isoler les conteneurs : les namespaces et les cgroups.
Les namespaces Linux#
Un namespace est un mécanisme qui partitionne les ressources du noyau de sorte que chaque ensemble de processus ne voit qu’un sous-ensemble de ces ressources. Docker utilise six types de namespaces :
Namespace |
Ressource isolée |
Impact sécurité |
|---|---|---|
|
Arbre des processus |
Les processus du conteneur ne voient pas ceux de l’hôte |
|
Interfaces réseau, ports |
Réseau isolé par défaut |
|
Points de montage |
Système de fichiers indépendant |
|
Hostname, domainname |
Identité réseau propre |
|
File d’attente de messages, sémaphores |
IPC cloisonné |
|
UIDs/GIDs |
Remappage des utilisateurs (optionnel) |
Les cgroups (Control Groups)#
Les cgroups limitent les ressources consommables (CPU, mémoire, I/O, réseau). Ils protègent contre les attaques de type DoS par épuisement de ressources mais n’empêchent pas les escalades de privilèges.
Limites fondamentales de l’isolation#
L’isolation Docker est logicielle, pas matérielle. Tous les conteneurs partagent le même noyau Linux de l’hôte. Cette architecture crée des risques structurels :
Vulnérabilités du noyau : une faille dans le kernel (ex. CVE-2022-0185 dans
fs/legacy_fs.c) peut permettre une évasion de conteneur.Partage des appels système : un conteneur peut appeler n’importe quel syscall non filtré par seccomp.
Absence d’isolation matérielle : contrairement aux VMs (VMware, KVM), Docker ne virtualise pas le matériel.
Distinction conteneur vs machine virtuelle
Une VM exécute son propre noyau sur un hyperviseur. Un conteneur partage le noyau de l’hôte. En cas de compromission du noyau, tous les conteneurs de l’hôte sont exposés. Les déploiements haute sécurité utilisent Kata Containers ou gVisor pour ajouter une isolation noyau.
Surfaces d’attaque Docker#
Supply chain des images#
Les images Docker sont construites par couches, souvent en héritant d’images publiques. Un attaquant peut :
Publier une image malveillante sur Docker Hub avec un nom proche d’une image légitime (typosquatting).
Compromettre un registre privé pour substituer des images.
Injecter du code dans un Dockerfile lors d’un build CI/CD compromis.
Exploiter des dépendances obsolètes dans des images non maintenues.
Évasion de conteneur (Container Escape)#
Les techniques d’évasion les plus documentées :
--privilegedflag : donne accès à tous les devices et capabilities de l’hôte. Un attaquant peut monter le filesystem hôte et modifier/etc/cron.d.Socket Docker exposé (
/var/run/docker.sock) : donne un contrôle total sur le démon Docker, équivalent àrootsur l’hôte.Montages sensibles : monter
/ou/etcde l’hôte dans le conteneur.Capabilities dangereuses :
CAP_SYS_ADMIN,CAP_NET_ADMIN,CAP_SYS_PTRACE.
Risques liés au socket Docker#
Le socket Docker est une backdoor root
Monter /var/run/docker.sock dans un conteneur revient à donner les privilèges root sur l’hôte. N’importe quel conteneur avec accès au socket peut lancer un conteneur --privileged avec le filesystem hôte monté.
Hardening runtime#
Principe 1 : Utilisateur non-root#
Par défaut, les processus dans un conteneur s’exécutent en root (UID 0). La directive USER dans le Dockerfile y remédie :
# Mauvaise pratique — utilisateur root implicite
FROM node:20
COPY app/ /app
CMD ["node", "/app/server.js"]
# Bonne pratique — utilisateur dédié
FROM node:20-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --chown=appuser:appgroup app/ /app
USER appuser
CMD ["node", "/app/server.js"]
Principe 2 : Capabilities Linux#
Linux divise les privilèges root en unités discrètes appelées capabilities. La stratégie recommandée est drop ALL, add only needed :
# Dans docker-compose.yml ou docker run
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE # Autoriser les ports < 1024
- CHOWN # Changer les propriétaires de fichiers
Principe 3 : Profils seccomp#
Seccomp (Secure Computing Mode) filtre les appels système autorisés. Docker applique un profil par défaut bloquant ~44 syscalls dangereux. Un profil personnalisé peut être plus restrictif :
{
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [
{
"names": ["read", "write", "open", "close", "stat", "fstat",
"mmap", "mprotect", "munmap", "brk", "rt_sigaction",
"rt_sigprocmask", "exit", "futex", "clone", "execve"],
"action": "SCMP_ACT_ALLOW"
}
]
}
Principe 4 : Profil AppArmor#
AppArmor confine les programmes en définissant quelles ressources ils peuvent accéder :
#include <tunables/global>
profile docker-app flags=(attach_disconnected,mediate_deleted) {
#include <abstractions/base>
network inet tcp,
network inet udp,
deny @{PROC}/sys/kernel/shm* wklx,
deny mount,
deny /sys/** wklx,
/app/** r,
/tmp/** rw,
}
Images sécurisées : multi-stage builds et distroless#
Multi-stage builds#
Les multi-stage builds séparent l’environnement de compilation de l’environnement d’exécution, réduisant drastiquement la surface d’attaque :
# Stage 1 : Build (contient compilateur, outils de dev)
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /bin/app ./cmd/app
# Stage 2 : Runtime (image minimale, pas de shell, pas de compilateur)
FROM gcr.io/distroless/static-debian12
COPY --from=builder /bin/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
Images distroless et scratch#
Distroless (Google) : images sans gestionnaire de paquets, sans shell, sans outils de débogage. Contient uniquement les bibliothèques d’exécution nécessaires.
scratch : image vide absolue, utilisée pour les binaires statiques Go ou Rust.
Avantages des images minimales
Moins de paquets = moins de CVE potentielles. Une image scratch avec un binaire Go statique n’a littéralement aucune dépendance système pouvant être vulnérable. En contrepartie, le débogage en production devient plus difficile — prévoyez des outils de diagnostic externes (sidecar containers, ephemeral debug containers).
Scan de vulnérabilités : Trivy et Grype#
Trivy (Aqua Security)#
Trivy est un scanner de vulnérabilités polyvalent couvrant images, systèmes de fichiers, dépôts Git et configurations IaC :
# Scanner une image locale
trivy image python:3.12-slim
# Résultat JSON pour intégration CI
trivy image --format json --output results.json myapp:latest
# Scanner uniquement les CVE critiques et élevées
trivy image --severity HIGH,CRITICAL myapp:latest
# Générer un SBOM
trivy image --format spdx-json --output sbom.json myapp:latest
Grype (Anchore)#
Grype se concentre sur la détection de vulnérabilités dans les SBOM et images :
# Analyser une image
grype myapp:latest
# Analyser un SBOM existant
grype sbom:./sbom.json
# Politique : échouer si CVSS >= 7.0
grype myapp:latest --fail-on high
Intégration CI/CD#
# GitHub Actions — scan de sécurité intégré
- name: Scan image avec Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: HIGH,CRITICAL
exit-code: 1 # Bloquer le pipeline si vulnérabilité trouvée
Rootless Docker et Podman#
Docker rootless#
Docker rootless exécute le démon Docker sans privilèges root, limitant l’impact d’une compromission :
# Installation Docker rootless
dockerd-rootless-setuptool.sh install
# Utilisation identique
docker run --rm hello-world
Podman : sans démon#
Podman (Red Hat) est une alternative à Docker qui ne nécessite pas de démon centralisé. Chaque commande podman est un processus distinct s’exécutant sous l’utilisateur courant :
# Aucun démon requis
podman run --rm -it python:3.12 python3
# Compatible avec les Dockerfiles existants
podman build -t myapp .
# Pods natifs (sans Kubernetes)
podman pod create --name mypod
BuildKit et secrets#
BuildKit (backend de build Docker moderne) permet de passer des secrets sans les stocker dans les couches de l’image :
# Utilisation de secrets BuildKit — le secret n'est jamais dans l'image
FROM python:3.12-slim
RUN --mount=type=secret,id=pip_token \
PIP_INDEX_URL=$(cat /run/secrets/pip_token) pip install mypackage
Docker Content Trust et signatures Cosign#
Docker Content Trust (DCT)#
DCT utilise The Update Framework (TUF) pour signer et vérifier les images :
# Activer la vérification des signatures
export DOCKER_CONTENT_TRUST=1
docker pull myregistry.io/myapp:latest # Échoue si non signé
# Signer une image
docker trust sign myregistry.io/myapp:v1.0.0
Cosign (Sigstore)#
Cosign permet la signature sans infrastructure de clés traditionnelle grâce à la signature keyless via OIDC :
# Signature keyless (utilise l'identité GitHub Actions OIDC)
cosign sign --yes ghcr.io/myorg/myapp:v1.0.0
# Vérification
cosign verify \
--certificate-identity="https://github.com/myorg/myrepo/.github/workflows/release.yml@refs/heads/main" \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
ghcr.io/myorg/myapp:v1.0.0
Transparence des signatures avec Rekor
Cosign enregistre automatiquement les signatures dans Rekor, un journal de transparence immuable (similaire aux Certificate Transparency logs pour TLS). Cela permet d’auditer rétrospectivement toutes les signatures d’une image.
Visualisations#
Comparaison des tailles d’images#
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
images = [
"ubuntu:22.04\n(full)",
"python:3.12\n(standard)",
"python:3.12-slim",
"python:3.12-alpine",
"distroless\n(python3)",
"scratch\n(binaire Go)"
]
tailles_mo = [77, 1020, 130, 52, 55, 8]
couleurs = ["#e74c3c", "#e67e22", "#f1c40f", "#2ecc71", "#27ae60", "#1abc9c"]
fig, ax = plt.subplots(figsize=(10, 5))
bars = ax.barh(images, tailles_mo, color=couleurs, edgecolor="white", linewidth=0.8)
for bar, val in zip(bars, tailles_mo):
ax.text(bar.get_width() + 10, bar.get_y() + bar.get_height() / 2,
f"{val} Mo", va="center", fontsize=10, color="#2c3e50")
ax.set_xlabel("Taille compressée (Mo)", fontsize=11)
ax.set_title("Taille des images Docker selon la base choisie\n(données synthétiques représentatives)", fontsize=13)
ax.set_xlim(0, 1200)
ax.invert_yaxis()
sns.despine(left=True, bottom=False)
plt.savefig("docker_image_sizes.png", dpi=100, bbox_inches="tight")
plt.show()
Heatmap des risques Docker#
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
vecteurs = ["Supply chain\n(image malveillante)", "Runtime escape\n(--privileged)", "Socket Docker\nexposé", "Réseau\n(exposition ports)", "Secrets dans\nl'image"]
dimensions = ["Probabilité\n(1-5)", "Impact\n(1-5)", "Score risque\n(P×I)"]
data_prob = [4, 3, 2, 3, 4]
data_impact = [4, 5, 5, 3, 4]
data_score = [p * i for p, i in zip(data_prob, data_impact)]
matrix = np.array([data_prob, data_impact, data_score], dtype=float)
df_heat = pd.DataFrame(matrix, index=dimensions, columns=vecteurs)
fig, ax = plt.subplots(figsize=(11, 4))
sns.heatmap(df_heat, annot=True, fmt=".0f", cmap="YlOrRd",
linewidths=0.5, linecolor="white",
vmin=1, vmax=25, ax=ax,
cbar_kws={"label": "Score"})
ax.set_title("Heatmap des risques Docker par vecteur d'attaque", fontsize=13, pad=15)
ax.set_xticklabels(ax.get_xticklabels(), rotation=0, ha="center")
ax.set_yticklabels(ax.get_yticklabels(), rotation=0)
plt.savefig("docker_risk_heatmap.png", dpi=100, bbox_inches="tight")
plt.show()
Scoring sécurité d’un Dockerfile#
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
regles = {
"Utilisateur non-root (USER)": (True, 2),
"Multi-stage build": (True, 2),
"COPY au lieu de ADD": (True, 1),
"Pas de secrets en ARG/ENV": (False, 3),
"Image de base minimale": (True, 2),
"Version épinglée (tag digest)": (False, 2),
"Healthcheck défini": (True, 1),
".dockerignore présent": (True, 1),
"Pas de curl|bash pipe install": (True, 2),
"Pas de --privileged": (True, 3),
}
noms = list(regles.keys())
passes = [v[0] for v in regles.values()]
poids = [v[1] for v in regles.values()]
score_obtenu = sum(p for ok, p in zip(passes, poids) if ok)
score_max = sum(poids)
pourcentage = score_obtenu / score_max * 100
couleurs_barres = ["#2ecc71" if ok else "#e74c3c" for ok in passes]
labels_barres = ["✓" if ok else "✗" for ok in passes]
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 5), gridspec_kw={"width_ratios": [3, 1]})
bars = ax1.barh(noms, poids, color=couleurs_barres, edgecolor="white", linewidth=0.8)
for bar, label in zip(bars, labels_barres):
ax1.text(bar.get_width() + 0.05, bar.get_y() + bar.get_height() / 2,
label, va="center", fontsize=13, fontweight="bold",
color="#2c3e50")
ax1.set_xlabel("Poids de la règle", fontsize=10)
ax1.set_title("Règles de sécurité Dockerfile", fontsize=12)
ax1.set_xlim(0, 4.5)
ax1.invert_yaxis()
sns.despine(ax=ax1, left=True)
niveau = "Bon" if pourcentage >= 75 else ("Moyen" if pourcentage >= 50 else "Insuffisant")
couleur_score = "#2ecc71" if pourcentage >= 75 else ("#f39c12" if pourcentage >= 50 else "#e74c3c")
ax2.pie([score_obtenu, score_max - score_obtenu],
colors=[couleur_score, "#ecf0f1"],
startangle=90,
wedgeprops={"width": 0.4, "edgecolor": "white"})
ax2.text(0, 0, f"{pourcentage:.0f}%", ha="center", va="center",
fontsize=20, fontweight="bold", color=couleur_score)
ax2.text(0, -0.6, f"Niveau : {niveau}", ha="center", va="center",
fontsize=11, color="#2c3e50")
ax2.set_title(f"Score global\n({score_obtenu}/{score_max} pts)", fontsize=12)
plt.suptitle("Analyse de sécurité d'un Dockerfile exemple", fontsize=13, y=1.02)
plt.savefig("dockerfile_security_score.png", dpi=100, bbox_inches="tight")
plt.show()
print(f"\nRécapitulatif : {score_obtenu}/{score_max} points ({pourcentage:.0f}%) — Niveau : {niveau}")
Récapitulatif : 14/19 points (74%) — Niveau : Moyen
Résumé#
Isolation partielle : Docker repose sur les namespaces et cgroups Linux, mais partage le noyau hôte. Une vulnérabilité noyau compromet tous les conteneurs.
Surfaces d’attaque majeures : supply chain des images (images malveillantes, typosquatting), évasion de conteneur via
--privilegedou socket Docker exposé, secrets dans les couches d’image.Hardening runtime : exécuter en utilisateur non-root (
USERdans Dockerfile), appliquer la politiquedrop ALL + add minimalpour les capabilities Linux, utiliser des profils seccomp et AppArmor personnalisés.Images minimales : les multi-stage builds séparent compilation et exécution ; les images distroless et scratch réduisent la surface d’attaque à son minimum absolu.
Scan de vulnérabilités : Trivy et Grype s’intègrent dans les pipelines CI/CD pour bloquer les images avec des CVE critiques avant le déploiement.
Alternatives sécurisées : Docker rootless et Podman (sans démon) limitent l’impact d’une compromission. BuildKit permet de passer des secrets sans les persister dans les couches.
Chaîne de confiance : Docker Content Trust et Cosign (Sigstore) permettent de signer et vérifier l’intégrité des images. Rekor assure la transparence immuable des signatures.