Mathieu Bernard Réserver 30 min

Le premier jour d’un audit : 7 scanners dans une image Docker

Le premier tour d’un audit de sécurité externe tient en une commande Docker et une quarantaine de minutes. Mais un scan reproductible demande des versions figées et une fusion des résultats déterministe, et c’est là que l’outil examiné présentait des failles.

Dans un audit, la première journée sert à voir ce que tout le monde peut voir depuis Internet : les ports ouverts, la configuration TLS, les en-têtes HTTP, les technologies affichées, les failles connues des composants. Rien de tout cela ne demande d’accès privilégié. Mais à la main, c’est une demi-douzaine d’outils à installer, à configurer, à lancer dans le bon ordre et à relire un par un.

Un outil d’audit construit pour ce besoin empaquette ce premier passage dans une image Docker : une commande, une URL, et on récupère des rapports bruts, un rapport HTML et un PDF combiné. Voici comment l’image est construite, ce que fait chaque scanner et ce qu’il ne voit pas, et surtout ce que le premier vrai passage a révélé, y compris sur les erreurs de l’outil lui-même. Les cibles et les adresses de cet article sont des exemples (www.exemple.fr, 203.0.113.10).

1. Pourquoi une image Docker

Trois raisons, dans cet ordre.

Rien à installer chez personne. Les scans partent du poste de l’auditeur, ou de celui d’un membre de l’équipe, vers la surface exposée de la cible. Il faut Docker et un accès réseau, rien d’autre. Pas de Perl pour Nikto, pas de Ruby pour WhatWeb, pas de Java pour ZAP sur le poste. Trois scripts de lancement (PowerShell, cmd et Bash) font la même chose sous Windows, macOS et Linux.

La reproductibilité. Deux passages à trois mois d’écart doivent pouvoir se comparer. Si l’outil a changé entre-temps, on ne sait plus si c’est la cible qui s’est améliorée ou le scanner qui regarde autrement.

Un point d’entrée unique. L’image part de celle de ZAP, qui fournit déjà Java et le script de scan, et ajoute le reste :

FROM ghcr.io/zaproxy/zaproxy:stable
USER root

