Blog

Nuclei vs ZAP Proxy : faut-il deux scanners dans votre chaîne ?

Deux scanners DAST, deux terrains. Ce que Nuclei couvre réellement, ce qu'il recoupe avec ZAP Proxy, et sur quelle stack l'un ou l'autre se justifie.

Mathieu Ducrot Mathieu Ducrot
|
|
29 min de lecture
| Tech
Résumez cette page avec votre IA préférée :
Nuclei vs ZAP Proxy : quel scanner pour votre stack ?

Introduction

Avant d’ajouter un outil à une chaîne de sécurité, une seule question compte : que couvre-t-il que les autres ne couvrent pas déjà ?

Nous nous la sommes posée pour Nuclei. Il est très présent dans l’écosystème DevSecOps, et il est souvent recommandé en duo avec ZAP Proxy dans une grande partie des guides de sécurité applicative.

Nous l’avons évalué sur les trois familles de projets que nous construisons, parce que la réponse n’est pas la même sur chacune : une application Symfony à rendu serveur avec Twig, un back-office Symfony à rendu client sous Inertia et Vue, et un front statique buildé par Vite. Installation, pipeline locale, templates maison, scans comparés à ceux de ZAP Proxy : nous avons finalement décidé de ne pas l’intégrer à notre suite d’outils sécurité.

Cet article présente l’outil tel qu’il est, puis explique le cheminement qui nous a menés à ce choix, stack par stack.

1. Nuclei et l’écosystème ProjectDiscovery

ProjectDiscovery est un éditeur open source qui publie une suite d’outils de sécurité écrits en Go, rangés en trois phases : « Discover, Enrich, Detect ».

Nuclei est l’outil de la phase Detect, et le plus connu de la suite : un scanner qui cherche les faiblesses exploitables d’une surface d’attaque à partir d’une bibliothèque de templates.

Une chaîne d’outils, pas un scanner monolithique

Concrètement, les outils de la suite se chaînent par leurs entrées et sorties standard : ce que l’un produit alimente le suivant. Chacun couvre une étape, et une seule, du parcours « découvrir, qualifier, détecter ».

flowchart LR
  A["subfinder<br/>sous-domaines"] --> B["httpx<br/>hôtes vivants"]
  B --> C["katana<br/>crawl des URLs"]
  C --> D["nuclei<br/>détection par templates"]
  D --> E["notify<br/>Slack, webhook"]
  F["schéma OpenAPI"] --> D
  G["liste d'URLs<br/>écrite à la main"] --> D
  style D fill:#2b4bb8,color:#fff
OutilCe qu’il fait
subfinderÉnumération passive de sous-domaines depuis des sources publiques
httpxSonde HTTP en masse : code de statut, titre, technologies détectées, TLS
katanaCrawler web, avec un mode navigateur headless capable de suivre une application JavaScript
nucleiLe scanner de vulnérabilités, piloté par des templates YAML
notifyPousse la sortie d’un outil vers Slack, Discord ou un webhook
pdtmInstalle et met à jour tous les outils de la suite

Le reste du catalogue (naabu, dnsx, tlsx, asnmap, cloudlist, uncover, mapcidr, cdncheck) relève de la reconnaissance d’infrastructure : cartographier des plages réseau, des ports ouverts, des actifs cloud.

Cette organisation dit déjà à qui la suite s’adresse. Elle est pensée pour explorer une surface qu’on ne connaît pas, logique du test d’intrusion et du bug bounty. Retenez ce point, il revient en fin d’article.

Ce que Nuclei fait, et surtout ce qu’il ne fait pas

Nuclei est un scanner dont chaque test est un fichier YAML appelé template. À l’exécution, il charge les templates sélectionnés depuis ~/nuclei-templates/, envoie les requêtes que chacun décrit, compare la réponse aux critères déclarés et signale les correspondances.

Le point qui surprend le plus quand on vient de ZAP : Nuclei n’explore pas l’application. Il n’a pas de crawler. Là où ZAP découvre lui-même la surface avec son spider, Nuclei reçoit sa liste de cibles en entrée. Sans katana ni liste d’URLs, il ne teste que ce que ses templates savent sonder depuis la racine d’un domaine.

La surface de scan est donc une donnée d’entrée, pas un résultat. C’est ce qui explique pourquoi la suite compte autant d’outils autour de lui.

Ce qu’il détecte

  • Les CVE connues, par empreinte de version renvoyée par le service exposé.
  • Les erreurs de configuration : en-têtes de sécurité manquants, mode debug actif en production, CORS permissif.
  • Les expositions de fichiers : répertoire .git, sauvegardes oubliées, panneaux d’administration sans mot de passe.
  • L’empreinte technologique : version du CMS, serveur web, frameworks front.
  • Les secrets renvoyés par erreur dans une réponse HTTP : clés d’API, jetons.

