Distiller Claude vers un LoRA SOC : leçons d'un pilote à 3 €
TL;DR
J’ai dépensé 3,10 € de Claude Sonnet 4.6 (via OpenRouter) pour générer un corpus de distillation de 293 paires destiné au fine-tune d’un LoRA SOC. Sur ces 293 paires, 94 % étaient du bruit ambient SSH (sessions PAM, sshd auth success) et seulement 3 % du signal d’attaque réel (FIM sur fichiers sensibles). Le piège est subtil : plus l’attaquant qu’on utilise pour générer le corpus est scriptable (SSH-driven), plus il génère lui-même du bruit qui contamine le dataset.
Cet article documente le diagnostic, le piège, et le fix.
Le contexte
asp-forge est un orchestrateur agentique purpleteam qui chaîne plusieurs agents LLM locaux pour qualifier des incidents de sécurité. Trois rôles principaux : SOC (analyse l’alerte), renfort (contre-expertise), OPNsense (propose une règle pare-feu). Chaque rôle tourne sur un Qwen 2.5 3B Instruct + LoRA fine-tuné par rôle.
Pour stresser cette chaîne défensive en continu, on utilise un autre projet, arc (Atomic Red Canary) — un attaquant scriptable qui rejoue 20 techniques MITRE et 3 kill chains pré-codés à la commande contre des cibles dédiées. arc joue, asp-forge réagit.
Le problème observé : l’agent SOC produit des analyses pauvres. Sur une alerte
Wazuh FIM Integrity checksum changed (rule 550) déclenchée par l’arc atomic
T1098.004 (dépose d’une clé SSH backdoor dans /root/.ssh/authorized_keys), le
LoRA actuel répond par des généralités, manque le TTP exact, et hallucine des
recommandations incohérentes.
Un baseline rapide a comparé la même alerte passée à Qwen 3B + LoRA et à Claude Sonnet 4.6 via OpenRouter :
| Qwen 3B + LoRA | Claude Sonnet 4.6 | |
|---|---|---|
| Identifie le path critique | ✅ | ✅ |
| Saute au vrai TTP (T1098.004) | ❌ | ✅ |
| Niveau de risque CRITIQUE explicite | ❌ | ✅ |
| Recommandation pertinente | « bloquer l’agent SIEM » (faux sens) | ✅ |
| Latence | 26 s (CPU ARM cax21) | 11 s |
| Coût / inférence | ~0 € | 0,0085 $ |
Conclusion claire : c’est un problème de capacité du modèle, pas seulement
de contexte. Qwen 3B ne fait pas le raisonnement multi-hop
path → TTP → intent → action.
Deux voies :
- A — Hybride : router le SOC vers OpenRouter Claude. Qualité premium immédiate, ~0,01 €/incident.
- C — Distillation : générer un corpus de paires (
alerte,analyse Claude) via arc + Claude oracle, fine-tuner Qwen 3B dessus.
Le choix s’est porté sur C pour rester souverain et auto-hébergé. La suite de cet article concerne ce que ce choix a révélé en pratique.
La méthode de distillation
Le pipeline de collecte :
arc scenario → alertes Wazuh → user_msg enrichi → Claude oracle → CorpusPair JSONL
(attaque) (FIM, auth) (mitre+syscheck) (Sonnet 4.6) (annoté)
Trois bricks de code :
wazuh_capture.py— récupère les alertes Wazuh dans une fenêtre temporelle viavirsh qemu-agent-command(le manager Wazuh n’a pas de SSH opérateur déployé, on passe par le guest-agent).claude_oracle.py— appelle Claude via OpenRouter avec retry exponentiel, flag heuristique des réponses douteuses (< 200 chars, markers de refus).collect_pairs.py— CLI qui orchestre : lance arc, attend la propagation, capture les alertes, appelle Claude pour chacune, écrit uneCorpusPairJSONL, et incrémente un compteur Redis pour visibilité live.
Le user_msg qu’on envoie à Claude n’est pas l’alerte JSON brute. On utilise le
même builder que la prod (_build_soc_user_msg), enrichi pour exposer le champ
syscheck.path, l’événement (modified/added/deleted), l’auteur
(uname_after), et — critique — le MITRE natif Wazuh
(rule.mitre.id/rule.mitre.tactic) plutôt qu’un mapping interne pauvre.
Une CorpusPair Pydantic stocke :
{
"pair_id": "...", "captured_at": "...",
"source": "arc_scenario" | "arc_atomic" | "ambient",
"arc_atomic_id": "T1098.004", # ground truth MITRE
"wazuh_alert_raw": {...}, # alerte complète pour reproduction
"system_prompt": "...", # figé à la collecte
"user_msg": "...", # généré
"claude_response": "...", # la cible d'entraînement
"claude_meta": { "model", "latency_s", "tokens", "cost_usd" },
"mitre_truth": ["T1098.004"],
"holdout": false # 10 % réservés à l'eval
}
Le hold-out (10 % par défaut) est décidé à la collecte avec un seed
déterministe par (atomic_id, phase). Pas de re-shuffle ultérieur. Certains
atomics signatures (T1098.004, T1548.003) sont en force_holdout pour garantir
qu’ils restent en test set quoi qu’il arrive.
Le pilote — 9 runs, 293 paires, 3,10 €
Le pilote enchaîne 3 scenarios × 3 cibles = 9 runs séquentiels :
kill-chain-basic(6 atomics, profil opportunist)insider-threat(4 atomics, profil DBA en fin de contrat)opportunist(4 atomics, profil bot Internet)
Chacun tourne sur les 3 cibles arc : ws01 (DMZ web), srv01 (LAN app),
fs01 (LAN db). Wall-time : 55 minutes.
Métriques globales :
| Paires totales | 293 |
| Coût Claude | 3,10 $ (~0,011 $ / paire) |
| Latence Claude p50 / p95 | 12,1 s / 14,2 s |
| Hold-out | 27 paires (9 %) |
| needs_review | 0 |
| Tokens completion p50 | 600 (max_tokens hit à 81 %) |
Claude n’a jamais refusé une alerte. Pas une seule. Latence stable. Coût pile sur les estimations. Et pourtant, le corpus est largement inutilisable.
La découverte : 94 % de bruit
Distribution des rules Wazuh observées :
| rule | desc | level | count | % |
|---|---|---|---|---|
| 5501 | PAM: Login session opened | 3 | 97 | 33 % |
| 5502 | PAM: Login session closed | 3 | 93 | 32 % |
| 5715 | sshd: authentication success | 3 | 88 | 30 % |
| 550 | Integrity checksum changed (FIM modify) | 7 | 9 | 3 % |
| 2904 | Dpkg half configured | 7 | 2 | 0,7 % |
| 2902 | New dpkg installed | 7 | 2 | 0,7 % |
| 554 | File added to the system | 5 | 1 | 0,3 % |
Trois rules d’authentification dominent à elles seules 94 % du corpus. Le signal d’attaque (FIM 550 + 554) ne fait que 3 %.
Pourquoi ? Parce qu’arc, l’attaquant scriptable, est lui-même un client SSH. Chaque atomic exécute des commandes via :
ssh -o BatchMode=yes root@<target> bash -s <<<"$cmd"
Une telle invocation SSH génère côté Wazuh trois alertes auth :
- rule 5715 :
sshd: authentication success(la clé est acceptée) - rule 5501 :
PAM: Login session opened(la session démarre) - rule 5502 :
PAM: Login session closed(la session se ferme)
Chaque atomic exécute typiquement 1 à 5 étapes, chacune dans une session SSH. Un kill chain de 6 atomics × ~3 étapes = ~18 sessions SSH = 54 alertes auth ambient, contre 1-3 alertes signal (FIM, sudoers, cron drop) pour les modifications effectivement faites par l’atomic.
Ratio constaté : 30:1 bruit/signal.
Les 2,90 $ sur les 3,10 $ dépensés en Claude sont partis dans 280 analyses quasi identiques de « session SSH normale, niveau FAIBLE, RAS ».
Pourquoi c’est piégeux
Un corpus déséquilibré sur de la classification, on connaît : le modèle apprend à toujours prédire la classe majoritaire. C’est documenté.
Mais en distillation pour génération de texte, le problème est plus subtil. Le LoRA ne fait pas de classification — il apprend à produire des analyses ressemblant à celles de Claude. Si 280 paires d’entraînement contiennent une analyse Claude qui dit en substance « session normale, FAIBLE, surveiller », et que 10 paires disent « T1098.004 backdoor, CRITIQUE, bloquer », le LoRA va optimiser sa loss pour produire la première, qui est statistiquement la cible.
Pire : l’évaluation va paraître bonne. Si on prend une matrice de confusion sur les analyses produites, le LoRA sera correct sur 94 % des cas (les sessions normales), ce qui ressemble à du progrès. Mais sur les rares vraies attaques — celles qu’on cherche précisément à détecter — il restera flou et restituera des analyses de bas niveau.
C’est un overfit pervers : le dataset reflète parfaitement la distribution réelle des alertes Wazuh en production (où le bruit ambient écrase aussi le signal), donc l’évaluation sur un sample iid sera flatteuse, mais l’utilité opérationnelle sera médiocre.
Le contre-exemple silencieux
Un autre élément du pilote mérite l’attention : 81 % des réponses Claude ont
atteint le max_tokens = 600. La pensée est tronquée en plein milieu.
"...Cette technique correspond à T1098.004 (Account Manipulation: SSH
Authorized Keys) en complément de T1565.001, et peut indiquer une tentative
de persistance (ajout d'une backdoor SSH) ou au contraire une tentative de
[CUT]
Sur les rares paires signal (10 FIM), la troncature mange la moitié de l’analyse — précisément la moitié qui contient la recommandation et l’évaluation de risque finale. Le LoRA distillerait des cibles incomplètes.
Combiné au bruit : on a 280 paires bien complètes mais sans intérêt, et 10 paires signal mais tronquées. Pile l’inverse de ce qu’on veut.
Les leçons généralisables
Pour quiconque veut faire de la distillation knowledge-transfer d’un grand modèle vers un petit, sur un domaine narrow (SOC, code review, classification de tickets…) :
-
Toujours faire un pilote de 200-500 paires AVANT de lancer la collecte massive. Le coût est négligeable (3 € ici), le diagnostic est massif.
-
Mesurer la distribution des classes/rules dans le pilote. Si l’une représente > 50 %, c’est presque sûrement du bruit qui va dominer l’apprentissage. Décider explicitement : down-sample, filtre dur, ou ré-équilibrage par class weights.
-
Si la source de données est un système réactif (Wazuh ici, mais pareil pour des logs applicatifs, des tickets support, etc.) et que la méthode de génération du corpus passe par des actions répétées (SSH, API call, …), vérifier que ces actions ne génèrent pas elles-mêmes du signal qui pollue le dataset. C’est le cas le plus piégeux parce que c’est de la contamination par instrumentation.
-
max_tokensdoit dépasser la longueur médiane de réponse de l’oracle de 30-50 %. Sinon une partie du dataset arrive tronquée. La latence augmente peu (Claude streame), le coût augmente proportionnellement mais reste contenu. -
Distillation depuis un modèle puissant ≠ corpus de qualité auto. Le modèle oracle est le plafond de qualité, pas un magicien qui résout les problèmes amont de la chaîne de collecte.
-
Un attaquant scriptable est un excellent outil de génération de labels (le TTP est connu), mais ses propres traces réseau / système sont du bruit qu’il faut filtrer activement.
Le fix appliqué
Deux changements pour la prochaine itération :
-
Filtrer les rules d’auth-success en amont dans
collect_pairs.py: ne pas envoyer à Claude les alertes dontrule.id ∈ {5501, 5502, 5715}— les 3 alertes générées par chaque session SSH d’arc (session opened, closed, auth success). Économie immédiate : 94 % du coût et 94 % du volume inutile. Les rules d’auth-échec (5710sshd auth failed, 5503 PAM auth failure, 5712 / 5763 « multiple authentication failures ») sont conservées — ce sont les vrais signaux de bruteforce. -
Bumper
max_tokensà 1200 dansclaude_oracle.py: les analyses Claude tournent en pratique à 700-900 tokens quand elles ne sont pas tronquées. 1200 laisse de la marge sans cost-blowup significatif.
Le mini-pilote de validation — 10 paires, 0,12 €
Avant de relancer une collecte massive, j’ai exécuté un mini-pilote de 3 runs sur la cible ws01 (2 atomics signature + 1 scenario complet) pour mesurer l’effet réel des deux fixes :
| Métrique | Pilote pollué (293 paires) | Mini-pilote validé (10 paires) | Évolution |
|---|---|---|---|
| Signal FIM (rules 550, 553, 554) | 3 % | 60 % | × 20 |
| Bruit auth-success (5501/5502/5715) | 94 % | 0 % | éliminé |
| Signal user/group (rules 5901, 5902) | 0 % | 40 % | apparition |
Tokens tronqués (hit max_tokens) |
81 % | 0 % | éliminé |
| Latence Claude p50 | 12,1 s | 13,6 s | +12 % (plus de tokens à générer) |
| Coût par paire | 0,011 $ | 0,012 $ | quasi identique |
| needs_review | 0 | 0 | stable |
Sur les 10 paires capturées, toutes sont du signal. Au-delà des FIM
strictes, le filtre a laissé passer les rules 5901 New group added et
5902 New user added — déclenchées par les atomics T1078.003 (création
de compte attaquant). Ce sont aussi des signaux légitimes côté MITRE,
même s’ils n’étaient pas dans ma liste « signal » initiale.
Distribution observée :
| rule | description | level | count |
|---|---|---|---|
| 554 | File added to the system | 5 | 4 |
| 550 | Integrity checksum changed | 7 | 2 |
| 5901 | New group added | 8 | 2 |
| 5902 | New user added | 8 | 2 |
Un kill chain complet (insider-threat) a produit à lui seul 7 paires
toutes cohérentes — chaîne de persistence T1078.003 → T1098.004 →
T1546.005 → T1070.004 reflétée fidèlement par les rules Wazuh.
Projection corpus complet recalibrée
| Métrique | Projection avant fix | Projection après fix |
|---|---|---|
| Runs scenario nécessaires pour 1 000 paires utiles | 70 (3 % signal) | ~140 (gain en densité, perte en volume brut) |
| Coût Claude total | ~10 $ | ~12 $ (légère hausse à cause du max_tokens doublé) |
| Wall-time cumulé | ~5-6 j | ~5-7 j |
| Ratio signal final attendu | 60-70 % | 80-100 % |
Le nombre de runs nécessaires augmente parce que chaque run produit maintenant ~6-10 paires utiles au lieu de ~30 paires polluées. Mais chaque paire compte pour 10× plus dans l’apprentissage du LoRA — c’est un gain net massif côté qualité.
Le training proprement dit tournera sur une RTX 4070 Ti 12 Go en
local (Ryzen 7 5800X, 128 Go RAM) — QLoRA 4-bit + batch_size=2 +
grad_accum=8, gradient checkpointing actif. Durée estimée : ~20 h
pour le base Qwen 2.5 7B sur 1 000 paires (passage de 3B à 7B comme
base décidé après un second baseline qui a montré que monter en taille
SANS distillation n’apporte rien — le LoRA distillé reste indispensable).
Ce que ce pilote raté nous a coûté
3,10 € de Claude. Le code de l’outillage de collecte. Une heure de diagnostic post-mortem.
Ce qu’il nous a permis
D’éviter de lancer 15 € de Claude supplémentaires pour collecter 1000 paires dont 940 auraient été du bruit. D’éviter une nuit de training sur un dataset qui aurait produit un LoRA opérationnellement décevant. Et de documenter un piège auquel on n’aurait pas pensé sans le voir.
Le ticket d’entrée à 3 € pour découvrir 30 € d’erreur évitée — c’est le ratio normal d’un pilote bien conduit.
Et si Claude n’était pas indispensable ? Le bench multi-providers
Une fois le pilote validé et le corpus complet collecté (1 011 paires sur
fix max_tokens + filtre noise), une question gênante est revenue : Claude
Sonnet 4.6 coûte 12 € pour 1 000 paires. Sur OpenRouter, DeepSeek V3.1
coûte 20× moins cher, Llama 3.3 70B 65× moins cher, GPT-5 mini
5× moins cher. Et si l’un d’eux faisait le job ?
J’ai d’abord lancé un bench bête et méchant à 3 samples sur 6 providers en parallèle (Claude, DeepSeek, Gemini 2.5 Pro, Llama 70B, GPT-5 mini, Mistral Large 2). Verdict en surface : GPT-5 mini paraissait tenir ~80 % de la qualité Claude pour 20 % du coût — pivot v2 recommandé.
Cette conclusion m’a paru trop belle. J’ai écrit un second bench cette fois avec un protocole sérieux :
- 20 samples diversifiés du corpus, biaisés vers les rules signature (550 sshd, 554 file added, 5902 user added, 5904 sudoers)
- Claude Sonnet 4.6 jouant à la fois la référence ET le judge, avec
un rubric JSON structuré :
ttp_correct,ttp_match_exact,risk_level_match,format_3_sections,hallucinations,recommandations_utiles,overall_score0-100 - Critère de pivot pré-déclaré :
score ≥ 90 ET ttp_correct ≥ 90 % ET hallucinations ≤ 10 %
Et pour être fair-play vis-à-vis des candidats qui galéraient à
max_tokens=1200 (Gemini systématiquement tronqué, GPT-5 mini sur 3
samples), j’ai relancé chaque candidat avec son max_tokens confortable.
Le tableau qui change la conclusion
| Provider | Score | TTP correct | TTP exact | Risque aligné | Format | Hallu. | Reco utiles | Coût vs Claude |
|---|---|---|---|---|---|---|---|---|
| Claude Sonnet 4.6 | 100 (réf) | 100 % | 100 % | 100 % | 100 % | 0 % | 100 % | 100 % |
| Gemini 2.5 Pro @4096 | 70,4 | 100 % | 36,8 % | 89,5 % | 100 % | 0 % | 89,5 % | 188 % |
| GPT-5 mini @2000 | 68,0 | 100 % | 50,0 % | 30,0 % | 90 % | 0 % | 100 % | 20,1 % |
| GPT-5 mini @1200 | 62,2 | 88,9 % | 38,9 % | 38,9 % | 83,3 % | 0 % | 88,9 % | 20,8 % |
| Mistral Large 2 @4096 | 44,0 | 75,0 % | 60,0 % | 5,0 % | 100 % | 0 % | 30,0 % | 24,1 % |
Aucun candidat n’atteint le seuil pivot. Le bench 3-samples surévaluait
parce qu’il était tombé sur des alertes signature bien posées
(sshd_config modifié, sudoers, etc.) où tout le monde s’aligne. Sur
les rules ambiguës 554 file added qui dominent le corpus réel, l’écart
se creuse brutalement.
Le pattern qui m’a sauté aux yeux
Sauf Gemini, tous les candidats sous-évaluent systématiquement le
niveau de risque : 5 à 40 % d’alignement seulement. Ils voient MOYEN
là où Claude voit ÉLEVÉ ou CRITIQUE. Pas parce qu’ils ratent la
technique MITRE (TTP correct à 75-100 %) mais parce qu’ils manquent les
signaux contextuels :
- le nom d’agent
breach1qui indique un environnement explicitement marqué comme compromis dans le lab - la modification par
root(uid=0) sur un binaire système - le chemin
/tmp/arc-evidence-*qui crie « staging d’attaque »
Claude intègre naturellement ces tells, les autres les ratent. Et un analyste SOC qui reçoit un ticket MOYEN à 2h du matin… le mettra en queue de backlog jusqu’à demain. Pour un compromis root en cours, c’est disqualifiant.
Le seul candidat correct est trop cher
Gemini 2.5 Pro est le seul à aligner sur le risque (89,5 %) et à tenir un format propre. Mais il coûte 1,88× le prix de Claude. Pour un oracle dans un pipeline distillation, c’est l’inverse de ce qu’on cherche. Il pourrait servir en validateur croisé dans un schéma consensus voting (Phase 4 du RFC), mais le ROI est faible quand le seul allié de qualité double déjà la facture.
Le twist : l’échec du pivot renforce le LoRA
Si même les frontiers OpenRouter (Gemini 2.5 Pro, GPT-5 mini, Mistral Large 2) plafonnent à 44-70 % du score Claude sur ce rubric, alors un Qwen 7B local correctement distillé qui atteint 80/100 deviendrait un asset de souveraineté décisif :
- gratuit en inférence (CPU/GPU local, pas d’API externe)
- FR-native, hébergé chez soi
- qualité supérieure aux frontiers concurrents en oracle solo, sur cette tâche précise
- latence prévisible (pas de routage OpenRouter, pas de quota)
Bref : le pivot oracle a échoué, mais l’argument distillation est renforcé. v2 reste 100 % Claude, axe prioritaire = parallélisation du pipeline + extension à 250 atomics ART, et le LoRA reste la seule voie crédible vers de l’inférence pas chère et locale.
Coût du bench
| Bench | Coût total |
|---|---|
| Multi-providers 3 samples | ~0,15 € |
| Judge GPT-5 mini @1200, 20 samples | ~0,40 € |
| Judge GPT-5 mini @2000, 20 samples | ~0,44 € |
| Judge Mistral Large 2 @4096, 20 samples | ~0,44 € |
| Judge Gemini 2.5 Pro @4096, 20 samples | ~0,77 € |
| Total | ~2,20 € |
2 € pour mesurer qu’un pivot à 5×-65× moins cher aurait dégradé la qualité du corpus, et donc du LoRA final. À mettre côté économies évitées, le ratio reste très favorable.
Annexes — code et ressources
Le pipeline de conception/training du LoRA SOC vit dans
mlops/lora-factory
(migré depuis asp-forge le 2026-05-23, en cohérence avec les autres LoRAs
crowdsec/opnsense/iac déjà produits là-bas). asp-forge ne consomme plus
que le .gguf final via son pool LLM runtime.
- Plan Phase 2 (distillation) :
distillation-plan.md - RFC Phase 3 (industrialisation + multi-oracle) :
phase3-rfc.md - Pipeline collecte :
factory/generators/soc_distillation/ - Benches multi-providers + Claude-as-judge :
benchmarks/soc/baseline_multi_oracle.py— bench 6 providers parallèlejudge_candidate_vs_claude.py— Claude-as-judge 20 samples
- Config training :
configs/soc_training.yaml - arc (attaquant scriptable) : gitlab.com/llm_tests/arc
- Pointeur côté asp-forge (post-migration) :
docs/lora-soc-MOVED.md
Note méthodologique
Cet article est un retour d’expérience à chaud sur 24 h de travail
2026-05-22/23. Les chiffres et tableaux sont issus directement des
fichiers JSONL générés et des compteurs Redis (arc:corpus:pairs_collected
= 293 à la clôture du pilote, wazuh-550 stream à 4662 events
cumulés côté asp-forge). Aucune extrapolation, aucun retraitement.
Édit 2026-05-24 : ajout de la section Et si Claude n’était pas
indispensable ? Le bench multi-providers. Les tableaux de cette section
sont issus des verdicts JSON bruts produits par judge_candidate_vs_claude.py
sur le corpus complet (1 011 paires, 20 samples échantillonnés avec biais
signature rules, seed=42 pour la reproductibilité). Les coûts proviennent
des champs usage.cost retournés par OpenRouter. Le RFC Phase 3
(phase3-rfc.md §10)
contient le détail des verdicts par sample et les décisions actées.
by Patrice Le Guyader
