← Blog
Mise à jour du 19 avril 2026 : les chiffres annoncés dans cet article étaient basés sur un corpus pollué par des labels automatiques (44 807 records labellisés 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égorieFeaturesExemples
Scoring4score, max_file_score, package_score
Sévérité5count_critical, count_high, count_medium
Types de menaces32type_env_access, type_obfuscation_detected
Signaux booléens10has_lifecycle, has_eval, has_ioc_match
Distribution fichiers3file_count_with_threats, file_score_max
Ratios3severity_ratio_high, points_concentration
Metadata package3unpacked_size, dep_count, reputation
Enrichies (v2.10)9package_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 :

  1. Score < 20 → clean sans ML. Pas besoin du modèle pour les packages déjà sous le seuil.
  2. Score ≥ 35 → bypass. Le ML ne touche pas aux T2+, les règles décident.
  3. 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.
  4. Modèle absent → bypass. Si le fichier model-trees.js est le stub null, 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) :

  1. Charge les JSONL du moniteur. Positifs = packages confirmed (malware vérifié). Négatifs = packages clean (0 findings).
  2. Exclut les labels suspect (non vérifié) et fp (auto-labélé, biaisé).
  3. Sélection SHAP des top 30-40 features les plus discriminantes.
  4. 5-fold CV stratifiée, optimise précision avec recall ≥ 93.9% (= TPR ground truth).
  5. 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 :

  1. Semaine 1 : Collecte des features enrichies (deploy immédiat)
  2. Semaine 2 : Entraînement du modèle sur les données collectées
  3. Semaine 3 : Shadow mode - le ML log ses prédictions mais ne filtre pas
  4. 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.