2. Installer Nuclei

Avec Go

C’est la méthode que la documentation officielle met en avant, et la plus simple si la machine a déjà Golang d’installé.

go install -v github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest

Une remarque qui vaut pour toutes les méthodes : préférez une version épinglée à @latest dès que la commande finit dans un script ou dans une pipeline. Un scan qui change de moteur entre deux exécutions ne se compare plus au précédent.

Avec PDTM, le gestionnaire de la suite

PDTM installe et met à jour tous les binaires ProjectDiscovery d’un coup.

go install -v github.com/projectdiscovery/pdtm/cmd/pdtm@latest
pdtm -install-all

C’est pertinent si vous visez la chaîne complète (katana pour le crawl, httpx pour la qualification, nuclei pour la détection). Pour le seul scanner, cela n’apporte rien de plus que la commande précédente, et cela suppose Go de toute façon.

Avec Docker, la méthode à privilégier

docker pull projectdiscovery/nuclei:latest
docker run projectdiscovery/nuclei:latest -h

Le raisonnement est le même que pour nos commandes d’audit de Dockerfile : rien à installer sur le poste, la même version de l’outil pour toute l’équipe, et un job de CI qui tourne sans préparer le runner. En pipeline, épinglez l’image par version ou par empreinte plutôt que d’utiliser latest.

Les templates s’installent à part

Le binaire ne contient aucune règle. Au premier lancement, Nuclei télécharge les templates communautaires dans ~/nuclei-templates/, et la mise à jour se demande explicitement.

nuclei -update-templates

3. Comment fonctionne un scan

Le déroulé

Un scan se lit en quatre temps : chargement des templates retenus, envoi des requêtes que chacun décrit, comparaison des réponses aux critères de détection, restitution des correspondances.

nuclei -u https://integration.mon-projet.fr
nuclei -l cibles.txt
nuclei -l openapi.json -im openapi

L’option -im mérite un arrêt. Nuclei accepte autre chose qu’une liste d’URLs en entrée : list par défaut, mais aussi burp, jsonl, yaml, openapi et swagger. Le mode OpenAPI déplie les opérations décrites dans un schéma en requêtes réelles.

Lire une ligne de résultat

Exemple de rendu de rapport de scan Nuclei dans le terminal

Chaque correspondance suit le même format dans l’output standard, ici sur un endpoint de métriques laissé accessible :

[prometheus-metrics] [http] [medium] https://integration.mon-projet.fr/metrics

L’identifiant du template qui a levé l’alerte, le protocole utilisé, la sévérité déclarée, puis l’URL exacte où le problème a été observé. Rien de plus : ni explication du risque, ni correction suggérée. Pour comprendre pourquoi l’alerte est tombée, on ouvre le YAML du template.

Filtrer, sinon on lance treize mille règles

Sans filtre, Nuclei charge la totalité du dépôt communautaire, dont l’écrasante majorité ne correspond à aucune technologie de votre projet. Pour donner un ordre de grandeur, la version v10.4.8 de nuclei-templates, celle que nous avons utilisée pour cet article, compte 13 619 templates au total : 11 246 en protocole http, 4 283 portant le tag cve, et 447 en protocole file. Le fichier TEMPLATES-STATS.md livré avec le dépôt donne le décompte de votre propre version.

nuclei -u https://integration.mon-projet.fr -tags exposure,misconfig
nuclei -u https://integration.mon-projet.fr -severity critical,high
nuclei -u https://integration.mon-projet.fr -t http/exposures/ -t http/misconfiguration/
nuclei -u https://integration.mon-projet.fr -etags dos,fuzz

Les filtres se cumulent en ET. -t désigne un fichier ou un répertoire et peut être répété, -tags et -severity restreignent le jeu chargé, -etags exclut. Les deux exclusions à connaître sont dos, qui peut perturber le service, et fuzz, qui génère un volume de requêtes considérable.

Brider le débit

OptionRôleValeur par défaut
-rate-limitRequêtes par seconde, prévaut sur les autres150
-cTemplates exécutés en parallèle25
-bulk-sizeHôtes traités en parallèle par template25
-timeoutDélai maximum par requête, en secondes10

Scanner derrière une authentification

Sans identifiants, un scan ne voit que la partie publique de l’application. Sur un back-office Symfony, c’est-à-dire sur l’essentiel de ce que nous construisons, cela revient à ne scanner que la page de connexion. Nuclei accepte donc des en-têtes arbitraires, jeton Bearer d’une API ou cookie de session d’une application classique.

