Blog

Auditer vos Dockerfile : sécurité, CVE et bonnes pratiques

Un Dockerfile se relit, se lint et se scanne comme du code applicatif. Voici les outils que nous utilisons pour garder nos images saines, en local comme dans la CI.

Mathieu Ducrot Mathieu Ducrot
|
|
15 min de lecture
| Tech
Résumez cette page avec votre IA préférée :
Outils d'audit sécurité et qualité pour vos Dockerfile

Introduction

Le Dockerfile est souvent le fichier le moins relu d’un projet. Il est écrit une fois au démarrage, il fonctionne, et plus personne n’y touche pendant deux ans. Pendant ce temps, l’image de base accumule des CVE et des layers ajoutés à un moment du donné du projet, bien que pertinents à l’origine, ne le sont peut-être plus aujourd’hui.

C’est un angle mort d’autant plus gênant que ce fichier définit l’environnement d’exécution de toute votre application. Une faille dans l’image de base est une faille en production, au même titre qu’une dépendance applicative vulnérable.

Nous verrons pourquoi un Dockerfile mérite le même traitement que le code applicatif, quels outils utiliser pour l’auditer (Hadolint, Dockle et OSV Scanner), et comment adapter le niveau d’exigence selon que l’image sert au développement ou à la production.

1. Pourquoi auditer un Dockerfile comme du code

Un Dockerfile est du code livré en production

Sur un projet Symfony, vous avez probablement déjà un linter, un analyseur statique comme PHPStan et une suite de tests PHPUnit. Ces outils tournent à chaque commit les développeurs y sont souvent déjà sensibilisés.

Le Dockerfile, lui, échappe le plus souvent à cette discipline. Pourtant il déclare la version de PHP, les extensions installées, les paquets système, les droits sur les fichiers et l’utilisateur qui exécute le processus. C’est du code, avec les mêmes risques de dérive et les mêmes bénéfices à être vérifié automatiquement.

Les mêmes réflexes que sur le code applicatif : lint et recherche de CVE

L’audit d’un Dockerfile reprend exactement les deux réflexes appliqués au code :

  • Le lint : vérifier la forme et les bonnes pratiques d’écriture. Un apt-get install sans version épinglée casse la reproductibilité de l’image, exactement comme une dépendance non verrouillée dans un composer.json.
  • La recherche de CVE : vérifier que les paquets embarqués ne sont pas affectés par des vulnérabilités connues. C’est le pendant de composer audit ou npm audit, appliqué cette fois aux paquets système de l’image.

La différence avec le code applicatif tient à un point : une image peut devenir vulnérable sans qu’une seule ligne du Dockerfile ait changé. Une CVE publiée sur openssl rend votre image d’hier vulnérable aujourd’hui. C’est ce qui justifie de rejouer le scan périodiquement, et pas seulement au moment où le fichier est modifié.

Ce qu’on trouve concrètement

Les défauts remontés par ces outils sont rarement exotiques. Ce sont presque toujours les mêmes :

  • un FROM php:latest qui rend le build non reproductible et fait changer la version de PHP sans prévenir
  • un conteneur qui tourne en root alors qu’aucun processus n’en a besoin
  • un cache apt ou npm jamais nettoyé, qui alourdit l’image de plusieurs centaines de mégaoctets
  • des identifiants ou tokens passés en ENV et donc lisibles dans les métadonnées de l’image
  • une image de base dont la distribution n’est plus maintenue, avec les CVE non corrigées qui vont avec

Aucun de ces points n’est bloquant au quotidien. C’est précisément pour ça qu’ils s’accumulent, et qu’ils méritent une vérification automatisée plutôt qu’une relecture humaine ponctuelle.

2. Les outils d’audit à connaître

Ces trois outils ne se recouvrent pas : ils interviennent à trois moments différents et n’ont pas le même niveau de restitution.

Hadolint : le linter de Dockerfile

Hadolint analyse le Dockerfile avant le build. Il en construit l’arbre syntaxique et passe chaque instruction RUN à ShellCheck pour vérifier aussi les commandes shell qu’elle contient.

