Pendant des semaines, notre framework d'évaluation affichait fièrement : FPR 0% (0/98). Aucun faux positif sur 98 packages npm populaires. On était satisfaits.
On avait tort.
La découverte embarrassante
En examinant le code de evaluateBenign(), une réalité gênante est apparue. La fonction créait des répertoires temporaires vides contenant uniquement un package.json avec le nom du package, puis lançait le scanner sur ces répertoires vides.
Les 14 scanners (AST, dataflow, obfuscation, entropie, etc.) n'avaient littéralement rien à analyser. Le 0% ne mesurait que le matching IOC et la détection de typosquatting sur les noms. Pas du tout la capacité du scanner à éviter les faux positifs sur du vrai code source.
Pendant ce temps, le TPR (ground truth) et l'ADR (adversarial) scannaient de vrais fichiers JavaScript. L'asymétrie était flagrante : on testait les vrais positifs sur du vrai code, et les faux positifs sur du vide.
Ne jamais faire confiance à un FPR de 0%. Un scanner de sécurité qui ne génère aucun faux positif ne scanne probablement rien.
La correction : scanner du vrai code
Réécriture complète pour télécharger et scanner les vrais tarballs npm. Et les galères Windows en bonus :
execFileSync('npm', ...)échoue sur Windows - npm est un wrapper.cmdtar xzf "C:\path\file.tgz"interprèteC:comme un remote host SSH- Solution : extracteur tar natif en Node.js (
zlib.gunzipSync+ parsing des headers tar 512 octets)
Le vrai FPR : 38%
Premier test sur 50 packages réels. Résultat brutal :
| Package | Score | Cause principale |
|---|---|---|
| next | 100 | 76 dynamic_require, 45 dynamic_import (système de plugins) |
| gatsby | 100 | 20 dynamic_require, 8 require.cache (HMR) |
| restify | 100 | 52 prototype_hook (Request/Response.prototype) |
| htmx.org | 100 | eval pour expressions CSS dynamiques, 5 staged_payload |
| moleculer | 100 | os.hostname + fetch pour métriques Datadog |
Next.js a 76 dynamic_require. Un malware en a 1 ou 2.
L'observation clé : volume = légitimité
En analysant les 19 faux positifs initiaux, un pattern est apparu : les packages légitimes produisent des volumes massifs de certains types de menaces, alors que les malwares n'en ont que 1 à 3.
Un framework est bruyant - il utilise des centaines de patterns qui déclenchent les heuristiques. Un malware est furtif - il utilise le minimum de techniques pour atteindre son objectif.
Le parcours : 38% → 7%
Pass 1 - Seuils de volume (38% → 19.4%)
dynamic_require>10 hits → tous LOWdangerous_call_function>5 hits → tous LOWprototype_hooksur prototypes custom (Request, Response) → MEDIUMrequire_cache_poison>3 hits → tous LOW
Pass 2 - Filtrage au scanner (19.4% → 17.5%)
- Expansion de
SAFE_ENV_VARS(+13 variables de config) - Fichiers dans
dist/,build/→ obfuscation LOW - Cap scoring pour
prototype_hookMEDIUM en volume
Le changement structurel - Per-file max scoring (17.5% → 13%)
Le levier le plus puissant. Au lieu d'additionner les findings de tous les fichiers :
Ancienne formule :
riskScore = min(100, somme(tous_les_findings))
Nouvelle formule :
riskScore = min(100, max(scores_par_fichier) + score_package_level)
Un package n'est suspect que si au moins un fichier est suspect. Next.js avec 3162 fichiers .js accumule des findings LOW/MEDIUM partout, mais aucun fichier individuel n'est vraiment suspect.
Passes 3-4 - Précision (13% → 7.4%)
- Catégorisation
os.platform/os.archcomme télémétrie (pas credentials) module_compilecount-based downgrade (>3 hits → LOW)- Skip des aliases npm dans les IOC
- Fichiers
.cjs/.mjs>100KB → bundled output (LOW)
L'analyse par taille
Le FPR n'est pas uniforme. Il dépend directement de la taille du package :
| Catégorie | Packages | FPR |
|---|---|---|
| Petits (<10 fichiers .js) | 290 | 6.2% |
| Moyens (10-50 .js) | 135 | 11.9% |
| Gros (50-100 .js) | 40 | 25.0% |
| Très gros (100+ .js) | 62 | 40.3% |
51% du dataset npm sont des petits packages. Pour un dev typique scannant ses dépendances, le FPR effectif est plus proche de 6% que de 13%.
Progression complète
| Version | FPR | Correction principale |
|---|---|---|
| v2.2.7 | 38% | Premier FPR réel (scan de vrais tarballs) |
| v2.2.8 | 19.4% | Seuils de volume par type |
| v2.2.9 | 17.5% | Filtrage scanner-level (env vars, dist/) |
| v2.2.11 | 13.1% | Scoring per-file max |
| v2.3.0 | 8.9% | Télémétrie vs credentials |
| v2.3.1 | 7.4% | Précision heuristique |
| v2.5.8 | 6.0% | IOC wildcard audit |
Aucune de ces corrections n'a causé de régression sur les adversariaux ou le ground truth. Le principe : on downgrade la sévérité, on ne supprime jamais les findings.
Leçon
Un scanner qui affiche 0% de faux positifs ment probablement. Le 38% était embarrassant mais honnête - c'était la première mesure réelle. La suite a montré que la distinction malware/légitime repose souvent sur le volume (un malware est furtif) et la concentration (un malware agit dans 1-2 fichiers, pas dans 3162).
La transparence sur les métriques n'est pas optionnelle. Si on avait continué avec le 0% fictif, toutes les décisions d'amélioration auraient été basées sur une métrique fausse.