← Blog

Le moniteur MUAD'DIB scanne chaque nouveau package npm en temps reel. Le probleme : sur ~10 000-12 000 packages/jour, environ 1400 tombent en zone T1 (score 20-34) et declenchent une alerte. La plupart sont des faux positifs. Le FPR prod est a 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 regles - pour les completer la ou elles hesitent.

Pourquoi la zone T1 est un probleme

Le scoring de MUAD'DIB attribue un score 0-100 a chaque package. Les seuils :

  • Score < 20 : clean. Pas d'alerte.
  • Score 20-34 (T1) : suspect. Sandbox + webhook Discord.
  • Score ≥ 35 (T2) : tres 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. Ca peut etre un malware... ou un bundler webpack qui concatene du code minifie. Les regles seules ne savent pas faire la difference.

L'architecture du classifier

Le classifier est un module JavaScript pur (src/ml/classifier.js) qui traverse des arbres de decision XGBoost sans aucune dependance Python en production. Le modele 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 passe de 62 a 71 avec l'ajout de signaux structurels et metadata :

CategorieFeaturesExemples
Scoring4score, max_file_score, package_score
Severite5count_critical, count_high, count_medium
Types de menaces32type_env_access, type_obfuscation_detected
Signaux booleens10has_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 litterature : un package de 3 jours avec 0 downloads et 0 tests a un profil tres different d'un package de 5 ans avec 100K downloads/semaine et un repertoire test/.

Les 4 guard rails

Le ML ne decide jamais seul. 4 garde-fous empechent toute regression :

  1. Score < 20 → clean sans ML. Pas besoin du modele pour les packages deja sous le seuil.
  2. Score ≥ 35 → bypass. Le ML ne touche pas aux T2+, les regles decident.
  3. Type haute confiance → bypass. Un IOC match, un reverse shell, un binary dropper ne seront jamais filtres par le ML, meme si le score est 22.
  4. Modele absent → bypass. Si le fichier model-trees.js est le stub null, tout passe comme avant. Zero regression possible.

L'ordre est important : le check haute confiance est fait avant le chargement du modele. Meme si le modele est corrompu, les types critiques ne sont jamais supprimes.

XGBoost en JavaScript pur

XGBoost produit un ensemble d'arbres de decision. 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 prediction traverse tous les arbres, somme les marges, et passe par une sigmoide :

function predict(featureValues) {
  let margin = 0;
  for (const tree of model.trees) {
    margin += traverseTree(tree, featureValues);
  }
  return sigmoid(margin); // → probabilite [0, 1]
}

Zero dependance externe. Le module genere par tools/export-model-js.py est un fichier JS statique avec les donnees des arbres. Environ 50-100 KB pour 200 arbres.

Le pipeline d'entrainement

L'entrainement est offline en Python (tools/train-classifier.py) :

  1. Charge les JSONL du moniteur. Positifs = packages confirmed (malware verifie). Negatifs = packages clean (0 findings).
  2. Exclut les labels suspect (non verifie) et fp (auto-labele, biaise).
  3. Selection SHAP des top 30-40 features les plus discriminantes.
  4. 5-fold CV stratifiee, optimise precision 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 supprime et 0 adversarial supprime.

Optimisation HTTP : 1 appel au lieu de 2

Avant v2.10, chaque package suspect declenchait 2 appels a registry.npmjs.org : un dans recordTrainingSample() (features ML), un dans le reputation scoring (webhook decision). Maintenant, un seul appel getPackageMetadata() est fait apres le scan, et le resultat est reutilise pour les deux. Sur 1400 suspects/jour, ca economise 1400 requetes HTTP.

Et maintenant ?

Le classifier est pret mais en mode stub. Le VPS collecte maintenant les 71 features enrichies. Le plan :

  1. Semaine 1 : Collecte des features enrichies (deploy immediat)
  2. Semaine 2 : Entrainement du modele sur les donnees collectees
  3. Semaine 3 : Shadow mode - le ML log ses predictions mais ne filtre pas
  4. Semaine 4 : Activation du filtrage ML apres validation shadow

Objectif : precision T1 ≥ 95%, soit une reduction de ~70% du bruit webhook. Avec zero regression sur le TPR et l'ADR.

Les heuristiques ne meurent pas. Elles deviennent les features du modele.