← Blog

L'article du 5 avril annonçait FPR 11% à 2.85%, TPR 99.93%, AUC 0.999. Ces chiffres étaient faux. Pas inventés : mesurés sur un corpus de validation contenant 44 807 packages labellisés clean par le scanner lui-même, sans supervision externe. Le modèle apprenait à reproduire les décisions du scanner, pas à corriger ses faux positifs.

Note méthodologique : MUAD'DIB est un projet où je définis la direction technique, les décisions architecturales et le scope. Le code est écrit par Claude Code sous supervision. Les reviews de packages sont faites par des agents Claude Code en parallèle sur VPS avec spot-check humain. Je valide les patterns, je décide des pivots, je mesure les résultats. Cette retrospective raconte une session de travail du 18-19 avril où un classifier ML a été remplacé par un post-filtre déterministe.

Le diagnostic de contamination

Le FPR production du scanner restait élevé après les fixes de v2.10.74 (P1-P4 FP cluster fixes basés sur l'audit forensique de 53 953 alertes). Les clusters faciles avaient été capturés. Les FP qui restaient en zone CRITICAL généraient des webhooks Discord P1 qui noyaient les vraies alertes. Avant d'ajouter encore des heuristiques au scanner, il fallait comprendre quels patterns produisaient ces FP résiduels.

La semaine du 10 au 18 avril, le moniteur a flaggé 2 146 packages avec un score ≥ 70. La review a été lancée sur le VPS : plusieurs agents Claude Code en parallèle, chacun ouvre les tarballs, lit le code, classe malware ou FP. Spot-check humain sur 20 packages tirés au hasard (17/20 FP confirmés, 3 reclassés en faveur du verdict d'agent). Première passe sur 1 481 packages, puis review étendue jusqu'à 2 146.

8 clusters de faux positifs ressortent :

  • Bundles minifiés sans script lifecycle (babylonjs, vue.runtime.global.prod.js, electron)
  • Installers binaires depuis GitHub Releases (esbuild, swc, sharp, prebuild-install)
  • Endpoints réseau dans le scope du package (@stripe/stripe-js qui fetch api.stripe.com)
  • Git hooks écrits depuis source locale (husky, simple-git-hooks)
  • Typosquats sur noms scoped (faux trigger Levenshtein sur @scope/foo vs @scope/foobar)
  • Obfuscation commerciale (jscrambler, javascript-obfuscator) sans signal d'attaque
  • Placeholders anti-dep-confusion (index.js qui fait module.exports = {})
  • Install scripts sans network egress (config loaders, build scripts)

2 vrais malwares sont surfacés au passage (percy-cake-docker, renovate-config-doctolib). Ils étaient passés en zone P3 jaune sans déclencher d'alerte forte. Sans review systématique sur tous les flagged, ils restaient invisibles.

Sortie de la review : un corpus de 302 packages avec verdicts agents validés par spot-check, 198 FP + 104 malware. Dans le reste de l'article il est nommé "corpus humain" par opposition aux corpus auto-labellés par le scanner, pas parce qu'un humain aurait lu chacun des 302 packages.

Les 8 features F1-F8 (v2.10.96)

L'approche retenue : au lieu d'ajouter encore 8 heuristiques au scanner (qui finiraient par tirer aussi sur des malwares qui imitent ces patterns), construire 8 features contextuelles pour que le ML puisse les utiliser comme inputs. 8 features contextuelles sont implémentées dans src/ml/feature-extractor.js. Chaque feature est un boolean ciblant un cluster :

IDFeatureCluster
F1bundle_without_install_scriptsBundles minifiés sans lifecycle
F2install_url_github_releasesBinaires depuis GitHub Releases
F3network_destination_first_partyRéseau dans le scope du package
F4git_hook_source_localGit hooks locaux
F5typosquat_scoped_packageFaux trigger Levenshtein scoped
F6obfuscation_without_vectorObfuscation commerciale
F7placeholder_anti_dep_confusionPlaceholder dependency confusion
F8install_script_no_network_egressInstall sans egress réseau (DESACTIVEE)

F8 est laissée inerte (features.install_script_no_network_egress = 0). La mesure sur le corpus malveillant montre qu'elle classifie 19 malwares comme FP : ils exfiltrent via dangerous_exec (curl/wget direct), pas via les types réseau Node.js trackés par EGRESS_TYPES. Si on l'active, le ML apprend "install_script_no_network_egress = benign" sur des malwares confirmés. Elle reste désactivée jusqu'à élargissement de EGRESS_TYPES.

Le reste de la PR est de la plomberie : homepage extrait de npm-registry.js (nécessaire pour F3), threat.urls[] ajouté dans handle-post-walk.js, fileSizes propagé jusqu'au build du record ML. Aucun changement de scoring : v2.10.96 produit les mêmes scores que v2.10.95 sur tous les packages.

Le gate AUC qui ne passe pas

Validation des features individuelles sur le corpus 302. AUC par feature :

FeatureAUCNote
F1 bundle_without_install_scripts0.6061Le moins pire
F2 install_url_github_releases0.5202
F3 network_destination_first_party0.5076
F4 git_hook_source_local0.5051
F5 typosquat_scoped_package0.5354
F6 obfuscation_without_vector0.5152
F7 placeholder_anti_dep_confusion0.5000Jamais tirée sur le corpus
F8 install_script_no_network_egress0.5913Direction inversée (malware)

Gate AUC FAIL : aucune feature n'atteint 0.70.

La matrice de confusion par feature montre autre chose : la précision est 100% sur F1 à F6. Quand elles fire, c'est un FP, jamais un malware. 0% de malware impacté, mesuré sur le corpus reviewé.

L'écart entre AUC ~0.55 et précision 100% vient des features binaires sparses. AUC mesure le ranking de l'ensemble du corpus. Une feature qui tire sur 35 packages sur 302 (cas de F1) a un AUC max théorique d'environ 0.61, même avec précision parfaite, parce que les 267 autres packages sont rankés au hasard. AUC est une mauvaise métrique pour un signal binaire qui ne s'applique qu'à un sous-ensemble. Précision conditionnelle est la bonne.

Le retrain XGBoost à AUC 0.9998

Première tentative de retrain pour activer un classifier ML. Inputs : 96 features (88 originales + 8 nouvelles F1-F8). Le corpus d'entraînement combine les 302 labels reviewés et ml-training-merged.jsonl qui contient 44 807 records labellisés clean automatiquement par le moniteur.

MétriqueValeur
AUC holdout0.9998
TPR @ 1% FPR1.0
Precision1.0
Recall1.0

Quand un modèle atteint precision 1.0 et recall 1.0 sur du data réel, c'est presque toujours du data leakage. Diagnostic : le modèle n'apprend pas à distinguer FP de malware. Il apprend à distinguer "scanné clean par le scanner" (44 807 labels auto) de "malware connu". Le signal vient des labels auto pollués.

C'est la même erreur que celle documentée dans l'article du 22 mars, sous une variante : cette fois le pipeline n'utilisait pas directement les labels fp, il utilisait les labels clean du scanner. Règle violée : ml-training*.jsonl est un fichier de dédup référentiel, jamais une source de labels. Le retrain est rejeté. La règle est notée dans le repo, pour ne pas la re-perdre au prochain cycle.

Le retrain propre sur 302 labels reviewés

Deuxième tentative. Corpus reviewé uniquement (les 302 packages décrits en section 1) : 198 FP + 104 malware. Stratification 80/20, holdout 60 packages, 5-fold cross validation.

MétriqueValeur
AUC holdout0.9917
5-fold CV0.9888 ± 0.0041
TPR @ 1% FPR0.6667
TPR @ 5% FPR1.0
Precision (holdout)0.9130 (21 TP, 2 FP)
Recall (holdout)1.0 sur 3 malwares

Écart AUC holdout vs CV inférieur à 0.003. Pas d'overfitting au sens classique sur ce corpus. F1 (bundle_without_install_scripts) arrive en rang 3 du feature importance avec 0.1149. Les autres features F2-F8 ont une importance proche de zéro.

Les 8 features ciblées ont importance 0 dans XGBoost

Top 5 features du modèle retrainé :

  1. file_count_total
  2. count_low
  3. dep_count
  4. type_dangerous_exec
  5. type_crypto_decipher

Les 8 features qu'on avait ciblées sur les clusters FP : importance 0 ou proche. Précision 100% sur leur sous-ensemble, importance 0 dans le modèle. Comportement contre-intuitif au premier abord, classique en relecture.

Sur 302 samples, XGBoost préfère splitter sur des features continues qui partitionnent 100% du corpus (file_count_total divise 302 en 2 groupes équilibrés à chaque split) plutôt que sur des features binaires sparses qui ne discriminent que sur 14% du corpus (F1 ne tire que sur 42 packages). Le gain d'information par split est plus grand sur les continues, même si le gain final sur les FP est plus petit. Pour que des features sparses émergent, il faut beaucoup plus de données. Estimation : 2 000+ samples avec une meilleure couverture par cluster.

Le pivot vers post-filtre déterministe (v2.10.97)

Les 7 features actives ont précision 100% sur leur sous-ensemble. Ce n'est pas un signal probabiliste qu'un classifier doit pondérer. C'est un signal déterministe. J'ai décidé de pivoter vers un post-filtre de règles, plus approprié pour ce type de signal.

L'implémentation : applyContextualFPCaps() dans src/scoring.js. Sept caps déterministes branchés sur les helpers de feature-extractor.js. Appliqué APRES calculateRiskScore, donc les compound boosts et les lifecycle floors ont déjà eu le dernier mot.

CodeCapJustification
F130Bundle minifié sans lifecycle = bibliothèque, pas un dropper
F235Installer binaire depuis GitHub Releases = pattern légitime
F330Endpoint réseau dans le scope du package
F435Git hooks écrits depuis source locale
F5--Soustrait les points typosquat (pas un cap)
F635Obfuscation commerciale sans vecteur d'attaque
F720Placeholder anti-dep-confusion

Quand plusieurs caps s'appliquent, le Math.min() gagne (cap le plus serré). F5 est traité séparément : il soustrait les points typosquat au score au lieu de plafonner.

Les résultats mesurés

Validation sur le corpus reviewé de 302 packages (198 FP + 104 malware). Comparaison v2.10.96 (sans post-filtre) vs v2.10.97 (avec post-filtre).

MétriqueValeur
FP cappés (sur 198)67 (33.8%)
Malware impactés (sur 104)0
FP CRITICAL avant165
FP CRITICAL après116 (-49)

Ventilation par feature :

FeaturePackages cappés
F1 bundle_without_install_scripts35
F5 typosquat_scoped_package13
F2 install_url_github_releases9
F6 obfuscation_without_vector7
F4 git_hook_source_local2
F3 network_destination_first_party1
Total67

49 webhooks Discord P1 évités sur la semaine de validation. Les 131 FP CRITICAL restants ne matchent aucun des 7 clusters. Pour les attaquer, il faut chercher ailleurs : probablement la dédup compound scoring de src/scoring.js, où certaines combinaisons de threats CRITICAL se renforcent mutuellement sans bénéfice de détection. Roadmap v2.10.98+.

Limites mesurables de cette retrospective

Le holdout du retrain XGBoost contient 60 packages dont 3 malwares. TPR@1%FPR = 2/3 sur 3 samples, c'est du bruit statistique. Les chiffres de la table "retrain propre" sont indicatifs, pas robustes.

Le ML shadow en prod n'est pas le modèle retrainé ici. C'est l'ancien modèle de mars avec les problèmes documentés. Le retrain n'a pas été activé en production parce qu'on a pivoté vers le post-filtre déterministe avant.

Le post-filtre F1-F7 a été validé sur les mêmes 302 packages qui ont servi à identifier les clusters. Biais de circularité : on mesure 33.8% de FP cappés sur un corpus dont les FP ont été utilisés pour définir les features. Une vraie validation demande un nouveau corpus reviewé sans overlap.

Les 198 FP du corpus ont été identifiés à partir des packages flaggés à score ≥ 70 sur une semaine donnée. Ils ne couvrent pas la distribution complète des FP du scanner. D'autres clusters peuvent exister à des scores plus bas et ne sont pas capturés par F1-F7.

Leçon

AUC 0.999 sur petit dataset avec sources mixtes signale presque toujours du data leakage. Le 0.999 d'avril venait du modèle apprenant à reconnaître la source des labels, pas le contenu des packages. Les labels auto du scanner (clean ou fp) ne peuvent pas servir de ground truth pour entraîner ce même scanner. C'était la leçon de mars, redécouverte en avril sous une variante.

XGBoost sur petit corpus n'exploite pas les features binaires sparses : le modèle préfère splitter sur des continues, même si le gain final est plus petit. Une feature à précision 100% sur son sous-ensemble est un signal déterministe. Un post-filtre de règles le traite mieux qu'un classifier sur ce volume de données.

Sur ce corpus et à ce stade du projet, un post-filtre déterministe fonctionne mieux qu'un ML qu'on ne peut pas entraîner correctement. Ça ne veut pas dire que le ML est une mauvaise idée en général. Ça veut dire qu'il faut plus de données labellisées avant d'y revenir.

Ce qui reste

Le ML shadow tourne toujours sur le moniteur, mais le retrain n'est pas activé. Le post-filtre F1-F7 est en prod depuis v2.10.97. F8 sera réactivée après élargissement de EGRESS_TYPES (ajouter dangerous_exec, lifecycle_dangerous_exec, node_inline_exec, child_process).

Le retrain ML est reporté à quand le corpus reviewé atteindra 2 000+ labels. Rythme : 150-200 packages par session d'agents sur le VPS, plus le spot-check humain. 5 à 10 sessions pour atteindre la masse critique.