Flux réseau et périmètre de sécurité — architecture cible
Couche 0
🌐 Internet
Trafic non maîtrisé. Source d'attaques. R78, R79.
Hostile HTTPS :443
UFW
R44/R80
Couche 1 — Reverse proxy
⚙ Nginx
Seul service exposé. TLS, headers, rate limit. Compte www-data. Systemd sandbox.
www-data TLS 1.3 HSTS
127.0.0.1
:3000
Couche 2 — Runtime
🐳 Docker
Daemon durci. User namespace. Socket protégée. Réseau bridge isolé.
userns-remap no-new-priv
app-net
bridge
Couche 3 — Application
📦 Container
UID 1001 non-root. Filesystem read-only. cap_drop ALL. Mémoire limitée.
UID 1001 read-only cap_drop ALL
Surface exposée
Nginx seul
App joignable depuis net
Non — 127.0.0.1
Container tourne root
Non — UID 1001
Docker socket exposée
Non — groupe restreint
Mapping recommandations ANSSI pour cette architecture
Reco Niveau Application concrète
R35 M·I Compte www-data dédié nginx. appuser UID 1001 dans le container. Aucun partage de compte nobody.
R62 M Seuls nginx, docker, ssh, auditd actifs. Avahi, CUPS, Bluetooth désactivés.
R63 M·I Nginx sans server_tokens, sans autoindex, sans méthodes inutiles (TRACE).
R64 M·I·R Nginx : CAP_NET_BIND_SERVICE seulement. Container : cap_drop: ALL.
R65 M·I·R Nginx sandboxé par systemd (ProtectSystem, PrivateTmp). App isolée via namespaces Docker.
R66 M·I·R·E daemon.json : userns-remap, no-new-privileges, profil seccomp par défaut, journald.
R72 M·I·R Journaux nginx dans /var/log/nginx/. Journaux Docker via journald. Un journal par service.
R78 M·I·R Nginx dans son processus isolé. App dans un réseau Docker bridge dédié app-net.
R79 M·I Nginx : headers HSTS/CSP/X-Frame, TLS 1.3, rate limiting, logs access/error surveillés.
R80 M Port 3000 lié sur 127.0.0.1 uniquement — invisible depuis Internet.
Étape 1 Audit initial Lynis — mesurer avant de toucher 20 min

Un serveur fraîchement installé avec nginx et Docker obtient environ 50–60. Objectif après durcissement complet : 80+. Notez le score de départ.

Installation et audit
sudo apt update && sudo apt install lynis
sudo lynis audit system
grep "hardening_index" /var/log/lynis-report.dat
M · Socle Socle ANSSI niveau M — sysctl adapté à Docker 35 min

Le socle est identique à tout serveur, avec une adaptation clé : ip_forward = 1 est obligatoire pour Docker. Les services inutiles sont désactivés sans toucher nginx ni docker.

MàJ et services
Mises à jour + désactiver les services inutiles
sudo apt update && sudo apt upgrade -y

# Désactiver — ne PAS toucher nginx et docker
sudo systemctl disable --now avahi-daemon cups bluetooth ModemManager rpcbind

# Vérifier que nginx et docker sont actifs
systemctl is-active nginx docker
sysctl — /etc/sysctl.d/99-hardening.conf
ip_forward = 1 est obligatoire pour Docker. Docker en a besoin pour son NAT interne. UFW protège l'exposition externe. Contrairement au cas serveur générique ANSSI où on le désactive.
/etc/sysctl.d/99-hardening.conf
sudo tee /etc/sysctl.d/99-hardening.conf << 'EOF'
## Mémoire & noyau
kernel.randomize_va_space = 2
kernel.kptr_restrict      = 2
kernel.dmesg_restrict     = 1
kernel.sysrq              = 0
fs.suid_dumpable          = 0
fs.protected_symlinks     = 1
fs.protected_hardlinks    = 1

## Réseau IPv4 — ip_forward=1 requis pour Docker
net.ipv4.ip_forward                       = 1
net.ipv4.conf.all.accept_redirects         = 0
net.ipv4.conf.default.accept_redirects     = 0
net.ipv4.conf.all.send_redirects           = 0
net.ipv4.conf.all.accept_source_route      = 0
net.ipv4.tcp_syncookies                    = 1
net.ipv4.icmp_ignore_bogus_error_responses = 1
net.ipv4.conf.all.log_martians             = 1
net.ipv4.conf.default.rp_filter            = 1

## IPv6 — désactiver si non utilisé
net.ipv6.conf.all.disable_ipv6     = 1
net.ipv6.conf.default.disable_ipv6 = 1
EOF
sudo sysctl --system
M · Pare-feu UFW — règles adaptées Docker, éviter le bypass iptables (R44, R80) 20 min
🚫 Docker bypass UFW par défaut. Publier un port avec -p 3000:3000 l'expose sur Internet même si UFW bloque 3000. La solution : toujours lier sur 127.0.0.1:3000:3000 dans docker-compose.
Configuration UFW complète
sudo apt install ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing

# SSH — restreindre à l'IP admin si possible
sudo ufw allow from <IP_ADMIN> to any port 22