nuclei -u https://recette.exemple.fr -H "Authorization: Bearer $API_TOKEN" -tags misconfig,exposure
nuclei -u https://recette.exemple.fr -secret-file secrets.yaml

La seconde forme est préférable dès qu’on quitte son propre poste. Un jeton passé en -H se retrouve dans l’historique du shell et dans la table des processus, donc lisible par tout compte présent sur la machine : sur un runner de CI partagé, c’est une fuite d’identifiants qu’on s’inflige soi-même. -secret-file lit les identifiants depuis un fichier dédié, qui reste un secret d’exécution comme un autre.

Les sorties : tout, sauf un rapport HTML

C’est le premier écart concret avec ZAP Proxy, et il se voit dès la fin du premier scan. ZAP produit un rapport HTML autonome, lisible par n’importe qui, archivable en artefact de pipeline et transmissible tel quel à une équipe support sécurité. Nuclei n’a aucun export HTML natif.

SortieOptionCe qu’on en fait
Texte-oLecture immédiate, log du job de CI
JSONL-jleLe format pivot : filtrage jq, comparaison entre deux exécutions, seuil de rupture en CI
JSON-jeConsommation par un autre outil
Markdown-meUn répertoire, pas un fichier, voir ci-dessous
PDF-peLe seul export natif tenant en un seul fichier
SARIF-seIngestion par une plateforme de code scanning
Base de données-rdbHistorisation, pour comparer dans le temps

Le format qui s’en rapproche le plus en présentation est le markdown. Sauf que -me n’écrit pas un rapport, il écrit un répertoire : un fichier par correspondance, nommé [TemplateID]-[Host]-[UUID]-[MatcherName].md, plus un index.md récapitulatif listant les résultats dans un tableau Hostname, Finding, Severity. Sur un scan qui remonte quarante alertes, vous obtenez quarante-et-un fichiers. Il n’existe pas de markdown global.

Exemple de structure dossier des markdown à répétition par match de scan Nuclei

Le seul export mono-fichier natif est le PDF, qui convient à une transmission ponctuelle mais se prête mal à un suivi si on veut par exemple l’historiser via git.

Reste le JSONL, une ligne de JSON par résultat. C’est un format machine, et c’est précisément pour ça qu’il sert de base : on l’agrège soi-même pour produire un rapport lisible. C’est ce que nous avons fait pendant l’évaluation, avec un script qui lit le JSONL et génère un report.html autonome, avec en-tête (cible, date, templates utilisés), synthèse par sévérité et détail de chaque résultat.

Ce n’est pas rédhibitoire, mais c’est du travail que ZAP ne demande pas. Sur un outil qu’on envisage d’ajouter à côté d’un autre, ce genre d’écart se cumule.

4. La notion de template

Anatomie

Un template répond à une seule question : comment détecter ce problème ? Il décrit ce qu’on envoie, ce qu’on cherche dans la réponse, et la conclusion à en tirer.

id: git-config-exposure

info:
  name: Git Config File Exposure
  author: ice3man
  severity: medium
  description: Détecte l'exposition du fichier .git/config
  tags: git,config,exposure

http:
  - method: GET
    path:
      - "{{BaseURL}}/.git/config"

    matchers:
      - type: word
        words:
          - "[core]"
          - "[remote"
        condition: and

Quatre éléments : l’identifiant, le bloc info qui porte le nom, l’auteur, la sévérité et les tags, le bloc de protocole qui décrit la requête, et les matchers qui décident. {{BaseURL}} est remplacé par la cible à l’exécution.

Ce template accède à /.git/config et lève une alerte si la réponse contient les deux marqueurs d’un fichier de configuration Git. Une trentaine de lignes de YAML, sans SDK ni compilation : c’est la vraie force de l’outil.

Les matchers, là où se joue la fiabilité

Les types disponibles sont status (code de retour), word (présence d’une chaîne), regex, et dsl pour une expression. Ils se combinent par matchers-condition: and ou or, et les extractors récupèrent au passage une information utile de la réponse.

Explorer les templates existants

Ces commandes lisent le dépôt local sans envoyer la moindre requête à une cible.

nuclei -tl
nuclei -tgl
nuclei -t http/cves/2021/CVE-2021-44228.yaml -td

La première liste tous les chemins de templates, la deuxième les tags utilisables avec -tags, la troisième affiche le YAML d’un template précis. C’est le réflexe à prendre avant d’accorder du crédit à une alerte.

Un piège attend au premier essai : les chemins sont relatifs à ~/nuclei-templates/, et les templates HTTP vivent sous http/. -t cves/2021/CVE-2021-44228.yaml répond Could not find template, alors que le même chemin préfixé de http/ fonctionne.

