DevSecOps

Sécurité des images de conteneurs en 2026 : guide complet

Sécurité images conteneurs 2026 : Dockerfile durci, distroless / Chainguard, BuildKit secrets, signature Cosign, registries, runtime hardening, NIST SP 800-190.

Naim Aouaichia
13 min de lecture
  • Container Security
  • Docker
  • Kubernetes
  • Distroless
  • Chainguard
  • Cosign
  • Hardening
  • DevSecOps

Sécuriser une image de conteneur en 2026 nécessite quatre couches coordonnées : construction durcie (Dockerfile minimal, base image distroless ou Chainguard, gestion BuildKit des secrets, multi-stage build), distribution sécurisée (registry privé avec scan continu, signature Cosign keyless, attestations SLSA), runtime hardening (utilisateur non-root, capabilities drop, seccomp, AppArmor, read-only rootfs) et admission policies (vérification de signature, refus des images non conformes au déploiement). Le scan de vulnérabilités (Trivy, Grype, Docker Scout) reste essentiel mais n'est qu'une des quatre couches. Les référentiels qui structurent ce périmètre en 2026 sont NIST SP 800-190 (Application Container Security Guide), CIS Docker Benchmark v1.6, OWASP Container Security Verification Standard. Les CVE récentes runc Leaky Vessels (CVE-2024-21626, CVE-2024-23651, CVE-2024-23652, février 2024) ont rappelé que la sécurité conteneur ne se résume pas au scan applicatif. Cet article détaille la construction d'images sécurisées (Dockerfile patterns, base images comparées), la gestion des secrets au build, la signature et la provenance, le durcissement runtime, les outils 2026 (BuildKit, Cosign, Hadolint, Dive, Sigstore Policy Controller) et un Dockerfile production de référence.

La menace : pourquoi les images conteneurs sont une cible

Trois caractéristiques font des images conteneurs une cible privilégiée en 2026.

Multiplication des images dans une chaîne de production. Une application moderne déploie typiquement 10 à 100 images conteneurs distinctes (services applicatifs, sidecars, init containers, jobs batch). Chaque image est une surface d'attaque potentielle, et une seule image compromise peut servir de pivot pour latéraliser dans le cluster.

Hérédité des CVE depuis les bases. Une image construite sur ubuntu:22.04 hérite immédiatement de toutes les CVE des paquets système installés. Une image qui n'est pas reconstruite régulièrement accumule des CVE critiques en quelques semaines. Les bases les plus minces (distroless, Chainguard) réduisent drastiquement cette accumulation.

Container escape : la menace asymétrique. Une vulnérabilité dans le runtime container (runc, containerd, Docker, Kubernetes kubelet) permet de s'évader du container vers l'hôte. CVE-2024-21626 (Leaky Vessels, février 2024) a démontré qu'une CVE runc unique pouvait potentiellement compromettre des milliers de clusters Kubernetes. Le hardening container réduit la probabilité d'exploitation de ces CVE.

Construction d'images sécurisées : le Dockerfile

La majorité des vulnérabilités évitables sont introduites lors de la rédaction du Dockerfile. Six règles cumulatives produisent une image durcie.

Multi-stage build

Séparer la phase de build (compilation, installation de dépendances de build) de la phase finale qui ne contient que le runtime nécessaire. Permet de retirer les outils de build (gcc, npm dev dependencies, debug symbols) de l'image finale.

# Multi-stage : build complet vs runtime minimal
FROM golang:1.23-bookworm AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags='-s -w' -o /app/server ./cmd/server
 
# Stage final : distroless avec seulement le binaire
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/server /server
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]

Différence de surface d'attaque : l'image finale Go avec distroless static fait typiquement 5 à 15 MB et ne contient ni shell, ni libc, ni package manager. Le binaire et les certificats CA, c'est tout.

Base image minimale

Trois familles par ordre de durcissement croissant.

