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 moniteur MUAD'DIB scanne chaque nouveau package npm en temps réel. Le problème : sur ~10 000-12 000 packages/jour, environ 1400 tombent en zone T1 (score 20-34) et déclenchent une alerte. La plupart sont des faux positifs. Le FPR prod est à 27.3%. Le pipeline rule-based a atteint son plafond.
La solution : un classifier binaire XGBoost qui filtre les FP avant le sandbox et le webhook. Pas pour remplacer les règles - pour les compléter là où elles hésitent.
Pourquoi la zone T1 est un problème
Le scoring de MUAD'DIB attribue un score 0-100 à chaque package. Les seuils :
- Score < 20 : clean. Pas d'alerte.
- Score 20-34 (T1) : suspect. Sandbox + webhook Discord.
- Score ≥ 35 (T2) : très suspect. Toujours alerte.
La zone T1 est la zone grise. Un package avec 2-3 findings MEDIUM (env_access + sensitive_string + obfuscation) atteint facilement score 23. Ça peut être un malware... ou un bundler webpack qui concatène du code minifié. Les règles seules ne savent pas faire la différence.
L'architecture du classifier
Le classifier est un module JavaScript pur (src/ml/classifier.js) qui traverse des arbres de décision XGBoost sans aucune dépendance Python en production. Le modèle est un fichier JSON converti en JS par un script offline.
Le pipeline
Scan statique (14 scanners)
|
Classification T1/T2 (isSuspectClassification)
|
[T1, score 20-34] ──► ML classifier
| |
| clean? ──► skip sandbox/webhook
| |
| suspect ──► continue normalement
|
Sandbox + Webhook
71 features
Le vecteur de features est passé de 62 à 71 avec l'ajout de signaux structurels et metadata :
| Catégorie | Features | Exemples |
|---|---|---|
| Scoring | 4 | score, max_file_score, package_score |
| Sévérité | 5 | count_critical, count_high, count_medium |
| Types de menaces | 32 | type_env_access, type_obfuscation_detected |
| Signaux booléens | 10 | has_lifecycle, has_eval, has_ioc_match |
| Distribution fichiers | 3 | file_count_with_threats, file_score_max |
| Ratios | 3 | severity_ratio_high, points_concentration |
| Metadata package | 3 | unpacked_size, dep_count, reputation |
| Enrichies (v2.10) | 9 | package_age_days, weekly_downloads, has_tests |
Les 9 nouvelles features sont discriminantes selon la littérature : un package de 3 jours avec 0 downloads et 0 tests a un profil très différent d'un package de 5 ans avec 100K downloads/semaine et un répertoire test/.
Les 4 guard rails
Le ML ne décide jamais seul. 4 garde-fous empêchent toute régression :
- Score < 20 → clean sans ML. Pas besoin du modèle pour les packages déjà sous le seuil.
- Score ≥ 35 → bypass. Le ML ne touche pas aux T2+, les règles décident.
- Type haute confiance → bypass. Un IOC match, un reverse shell, un binary dropper ne seront jamais filtrés par le ML, même si le score est 22.
- Modèle absent → bypass. Si le fichier
model-trees.jsest le stubnull, tout passe comme avant. Zéro régression possible.
L'ordre est important : le check haute confiance est fait avant le chargement du modèle. Même si le modèle est corrompu, les types critiques ne sont jamais supprimés.
XGBoost en JavaScript pur
XGBoost produit un ensemble d'arbres de décision. Chaque arbre est un tableau de noeuds :
// Un noeud : f=feature index, t=seuil, y=fils gauche, n=fils droit, v=valeur feuille
{ f: 0, t: 25.0, y: 1, n: 2, v: 0 } // si feature[0] < 25 → gauche
{ f: -1, t: 0, y: 0, n: 0, v: 0.8 } // feuille (f=-1)
La prédiction traverse tous les arbres, somme les marges, et passe par une sigmoïde :
function predict(featureValues) {
let margin = 0;
for (const tree of model.trees) {
margin += traverseTree(tree, featureValues);
}
return sigmoid(margin); // → probabilité [0, 1]
}
Zéro dépendance externe. Le module généré par tools/export-model-js.py est un fichier JS statique avec les données des arbres. Environ 50-100 KB pour 200 arbres.
Le pipeline d'entraînement
L'entraînement est offline en Python (tools/train-classifier.py) :
- Charge les JSONL du moniteur. Positifs = packages
confirmed(malware vérifié). Négatifs = packagesclean(0 findings). - Exclut les labels
suspect(non vérifié) etfp(auto-labélé, biaisé). - Sélection SHAP des top 30-40 features les plus discriminantes.
- 5-fold CV stratifiée, optimise précision avec recall ≥ 93.9% (= TPR ground truth).
- Export JSON → conversion JS via
export-model-js.py.
La contrainte de recall est dure : on ne veut jamais que le ML supprime un vrai malware. C'est le guard rail #3 (HC types) + la validation evaluateMLClassifier() qui garantit 0 ground-truth supprimé et 0 adversarial supprimé.
Optimisation HTTP : 1 appel au lieu de 2
Avant v2.10, chaque package suspect déclenchait 2 appels à registry.npmjs.org : un dans recordTrainingSample() (features ML), un dans le reputation scoring (webhook décision). Maintenant, un seul appel getPackageMetadata() est fait après le scan, et le résultat est réutilisé pour les deux. Sur 1400 suspects/jour, ça économise 1400 requêtes HTTP.
Et maintenant ?
Le classifier est prêt mais en mode stub. Le VPS collecte maintenant les 71 features enrichies. Le plan :
- Semaine 1 : Collecte des features enrichies (deploy immédiat)
- Semaine 2 : Entraînement du modèle sur les données collectées
- Semaine 3 : Shadow mode - le ML log ses prédictions mais ne filtre pas
- Semaine 4 : Activation du filtrage ML après validation shadow
Objectif : précision T1 ≥ 95%, soit une réduction de ~70% du bruit webhook. Avec zéro régression sur le TPR et l'ADR.
Les heuristiques ne meurent pas. Elles deviennent les features du modèle.