Écrire ses propres règles

Une des forces mises en avant par l’outil est de pouvoir écrire ses propres templates. Les templates communautaires couvrent mal ce qui est propre à votre application, puisque les chemins et les marqueurs varient d’un projet à l’autre.

id: symfony-profiler-exposed

info:
  name: Profiler Symfony accessible
  author: smartbooster
  severity: high
  description: Le Web Profiler expose les requêtes SQL, la configuration et les variables d'environnement.
  tags: symfony,exposure

http:
  - method: GET
    path:
      - "{{BaseURL}}/_profiler"
      - "{{BaseURL}}/_wdt/latest"

    matchers-condition: and
    matchers:
      - type: status
        status:
          - 200
      - type: word
        part: body
        words:
          - "Symfony Profiler"

Notez le matchers-condition: and : le statut seul ne suffit pas, il faut aussi le marqueur dans le corps de la réponse. C’est presque toujours le bon choix pour une règle destinée à tourner en automatique.

Les protocoles au-delà du HTTP

http couvre la quasi-totalité des besoins applicatifs. À côté, file scanne un répertoire du système de fichiers au lieu d’une URL, headless pilote un vrai navigateur, et dns, ssl, network et code traitent le reste.

Le faux ami à désamorcer : le protocole javascript n’audite pas le JavaScript exécuté dans le navigateur. C’est un moteur JS embarqué dans le scanner, prévu pour dialoguer avec des services réseau (SSH, Redis, LDAP) ou construire des paquets à la main. Pour auditer une application à rendu client, c’est headless et katana qu’il faut regarder.

5. Automatiser le scan

Le cadre légal et l’environnement, avant toute commande

Un scan sans autorisation est un délit. Nuclei génère du trafic actif contre une cible : ce n’est pas une relecture de code, c’est un test intrusif. En France, accéder ou se maintenir frauduleusement dans un système d’information est puni par l’article 323-1 du Code pénal de trois ans d’emprisonnement et 100 000 euros d’amende, peines aggravées si le scan cause un dommage. Il faut donc une autorisation écrite du propriétaire du système, ou un périmètre dont on est soi-même responsable.

Côté technique, les règles sont celles que nous appliquons déjà à nos scans DAST : un environnement d’intégration alimenté par des données factices, iso-production sur les versions et la configuration, et l’hébergeur prévenu. Vu depuis un pare-feu applicatif, un scan ressemble beaucoup à une attaque.

En CI via l’image Docker

nuclei-scan:
  stage: security
  image:
    name: projectdiscovery/nuclei:latest
    entrypoint: [""]
  when: manual
  script:
    - nuclei -ut
    - nuclei -l cibles.txt -severity critical,high -etags dos,fuzz -rl 25
        -jle nuclei/findings.jsonl -o nuclei/findings.txt
    - if [ -s nuclei/findings.jsonl ]; then echo "Findings détectés"; exit 1; fi
  artifacts:
    when: always
    expire_in: 30 days
    paths:
      - nuclei/

L’exemple est simplifié sur un point qui compte : il suppose cibles.txt déjà présent. Ce fichier est la surface de scan, et Nuclei ne sait pas la produire. Sur une application dont les routes ne se devinent pas depuis la racine du domaine, il faut la générer en amont avec katana, qui crawle le site et sort la liste des URLs atteignables.

En pratique, cela fait deux jobs consécutifs plutôt qu’un, chacun tournant sur son image, le premier passant son résultat au second par les artefacts de la pipeline.

katana-crawl:
  stage: security
  image:
    name: projectdiscovery/katana:latest
    entrypoint: [""]
  script:
    - katana -u https://integration.mon-projet.fr -jc -d 3 -f url -o cibles.txt
  artifacts:
    paths:
      - cibles.txt

nuclei-scan:
  needs:
    - job: katana-crawl
      artifacts: true
  # le reste du job, identique à l'exemple précédent

Si l’application a un rendu client, le crawl demande le mode headless, donc l’image katana reconstruite évoquée plus haut. Et si la surface est stable et connue, un cibles.txt versionné dans le dépôt reste la solution la plus simple : le crawl n’a d’intérêt que sur une application dont les routes bougent.

Le seuil de rupture se pose sur le fichier JSONL : la pipeline échoue s’il n’est pas vide. En pratique on affine, en comparant les identifiants de templates à une liste de référence pour ne faire échouer le job que sur les nouveautés.

GitHub a son action officielle, GitLab non

C’est un point à connaître avant de s’engager. ProjectDiscovery ne documente officiellement qu’une intégration : l’action GitHub projectdiscovery/nuclei-action@v3.

      - name: Run Nuclei
        uses: projectdiscovery/nuclei-action@v3
        with:
          args: -u https://example.com

