Pendant des semaines, notre framework d'evaluation affichait fierement : FPR 0% (0/98). Aucun faux positif sur 98 packages npm populaires. On etait satisfaits.
On avait tort.
La decouverte embarrassante
En examinant le code de evaluateBenign(), une realite genante est apparue. La fonction creait des repertoires temporaires vides contenant uniquement un package.json avec le nom du package, puis lancait le scanner sur ces repertoires vides.
Les 14 scanners (AST, dataflow, obfuscation, entropie, etc.) n'avaient litteralement rien a analyser. Le 0% ne mesurait que le matching IOC et la detection de typosquatting sur les noms. Pas du tout la capacite du scanner a eviter 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'asymetrie etait flagrante : on testait les vrais positifs sur du vrai code, et les faux positifs sur du vide.
Ne jamais faire confiance a un FPR de 0%. Un scanner de securite qui ne genere aucun faux positif ne scanne probablement rien.
La correction : scanner du vrai code
Reecriture complete pour telecharger et scanner les vrais tarballs npm. Et les galeres Windows en bonus :
execFileSync('npm', ...)echoue sur Windows - npm est un wrapper.cmdtar xzf "C:\path\file.tgz"interpreteC: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 reels. Resultat brutal :
| Package | Score | Cause principale |
|---|---|---|
| next | 100 | 76 dynamic_require, 45 dynamic_import (systeme 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 metriques Datadog |
Next.js a 76 dynamic_require. Un malware en a 1 ou 2.
L'observation cle : volume = legitimite
En analysant les 19 faux positifs initiaux, un pattern est apparu : les packages legitimes produisent des volumes massifs de certains types de menaces, alors que les malwares n'en ont que 1 a 3.
Un framework est bruyant - il utilise des centaines de patterns qui declenchent 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 - Precision (13% → 7.4%)
- Categorisation
os.platform/os.archcomme telemetrie (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 depend directement de la taille du package :
| Categorie | Packages | FPR |
|---|---|---|
| Petits (<10 fichiers .js) | 290 | 6.2% |
| Moyens (10-50 .js) | 135 | 11.9% |
| Gros (50-100 .js) | 40 | 25.0% |
| Tres gros (100+ .js) | 62 | 40.3% |
51% du dataset npm sont des petits packages. Pour un dev typique scannant ses dependances, le FPR effectif est plus proche de 6% que de 13%.
Progression complete
| Version | FPR | Correction principale |
|---|---|---|
| v2.2.7 | 38% | Premier FPR reel (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% | Telemetrie vs credentials |
| v2.3.1 | 7.4% | Precision heuristique |
| v2.5.8 | 6.0% | IOC wildcard audit |
Aucune de ces corrections n'a cause de regression sur les adversariaux ou le ground truth. Le principe : on downgrade la severite, on ne supprime jamais les findings.
Lecon
Un scanner qui affiche 0% de faux positifs ment probablement. Le 38% etait embarrassant mais honnete - c'etait la premiere mesure reelle. La suite a montre que la distinction malware/legitime 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 metriques n'est pas optionnelle. Si on avait continue avec le 0% fictif, toutes les decisions d'amelioration auraient ete basees sur une metrique fausse.