Ses vérifications sont reprises des bonnes pratiques d’écriture d’un Dockerfile publiées par Docker.

Ses règles portent deux préfixes : DL pour les règles Dockerfile et SC pour celles héritées de ShellCheck. Quelques exemples représentatifs :

CodeCe qu’il signale
DL3007Utilisation du tag latest sur l’image de base
DL3008Paquet apt-get installé sans version épinglée
DL3059Instructions RUN consécutives à fusionner pour limiter les couches
DL4006Pipe dans un RUN sans SHELL -o pipefail : une erreur en milieu de pipe passe inaperçue

L’usage est direct, sans installation puisque l’outil est distribué en image :

docker run --rm -i hadolint/hadolint < Dockerfile

Les règles non pertinentes pour votre contexte se désactivent dans un fichier .hadolint.yaml à la racine :

ignored:
  - DL3008

Dockle : l’audit de l’image construite

Dockle intervient après le build. Il n’ouvre pas le Dockerfile : il inspecte l’image produite, ses couches, ses métadonnées et son système de fichiers. Il vérifie la conformité au CIS Docker Benchmark, le référentiel de sécurité de référence pour les conteneurs.

Ses contrôles se répartissent en cinq familles : utilisateur d’exécution, secrets exposés, fichiers sensibles, propreté des caches et bonnes pratiques générales. Les codes suivent deux conventions : CIS-DI-XXXX pour les règles issues du benchmark et DKL-DI-XXXX pour les recommandations propres à Dockle.

CodeCe qu’il signale
CIS-DI-0001Le conteneur tourne en root : aucun utilisateur dédié n’est déclaré
CIS-DI-0005Pas de sudo ni de binaire setuid superflu dans l’image
CIS-DI-0006Absence de HEALTHCHECK
DKL-DI-0005Cache apt non nettoyé après installation
DKL-LI-0003Clé privée ou identifiant détecté dans les fichiers de l’image
dockle mon-image:1.0

Chaque résultat est classé en FATAL, WARN, INFO ou PASS. En CI, --exit-code 1 fait échouer le job dès qu’une alerte est levée et --exit-level FATAL limite le blocage aux problèmes critiques. Les exceptions assumées se déclarent dans un fichier .dockleignore.

Le .dockleignore est un fichier à surveiller en revue de code. Ajouter une ligne pour faire taire une alerte est aussi rapide que d’ajouter un @phpstan-ignore : la règle de l’équipe doit être de corriger d’abord, et de n’y inscrire que les exceptions réellement justifiées, avec un commentaire expliquant le pourquoi.

OSV Scanner : la recherche de vulnérabilités

OSV Scanner est l’outil développé par Google qui interroge la base OSV.dev, un agrégateur de vulnérabilités open source qui consolide les sources CVE, GHSA, PYSEC et les signalements de paquets malveillants de l’OpenSSF.

Il analyse aussi bien les fichiers de verrouillage de dépendances (composer.lock, package-lock.json, yarn.lock, go.sum…) qu’une image Docker complète :

osv-scanner scan image mon-image:1.0

Le scan est statique : il lit les fichiers de verrouillage sans jamais installer ni exécuter le moindre paquet. C’est un point important face aux attaques de chaîne d’approvisionnement, où le code malveillant se déclenche justement au moment de l’installation. Le code de sortie vaut 0 si tout est sain et 1 dès qu’une vulnérabilité est détectée, ce qui rend l’intégration en CI immédiate.

La sortie console suffit en CI, mais elle devient vite illisible dès qu’une image remonte plusieurs dizaines de vulnérabilités. L’option --serve produit alors un rapport HTML accessible via un serveur http nettement plus exploitable :

osv-scanner scan image mon-image:1.0 --serve

Rapport HTML généré par OSV Scanner : synthèse par sévérité, vulnérabilités regroupées par couche d'image et détail des CVE par paquet

Le rapport indique la couche d’image qui a introduit chaque vulnérabilité et distingue ce qui vient de l’image de base de ce que vous avez ajouté. C’est ce qui permet de trancher rapidement entre corriger votre Dockerfile et/ou changer d’image de base. Chaque paquet affiche aussi la version corrigée quand elle existe.

