Un LLM avec les droits d'admin sur ton firewall : comment ne pas se faire pwn
🇬🇧 English version 💻 Code source (AGPL) : github.com/patlegu/cyber-agent-engine
Un LLM qui peut bloquer une IP
Il y a quelque chose de vertigineux à donner à un modèle de langage la capacité
d’appeler block_ip ou add_filter_rule sur un pare-feu de production. On lui
tend une phrase — « bloque cette IP qui scanne le LAN » — et quelques centaines
de millisecondes plus tard, une règle apparaît dans OPNsense. C’est exactement
la promesse du DevSecOps assisté par IA : traduire l’intention en action, sans
passer par une console, un ticket ou une CLI.
C’est aussi exactement le genre de promesse qui devrait faire dresser les cheveux sur la tête de n’importe quel ingénieur sécurité. Un précédent article de ce blog, opnsense-ai-firewall, racontait un PoC où un LLM fine-tuné (LoRA) tournait embarqué à l’intérieur même de la VM OPNsense, pilotant directement l’API REST du firewall depuis une intention en langage naturel — 101 fonctions sur 102 validées, latence mesurée, tout fonctionnait. Et l’article se terminait par quatre raisons concrètes pour lesquelles cette topologie in-box ne devait jamais aller en production : surface d’attaque, contention CPU, cycle de vie du modèle couplé à celui de l’équipement, absence d’audit exploitable.
cyber-agent-engine est ce qui reste quand on prend cette leçon au sérieux.
Le LLM sort de la boîte (ça, c’est le sujet de l’article 4). Mais sortir le
modèle de l’équipement ne résout qu’un des problèmes. Le problème de fond —
donner à un système probabiliste, non déterministe, entraîné sur des
pétaoctets de texte public, la capacité d’exécuter des actions destructrices
sur une infrastructure réseau — reste entier tant qu’on ne l’a pas encadré
structurellement. Pas avec un meilleur prompt. Avec une architecture.
Les quatre dangers concrets
Ce ne sont pas des dangers théoriques agités pour justifier un article. Ce sont les quatre classes de défaillance qui se matérialisent dès qu’on connecte un LLM à un plan de contrôle réseau — chacune avec un scénario d’incident identifiable.
(a) Injection de prompt → action destructrice. Le contenu que le LLM « lit » n’est pas toujours celui que l’opérateur a tapé. Un nom d’hôte malveillant dans un log, un commentaire dans un ticket, une bannière de service scannée — tout texte qui transite par le contexte du modèle est une surface d’injection. Si ce texte peut faire dévier une intention de « lister les règles » vers « supprimer la règle par défaut deny », l’attaquant n’a plus besoin d’exploiter le firewall : il exploite le modèle qui l’administre.
(b) Hallucination d’une règle catastrophique. Même sans attaquant actif,
un LLM peut proposer un appel de fonction qui n’a pas de sens — un
add_filter_rule avec un direction inversé, un CIDR 0.0.0.0/0 là où
l’opérateur voulait une IP précise, un ordre de suppression massif. Un modèle
de langage optimise pour la plausibilité du texte, pas pour la sûreté d’un
plan de contrôle réseau.
(c) Fuite de PII vers un LLM tiers. Dès qu’on relie un coordinateur à une API LLM externe (OpenRouter, un fournisseur cloud), chaque prompt transite par l’infrastructure de ce tiers. Si ce prompt contient les IP réelles du LAN, les hostnames internes, les identifiants VPN — c’est une fuite de données d’infrastructure vers un service qu’on ne contrôle pas, à chaque requête.
(d) Aucun audit exploitable. Sans frontière explicite, tracer « qui a demandé quoi, qui a décidé quoi, qu’est-ce qui a été exécuté » revient à parser des logs de conversation en langage naturel — invérifiable, et généralement rempli des mêmes données sensibles qu’on cherche justement à protéger.
Ces quatre défaillances ont un point commun : aucune ne se corrige en peaufinant le prompt système. « Ne fais jamais ça » dans une instruction système est une préférence statistique, pas un contrôle. Un modèle qui suit ses instructions 99,9 % du temps produit, à l’échelle d’une infrastructure qui traite des dizaines de requêtes par jour, un incident tôt ou tard.
La thèse : une architecture de confiance, pas un meilleur prompt
Le LLM ne doit jamais être en position de s’auto-autoriser. C’est la thèse qui
structure tout cyber-agent-engine, et elle se décline en cinq principes
concrets, chacun matérialisé dans le code — pas seulement dans la
documentation.
1. Fail-closed. Par défaut, tout est refusé. Une politique qui ne
correspond à aucune règle explicite refuse l’action, elle ne l’autorise pas
« par prudence ». C’est littéralement écrit dans le docstring du module qui
évalue les décisions (core/policy/engine.py) :
« Évaluateur de politique — fonction pure, déterministe, défaut fail-closed. […] Première règle qui matche gagne (l’ordre EST la priorité, comme un firewall) ; aucune règle ne matche →
deny. »
2. Moindre privilège. Le LLM ne peut proposer que des capacités qui
existent dans un catalogue explicite (core/policy/catalog.py), avec des
arguments validés contre ce catalogue avant même d’être évalués contre la
politique. Il n’y a pas de canal d’exécution générique — pas de eval, pas de
shell, pas d’appel HTTP arbitraire à la discrétion du modèle.
3. Humain dans la boucle. Les actions irréversibles ne s’exécutent pas
automatiquement : elles suspendent le run et attendent une décision humaine
explicite (resume ou reject). C’est le sujet complet de l’article 3 de
cette série, mais le principe est simple — un modèle ne coupe jamais tout
seul un accès VPN de production.
4. Jetons uniquement. Le LLM ne voit jamais une IP réelle, un hostname
réel, un identifiant réel. Il manipule des jetons (IP_1, VPN_USER_2)
générés par un vault tenu côté serveur ; la valeur réelle n’est
substituée qu’à la toute fin, à la frontière d’exécution. C’est le sujet de
l’article 2 — et c’est probablement le principe le plus contre-intuitif de la
série, parce qu’il change fondamentalement ce qu’« un prompt qui fuite »
signifie.
5. Auditable. Chaque décision de politique s’écrit dans un journal append-only, avant même de savoir si l’action sera exécutée. Et ce journal ne contient — comme le reste du pipeline — que des jetons, jamais de valeurs réelles.
Ces cinq principes ne sont pas des slogans marketing accrochés après coup Ã
un produit qui fonctionnait déjà . Ce sont les invariants que le code du
core/ est construit pour faire respecter structurellement — au point que
certains sont imposés par le système de types lui-même, comme on le verra
dans l’article 3 avec la classe Authorized, qu’il est impossible de
construire hors des deux seules fabriques qui prouvent soit un verdict
allow, soit une approbation humaine résolue.
Vue d’ensemble : le core/
Concrètement, cette architecture de confiance vit dans un module bien précis du dépôt, séparé du reste de l’application. Voici l’arborescence réelle :
core/
├── decision.py # decide(): validate → evaluate → audit → verdict
├── orchestrator.py # orchestration mono-action, partage decide() avec la boucle
├── policy/ # Moteur de politique fail-closed + catalogue de capacités
│ ├── engine.py # evaluate() — fonction pure, déterministe
│ ├── catalog.py # CapabilityCatalog — validation des intentions
│ ├── models.py # Intention, Rule, Verdict, Match, ArgMatch
│ └── loading.py # Chargement de la politique (YAML)
├── approval/ # Approbation humaine — fail-closed par défaut
│ └── store.py # pending → approved/rejected, liaison par hash
├── audit/ # Journal d'audit — append-only, jetons uniquement
│ ├── sink.py # AuditSink (protocole) + puits mémoire
│ └── file_sink.py # Puits fichier, rotation bornée
├── auth/ # Authentification par clé API — fail-closed au démarrage
│ └── api_key.py
├── tokens/ # Vault de tokenisation PII — le LLM ne voit que des jetons
│ └── vault.py
└── execution/ # Frontière d'exécution — seul point où une valeur réelle réapparaît
├── authorization.py # Authorized — preuve infalsifiable (sentinelle privée)
└── boundary.py # execute() — détokenise puis appelle l'agent-outil
Cette arborescence n’est pas un découpage arbitraire en dossiers. Elle
matérialise le chemin que suit une intention entre le moment où le LLM la
propose et le moment où un paquet réseau change effectivement de sort. Le
module decision.py orchestre la séquence commune — « valide, évalue, audite,
renvoie un verdict » — et cette même fonction decide() est partagée par
l’orchestrateur mono-action (orchestrator.py, pour un appel isolé) et par la
boucle gatée du coordinateur (coordinator/loop.py, pour le flux ReAct
complet), afin qu’aucune des deux voies ne puisse dériver de l’autre. C’est du
DRY appliqué à un chemin de sécurité — la pire chose qu’on puisse faire, c’est
avoir deux implémentations de « est-ce que cette action est autorisée » qui
finissent par diverger.
Le principe organisateur est simple à énoncer : une intention proposée par
le LLM ne peut atteindre un équipement réel qu’en traversant, dans l’ordre,
la tokenisation, la politique, l’approbation éventuelle et la frontière
d’exécution. Aucun raccourci n’existe dans le code — pas de fonction
d’appel direct à un agent-outil qui contournerait core.execution.
flowchart LR
Op["Opérateur / UI"] -->|langage naturel| Coord
subgraph Coord["Coordinateur — cœur de confiance"]
direction TB
Tok["Tokenisation PII"] --> Pol["Politique fail-closed"] --> App["Approbation humaine"] --> Bnd["Frontière d'exécution"]
Aud[("Audit borné — jetons uniquement")]
Pol -.-> Aud
end
Coord -->|"fonction + args"| Agents["Agents-outils<br/>opnsense · wireguard · crowdsec"]
Agents -->|"API REST"| Dev["Équipements réseau"]
L’opérateur envoie du langage naturel ; le LLM, à l’intérieur du coordinateur,
ne franchit jamais lui-même la frontière de droite. Il produit une intention
— une capacité et des arguments,
exprimés en jetons — que le core/ valide, décide et audite avant qu’un
agent-outil (agents/opnsense/, agents/wireguard_agent.py,
agents/crowdsec_agent.py) ne l’exécute réellement via l’API REST de
l’équipement ciblé.
La suite
Cet article a posé le problème et la thèse : sans architecture de confiance,
brancher un LLM sur un plan de contrôle réseau est une prise de risque non
maîtrisée, quelle que soit la qualité du prompt. Les cinq prochains articles
de cette série descendent chacun dans un pilier concret de ce core/.
Le prochain arrête son regard sur le principe le plus contre-intuitif :
le LLM ne voit que des jetons. Pas d’IP, pas de hostname, pas
d’identifiant réel — jamais, à aucune étape du raisonnement. On y détaillera
le vault de core/tokens/vault.py, pourquoi cette bijection jeton↔valeur
change radicalement le rayon de souffle d’une injection de prompt, et
pourquoi c’est aussi, incidemment, une question de souveraineté des
données face aux fournisseurs de LLM externes.
Pour aller plus loin :
- opnsense-ai-firewall — le PoC in-box dont les limites ont motivé cette architecture externe.
- Agents en Production — migration Qwen2.5 — la lignée d’expériences LoRA/agents qui a précédé
cyber-agent-engine.
