← Blog

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 .cmd
  • tar xzf "C:\path\file.tgz" interprète C: 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 :

PackageScoreCause principale
next10076 dynamic_require, 45 dynamic_import (système de plugins)
gatsby10020 dynamic_require, 8 require.cache (HMR)
restify10052 prototype_hook (Request/Response.prototype)
htmx.org100eval pour expressions CSS dynamiques, 5 staged_payload
moleculer100os.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 LOW
  • dangerous_call_function >5 hits → tous LOW
  • prototype_hook sur prototypes custom (Request, Response) → MEDIUM
  • require_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_hook MEDIUM 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.arch comme télémétrie (pas credentials)
  • module_compile count-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égoriePackagesFPR
Petits (<10 fichiers .js)2906.2%
Moyens (10-50 .js)13511.9%
Gros (50-100 .js)4025.0%
Très gros (100+ .js)6240.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

VersionFPRCorrection principale
v2.2.738%Premier FPR réel (scan de vrais tarballs)
v2.2.819.4%Seuils de volume par type
v2.2.917.5%Filtrage scanner-level (env vars, dist/)
v2.2.1113.1%Scoring per-file max
v2.3.08.9%Télémétrie vs credentials
v2.3.17.4%Précision heuristique
v2.5.86.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.