asp-forge (2/3) — Le pipeline agentique : cascade Triage → SOC → CERT
Deuxième volet de la série asp-forge. Après les choix structurants posés dans le premier article, on entre dans le cœur du système : comment plusieurs agents LLM collaborent sur une même alerte, et pourquoi cette collaboration est organisée en cascade plutôt qu’en agent unique.
Pourquoi une cascade et pas un seul agent ?
L’instinct premier, quand on dispose d’un LLM 3 milliards de paramètres qui raisonne correctement, est de lui confier l’intégralité de la décision : alerte en entrée, verdict en sortie. C’est tentant, c’est simple, c’est faux.
Sur un volume réel d’alertes Wazuh, la distribution est très inégale :
- une grande majorité (~70 %) sont des faux positifs évidents reconnaissables par un humain en quelques secondes (scan SSH banal, brute force sur un port qui ne devrait jamais répondre, alerte syscheck sur un fichier connu) ;
- une part substantielle (~25 %) demande un peu de contexte mais pas de raisonnement profond — vérifier la réputation IP, la fenêtre temporelle, la corrélation avec d’autres événements ;
- une petite fraction (~5 %) est réellement ambiguë et mérite une analyse poussée, voire une contre-analyse.
Faire tourner un Qwen 3B sur les 70 % de faux positifs évidents, c’est gaspiller du CPU et de la latence. La cascade répond à ce constat avec trois agents de capacités croissantes, chacun pouvant trancher ou escalader.
Niveau 0 — Triage
Premier filtre. Modèle : un Qwen 2.5 0.5B Q8 (très léger, ~500 Mo en RAM, 80 à 120 tokens/s sur CPU). Son rôle est volontairement étroit : dire en quelques secondes si l’alerte mérite plus d’analyse, ou si elle peut être classée en faux positif évident.
Trois sorties typées possibles :
dismiss— faux positif évident, on ferme et on logue ;escalate— alerte non triviale, on passe au SOC ;urgent— alerte critique (Wazuh level ≥ 13), shortcut direct vers le canal d’alerte humain.
Le contrat d’entrée du Triage est minimaliste : un payload Wazuh normalisé (rule_id, level, decoder, location, full_log tronqué). Le prompt système ne demande pas un raisonnement long ; il demande une classification rapide selon une grille mentale fournie. C’est important : plus on contraint la sortie, plus le 0.5B est fiable.
Pourquoi un modèle plus petit ? Parce qu’on n’a pas besoin de raisonnement profond pour reconnaître un scan automatisé. Et parce que faire tourner 70 % du volume sur un 3B serait du gâchis. Le Triage absorbe le débit ; il économise les agents lourds.
Niveau 1 — SOC
Cœur du dispositif. Modèle : Qwen 2.5 3B Q4_K_M + adaptateur LoRA spécialisé selon le contexte de l’alerte (firewall, configuration réseau, réputation collaborative). C’est ce niveau qui prend la décision d’action effective.
Le SOC reçoit du Triage un objet typé enrichi :
- l’alerte normalisée d’origine ;
- l’historique des alertes récentes liées à la même IP / au même asset ;
- un éventuel
cti_context(lookup OpenCTI sur les observables) ; - la décision de Triage (uniquement
escalate;dismissn’arrive jamais ici par construction).
Sa sortie est elle aussi un objet typé strict : {action, target, justification, confidence, next_role} où action est dans une
whitelist d’opérations autorisées (block_ip, allow_ip,
add_tag, request_review). Pas de free-form. Pas de génération de
commande shell. Le LLM choisit dans un menu, il ne tape pas.
Le routage par LoRA est l’élément le plus original architecturalement. On charge dynamiquement, par requête HTTP, l’adaptateur correspondant au domaine de l’alerte :
# Routage simplifié — pas le code réel
{
"messages": [...],
"lora": [{"id": LORA_ID_OPNSENSE, "scale": 1.0}],
"response_format": {"type": "json_schema", "schema": SOCDecision.schema()}
}
L’avantage est double : un seul processus llama-server sert toutes les spécialités, et chaque LoRA (~30 Mo) peut être réentraîné, tagué et déployé indépendamment. La promesse architecturale “1 modèle de base, N spécialités” devient opérationnelle.
L’agent OPNsense, double emploi : décider et valider le LoRA
Un point important souvent passé sous silence dans les démos agentiques : à quoi sert vraiment d’entraîner un LoRA spécialisé, plutôt que de tout passer en prompt système au modèle de base ? La réponse opérationnelle est dans la boucle d’usage de l’agent OPNsense.
Cet agent est sollicité dans deux directions :
- À l’aller — formuler une décision de blocage typée à partir de l’alerte enrichie (rule firewall, source, durée, justification). C’est l’usage qu’on imagine spontanément.
- Au retour — formuler une décision de levée de blocage quand le
CERT a invalidé l’analyse SOC, ou simplement quand la TTL d’une règle
arrive à terme. L’agent OPNsense produit alors un payload
delete_ruleciblé sur l’uuidhistorisé au moment du blocage initial.
Cette boucle complète n’est pas un détail. Elle a deux conséquences structurelles :
- Le LoRA OPNsense trouve un débouché opérationnel direct. Tout le temps passé à le construire — extraction de la connaissance métier OPNsense, formatage du jeu d’entraînement, itérations d’évaluation — se concrétise dans un agent qui tourne en production et émet des décisions chaque jour. Ça change radicalement la relation au cycle d’entraînement : on ne fait pas un LoRA pour la démo, on fait un LoRA pour qu’un agent l’utilise.
- L’agent valide le LoRA en continu. Chaque décision rédigée par l’agent (qu’elle parte vers OPNsense ou non) est une preuve fraîche que le LoRA encode bien le métier — orthographe correcte des champs de la règle, options compatibles avec la version OPNsense déployée, syntaxe d’API valide à la première tentative. Une régression dans le LoRA serait visible immédiatement par taux d’échec API sur les actions effectives, sans qu’on ait besoin d’un benchmark séparé. Le pipeline réel est le benchmark.
Le même schéma vaut pour les LoRA WireGuard et CrowdSec, sur leurs domaines respectifs : produire une décision et servir de banc d’usage permanent qui valide la pertinence du fine-tuning.
Niveau 2 — CERT (contre-analyse indépendante)
C’est la couche qui interroge le plus de débutants en agentique : à quoi sert un agent qui re-analyse la décision d’un autre agent ?
La modélisation est calquée sur la relation SOC / CERT du monde réel : le SOC produit une décision opérationnelle dans la durée courte de l’alerte, le CERT (Computer Emergency Response Team) prend la même incident à froid, raisonne indépendamment et statue. C’est l’équivalent agentique du peer review humain. Quand le SOC propose une action significative — typiquement un blocage long ou une mise sur liste rouge — l’agent CERT reprend l’incident en ignorant la décision du SOC, raisonne indépendamment, et compare. Trois issues :
confirm— il arrive à la même conclusion ;refute— il arrive à une conclusion différente ;uncertain— il n’arrive pas à trancher seul.
Si confirm, l’action passe. Si refute, l’incident est routé vers la
revue humaine avec les deux raisonnements présentés côte à côte. Si
uncertain, on applique la décision du SOC mais on tague l’incident
pour audit ultérieur.
L’agent CERT n’est pas invoqué systématiquement — son coût n’a de sens que pour les décisions à fort impact. Il est sollicité selon des règles statiques : seuils de confidence du SOC, type d’action, criticité de l’asset ciblé. Les détails de cette politique restent ajustables sans réentraîner les modèles.
Le graphe d’états
L’enchaînement Triage → SOC → CERT → Action n’est pas une boucle conversationnelle ouverte. C’est un graphe orienté avec un nombre fini de nœuds et des transitions strictes :
┌────────────┐
Alert in ───────►│ triage │─── dismiss ───► dropped
└─────┬──────┘
│ escalate
▼
┌────────────┐
│ soc │
└─────┬──────┘
┌───────────┼───────────┐
│ │ │
review needed ok-ish strong
│ │ │
│ ▼ ▼
│ ┌────────────┐ ┌────────────┐
│ │ action │ │ cert │
│ └─────┬──────┘ └─────┬──────┘
│ │ │
│ │ confirm/refute
│ │ │
▼ ▼ ▼
┌──────────────────────────────────┐
│ finalize (logging, kanban, push)│
└──────────────────────────────────┘
Chaque transition est portée par une chaîne strictement typée
(StrEnum), pas par un prompt ouvert. Cela élimine la possibilité
qu’une réponse LLM influence le flux de contrôle par injection — un
risque réel décrit dans les retours d’expérience récents sur les
plateformes agentiques de production.
Deux gardes-fous structurels protègent ce graphe :
Steps counter. Un compteur d’itérations dans l’état global. Si l’on
dépasse un seuil défini (par exemple 8 transitions sur un même
incident), on ne se contente pas de la recursion_limit du runtime —
on bifurque explicitement vers un nœud soft_landing qui produit une
décision best effort + une alerte humaine, plutôt que de planter
violemment. C’est l’équivalent du circuit breaker pour un graphe
agentique.
Persistance globale. L’état traverse les nœuds via un checkpointer persistant (PostgreSQL en l’occurrence). Si le service redémarre, les incidents en cours reprennent à la transition exacte où ils étaient. Ça paraît évident une fois écrit ; en réalité, beaucoup de démonstrateurs publiés en 2024-2025 reposent sur de la mémoire volatile et perdent l’état au moindre redémarrage.
La couche d’action : OPNsense, mais réversible
Quand le pipeline aboutit à une action de blocage, celle-ci est appliquée sur OPNsense via son API REST. Mais avec une discipline qui distingue asp-forge des démonstrateurs naïfs : chaque action est réversible et tracée par UUID.
Quand le SOC dit “block 1.2.3.4 pour 24h”, la couche d’action :
- crée la règle firewall ;
- récupère l’
uuidretourné par OPNsense ; - persiste cet
uuiddans la base ASP, lié à l’incident d’origine ; - déclenche un timer pour la suppression automatique en fin de TTL ou si une revue humaine la rejette.
La conséquence opérationnelle est qu’un blocage erroné peut être défait en une seconde, et que l’historique des règles n’enfle pas indéfiniment (un rule janitor nettoie périodiquement les règles expirées). Cette propriété rend le système beaucoup plus acceptable pour un opérateur réel — l’erreur n’est pas définitive.
Auto-block immédiat : un choix contre-intuitif
Détail de pipeline qui mérite mention. Pour les alertes au-dessus d’un certain seuil de level Wazuh, on bloque immédiatement — avant même que le LLM ait analysé. Le LLM passe ensuite, et peut décider de supprimer la règle s’il estime que c’était un faux positif.
Pourquoi ? Parce que la latence cumulée d’un pipeline agentique (triage + SOC + éventuelle contre-analyse CERT) est de l’ordre de 20 à 30 secondes. Pour une attaque type brute force SSH agressive, c’est trop long ; entre le moment où l’alerte tombe et le moment où on bloque, l’attaquant a fait des dizaines de tentatives.
Le compromis retenu : agir d’abord, raisonner ensuite, accepter
quelques faux positifs courts plutôt que des intrusions réelles.
C’est un trade-off opérationnel défendable parce qu’on a la propriété
de réversibilité. Sans la couche delete_rule(uuid), ce choix serait
intenable.
Récapitulatif des contracts
Pour clore, vue d’ensemble des frontières typées qui font que ce système ne dérive pas :
WazuhPayload ──► AlertNormalized ──► TriageDecision
│
▼
enrichment + history
│
▼
SocDecision (whitelist actions)
│
▼
[optional] CertVerdict
│
▼
ActionApplied (+ uuid + ttl)
│
▼
FinalizedIncident (audit log)
Chaque flèche est une BaseModel Pydantic stricte. Chaque transition
peut échouer en ValidationError, et chaque échec déclenche une boucle
de correction locale plutôt qu’un crash global.
Ce qui suit
L’architecture est posée et le pipeline détaillé. Reste la question la plus utile pour qui veut s’en inspirer : est-ce que ça marche en pratique ? Quels sont les pièges qu’on n’avait pas anticipés ? Le CERT justifie-t-il son coût ? Quelles sont les limites que nous avons rencontrées ?
C’est l’objet du dernier article de la série — le retour d’expérience honnête, sans success story édulcorée.