Sur GitHub, l’export SARIF est ingéré nativement par l’onglet Security et les vulnérabilités apparaissent dans l’interface (si le dépôt est public, sinon il faut a minima le plan tarifaire GitHub Code Security).

Sur GitLab, rien de tout ça : pas d’action officielle, et le SARIF se conserve en artefact, son ingestion native dépendant du niveau de licence. On repasse donc par l’image Docker, comme ci-dessus. Ce n’est pas bloquant, mais c’est un coût d’intégration qui reste à la charge de l’équipe.

Le cas à part : scanner un répertoire de build en local

Une capacité que ZAP n’a pas du tout, et qui ne demande ni serveur, ni réseau, ni autorisation.

nuclei -file -target dist/ -t file/keys/

Avec le protocole file, Nuclei applique ses matchers au contenu des fichiers au lieu des réponses HTTP. Sur le dist/ d’un build front, on attend de lui qu’il attrape les régressions typiques : une source map réactivée, un secret inliné dans le bundle.

Noter le -t file/keys/, qui n’est pas un détail de confort. Le catalogue file/ complet vise des systèmes de fichiers compromis, serveurs et postes de travail, et non des artefacts de build : le lancer entier sur un dist/ coûte très cher pour ce qu’il rapporte. Nous l’avons mesuré sur ce site, les chiffres sont plus bas.

Un job de CI qui build puis scanne son propre dist/ ne demande aucune autorisation d’infrastructure. C’est le seul usage de Nuclei qui ne recoupe rien de notre chaîne actuelle.

6. Pourquoi nous ne l’avons pas retenu dans notre suite sécurité

Le duo ZAP plus Nuclei que l’on lit partout

La recommandation existe, elle est répétée dans la plupart des guides, et elle n’est pas absurde. Sur un périmètre de test d’intrusion ou de bug bounty, les deux outils se complètent réellement : l’un explore et attaque en profondeur une application, l’autre balaie vite et large une surface qu’on découvre.

Mais ce périmètre n’est pas le nôtre. Nous auditons des applications que nous avons construites, dont nous connaissons les routes, la stack et l’hébergement chez Clever Cloud. La découverte de surface, principale valeur de la suite ProjectDiscovery, est déjà acquise avant le premier scan. Un conseil pertinent dans un contexte ne l’est pas mécaniquement dans un autre.

Raison 1 : un recoupement large avec ZAP Proxy

Mis à plat, l’essentiel de ce que Nuclei apporterait est déjà couvert, soit par ZAP, soit ailleurs dans notre chaîne.

flowchart TB
  subgraph Zap["Déjà couvert par ZAP Proxy"]
    Z1["Crawl d'une application JS<br/>spider et spider AJAX"]
    Z2["En-têtes, cookies, fuites<br/>scan passif"]
    Z3["Injections SQL, XSS, traversée<br/>scan actif"]
  end
  subgraph Chaine["Déjà couvert ailleurs dans notre chaîne"]
    C1["CVE des dépendances PHP<br/>composer audit"]
    C2["CVE des dépendances JS<br/>npm audit"]
    C3["CVE de l'image Docker<br/>OSV Scanner"]
  end
  subgraph Reste["Ce que Nuclei ajoute réellement"]
    N1["Sonde de chemins connus<br/>.env, .git/config, sauvegardes"]
    N2["Scan d'un répertoire de build<br/>protocole file"]
  end
  style Reste fill:#0f766e,color:#fff

Le recoupement le plus frontal concerne le fuzzing. Les templates dast/ de Nuclei injectent des charges dans les paramètres pour détecter injections SQL, SSRF et XSS : c’est exactement la famille d’attaques du scan actif de ZAP, sur les mêmes paramètres. Ajouter les deux, c’est payer deux fois le même test et trier deux fois les mêmes alertes.

Le recoupement va jusqu’aux vulnérabilités aveugles, celles qui ne se voient pas dans la réponse HTTP. Une SSRF ou une exécution de code à distance sans retour ne se détecte qu’en observant si la cible contacte un serveur tiers après l’injection. Nuclei le fait avec Interactsh, actif par défaut : il insère des URLs d’écoute dans ses charges et guette le rappel. C’est le mécanisme d’OAST, et ZAP a le sien, via son extension du même nom. Là encore, deux outils pour la même détection.

Reste un gain net, la sonde de chemins connus. ZAP n’analyse que ce dont il a été paramétré et crawlé par les spiders, il ne va pas deviner /.env ou /.git/config. Sauf que ce gain se chiffre en une dizaine de règles, que nous couvrons par des règles maison sans introduire un second scanner.