RUN apt-get update && \
    apt-get install -y curl ca-certificates wget git openssl \
        nmap whatweb perl dnsutils nodejs npm unzip bsdextrautils && \
    rm -rf /var/lib/apt/lists/*

# Nikto et testssl.sh depuis leurs dépôts
RUN git clone https://github.com/sullo/nikto.git /opt/nikto && \
    ln -s /opt/nikto/program/nikto.pl /usr/local/bin/nikto
RUN git clone --depth 1 https://github.com/drwetter/testssl.sh.git /opt/testssl && \
    ln -s /opt/testssl/testssl.sh /usr/local/bin/testssl.sh

# Nuclei et Trivy en binaires précompilés, version explicite
RUN wget -O /tmp/nuclei.zip \
    https://github.com/projectdiscovery/nuclei/releases/download/v3.0.0/nuclei_3.0.0_linux_amd64.zip && ...
RUN wget -O /tmp/trivy.tar.gz \
    https://github.com/aquasecurity/trivy/releases/download/v0.67.2/trivy_0.67.2_Linux-64bit.tar.gz && ...

WORKDIR /zap/wrk
ENTRYPOINT ["node", "/opt/security-runner/orchestrator.js"]

Et le lancement tient en une ligne :

docker run --rm -v "$(pwd)/reports:/zap/wrk/reports" \
  security-runner --target https://www.exemple.fr

# avec analyse d'une image Docker en plus
docker run --rm -v "$(pwd)/reports:/zap/wrk/reports" \
  security-runner --target https://www.exemple.fr --image registry.exemple.fr/app:1.4.2

Il faut être honnête sur le mot « figées ». À la relecture de ce Dockerfile, seules deux versions le sont vraiment : Nuclei 3.0.0 et Trivy 0.67.2. L’image de base suit l’étiquette stable, qui bouge. Nmap et WhatWeb viennent d’apt, sans version. Nikto est cloné sur sa branche principale, testssl.sh aussi (le journal affiche version testssl: 3.3dev). Et Nuclei télécharge ses modèles de détection à la première exécution, donc la base de vulnérabilités change même quand le binaire ne change pas. L’image est reproductible le jour où on la construit, pas d’une construction à l’autre. La correction est connue : une empreinte @sha256: sur l’image de base, un git checkout sur une étiquette pour Nikto et testssl.sh, et une version de modèles Nuclei notée dans le rapport. Elle n’est pas encore faite.

Autre détail relevé à la relecture : Nuclei est téléchargé en linux_amd64 en dur, alors que la sortie de Nmap du même passage indique une image construite pour aarch64. L’architecture devrait être une variable de construction, pas une constante.

2. Ce que fait chaque scanner, et ce qu’il ne voit pas

Sept outils, dont six s’exécutent à chaque passage. Trivy ne tourne que si on lui donne une image.

OutilCe qu’il regardeCe qu’il ne voit pas
NmapPorts ouverts, services et versionsCe qui est derrière un pare-feu, la nature réelle d’un service non reconnu
WhatWebServeur, CMS, frameworks affichésTout ce qui ne se déclare pas dans les en-têtes ou le HTML
NiktoConfiguration du serveur web, fichiers connusL’application elle-même
testssl.shProtocoles, suites de chiffrement, certificat, failles TLS connuesRien au-dessus de la couche TLS
NucleiModèles de failles et de mauvaises configurations connuesCe qu’aucun modèle ne décrit
ZAP (baseline)Analyse passive des pages exploréesLes pages derrière une connexion, toute attaque active
TrivyPaquets vulnérables et mauvaise configuration d’une imageLe comportement du conteneur en production

Les commandes réellement lancées, telles que l’orchestrateur les enregistre dans son résumé :

nmap -sV -T4 -oN ./reports/security-nmap.txt www.exemple.fr
whatweb -a 3 https://www.exemple.fr > ./reports/security-whatweb.txt
nikto -h https://www.exemple.fr -output ./reports/security-nikto.txt
testssl.sh --quiet --sneaky --color 0 --logfile ./reports/security-testssl.log www.exemple.fr
nuclei -u https://www.exemple.fr -o ./reports/security-nuclei.txt
zap-baseline.py -t https://www.exemple.fr -r ./reports/security-zap.html -J ./reports/security-zap.json
trivy image --scanners vuln,config --exit-code 0 --format json \
  -o ./reports/security-trivy.json registry.exemple.fr/app:1.4.2

Quelques choix méritent une explication. Nmap utilise -sV pour la détection de version sur les 1 000 ports par défaut ; une version antérieure du script utilisait -F, plus rapide, qui ne teste que les 100 ports les plus courants et laisse de côté tout service exposé ailleurs. WhatWeb tourne en -a 3, le niveau agressif, qui envoie des requêtes supplémentaires pour affiner l’identification. testssl.sh reçoit --sneaky, qui laisse dans les journaux du serveur une trace moins voyante. Trivy reçoit --exit-code 0 : une image vulnérable ne doit pas arrêter le passage, on veut le rapport.

ZAP tourne en mode baseline, c’est-à-dire passif : il explore le site et lit les réponses, sans injecter d’attaque. À mon sens, c’est le seul mode acceptable sans fenêtre de test validée. Nikto et Nuclei, eux, sont bruyants. Ils envoient des centaines de requêtes vers des chemins connus, et un pare-feu applicatif les repère.

3. L’ordre d’exécution et les durées

L’orchestrateur enchaîne trois étapes : les scans, puis la génération des rapports HTML, puis la fusion en PDF. Les scans tournent l’un après l’autre, jamais en parallèle :

// 1) Nmap
results.push(await runCmd('nmap',
  `nmap -sV -T4 -oN ${outputPrefix}-nmap.txt ${host}`,
  { cwd: workdir, outfile: `${outputPrefix}-nmap.txt`, allowFail: true }));
// 2) WhatWeb, 3) Nikto, 4) testssl.sh, 5) Nuclei, 6) ZAP
// 7) Trivy, seulement si --image est fourni

Le séquentiel est un choix. Six outils qui frappent la même cible en même temps, c’est la meilleure façon de déclencher une protection anti-DDoS, de fausser les mesures de latence de Nmap et de rendre les journaux de l’organisation auditée illisibles. L’ordre va du moins intrusif au plus lourd : la cartographie réseau, l’identification, puis les tests de configuration, puis l’exploration applicative.

Chaque appel porte allowFail: true. Un outil qui échoue ne casse pas le passage ; son code de retour est consigné dans un security-summary.json. Sur le premier vrai passage, les horodatages des rapports donnent les durées suivantes :

ÉtapeDurée
Nmap213 s
WhatWeb et Niktoenviron 15 min à eux deux
testssl.sh177 s
Nuclei et ZAPenviron 16 min à eux deux
Passage complet des scansenviron 38 min

Il n’y a pas de durée séparée pour chaque outil du milieu : seuls Nmap et testssl.sh écrivent leur heure de début et de fin. C’est une ligne à ajouter dans runCmd. Mais l’ordre de grandeur suffit pour s’organiser : on lance le passage, on prépare les entretiens pendant ce temps, et on le relit dans l’heure.

4. Le cadre légal : une autorisation écrite avant tout

Le README de l’outil commence par un avertissement, avant même la commande de construction :

Ce scanner ne doit être utilisé que sur des cibles pour lesquelles vous avez une autorisation écrite.

Ce n’est pas une formule. En France, l’article 323-1 du Code pénal réprime l’accès frauduleux à un système de traitement automatisé de données, et un scan Nikto ou Nuclei contre un site qui ne vous a rien demandé y ressemble beaucoup. Un commanditaire qui dit « oui, allez-y » au téléphone ne suffit pas non plus : il faut une trace.

Concrètement, avant de lancer quoi que ce soit, il faut faire préciser par écrit, dans le contrat, l’ordre de mission ou un simple courriel :

  • le périmètre exact : les URL et les noms d’hôtes, et l’image Docker si Trivy est utilisé ;
  • qui possède l’infrastructure : si le site est chez un hébergeur ou derrière un CDN, l’organisation auditée n’est pas seule propriétaire des machines scannées, et certains hébergeurs exigent d’être prévenus ;
  • la fenêtre : la date et l’heure du passage, pour que l’équipe d’exploitation ne prenne pas Nikto pour une attaque ;
  • l’adresse IP d’où partent les scans, par exemple 203.0.113.10, pour qu’on puisse les reconnaître dans les journaux, voire les autoriser dans le pare-feu applicatif ;
  • ce que le passage n’est pas : un scan automatisé, pas un test d’intrusion.

Le dernier point compte autant que les autres. Une direction qui reçoit un rapport noté « C » doit savoir d’où vient cette note et ce qu’elle ne couvre pas.

5. Fusion et dédoublonnage : ce que fait l’outil, et ce qu’il aurait dû faire

Sept outils produisent sept formats : du texte pour Nmap et Nikto, des lignes pour Nuclei, un journal pour testssl.sh, du JSON pour ZAP et Trivy. Il faut en faire un seul document lisible.

Le dépôt contient deux approches dès le premier commit. La première est déterministe : chaque outil écrit du JSON, et une fonction par outil le ramène à un schéma commun (source, name, severity, description, url), avec une table de correspondance des sévérités :

function normalizeSeverity(value) {
  if (!value) return "info";
  const v = String(value).toLowerCase();
  if (["informational", "info"].includes(v)) return "info";
  if (["medium", "moderate"].includes(v)) return "medium";
  ...
}

Avec ses limites : Nikto n’a pas de notation officielle, donc tout ce qu’il trouve passe en medium par défaut. Et il n’y a aucun dédoublonnage, seulement une concaténation.

La seconde, celle que l’image exécute, confie la génération à un modèle de langage : chaque fichier brut est envoyé à Llama 3.3 70B (via OpenRouter, température 0,2, réponse en JSON imposée), qui rend une analyse structurée par fichier. Un second appel reçoit toutes ces analyses et produit la synthèse : note de A à F, score sur 100, statistiques par sévérité, plan de remédiation. Les rapports sont plus lisibles. Le prompt interdit même de citer le nom des outils dans le texte du rapport final.

La relecture du résultat du premier passage est la partie la plus instructive de cet article.

  • Les erreurs d’outil sont devenues des constats. testssl.sh avait fini son analyse TLS avec une note A+, mais renvoyé le code 9 parce que la partie en-têtes HTTP avait échoué. ZAP avait renvoyé 2, ce qui signifie seulement qu’il a émis des avertissements. Le résumé les marquait « KO », et le modèle en a tiré deux constats « Erreur lors de l’exécution », sévérité moyenne, dans le rapport final.
  • Un blocage est devenu une faille. Une réponse 403 du pare-feu applicatif, qui bloquait le scanner, est devenue un constat de sévérité moyenne sur « l’accès au site interdit ».
  • Le même fichier a été lu deux fois. ZAP écrit un rapport HTML et un rapport JSON. Les deux ont été analysés, d’où deux sections « Analyse dynamique » quasi identiques. Le fichier de résumé lui-même a été traité comme un rapport de scan.
  • Les doublons ne sont pas fusionnés. L’absence d’en-tête HSTS apparaît deux fois dans la sortie de Nikto, une fois dans Nuclei et une fois dans ZAP. Les autres en-têtes manquants suivent le même chemin.
  • La note n’est pas reproductible. Le « C, 60/100 » et la répartition « 0 critique, 0 élevé, 10 moyens, 8 faibles, 2 informatifs » sont produits par le modèle. Relancez la synthèse, vous obtiendrez peut-être autre chose. C’est l’exact contraire de la raison pour laquelle les outils ont été mis dans une image.

Il y a aussi une question de confidentialité : les sorties brutes, avec les ports ouverts et les versions de la cible, partent vers une API externe. Pour un audit, ça doit être écrit dans le périmètre, ou ne pas se faire.

La bonne architecture mélange les deux versions. Le déterministe fait tout ce qui doit être stable : lire le JSON de chaque outil, normaliser les sévérités, dédoublonner sur une clé simple (type de constat, hôte, port ou chemin), compter, et noter avec une grille écrite. Un outil qui échoue apparaît dans une section « couverture » du rapport, jamais parmi les constats. Le modèle de langage ne fait que ce qu’il fait bien : reformuler en français clair, pour un lecteur non technique, une liste de constats déjà triés et dédoublonnés. Il ne compte rien et ne note rien.

La dernière étape, elle, fonctionne bien. Un script charge le rapport global et les rapports par thème dans Chromium via Playwright, extrait les styles et le corps de chaque page, transforme les liens entre fichiers en ancres internes et imprime un PDF A4 avec sommaire. C’est le document qu’on peut envoyer.

6. Les faux positifs typiques, et comment les trier

Le premier passage en a fourni un bon échantillon.

Le service mal reconnu. Nmap a identifié un port non standard comme eppc?, avec un point d’interrogation. L’empreinte qu’il affichait juste en dessous contenait X-Powered-By: Next.js : c’était une application web. Un autre port était annoncé comme un service de supervision. Quand j’audite ce type de surface, je ne recopie jamais un nom de service Nmap dans un rapport sans avoir fait moi-même une requête dessus.

curl -skI https://www.exemple.fr:8443/ | head -5

La version « plus récente que la dernière ». Nuclei a détecté 18 extensions sur le CMS, chacune avec sa version et la dernière version connue du modèle. Pour plusieurs, la version installée était plus récente que la « dernière » annoncée : une extension en 6.0.0 face à un last_version="3.1.0". Ce n’est ni une faille ni une extension obsolète : c’est la donnée de référence du scanner qui est en retard. Ces lignes ne vont dans le rapport qu’après vérification de la version sur le dépôt de l’extension.

Le pare-feu en double. Nuclei a détecté deux pare-feux applicatifs de deux éditeurs différents sur la même page. Possible, mais peu probable. La détection repose sur des signatures dans les réponses, et une même réponse peut en satisfaire plusieurs.

BREACH. Nikto signale une compression deflate et en conclut que le serveur « peut » être vulnérable à BREACH. Cette attaque suppose une page compressée qui reflète une donnée contrôlée par l’attaquant à côté d’un secret, comme un jeton CSRF. Sur une page sans formulaire authentifié ni donnée reflétée, c’est théorique. testssl.sh, de son côté, n’a pas pu conclure, sa requête ayant été bloquée.

Le silence. Le plus dangereux n’est pas un faux positif, c’est un faux négatif. WhatWeb a renvoyé un code 0 et un fichier vide. Le générateur de rapports ignore les fichiers vides, donc l’identification des technologies a simplement disparu du rapport, sans aucune mention. Un code de retour à 0 ne prouve pas qu’un outil a travaillé. Il faut vérifier que la sortie existe et n’est pas vide.

La méthode de tri tient en quatre questions, dans l’ordre :

  1. Le constat est‑il reproductible avec une requête manuelle (curl, openssl s_client, un navigateur) ?
  2. Est‑il rapporté par plusieurs outils indépendants, ou par un seul ?
  3. La condition d’exploitation existe-t‑elle sur cette cible (formulaire, session, donnée sensible) ?
  4. Le scanner a-t‑il vu la vraie application, ou la page de blocage du pare-feu ?

Le dernier cas est fréquent. Quand Nikto et Nuclei sont bloqués après quelques dizaines de requêtes, ils analysent une page d’erreur. D’où l’intérêt de faire autoriser l’adresse IP de scan pendant la fenêtre de test.

7. Ce qu’un scanner ne remplace pas

Ce passage voit la surface. Il ne voit pas ce qu’il y a derrière.

  • Tout ce qui est derrière une connexion. ZAP en mode baseline explore les pages publiques. L’espace client, le back-office et l’API authentifiée lui sont invisibles.
  • La logique métier. Un utilisateur qui change un identifiant dans l’URL et voit la facture d’un autre client, une remise appliquée deux fois, un tunnel de paiement qu’on peut sauter : aucun modèle Nuclei ne décrit votre règle de gestion.
  • Le code. Une requête SQL construite à la main, un secret dans l’historique git, une dépendance abandonnée : ça se trouve en lisant le dépôt, pas en frappant à la porte. C’est l’objet de la due diligence technique, avec d’autres outils et d’autres accès.
  • L’exploitation. Un vrai test d’intrusion enchaîne les faiblesses : une version affichée, puis une faille connue de cette version, puis un accès. Un scanner les liste à plat.

Le scanner sert à ne pas perdre de temps humain sur ce qu’une machine trouve en quarante minutes, pour garder ce temps pour ce qu’elle ne trouvera jamais.

Reste un dernier constat, et il porte sur l’outil lui-même. La relecture du dépôt pour cet article l’a montré : l’outil contenait une clé d’API écrite en dur, dans un script de lancement et dans un des générateurs, alors que le script principal prend proprement la sienne dans une variable d’environnement. C’est exactement le défaut qu’un audit reproche aux équipes. Un scan externe ne l’aurait jamais vu.

Les leçons

  • Une image Docker n’est reproductible que si chaque version y est écrite. Une étiquette stable, un git clone sans étiquette et des modèles téléchargés au démarrage suffisent à rendre deux passages incomparables.
  • Un code de retour n’est pas un verdict. ZAP renvoie 2 quand il a des avertissements, testssl.sh peut renvoyer 9 après une analyse complète, WhatWeb renvoie 0 avec un fichier vide. Il faut vérifier les sorties, pas seulement les codes.
  • Les échecs d’outil ne sont pas des constats. Ils vont dans une section « couverture », qui dit au lecteur du rapport ce qui n’a pas été testé.
  • Le dédoublonnage et la note doivent être déterministes. Un modèle de langage peut rédiger le rapport, pas compter ni noter.
  • Le séquentiel est plus lent, et c’est voulu. Quarante minutes pour un passage lisible valent mieux que dix minutes de bruit et une adresse IP bannie.
  • L’autorisation écrite passe avant la commande. Périmètre, propriétaire de l’infrastructure, fenêtre et adresse de scan, par écrit.
  • Un scanner externe ne lit pas le code. Il ne remplace ni la revue du dépôt ni la compréhension du métier.
En résumé : une image Docker qui enchaîne Nmap, WhatWeb, Nikto, testssl.sh, Nuclei, ZAP et Trivy fait en une quarantaine de minutes le premier tour d’un audit externe, sans rien installer et dans un ordre qui ne brusque pas la cible. Mais sa valeur dépend de deux choses que l’outillage ne garantit pas seul : des versions réellement figées, et une fusion des résultats qui trie, dédoublonne et note de façon déterministe. Le reste, la logique métier et le code, reste un travail humain.