# Nginx — seuls ports publics
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp

sudo ufw enable
sudo ufw status verbose

# Le port 3000 ne doit PAS apparaître
sudo ufw status | grep 3000
Docker · R59 Docker — installation depuis le dépôt officiel 25 min
Installation Docker officielle — Debian/Ubuntu
sudo apt install ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | \
  sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg

echo "deb [arch=$(dpkg --print-architecture) \
  signed-by=/etc/apt/keyrings/docker.gpg] \
  https://download.docker.com/linux/debian \
  $(lsb_release -cs) stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list

sudo apt update && sudo apt install docker-ce docker-ce-cli containerd.io docker-compose-plugin
🚫 Ne jamais monter /var/run/docker.sock dans un container. Cela donne un accès root complet à l'hôte. Un container compromis peut créer d'autres containers privilégiés et s'échapper vers l'hôte.
Restreindre le groupe docker (R35)
# N'ajouter QUE les comptes de déploiement — pas les comptes standard
# ⚠ groupe docker = privilèges quasi-root sur l'hôte
sudo usermod -aG docker deployer

# Vérifier les permissions socket
ls -la /var/run/docker.sock
# Attendu : srw-rw---- root docker
Docker · R66 Docker daemon.json — user namespace, journald, no-new-privileges 25 min
/etc/docker/daemon.json
{
  // User namespace : root dans le container = UID non-privilégié sur l'hôte
  "userns-remap": "default",

  // Bloquer l'escalade de privilèges dans tous les containers
  "no-new-privileges": true,

  // Journalisation via journald (intégré à auditd)
  "log-driver": "journald",
  "log-opts": {
    "tag": "docker/{{.Name}}",
    "max-size": "10m",
    "max-file": "5"
  },

  // Maintenir les containers au redémarrage du daemon
  "live-restore": true
}
Appliquer et vérifier
sudo systemctl restart docker
docker info | grep -E "Userns|Logging"
cat /etc/subuid | grep dockremap
# Attendu : dockremap:100000:65536
🐳 userns-remap "default" : Le UID 0 dans le container est mappé sur UID 100000 de l'hôte (non-privilégié). Si le container s'échappe, le processus n'a aucun droit réel sur l'hôte.
Nginx · R63–R65 Nginx — configuration sécurisée, masquage version, sandboxing systemd 30 min

Nginx est le seul service exposé à Internet. Chaque fonctionnalité inutile est désactivée. Le processus est sandboxé par systemd (R65) sous le compte www-data (R35).

Compte de service et nginx.conf global
Compte www-data + nginx.conf sécurisé
# Désactiver le compte www-data pour les connexions (R34)
sudo usermod -s /bin/false -L www-data

# /etc/nginx/nginx.conf — options globales de sécurité
user www-data;
worker_processes auto;

