Docker pour les devs - 23 - Sécurité des conteneurs

Utilisateur non-root, images minimales, scan de vulnérabilités, filesystem read-only et gestion des secrets.

  1. 01 Docker pour les devs - 00 - Pourquoi Docker change tout
  2. 02 Docker pour les devs - 01 - Containers vs VMs
  3. 03 Docker pour les devs - 02 - L'architecture de Docker
  4. 04 Docker pour les devs - 03 - Docker Desktop, Engine et alternatives
  5. 05 Docker pour les devs - 04 - Écrire un Dockerfile
  6. 06 Docker pour les devs - 05 - Layers et cache
  7. 07 Docker pour les devs - 06 - Le .dockerignore
  8. 08 Docker pour les devs - 07 - Multi-stage builds
  9. 09 Docker pour les devs - 08 - Choisir son image de base
  10. 10 Docker pour les devs - 09 - Docker Compose, les bases
  11. 11 Docker pour les devs - 10 - Docker Compose avance
  12. 12 Docker pour les devs - 11 - Networking Docker, les bases
  13. 13 Docker pour les devs - 12 - Networking Docker avance
  14. 14 Docker pour les devs - 13 - Volumes et persistance
  15. 15 Docker pour les devs - 14 - Variables d'environnement et secrets
  16. 16 Docker pour les devs - 15 - Permissions et utilisateurs
  17. 17 Docker pour les devs - 16 - Docker et monorepo
  18. 18 Docker pour les devs - 17 - Dev vs Prod
  19. 19 Docker pour les devs - 18 - ENTRYPOINT, CMD et scripts d'initialisation
  20. 20 Docker pour les devs - 19 - Debugger ses conteneurs
  21. 21 Docker pour les devs - 20 - Bases de donnees dans Docker
  22. 22 Docker pour les devs - 21 - Sauvegardes et restauration
  23. 23 Docker pour les devs - 22 - Conteneuriser un frontend
  24. 24 Docker pour les devs - 23 - Sécurité des conteneurs
  25. 25 Docker pour les devs - 24 - Optimisation des images
  26. 26 Docker pour les devs - 25 - Builds multi-platform
  27. 27 Docker pour les devs - 26 - Limiter les ressources de tes conteneurs
  28. 28 Docker pour les devs - 27 - Gerer les logs comme un adulte
  29. 29 Docker pour les devs - 28 - Healthchecks et restart policies
  30. 30 Docker pour les devs - 29 - Nettoyer Docker avant qu'il mange ton disque
  31. 31 Docker pour les devs - 30 - Registries et stratégie de tags
  32. 32 Docker pour les devs - 31 - Docker en CI/CD
  33. 33 Docker pour les devs - 32 - Au-dela de Compose
  34. 34 Docker pour les devs - 33 - Glossaire Docker de A a Z

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é

  • USER dans le Dockerfile : jamais root en prod.
  • Alpine ou distroless pour minimiser la surface d'attaque.
  • trivy image --severity HIGH,CRITICAL dans la CI pour bloquer les CVE.
  • read_only: true + tmpfs pour verrouiller le filesystem.
  • no-new-privileges bloque 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

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.