Raison 2 : la CVE est déjà traitée, plus tôt et plus sûrement

Notre chaîne traite les vulnérabilités connues à la source, par un audit de dépendances à chaque étage : composer audit sur les dépendances PHP, npm audit sur les paquets front, OSV Scanner sur les paquets système de l’image Docker, et un suivi de versions qui empêche la dette de s’installer.

Cette couverture ne dépend pas du mode de rendu : elle est identique sur une application Twig, sur un back-office Vue et sur un front statique, puisqu’elle lit des fichiers de verrouillage et non des pages servies.

Les templates cve/ de Nuclei travaillent dans l’autre sens : ils déduisent une version vulnérable de l’extérieur, par empreinte. C’est plus tard dans le cycle, moins fiable qu’une lecture de composer.lock ou d’un package-lock.json, et la correction reste strictement la même. Sur ce terrain, un scanner externe confirme au mieux ce que l’analyse des dépendances a déjà dit.

Raison 3 : des templates communautaires sans standard de référence

C’est l’argument qui a pesé le plus lourd.

Les règles de ZAP viennent d’un projet gouverné par l’OWASP. Chaque alerte porte un identifiant stable, une page de documentation qui explique le risque et la correction attendue, et un niveau de confiance annoncé par l’outil lui-même.

Les templates Nuclei sont d’une autre nature. Ce sont des contributions communautaires, plus de treize mille, sans organisme de référence qui en valide la pertinence. Leur qualité est très inégale, et rien ne la signale de l’extérieur : un template écrit avec soin et un template approximatif se présentent exactement pareil dans la sortie du scan, avec la sévérité que son auteur a bien voulu déclarer.

Évaluer une règle suppose donc de lire son YAML et de juger ses matchers, comme vu plus haut. Il en découle deux conséquences, et la seconde est la plus coûteuse :

  • Un risque de faux positif théoriquement plus élevé qu’avec ZAP, dont le jeu de règles est stabilisé et documenté alerte par alerte.
  • Un coût de tri qui retombe sur l’équipe à chaque exécution. Une alerte dont on ne sait pas si elle est fiable doit être vérifiée à la main. Le bruit d’alerte a un effet connu : un scan dont on doute finit par ne plus être lu, et un scan qu’on ne lit plus ne protège de rien.

Il existe un flag qui promet d’aider à ce tri, et il faut le regarder de près plutôt que l’ignorer. Comme l’explique Stéphane Robert dans son guide de Nuclei, l’option -ms, pour matcher-status, affiche l’état des matchers et aide à distinguer un vrai positif d’un faux. Nous ne la connaissions pas avant de lire son article, merci à lui.

Nous l’avons donc mise à l’épreuve sur l’un des plus bruyants de nos résultats, le template php-scanner, relancé sur le même build avec et sans l’option.

# sans -ms
[php-scanner] [file] [info] /scan/404.html ["exit","die","and","header"]

# avec -ms
[php-scanner] [matched] [file] [info] /scan/404.html ["exit","die","and","header"]

245 lignes dans les deux cas, à un libellé près. Le flag ajoute [matched], et rien de plus : la liste des mots trouvés était déjà là. Il est conçu pour le protocole http, où il expose aussi les matchers qui ont échoué et aide à comprendre pourquoi un template ne se déclenche pas. Sur le protocole file, il n’apporte rien.

Surtout, il ne répond pas à la question qui nous occupe. -ms confirme que php-scanner a bien trouvé le mot exit dans une page HTML : le matcher a fonctionné, la règle a fait exactement ce qu’elle décrit. Le problème n’est pas là. Il est qu’une règle qui cherche le mot exit dans un fichier n’a aucune valeur de détection sur un site sans PHP, et aucun flag ne le dira. Ce jugement se rend en lisant le YAML, ou il ne se rend pas.

Dernier point, à traiter sans dramatiser : un template est du contenu exécutable tiré d’un dépôt public et rafraîchi à chaque -update-templates. ProjectDiscovery en a d’ailleurs tiré les conséquences sur le protocole le plus sensible, puisque depuis la version 3.11 un template personnalisé utilisant le protocole javascript doit être signé pour être chargé, les templates officiels étant pré-signés et vérifiés avec la clé publique du projet. C’est une bonne mesure, et elle rappelle que la question de la confiance se pose.

Raison 4 : deux scanners, deux fois le coût de fonctionnement

Un outil ne coûte pas ce qu’il coûte à installer, il coûte ce qu’il coûte à faire vivre. Deux scanners, ce sont deux formats de rapport, deux listes de faux positifs à qualifier avec le client, deux configurations de périmètre à tenir à jour, et deux outils à expliquer dans un cadrage.