Quel outil pour quel niveau

OutilObjet analyséMomentType d’alerte
HadolintLe Dockerfile sourceAvant le buildÉcriture, reproductibilité, bonnes pratiques
DockleL’image construiteAprès le buildConfiguration, privilèges, secrets, propreté
OSV ScannerPaquets système de l’image construiteAprès le build, puis en continuVulnérabilités connues (CVE)

Les trois sont complémentaires : Hadolint ne verra jamais une CVE, OSV Scanner ne dira jamais que vous tournez en root, et Dockle ne relira pas votre syntaxe. Les utiliser isolément laisse une zone d’ombre.

3. Développement ou production : deux niveaux d’exigence

Ce qui est acceptable dans une image de développement

Une image de développement n’a pas le même contrat qu’une image de production. Elle doit être confortable : pouvoir installer un paquet manquant sans rebuild complet, disposer de Xdebug, écrire dans le volume monté avec l’UID de l’utilisateur hôte pour faciliter les accès au dossier.

Le cas le plus parlant est celui du sudo. Dockle remonte sa présence via CIS-DI-0005, et il a raison sur une image de production : un layer avec cette commande remontera donc en erreur si elle n’est pas mise en ignore.

Sur une image de développement, c’est souvent le moyen le plus simple de laisser un développeur ajuster son environnement local sans attendre. La bonne réponse n’est donc pas de désactiver la règle globalement, mais de l’accepter explicitement sur l’image de dev tout en la maintenant bloquante sur l’image de prod.

C’est le même raisonnement qui s’applique aux outils de debug (par exemple le Profiler Symfony), aux vendors de dev et aux verbosités de log qui ici n’ont rien à faire dans une image livrée.

Ce qui ne l’est plus en production

Sur l’image de production, la liste des points non négociables est courte et stable :

  • utilisateur non privilégié : un USER dédié, jamais root
  • aucun secret dans l’image : les identifiants passent par des variables d’exécution ou un gestionnaire de secrets, jamais par un ENV figé dans une couche
  • version de base épinglée : un digest ou une version précise, jamais latest
  • pas d’outil d’élévation ni de debug : ni sudo, ni Xdebug, ni client de base de données interactif
  • image minimale : caches nettoyés, aucun paquet de build conservé dans l’image finale

Le multi-stage build résout une bonne partie de ces points : les outils de compilation restent dans l’étape de build et n’atteignent jamais l’image finale.

4. Notre pratique chez SmartBooster : Docker comme environnement de référence

Chez SmartBooster, Docker est notre environnement de développement pour tout projet PHP. Aucun projet ne tourne sur une stack installée directement sur la machine du développeur : la même image sert en local et dans les jobs de QA et de test de nos pipelines GitLab. Les versions de PHP, les extensions et les paquets système sont donc strictement identiques d’un poste à l’autre et jusqu’à la CI.

Cette homogénéité a un corollaire direct : un défaut dans le Dockerfile se propage instantanément à toute l’équipe et à toute la chaîne de validation. C’est ce qui rend l’audit automatisé rentable, et c’est pourquoi nous l’avons industrialisé au niveau de notre stack plutôt que projet par projet.

Notre socle symfony-docker embarque depuis la release 1.4 ces trois outils, exposés via des cibles de Makefile et exécutés dans leurs propres images, sans rien installer sur le poste :

make docker-scan            # les trois analyses d'un coup
make docker-lint            # hadolint sur le Dockerfile
make docker-scan-dockle     # dockle sur l'image construite
make docker-scan-osv        # osv-scanner sur les paquets et dépendances
make docker-scan-osv-html   # rapport OSV consultable sur http://localhost:8000/

Tous nos projets disposent ainsi du même standard d’audit, avec la même commande et les mêmes règles. La documentation Docker de la stack détaille chaque cible.

Les analyses portent sur l’image générée par make build. Un rebuild est donc nécessaire avant de scanner, sinon vous auditez l’image précédente et non vos derniers changements.