FamilleExempleTaille typiqueSurfaceDébuggabilité
Full distributionubuntu:22.04, debian:bookworm70 à 120 MBLargeExcellente
Slim / alpinepython:3.12-slim, node:22-alpine30 à 80 MBMoyenneBonne
Distroless (Google)gcr.io/distroless/python3-debian1220 à 60 MBFaibleDifficile
Wolfi / Chainguardcgr.dev/chainguard/python15 à 50 MBTrès faible (zéro CVE)Difficile
Static binarygcr.io/distroless/static, scratch1 à 15 MBMinimaleImpossible sans shell ext.

Choix recommandé en 2026 : Chainguard Images pour les workloads production, fondées sur Wolfi (distribution née pour conteneurs, mises à jour quotidiennes, signées Cosign par défaut, posture zéro CVE connue). Distroless reste une excellente option gratuite. Alpine garde une niche sur les workloads développement et les images simples.

Utilisateur non-root

Tout container production doit s'exécuter en utilisateur non-privilégié.

# Mauvais : par défaut, USER root
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y nginx
CMD ["nginx", "-g", "daemon off;"]
# Container exécuté en root, capabilities root sur l'image
 
# Bon : utilisateur dédié non-root
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y nginx \
 && groupadd -g 10001 app \
 && useradd -u 10001 -g app -s /sbin/nologin -M app \
 && chown -R app:app /var/cache/nginx /var/log/nginx
USER 10001:10001
CMD ["nginx", "-g", "daemon off;"]

Si l'application doit lier un port inférieur à 1024 (typiquement 80 ou 443), remplacer par 8080/8443 ou utiliser setcap cap_net_bind_service=+ep sur le binaire au build (ne nécessite pas root au runtime).

Capabilities et privileges

Compléter au runtime via Kubernetes ou Docker run :

# Kubernetes : pod sécurisé
apiVersion: v1
kind: Pod
metadata:
  name: secure-app
spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 10001
    runAsGroup: 10001
    fsGroup: 10001
    seccompProfile:
      type: RuntimeDefault
  containers:
  - name: app
    image: my-registry.example.test/app@sha256:abc123...
    securityContext:
      allowPrivilegeEscalation: false
      privileged: false
      readOnlyRootFilesystem: true
      capabilities:
        drop: ["ALL"]
    resources:
      limits:
        memory: "256Mi"
        cpu: "500m"
      requests:
        memory: "128Mi"
        cpu: "100m"
    volumeMounts:
    - name: tmp
      mountPath: /tmp
  volumes:
  - name: tmp
    emptyDir: {}

Configuration équivalente avec Docker run :

docker run \
  --read-only \
  --tmpfs /tmp \
  --user 10001:10001 \
  --cap-drop ALL \
  --security-opt no-new-privileges \
  --security-opt seccomp=default.json \
  my-registry.example.test/app@sha256:abc123...

.dockerignore strict

Le contexte de build envoyé à Docker daemon inclut tout le répertoire courant par défaut. Sans .dockerignore strict, des fichiers sensibles (.env, .git/, clés SSH) finissent involontairement dans l'image.

# .dockerignore type pour application Node.js
.git
.github
node_modules
npm-debug.log
.env*
*.log
.vscode
.idea
.DS_Store
coverage/
test/
docs/
*.md
Dockerfile*
docker-compose*
.terraform
*.tfstate*
secrets/

Pinning par digest SHA256

Référencer les bases par digest SHA256 plutôt que par tag mutable.

# Mauvais : tag mutable
FROM python:3.12-slim
 
# Bon : digest immuable + commentaire de version
FROM python:3.12-slim@sha256:7c8b41e8f8a9b6c5d3e2a1f4b7c8e9d0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
# python:3.12-slim au 2026-04-15

Renovate ou Dependabot mettent à jour automatiquement ces digests.

Gestion des secrets au build

Pattern dangereux fréquent : passer des secrets via ARG ou COPY qui les persiste dans une layer permanente, visible via docker history.

# DANGEREUX : secret persiste dans l'image
FROM debian:bookworm-slim
ARG NPM_TOKEN
ENV NPM_TOKEN=$NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc \
 && npm install