Le vrai arbitrage n’est donc pas « ZAP ou Nuclei », il est « un scanner ou deux ». Formulé ainsi, la question devient tranchable : le second doit rembourser son coût de fonctionnement par du gain de couverture, pas par du confort de découverte.

Le test grandeur nature : notre propre build

Un arbitrage sur dossier ne vaut pas grand-chose. Nous avons donc branché Nuclei sur le build de ce site, un front statique généré par Astro, et lancé le protocole file sur les 137 Mo de dist/, images comprises.

Le catalogue file/ communautaire compte 447 templates. Le scan a duré près de 25 minutes et remonté 818 résultats, dont pas un seul vrai positif. Les principaux contributeurs au bruit :

TemplateRemontéesSévérité déclaréeCe qu’il a réellement matché
dom-invader-xss263highJSON.parse et fetch
php-scanner245infoexit, die, and, header dans des pages HTML, sur un site sans PHP
credentials-disclosure-file134unknownPWd= dans un .webp binaire, ref_key: généré par Vue
js-analyse103infoles imports de nos propres chunks
url-extension-inspector55unknownla chaîne .db
*-framework-exceptions14info à mediumdes traces d’exception ASP.NET, Django et Node sur un site Astro
linkedin-id2lownotre propre URL LinkedIn, celle du pied de page

C’est la raison 3 démontrée par les chiffres plutôt que par le raisonnement. Un template high qui matche fetch, un medium qui cherche des exceptions Django sur un site qui n’exécute pas une ligne de Python : la sévérité est déclarative, et rien dans la sortie ne distingue une règle solide d’une règle approximative. L’explication tient à la vocation du catalogue, qui vise des systèmes de fichiers compromis, serveurs et postes de travail, et non des artefacts de build.

Le temps de scan mérite lui aussi une explication, parce qu’il est contre-intuitif. Le protocole file lit chaque octet du répertoire et lui applique l’ensemble des matchers chargés. Le coût est donc linéaire en poids, pas en nombre de pages : nos deux plus grosses images, 6,8 Mo et 1,6 Mo, coûtent à elles seules plus cher que tout le HTML du site. Sur une CI facturée à la minute, vingt-cinq minutes pour zéro information est un prix qu’on ne paie pas deux fois.

Restreindre le catalogue rend le scan exploitable. file/keys/ est la seule famille qui ait une chance de trouver quelque chose de réel dans un artefact de build, à savoir un secret inliné dans un bundle. En écartant deux de ses templates pour une raison structurelle et non pour faire taire un résultat gênant, credentials-disclosure-file dont la regex matche PWd= en base64 dans n’importe quelle image, et linkedin-id qui matche notre propre pied de page, il reste 155 règles, trois minutes d’exécution et zéro remontée.

Zéro est le bon résultat : il n’y a effectivement rien à trouver. Mais nous l’avons obtenu en écrivant nous-mêmes la liste de ce qu’il ne fallait pas écouter, c’est-à-dire en faisant à la place de l’outil le travail de tri que nous attendions de lui.

Reste la règle maison, celle qui devait justifier l’outil : détecter une source map livrée dans le build, qui republierait le code source du front. Elle fonctionne, une vingtaine de lignes de YAML ont suffi, et elle ne remonte rien puisque Astro n’en publie aucune. Mais find dist -name '*.map' répond exactement à la même question, en une ligne et sans scanner. Le gain net que nous pensions avoir identifié ne survit pas à sa mise à l’épreuve.

Ce qu’un build local ne peut pas dire

Une précision, pour ne pas conclure trop large : trois familles de vérifications échappent à un scan de répertoire.

  • Les headers de sécurité. X-Frame-Options, Content-Security-Policy ou Referrer-Policy sont posés par le serveur et par l’hébergeur, jamais par le générateur. Ils sont absents du build par construction.
  • La configuration TLS. Certificat, chaîne de confiance, suites de chiffrement : tout appartient à l’hôte.
  • Les chemins servis hors du build. Un .git/ ou un .env laissé à la racine du serveur ne se trouve pas dans dist/, mais reste servi si la racine est mal cadrée.

Nous avons voulu couvrir ces trois familles par une commande de scan dédiée, et le périmètre s’est vidé pendant qu’on le mesurait.

Les headers, d’abord, n’imposent pas d’aller en production. Le CLI de notre hébergeur sert le build avec le fichier de configuration réel du site, redirections comprises. Les six en-têtes arrivent tels qu’ils sont déclarés, CSP incluse, et les 301 répondent. La configuration est donc entièrement vérifiable hors production.