5. À quelle fréquence lancer ces audits

Les trois outils n’ont pas la même temporalité. Les lancer tous au même moment revient soit à ralentir chaque commit inutilement, soit à ne jamais détecter les vulnérabilités publiées après le build.

Au commit : le lint, immédiat et bloquant

Hadolint s’exécute en moins d’une seconde et ne dépend d’aucun build. Il a donc sa place au plus tôt, sur toute merge request qui touche le Dockerfile, avec un job bloquant : une règle violée, la pipeline échoue, comme pour un linter PHP ou JavaScript.

C’est aussi le seul des trois qui peut tourner en local avant le commit, pour éviter l’aller-retour avec la CI sur une correction de deux lignes.

À chaque pipeline : l’audit de l’image

Dockle a besoin de l’image construite. Il vient donc après le job de build et vérifie ce que le Dockerfile a réellement produit une fois toutes les instructions appliquées.

Le point important est de scanner l’image que vous allez publier, pas une image reconstruite pour l’occasion. Sinon vous auditez un artefact qui n’ira jamais en production.

Périodiquement : le scan CVE, parce que les failles arrivent après le build

C’est le cas particulier de cette chaîne. Une image validée hier peut être vulnérable aujourd’hui sans qu’aucun commit n’ait eu lieu : il suffit qu’une CVE soit publiée sur un paquet qu’elle embarque. Un scan déclenché uniquement par les modifications de code laisse donc passer l’essentiel des vulnérabilités.

C’est pourquoi nous branchons OSV Scanner sur une pipeline planifiée plutôt que sur les merge requests. La cible n’est pas l’image en cours de construction mais celle qui tourne réellement, et la question posée n’est pas “ai-je introduit une faille ?” mais “en a-t-on découvert une depuis la dernière fois ?”.

Exemple de branchement dans une CI GitLab

Voici le job que nous utilisons, déclenché uniquement par une pipeline programmée dans CI/CD > Schedules :