# Le NPM_TOKEN apparaît dans docker history et dans toutes les layers ENV

Solution 2026 : BuildKit secret mounts.

# syntax=docker/dockerfile:1.7
FROM debian:bookworm-slim
 
# Le secret est monté en mémoire pendant l'exécution du RUN seulement
RUN --mount=type=secret,id=npm_token,target=/root/.npmrc \
    cat /root/.npmrc \
 && npm install
# Aucune trace du secret dans les layers de l'image finale

Build avec BuildKit secret :

# Activer BuildKit (par défaut depuis Docker 23.0)
export DOCKER_BUILDKIT=1
 
# Build avec secret monté
docker build \
  --secret id=npm_token,src=$HOME/.secrets/npm-token.txt \
  -t my-app:latest .

Dans GitHub Actions :

- uses: docker/build-push-action@v6
  with:
    secrets: |
      "npm_token=${{ secrets.NPM_TOKEN }}"

Signature et provenance avec Sigstore

La signature d'image avec Cosign est devenue standard en 2026. Avec OIDC keyless (GitHub Actions, GitLab CI), aucune clé à gérer.

# GitHub Actions : signature keyless via OIDC GitHub
permissions:
  contents: read
  id-token: write     # requis pour OIDC keyless
  packages: write
 
steps:
  - uses: sigstore/cosign-installer@v3
 
  - name: Build et push image
    id: build
    uses: docker/build-push-action@v6
    with:
      push: true
      tags: ghcr.io/my-org/my-app:${{ github.sha }}
 
  - name: Sign image (keyless)
    run: |
      cosign sign --yes \
        ghcr.io/my-org/my-app@${{ steps.build.outputs.digest }}
 
  - name: Generate SBOM
    uses: anchore/sbom-action@v0
    with:
      image: ghcr.io/my-org/my-app@${{ steps.build.outputs.digest }}
      format: cyclonedx-json
      output-file: sbom.cyclonedx.json
 
  - name: Attest SBOM
    run: |
      cosign attest --yes \
        --predicate sbom.cyclonedx.json \
        --type cyclonedx \
        ghcr.io/my-org/my-app@${{ steps.build.outputs.digest }}

Vérification côté consommateur (admission controller, déploiement) :

# Vérifier la signature et la provenance attendue
cosign verify \
  --certificate-identity-regexp='^https://github.com/my-org/my-app/' \
  --certificate-oidc-issuer='https://token.actions.githubusercontent.com' \
  ghcr.io/my-org/my-app@sha256:abc123...
 
# Vérifier l'attestation SBOM
cosign verify-attestation \
  --type cyclonedx \
  --certificate-identity-regexp='^https://github.com/my-org/my-app/' \
  --certificate-oidc-issuer='https://token.actions.githubusercontent.com' \
  ghcr.io/my-org/my-app@sha256:abc123...

Registries sécurisés

Quatre options principales en 2026 selon le contexte.

RegistryTypeScan intégréSignature supportAvantage clé
HarborSelf-hosted open sourceTrivy intégréCosign, Notary v2Contrôle total, OSS, RBAC fin
GitHub Container Registry (GHCR)Cloud SaaSDependabotCosign keyless GitHub OIDCIntégration native GitHub Actions
AWS ECRCloud SaaSInspector / Enhanced ScanningCosign + ECRIntégration native AWS IAM, OIDC
Azure Container RegistryCloud SaaSMicrosoft DefenderNotation, CosignIntégration Azure
Google Artifact RegistryCloud SaaSContainer Analysis APICosign keyless GCP OIDCIntégration GCP
JFrog ArtifactorySaaS / self-hostedXrayCosign, customMulti-format, enterprise
Quay.io (Red Hat)Cloud SaaSClairCosignOpenShift integration