Mais pas avec un scanner. Les 334 templates de ce périmètre, lancés sur ce serveur local, l’ont fait tomber au bout de 301 requêtes et douze secondes, à 25 requêtes par seconde, sur une erreur interne de son proxy. Un serveur de développement n’encaisse pas ce qu’encaisse un CDN : passé ce point le scan ne mesure plus la configuration du site, il mesure la robustesse du serveur de test. Un curl -sI répond à la même question en une requête, et sans ambiguïté sur ce qu’il mesure.

Il reste alors deux vérifications, et aucune n’est un scan. Un curl -sI sur le domaine de production pour confirmer que les en-têtes arrivent bien et qu’aucune dérive ne s’est glissée entre le dépôt et la console de l’hébergeur. Un test TLS en lecture seule pour le certificat et les suites de chiffrement. Quant aux chemins servis hors du build, ils se cherchent dans le build, avec find.

Notre dépôt n’a donc aucune commande qui scanne la production. Ce n’est pas une précaution timide, c’est ce qui reste une fois qu’on a demandé à chaque vérification quel outil la sert le mieux.

Nuclei et ZAP Proxy, mis face à face

Les deux outils font du test dynamique sur une application déployée, et c’est à peu près tout ce qu’ils ont en commun. L’un balaie large une surface qu’on découvre, l’autre creuse une application qu’on connaît.

CritèreNucleiZAP Proxy
NatureMoteur de règles déclaratives, un fichier YAML par testProxy d’interception et scanner applicatif complet
Découverte de la surfaceAucune : la liste des cibles est une entrée, produite par katana ou écrite à la mainSpider et spider AJAX intégrés, la surface est un résultat
Détection par chemins connusPoint fort : .env, .git/config, sauvegardes, panneaux d’administrationAbsent, ZAP ne teste que ce qu’il a crawlé ou ce qu’on lui a donné
Injections SQL, XSS, traverséeTemplates dast/ et moteur de fuzzingScan actif, c’est son coeur de métier
CVE par empreinte de versionPlusieurs milliers de templates cveCouverture marginale, ce n’est pas son objet
Scan d’un répertoire localProtocole file, sans serveur ni réseau, mais catalogue inadapté à un buildImpossible, ZAP a besoin d’une cible HTTP
Gouvernance des règlesContributions communautaires, sans organisme de validationProjet OWASP, alertes documentées et niveau de confiance annoncé
Écriture d’une règle maisonUne trentaine de lignes de YAML, sans compilationScript Zest, Python ou JavaScript, ou greffon Java
Rapport lisible par un tiersAucun export HTML natif, JSONL à agréger soi-mêmeRapport HTML autonome, archivable en artefact
Intégration CIAction GitHub officielle, image Docker ailleursActions GitHub officielles, image Docker et mode démon
Terrain de prédilectionReconnaissance, bug bounty, parc d’actifs mal cartographiéAudit d’une application dont on maîtrise le périmètre

La lecture en diagonale du tableau donne la clé de notre arbitrage : Nuclei gagne nettement sur deux lignes, la sonde de chemins connus et le scan d’un répertoire local. La seconde ne tient pas à l’épreuve, comme la mesure ci-dessus l’a montré : la capacité existe, le catalogue qui la sert ne vise pas des artefacts de build. Reste un gain, et un gain ne paie pas un second scanner dans la chaîne.

Conclusion

Nuclei est un excellent scanner de vulnérabilités, et cet article ne dit pas le contraire. Il est rapide, ses templates sont lisibles, sa communauté réagit en quelques heures à une CVE majeure. Sur une surface qu’on ne connaît pas, dans un contexte de reconnaissance, c’est l’outil qu’il faut.

Notre situation est simplement différente. Nous connaissons la surface, le fuzzing recoupe notre scan actif, les CVE sont traitées plus tôt sur les dépendances, et l’évaluation de règles communautaires sans référentiel commun aurait ajouté du tri sans ajouter de couverture.

Ce qui vaut d’être repris, c’est la méthode plutôt que le verdict. Avant d’ajouter un outil à une chaîne de sécurité, cartographiez le recoupement de couverture avec l’existant, et demandez au candidat quel manque précis il complète. Une suite d’outils ne se construit pas par accumulation : chaque ajout se paie en tri d’alertes, en configuration et en maintenance évolutive, et doit être remboursé par un gain réel.

C’est exactement le raisonnement que nous menons sur les projets que nous suivons, et il vaut aussi bien pour un audit technique ponctuel que pour une chaîne d’outillage qu’on fait vivre pendant des années.


Liens utiles

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

Articles similaires

Vous avez un projet ?

Contactez-nous pour savoir comment nous pouvons vous aider.