audit_dockerfile:
  stage: audit
  image:
    name: ghcr.io/google/osv-scanner:latest
    entrypoint: [ "" ] # l'entrypoint par défaut est le binaire osv-scanner, la CI a besoin d'un shell
  rules:
    -   if: $CI_PIPELINE_SOURCE == "schedule" && $SCHEDULED_JOB == "audit_dockerfile"
  tags:
    - docker
    - concurrent
  services:
    - docker:dind # osv-scanner délègue le pull de l'image au binaire docker
  cache: { } # le cache vendor/node_modules ne sert pas ici
  before_script:
    # jq et docker ne sont pas fournis par l'image officielle osv-scanner (alpine)
    - apk add --no-cache jq docker-cli
    # Le démon dind met quelques secondes à générer ses certificats et à écouter
    - until docker info > /dev/null 2>&1; do sleep 1; done
    - echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER $CI_REGISTRY --password-stdin
  script:
    - |
      # osv-scanner n'a pas de filtre de sévérité, on filtre donc la sortie json sur les
      # CVSS >= 7, soit les niveaux CRITICAL (9.0-10.0) et HIGH (7.0-8.9).
      # La garde sur max_severity vide est indispensable : sans elle tonumber lève une
      # erreur et jq interrompt le flux en silence, ce qui masquerait des vulnérabilités.
      # Conséquence assumée : les advisories sans score CVSS ne sont pas remontées.
      # le binaire est à la racine de l'image et hors du PATH, d'où le chemin absolu.
      # osv-scanner sort en 1 dès qu'il trouve une vulnérabilité, quelle que soit sa
      # sévérité : sans ce piège, la CI couperait le script ici et le filtre ne
      # s'exécuterait jamais. Tout autre code reste une vraie erreur (127 = échec du pull).
      EXIT_CODE=0
      /osv-scanner scan image --format json --verbosity error "$CI_REGISTRY_IMAGE:php_8_4" > osv-report.json || EXIT_CODE=$?
      if [ "$EXIT_CODE" -ne 0 ] && [ "$EXIT_CODE" -ne 1 ]; then
        echo "osv-scanner a échoué (code $EXIT_CODE)"
        exit "$EXIT_CODE"
      fi
      OUT=$(jq -r '.results[].packages[]
        | {name: .package.name, version: .package.version, g: .groups[]}
        | select(((.g.max_severity // "") | if . == "" then 0 else tonumber end) >= 7)
        | "\(.g.max_severity)\t\(.name) \(.version)\t\(.g.ids[0])"' osv-report.json | sort -rn)
      if [ -n "$OUT" ]; then
        echo "osv-scanner: $(echo "$OUT" | wc -l) CRITICAL/HIGH vulnerabilities found"
        echo "$OUT"
        exit 1
      fi
      echo "osv-scanner: no CRITICAL or HIGH vulnerability found"
  artifacts:
    when: always # le rapport complet est utile surtout quand le job échoue
    expire_in: 30 days
    paths:
      - osv-report.json

Quatre points méritent d’être soulignés, parce qu’ils sont la source des blocages les plus fréquents quand on met ce job en place :

  • L’entrypoint doit être neutralisé. L’image officielle a pour entrypoint le binaire osv-scanner lui-même. GitLab attend un shell pour exécuter before_script et script : sans entrypoint: [ "" ], le job ne démarre pas.
  • Le code de sortie 1 ne signifie pas échec. OSV Scanner sort en 1 dès qu’il trouve la moindre vulnérabilité, même mineure. Sans le catch || EXIT_CODE=$?, la CI interromprait le script avant le filtrage et le job échouerait sur des advisories informatives. Les autres codes restent traités comme de vraies erreurs.
  • Le filtrage de sévérité se fait en aval. L’outil n’a pas d’option de seuil (comme Dockle) : on passe donc par --format json et un filtre jq sur les scores CVSS supérieurs ou égaux à 7, soit les niveaux HIGH et CRITICAL. Le rapport complet reste conservé en artefact 30 jours, y compris quand le job échoue.

La variable SCHEDULED_JOB permet de faire cohabiter plusieurs pipelines programmées sur le même projet sans qu’elles se déclenchent mutuellement : chaque planification déclare la sienne dans ses variables. Une fréquence hebdomadaire est un bon point de départ, à resserrer sur les projets exposés publiquement.

Un scan planifié n’a d’intérêt que si son échec est visible. Le plus efficace que nous avons mis en place est une notification par email via l’intégration Pipeline status emails de Gitlab. C’est ce qui fait entrer la sécurité dans le cycle de maintenance évolutive plutôt que dans les chantiers d’urgence.

Conclusion

Hadolint relit le Dockerfile avant le build, Dockle inspecte l’image produite, OSV Scanner surveille les vulnérabilités connues même quand personne ne touche au projet. Trois outils complémentaires couvrant l’ensemble des besoins de qualité de vie de vos Dockerfile.

L’objectif de ce dispositif n’est pas d’atteindre le zéro alerte, il est d’être informé. Pour l’équipe de développement, savoir qu’une CVE critique vient d’être publiée sur l’image de base permet de la planifier dans le prochain lot plutôt que de la découvrir pendant un incident. Pour les équipes métier et la direction, c’est la différence entre “on pense que c’est à jour” et une réponse datée, appuyée sur un rapport.

Un scan automatisé remplit son rôle : il tire la sonnette d’alarme. En revanche, le traitement de la CVE (mise à jour du paquet concerné, adaptation du code impacté, validation des tests) reste une intervention humaine, qui s’inscrit naturellement dans le cadre d’un contrat de TMA ou de maintenance préventive.

Une chaîne d’audit branchée dans la CI ne rend pas vos conteneurs invulnérables. Elle fait de la sécurité un sujet suivi au fil de l’eau, dans le cycle normal de maintenance évolutive, au lieu d’une inconnue qui ne se manifeste qu’au mauvais moment.


Liens utiles

Mathieu Ducrot
Mathieu Ducrot CTO
Mots clés :
#DevOps #Qualité

Articles similaires

Vous avez un projet ?

Contactez-nous pour savoir comment nous pouvons vous aider.