Configuration registry recommandée 2026 :

  • Repos privés par défaut, public uniquement si justifié.
  • Vulnerability scanning automatique à chaque push, alerting sur critical / high.
  • Image immutable tags activé (interdit la réécriture d'un tag déjà pushé).
  • Retention policy : suppression automatique des images non utilisées au-delà de N jours, sauf tags signés ou taggés release.
  • Authentification OIDC depuis le CI, pas de credentials long-lived.

Admission control : la dernière ligne

Empêcher le déploiement d'images non conformes. Trois outils dominants en Kubernetes 2026.

Kyverno

Approche policy-as-code en YAML, syntaxe Kubernetes-native, courbe d'apprentissage faible.

# Refuser les images non signées par notre identité GitHub Actions
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-image-signature
spec:
  validationFailureAction: Enforce
  rules:
  - name: verify-cosign
    match:
      any:
      - resources:
          kinds: [Pod]
    verifyImages:
    - imageReferences:
      - "ghcr.io/my-org/*"
      attestors:
      - entries:
        - keyless:
            subject: "https://github.com/my-org/*"
            issuer: "https://token.actions.githubusercontent.com"
 
# Refuser les containers root
---
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-non-root
spec:
  validationFailureAction: Enforce
  rules:
  - name: check-runAsNonRoot
    match:
      any:
      - resources:
          kinds: [Pod]
    validate:
      message: "Le container doit s'exécuter en non-root (runAsNonRoot: true)"
      pattern:
        spec:
          containers:
          - securityContext:
              runAsNonRoot: true
              allowPrivilegeEscalation: false

Sigstore Policy Controller

Spécialisé Sigstore, plus simple si l'usage est uniquement signature verification.

OPA Gatekeeper

Plus puissant et plus complexe (Rego). Recommandé pour les organisations avec besoin de policies très avancées.

Runtime hardening avancé

Au-delà des securityContext de base, trois technologies ajoutent une isolation supplémentaire.

Seccomp profiles

Restreindre les syscalls accessibles au container.

# Pod avec seccomp profile RuntimeDefault (recommandé)
securityContext:
  seccompProfile:
    type: RuntimeDefault
 
# Ou profile custom localisé
securityContext:
  seccompProfile:
    type: Localhost
    localhostProfile: profiles/my-app.json

AppArmor (Ubuntu, Debian) ou SELinux (RHEL, Fedora)

Profils MAC (Mandatory Access Control) qui restreignent les capacités d'un processus au-delà de seccomp.

# Pod avec AppArmor profile
metadata:
  annotations:
    container.apparmor.security.beta.kubernetes.io/app: localhost/my-app-profile

Runtimes alternatifs : gVisor, Kata Containers

Pour les workloads multi-tenant ou les containers exécutant du code non-fiable, remplacer runc par un runtime à isolation plus forte.

# Kubernetes : RuntimeClass pour gVisor
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: gvisor
handler: runsc
---
apiVersion: v1
kind: Pod
spec:
  runtimeClassName: gvisor
  containers:
  - image: untrusted/code:latest

Outils d'audit et linting

Quatre outils open source à intégrer en CI pour ne pas dépendre uniquement du scan post-build.

Hadolint : Dockerfile linter

# Audit d'un Dockerfile
hadolint Dockerfile
 
# En CI GitHub Actions
- uses: hadolint/hadolint-action@v3
  with:
    dockerfile: Dockerfile
    failure-threshold: warning

Détecte les anti-patterns courants : apt-get sans cleanup, COPY . trop large, absence de USER, version non pinée.

Dive : analyse de layers

# Inspection interactive d'une image (taille par layer, fichiers)
dive my-image:latest
 
# Mode CI pour vérifier l'efficacité (ratio espace utile / espace total)
CI=true dive my-image:latest --ci-config dive-ci.yaml

Trivy / Grype / Docker Scout

Scan de vulnérabilités. Détaillé dans l'article dédié scan-conteneurs-pourquoi-comment.

Syft : génération de SBOM

# SBOM d'une image au format CycloneDX
syft packages docker:my-image:latest -o cyclonedx-json=sbom.json

Dockerfile production de référence

Exemple complet combinant l'ensemble des bonnes pratiques pour une application Go.

# syntax=docker/dockerfile:1.7
 
# ============================================================
# Stage 1 — Builder avec Go toolchain complète
# ============================================================
FROM golang:1.23-bookworm@sha256:abc123def456789012345678901234567890abcdef123456789012345678901234 AS builder
 
WORKDIR /src
 
# Cache des dépendances en layer séparée
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download
 
# Copie du code source et build statique
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build -trimpath -ldflags='-s -w -buildid=' \
    -o /app/server ./cmd/server
 
# ============================================================
# Stage 2 — Image finale distroless static
# ============================================================
FROM gcr.io/distroless/static-debian12:nonroot@sha256:def456abc789012345678901234567890abcdef123456789012345678901234567
 
# Métadonnées OCI
LABEL org.opencontainers.image.title="my-app"
LABEL org.opencontainers.image.description="Application Go production-ready"
LABEL org.opencontainers.image.source="https://github.com/my-org/my-app"
LABEL org.opencontainers.image.licenses="Apache-2.0"
 
# Binaire compilé du builder
COPY --from=builder --chown=nonroot:nonroot /app/server /server
 
# Port non-privilégié
EXPOSE 8080
 
# Utilisateur non-root (UID 65532 dans distroless nonroot)
USER nonroot:nonroot
 
# Pas de SHELL : ENTRYPOINT direct au binaire
ENTRYPOINT ["/server"]

Caractéristiques de cette image :

  • Multi-stage : build env complet retiré de l'image finale.
  • Base distroless static : zéro shell, zéro libc, ~5 à 10 MB.
  • Pinning par digest SHA256 sur les deux stages.
  • Build cache mounts BuildKit pour accélérer les builds successifs.
  • Compilation reproductible (-trimpath, -ldflags='-s -w -buildid=').
  • Utilisateur non-root par défaut (UID 65532).
  • Métadonnées OCI normalisées.

Points clés à retenir

  • La sécurité d'image conteneur en 2026 combine quatre couches : construction durcie (Dockerfile, base minimale), distribution sécurisée (registry, signature Cosign, attestations), runtime hardening (non-root, capabilities, seccomp), admission policies. Le scan seul est insuffisant.
  • Les bases Chainguard Images ou distroless réduisent la surface d'attaque et la dette CVE quasi à zéro pour les workloads production. Standard 2026.
  • BuildKit secret mounts (RUN --mount=type=secret) est le seul pattern correct pour passer un secret au build sans persistence dans une layer. Jamais de secret via ENV ni COPY.
  • Cosign keyless via OIDC (GitHub Actions, GitLab CI) est le pattern standard 2026 pour signer et attester les images sans gérer de clés privées.
  • Le runtime hardening (non-root, drop capabilities, seccomp, AppArmor) reste indispensable même avec une image parfaite : CVE-2024-21626 Leaky Vessels a démontré que les CVE runtime existent et sont exploitables.

Pour aller plus loin

Questions fréquentes

  • Quelle base image choisir pour minimiser la surface d'attaque ?
    Trois options par ordre de durcissement croissant. Première : images officielles slim ou alpine (par exemple python:3.12-slim, node:22-alpine), CVE plus rares qu'avec les images full mais shell présent et package manager exploitable. Deuxième : images distroless de Google (gcr.io/distroless/python3-debian12, gcr.io/distroless/nodejs22-debian12), aucun shell, aucun package manager, surface minimale, mais débogage difficile. Troisième : images Chainguard (cgr.dev/chainguard/python, cgr.dev/chainguard/node), fondées sur Wolfi (distribution née pour conteneurs), zéro CVE par construction, mises à jour quotidiennes, support commercial. Pour un service production sans besoin de shell, distroless ou Chainguard est le standard 2026.
  • Comment passer un secret au build sans le persister dans l'image ?
    Trois patterns. Mauvais : `ENV API_KEY=xxxxx` ou `COPY .env .` qui persistent dans une layer définitive. Acceptable : variables d'environnement de build via `--build-arg` mais elles restent visibles via `docker history`. Recommandé en 2026 : BuildKit secret mounts via `RUN --mount=type=secret,id=mysecret cat /run/secrets/mysecret` qui passe le secret en mémoire pendant le build sans persistance. Compatible avec docker build (BuildKit activé par défaut depuis 23.0) et avec GitHub Actions docker/build-push-action via `secrets:`. Pour les secrets utilisés au runtime, jamais dans l'image : injection via Vault, Kubernetes secrets ou cloud secrets manager.
  • Pourquoi exécuter un container en non-root ?
    Réduction critique de l'impact en cas de compromission. Si un attaquant exploite une vulnérabilité applicative dans un container exécuté en root, il a immédiatement les capabilities root sur le container et peut potentiellement exploiter une CVE container escape (par exemple CVE-2019-5736 runc, CVE-2024-21626 Leaky Vessels) pour compromettre l'hôte. En non-root avec USER 1000 et NoNewPrivileges, l'attaquant doit chaîner deux escalades : application vers root local, puis container escape. Nettement plus difficile et fortement détectable. Standard 2026 : tous les containers production en non-root, avec read-only root filesystem si possible.
  • Cosign keyless ou Cosign avec clés : que choisir ?
    Cosign keyless via OIDC est recommandé en 2026 pour la plupart des cas. Le mode keyless utilise l'identité OIDC du builder (GitHub Actions, GitLab CI, etc.) pour obtenir un certificat court-lived auprès de Fulcio, signe l'image, et inscrit la signature dans Rekor (transparency log). Aucune clé privée à gérer, à stocker ou à rotater. Le consommateur vérifie via `cosign verify` en spécifiant le subject (identité du signeur) et l'issuer OIDC attendus. Le mode avec clés Cosign reste pertinent pour des cas spécifiques : déploiements air-gappés, exigences réglementaires de PKI propriétaire, mais ajoute la complexité de gestion de clés.
  • Quelle différence entre runc, gVisor et Kata Containers en termes de sécurité ?
    Trois runtimes avec différents trade-offs. runc (par défaut Docker, containerd) : isolation Linux namespaces et cgroups, partage du kernel hôte. Performance maximale, surface d'attaque la plus élevée (toute CVE kernel exploitable depuis container). gVisor (Google) : application kernel en user-space (Sentry) qui intercepte les syscalls, isolation supérieure sans VM. Performance dégradée 5 à 30 % selon workload, latence syscall augmentée. Kata Containers : chaque container dans une VM légère (Firecracker ou QEMU), isolation hardware comparable à VM classique. Performance proche de runc pour CPU/mémoire, overhead démarrage de quelques centaines de millisecondes. Pour multi-tenant ou workloads non-fiables : Kata. Pour workloads sensibles avec besoin d'isolation supérieure sans VM : gVisor. Pour tout le reste : runc avec hardening (non-root, seccomp, AppArmor).
  • Comment valider qu'une image déployée n'a pas été altérée ?
    Quatre couches cumulatives. Première : vérification de signature Cosign à l'admission (Kyverno verifyImages, Sigstore Policy Controller, OPA Gatekeeper avec ClusterImagePolicy). Deuxième : vérification de provenance SLSA pour s'assurer que l'image vient bien du build attendu (commit SHA, builder, repo). Troisième : pinning par digest SHA256 plutôt que tag mutable (`image: registry.example.test/app@sha256:abc...`). Quatrième : audit log côté registry + alerting sur push d'image avec tag déjà existant. La combinaison signature Cosign + admission policy + digest pinning rend pratiquement impossible le déploiement d'une image altérée sans détection.

Écrit par

Naim Aouaichia

Expert cybersécurité et fondateur de Zeroday Cyber Academy

Expert cybersécurité avec un master spécialisé et un parcours hybride : développement, DevOps, DevSecOps, SOC, GRC. Fondateur de Hash24Security et Zeroday Cyber Academy. Formateur et créateur de contenu technique sur la cybersécurité appliquée, la sécurité des LLM et le DevSecOps.