23 - Sécurité des conteneurs
Ce que tu vas apprendre
- Lancer tes conteneurs en tant qu'utilisateur non-root
- Choisir des images minimales (Alpine, distroless)
- Scanner tes images avec docker scout et trivy
- Verrouiller le filesystem en read-only
- Gerer les secrets sans les mettre dans l'image
- Les bases de la sécurité de la supply chain
Prerequisites
Avoir suivi l'article sur le frontend. Savoir écrire un Dockerfile multi-stage. Comprendre les bases du système de permissions Linux.
La sécurité Docker, c'est un sujet ou la plupart des devs se disent "ca ira" jusqu'au jour ou ca n'ira pas. J'ai vu des images de prod avec le mot de passe de la base en clair dans une variable ENV, des conteneurs root avec un shell ouvert, et des images basees sur ubuntu:latest avec 300 CVE. Voici comment éviter ca.
Utilisateur non-root
Par défaut, les processus dans un conteneur tournent en root. Si quelqu'un exploite une faille dans ton app, il est root dans le conteneur. Avec certaines configurations, il peut s'echapper vers l'hote.
dockerfileFROM node:20-alpine
# Creer un utilisateur dedie
RUN addgroup --system --gid 1001 appgroup && \
adduser --system --uid 1001 appuser
WORKDIR /app
COPY --chown=appuser:appgroup . .
RUN npm ci --omit=dev
# Passer en non-root
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]
Le --chown sur le COPY donne les droits a l'utilisateur sur les fichiers. Sans ca, les fichiers appartiennent a root et ton app ne peut pas les lire.
Certaines images officielles supportent deja un utilisateur non-root. Node.js a un user node (uid 1000) :
dockerfileFROM node:20-alpine
WORKDIR /app
COPY --chown=node:node . .
RUN npm ci --omit=dev
USER node
CMD ["node", "server.js"]
Sur paltemps.fr, tous mes conteneurs tournent en non-root. C'est un reflexe a prendre.
Images minimales
Moins il y a de choses dans l'image, moins il y a de surface d'attaque :
| Image | Taille | Shell | Package manager | CVE potentiels |
|---|---|---|---|---|
| ubuntu:22.04 | 77 Mo | oui | apt | beaucoup |
| node:20 | 1.1 Go | oui | apt | énormément |
| node:20-alpine | 130 Mo | sh | apk | peu |
| node:20-slim | 220 Mo | bash | apt (minimal) | moyen |
| gcr.io/distroless/nodejs20 | 130 Mo | non | non | tres peu |
Les images distroless de Google ne contiennent que le runtime. Pas de shell, pas de package manager, pas de curl. Tu ne peux pas faire docker exec -it dedans. C'est intentionnel : un attaquant qui entre ne peut rien faire.
dockerfileFROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["dist/server.js"]
Le compromis : le debug est plus difficile. Je les utilise en prod et je garde Alpine pour le dev et la staging.
Scanner ses images
Les scanners de vulnérabilités detectent les CVE connues dans les packages de ton image.
bash# Docker Scout (integre a Docker Desktop)
docker scout cves mon-image:latest
docker scout recommendations mon-image:latest
# Trivy (open source, mon prefere)
trivy image mon-image:latest
# Scan rapide, severite haute et critique uniquement
trivy image --severity HIGH,CRITICAL mon-image:latest
Trivy est rapide et donne des résultats clairs. Je le fais tourner dans la CI : si une vulnérabilité critique apparaît, le build echoue.
bash# Dans un pipeline CI
trivy image --exit-code 1 --severity CRITICAL mon-image:latest
Le --exit-code 1 fait échouer la commande si des vulnérabilités critiques sont trouvees. C'est radical mais efficace.
Filesystem read-only
Un conteneur n'a généralement pas besoin d'écrire sur son filesystem. Verrouille-le :
yamlservices:
app:
image: mon-app:latest
read_only: true
tmpfs:
- /tmp
- /app/logs
Le read_only: true empeche toute écriture sur le filesystem du conteneur. Les tmpfs sont des filesystems en mémoire pour les dossiers ou l'app doit écrire (fichiers temporaires, logs).
En ligne de commande :
bashdocker run --read-only --tmpfs /tmp mon-app:latest
Si ton app plante avec "read-only filesystem", ajoute un tmpfs pour le dossier concerne. Ca te force a identifier exactement ou ton app écrit, ce qui est une bonne chose.
no-new-privileges
Cette option empeche un processus de gagner des privileges supplementaires (via setuid, capabilities, etc.) :
yamlservices:
app:
image: mon-app:latest
security_opt:
- no-new-privileges:true
bashdocker run --security-opt=no-new-privileges:true mon-app:latest
C'est une ligne a ajouter partout. Ca ne casse quasi jamais rien et ca bloque une categorie entière d'escalade de privileges.
Gestion des secrets
Les secrets (mots de passe, clés API) ne doivent jamais etre dans l'image. Ni dans le Dockerfile, ni dans les variables ENV buildees dans l'image.
dockerfile# MAUVAIS : le secret est dans l'image, visible avec docker history
ENV DATABASE_PASSWORD=monsecret
# MAUVAIS : le secret est dans un layer, meme si tu le supprimes apres
COPY .env /app/.env
RUN source /app/.env && npm run build
RUN rm /app/.env
Le rm ne supprime pas le secret. Il est toujours dans le layer précédent. N'importe qui avec docker history ou docker save peut le retrouver.
Les bonnes approches :
yaml# 1. Variables d'environnement au runtime (pas au build)
services:
app:
image: mon-app:latest
environment:
DATABASE_PASSWORD: ${DATABASE_PASSWORD}
# 2. Docker secrets (Swarm ou Compose v2)
services:
app:
image: mon-app:latest
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txt
Avec Docker secrets, le fichier est monte dans /run/secrets/db_password a l'intérieur du conteneur. Ton app le lit comme un fichier. C'est plus sécurisé que les variables d'environnement parce que les env vars sont visibles dans docker inspect.
Pour les builds qui ont besoin de secrets (clés SSH pour cloner un repo prive, par exemple), BuildKit a une option dédiée :
dockerfile# syntax=docker/dockerfile:1
FROM node:20-alpine
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci
bashdocker build --secret id=npmrc,src=.npmrc .
Le secret est disponible pendant le build mais ne reste pas dans l'image. On en parlera plus en détail dans l'article sur l'optimisation.
Signer ses images
Pour s'assurer que l'image en production est bien celle que tu as buildee :
bash# Docker Content Trust
export DOCKER_CONTENT_TRUST=1
docker push mon-registry/mon-app:v1
# Cosign (sigstore, plus moderne)
cosign sign mon-registry/mon-app:v1
cosign verify mon-registry/mon-app:v1
Sur un projet solo, c'est probablement overkill. Sur un projet d'équipe avec un pipeline CI/CD, c'est une bonne pratique.
Résumé
USERdans le Dockerfile : jamais root en prod.- Alpine ou distroless pour minimiser la surface d'attaque.
trivy image --severity HIGH,CRITICALdans la CI pour bloquer les CVE.read_only: true+ tmpfs pour verrouiller le filesystem.no-new-privilegesbloque l'escalade de privileges.- Secrets au runtime (env vars ou Docker secrets), jamais dans l'image.
Article précédent : Docker 22 - Frontend Article suivant : Docker 24 - Optimisation