http {
  # Masquer la version nginx (R63)
  server_tokens off;

  # Anti-Slowloris
  client_body_timeout   10;
  client_header_timeout 10;
  keepalive_timeout     30;

  # Limiter la taille des requêtes
  client_max_body_size  10M;

  # Rate limiting global
  limit_req_zone $binary_remote_addr zone=global:10m rate=30r/s;
}
Sandboxing systemd (R65)
/etc/systemd/system/nginx.service.d/hardening.conf
sudo mkdir -p /etc/systemd/system/nginx.service.d/
sudo tee /etc/systemd/system/nginx.service.d/hardening.conf << 'EOF'
[Service]
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ReadWritePaths=/var/log/nginx /run/nginx.pid /var/cache/nginx
ProtectHome=yes
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID
ProtectProc=invisible
UMask=0027
EOF
sudo systemctl daemon-reload && sudo systemctl restart nginx
systemd-analyze security nginx
Nginx · R79 Nginx — reverse proxy, TLS 1.3, headers de sécurité 25 min
Certificat TLS (Let's Encrypt)
Certbot
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d mondomaine.fr
sudo certbot renew --dry-run
/etc/nginx/sites-available/app.conf
Vhost complet reverse proxy sécurisé
server {
  listen 80;
  server_name mondomaine.fr;
  return 301 https://$host$request_uri;
}

server {
  listen 443 ssl http2;
  server_name mondomaine.fr;

  ssl_certificate     /etc/letsencrypt/live/mondomaine.fr/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/mondomaine.fr/privkey.pem;
  ssl_protocols       TLSv1.2 TLSv1.3;
  ssl_ciphers         ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
  ssl_prefer_server_ciphers off;
  ssl_stapling on;

  # Headers de sécurité (R79)
  add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
  add_header X-Frame-Options            "SAMEORIGIN" always;
  add_header X-Content-Type-Options     "nosniff" always;
  add_header Referrer-Policy            "strict-origin-when-cross-origin" always;
  add_header Content-Security-Policy    "default-src 'self'" always;

  # Rate limiting
  limit_req zone=global burst=50 nodelay;

  # Reverse proxy vers l'app Docker (R78, R80)
  location / {
    proxy_pass       http://127.0.0.1:3000;
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_hide_header X-Powered-By;
    proxy_connect_timeout 5s;
    proxy_read_timeout    30s;
  }

  # Bloquer les fichiers cachés (.env, .git...)
  location ~ /\. { deny all; access_log off; }

  # Logs séparés (R72)
  access_log /var/log/nginx/app_access.log;
  error_log  /var/log/nginx/app_error.log warn;
}
Activer et tester
sudo ln -s /etc/nginx/sites-available/app.conf /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
curl -sI https://mondomaine.fr | grep -E "Strict|X-Frame|Content-Security"
App · R64–R66 Dockerfile sécurisé — non-root, multi-stage, Alpine 25 min

Le container applicatif applique le principe de moindre privilège : utilisateur non-root, filesystem read-only, image minimale Alpine, pas d'outils de build dans l'image finale.

Dockerfile — Multi-stage build (exemple Node.js)
# Étape 1 : Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .

# Étape 2 : Image finale — Alpine, minimale
FROM node:20-alpine

# Créer un utilisateur non-root dédié (R35)
RUN addgroup -g 1001 -S appgroup && \
    adduser  -u 1001 -S appuser -G appgroup

WORKDIR /app

# Copier uniquement les artefacts compilés
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist

# Passer sur l'utilisateur non-root AVANT CMD
USER appuser

EXPOSE 3000

# Healthcheck pour la supervision (R79)
HEALTHCHECK --interval=30s --timeout=3s \
  CMD wget -qO- http://localhost:3000/health || exit 1

CMD ["node", "dist/server.js"]
Vérifications post-build
docker build -t monapp:latest .
docker run --rm monapp:latest whoami   # → appuser
# Scanner les vulnérabilités (trivy)
trivy image monapp:latest
Docker · R65–R78 docker-compose — isolation réseau, read-only, cap_drop ALL, limites 20 min
docker-compose.yml — production sécurisée
services:
  app:
    image: monapp:latest
    container_name: app

    # Port sur 127.0.0.1 uniquement — invisible depuis Internet (R80)
    ports:
      - "127.0.0.1:3000:3000"

    networks:
      - app-net

    # Filesystem read-only (R65)
    read_only: true
    tmpfs:
      - /tmp:size=64m,mode=1777

    # Supprimer TOUTES les capabilities (R64)
    cap_drop:
      - ALL

    security_opt:
      - no-new-privileges:true

    # Limites ressources (anti-DoS)
    deploy:
      resources:
        limits:
          cpus: '0.5'
          memory: 256M

    environment:
      - NODE_ENV=production

    restart: unless-stopped

    logging:
      driver: journald
      options:
        tag: "app-container"

networks:
  app-net:
    driver: bridge
    driver_opts:
      com.docker.network.bridge.name: app-net
Déploiement et vérifications
docker compose up -d
docker exec app whoami                    # → appuser
docker exec app touch /test               # → Read-only file system ✓
docker exec app cat /proc/1/status | grep CapPrm
# → CapPrm: 0000000000000000 (aucune capability)
ss -tlnp | grep 3000                      # → 127.0.0.1:3000 ✓
R · R72–R73 Journalisation — auditd adapté nginx et Docker 25 min
/etc/audit/rules.d/99-nginx-docker.rules
# Modifications config nginx
-w /etc/nginx/ -p wa -k nginx_config

# Modifications config Docker
-w /etc/docker/ -p wa -k docker_config
-w /var/run/docker.sock -p rwxa -k docker_socket
-w /usr/bin/docker -p x -k docker_exec

# Logs auth (tentatives SSH)
-w /var/log/auth.log -p wa -k auth_log

-e 2
Appliquer et consulter
sudo augenrules --load
sudo journalctl -u nginx -f
sudo journalctl -t app-container -f
sudo ausearch -k docker_socket -ts today

Vérification finale

Relancer Lynis, puis vérifier chaque couche par les commandes ci-dessous.

Étape 4 Relancer Lynis — mesurer l'amélioration 20 min
Audit final
sudo lynis audit system
grep "hardening_index" /var/log/lynis-report.dat
# Score attendu : 78–85 (vs 50–60 initialement)
Checklist de vérification par couche
🔥 UFW
Règles actives ufw status verbose
Port 3000 fermé nmap -p 3000 localhost
SSH seul depuis admin ufw status | grep 22
⚙ Nginx
Version masquée curl -sI https://… | grep server
HSTS actif curl -sI https://… | grep Strict
Compte www-data ps aux | grep nginx
TLS 1.2/1.3 seulement nmap --script ssl-enum-ciphers
🐳 Docker
User namespace actif docker info | grep Userns
Socket protégée ls -la /var/run/docker.sock
Journalisation journald journalctl -t docker
📦 Container
Utilisateur non-root docker exec app whoami
Filesystem read-only docker exec app touch /test
Aucune capability … cat /proc/1/status | grep Cap
Mémoire limitée docker stats app --no-stream
🎯 Prochaines étapes (niveau R/E) : Profil AppArmor dédié pour le daemon Docker, Falco pour la détection d'intrusion runtime dans les containers, Trivy dans la CI/CD, rotation des secrets via HashiCorp Vault ou Docker Secrets.