clean par le scanner, sans review humaine). La vraie performance mesurée sur un corpus de 302 labels humains est AUC 0.9917, pas 0.999. Voir Quand le ML ne marche pas pour la retrospective complète et le pivot vers un post-filtre déterministe en v2.10.97.
Le classifier XGBoost de MUAD'DIB tournait en prod depuis mars 2026. 114 arbres, 40 features, seuil 0.52. Il filtrait les faux positifs de la zone T1 (score 20-34). Le problème : son ground truth était circulaire.
Le cercle vicieux
49 malwares confirmés dans le ground truth. 49. Pour entraîner un modèle binaire sur 87 features. Le reste des positifs venait du corpus Datadog (14 587 packages). Les négatifs venaient du moniteur lui-même : les packages que MUAD'DIB scannait et déclarait "clean".
Le modèle apprenait à reproduire ses propres décisions. Un package que le scanner rate, le modèle le rate aussi, parce qu'il n'a jamais vu de contre-exemple. FPR à 11%, TPR à 93.9%. Correct, mais pas brillant.
L'idée : corréler avec des sources externes
Si je ne peux pas confirmer manuellement des milliers de packages, les bases publiques peuvent le faire pour moi. Trois sources :
- OSSF malicious-packages : le repo de l'OpenSSF. 227 000 entrées malveillantes documentées en format OSV JSON. Clone sparse, npm uniquement.
- GitHub Advisory Database : 5 300 advisories de type malware pour npm. API publique, paginée.
- npm registry : un package supprimé (404) après notre détection, c'est un signal de takedown par l'équipe sécurité npm.
Un suspect confirmé par OSSF ou GHSA = confirmed_malicious. Un package supprimé de npm dans les 72h après publish avec un score MUAD'DIB ≥ 50 = confirmed_malicious aussi (pattern takedown). Un package supprimé sans autre signal = likely_malicious (label séparé, pas dans le ground truth hard). Un suspect toujours sur npm sans signal externe après 7 jours = unconfirmed (probable FP, utilisé comme négatif).
Résultat : 377 confirmed_malicious au lieu de 49. Et 211 000 "missed" historiques dans OSSF qui n'étaient pas dans nos détections (attendu : l'OSSF couvre des années d'historique, le moniteur tourne depuis décembre 2025).
Le retrain à 100%
Premier retrain avec le nouveau dataset. 56 564 samples. Le modèle sort :
Precision: 1.0000
Recall/TPR: 1.0000
FPR: 0.0000
Précision 1.000, recall 1.000, FPR 0.000. J'ai déjà vu ce film. Un modèle parfait, c'est un modèle cassé.
Le piège : data leakage par features manquantes
Le corpus Datadog contient les features de base (score, counts, types) mais pas les features enrichies ajoutées en v2.10 : package_age_days, weekly_downloads, version_count, author_package_count, etc. 16 features absentes.
Mon code mettait -1 pour les features manquantes du Datadog, et 0 pour les features manquantes du moniteur. XGBoost apprend les directions de split pour les valeurs manquantes. Résultat : le modèle apprenait "si package_age_days == -1 alors malicious" - il distinguait la source des données, pas le comportement malveillant.
Un seul split sur une feature à -1 suffit à séparer parfaitement les deux classes. P=1, R=1, zéro signal utile.
Le fix
Trois corrections :
- Features à 0 partout. Plus de -1. Si une feature est absente, c'est 0, point. Pas de signal de source.
- Filtre leaky features. Porté depuis
train-xgboost.py: si une feature est non-zero dans ≥ 99% d'une classe et < 0.1% de l'autre, c'est un proxy de source, pas un signal. Éjectée. 23 features mortes ou leaky sur 87. - Features depuis les alertes. Sur 377 confirmed_malicious, seulement 15 avaient un enregistrement JSONL. Les 362 autres n'avaient pas de features. Port Python de
feature-extractor.jspour reconstruire les features depuis les fichiers d'alerte (logs/alerts/*.json). Les metadata registry restent à 0 (pas disponibles dans les alertes).
Les résultats honnêtes
| Métrique | Avant | Après |
|---|---|---|
| FPR | 11.0% | 2.85% |
| TPR | 93.9% | 99.93% |
| Précision | 0.999 | 0.924 |
| F1 | - | 0.960 |
| AUC-ROC | - | 0.999 |
| Features | 87 (8 dead) | 64 actives |
| Ground truth | 49 | 377 |
| Dataset | ~58K | 56 564 |
Confusion matrix sur le holdout 20% (11 313 samples) :
Predicted
Clean Malicious
Actual Clean 8154 239
Malicious 2 2918
239 faux positifs sur 8 393 clean. 2 faux négatifs sur 2 920 malicious. Le FPR passe de 11% à 2.85%. Le TPR monte de 93.9% à 99.93%.
La précision baisse de 0.999 à 0.924. Normal : l'ancien modèle avait une précision artificielle parce qu'il ne voyait que ses propres prédictions comme négatifs. Le nouveau voit des négatifs réels (packages non-confirmés après 7 jours).
Grid search
27 combinaisons testées (depth [4,6,8] x estimators [100,200,300] x lr [0.05,0.1,0.2]). Plateau à F1=0.9603 pour presque toutes les configs. Le modèle final : depth=4, 300 estimators, lr=0.05. Plus simple et plus régulier que les alternatives.
Top features
unpacked_size_bytes- les malwares sont petits, les frameworks sont grosfile_count_total- même logique : 3 fichiers vs 500version_count- un malware a 1-2 versions, un package établi en a 50max_single_points- poids de la finding la plus gravescore- le score de risque global
Les 3 premières features sont des proxies de "taille et maturité du package". Un malware est typiquement petit, récent, avec peu de versions. Un framework légitime est gros, ancien, avec des dizaines de versions. Le modèle a appris cette asymétrie.
Ce qui reste
357 confirmed_malicious sans features (ni JSONL ni alerte). Le moniteur ne gardait pas les features extraites pour tous les packages avant v2.10. Ces samples sont perdus pour le training actuel. Le fix : le pipeline d'entraînement enregistre maintenant les features pour tous les suspects, pas seulement les clean.
Le retrain mensuel est prévu : l'auto-labeler tourne en cron quotidien, re-check les "pending" qui passent 7 jours, refresh les indices OSSF/GHSA, et re-labellise. Le prochain retrain aura plus de confirmed et moins de trous.