Bloquer une prompt injection sans la détecter, c'est aveugler son SOC. Détecter sans bloquer, c'est laisser passer l'attaque. La bonne posture combine les deux — et la détection doit se faire en temps réel, ou au plus tard en quasi-temps réel asynchrone, pour produire le signal qui alimente blocage, alertes et threat intelligence.
Cet article cartographie les quatre surfaces de détection opérationnelles (entrée, sortie, comportement, télémétrie), les outils du marché (Lakera Guard, Protect AI Rebuff, LLM Guard, NeMo Guardrails, Microsoft Prompt Shields), les métriques (FPR / FNR / latence) et l'intégration SOC (SIEM, runbooks, escalade). Code Python concret pour chaque couche.
Pourquoi détecter en temps réel et pas seulement bloquer
Un filtre binaire qui bloque ou laisse passer répond mal à trois besoins :
- Itération de l'attaquant. Sans signal de détection, l'attaquant ne sait pas pourquoi son payload échoue. Il itère silencieusement. Un blocage sans alerte côté défenseur, c'est l'inverse exact du fail-loud.
- Threat intelligence interne. Les patterns d'attaque réels sur votre système sont la donnée la plus précieuse pour calibrer vos défenses. Si vous ne loggez rien, vous travaillez à l'aveugle.
- Réponse à incident. Quand une exfiltration réussit, le forensique demande de remonter dans les logs : quels prompts ont été soumis, quels chunks retrieved, quelles actions tools déclenchées. Sans télémétrie structurée, l'incident est non-traçable.
La règle opérationnelle : détecter, logger, alerter, bloquer ou dégrader selon contexte. Quatre actions distinctes, jamais confondues.
Les quatre surfaces de détection
| Surface | Quand | Latence acceptable | Coût | Couverture |
|---|---|---|---|---|
| Entrée (input) | Avant appel LLM | Synchrone, 30-100ms | Faible | Injection directe + une partie de l'indirecte |
| Sortie (output) | Après réponse LLM | Synchrone ou async | Moyen | Exfiltration, fuite de données, comportement anormal |
| Comportement (tool calls + embedding) | Pendant orchestration | Synchrone sur tool calls | Moyen-élevé | Agents, RAG, function calling |
| Télémétrie (logs + métriques) | Asynchrone | Async pur | Faible | Patterns long-terme, attaque distribuée |
Chaque surface couvre une fraction du périmètre. La défense robuste les combine.
Surface 1 — Détection en entrée
L'entrée comprend le prompt utilisateur direct et tout contenu externe ingéré (RAG chunks, documents uploadés, emails parsés, pages web scrapées). Détecter à ce niveau attrape les payloads les plus évidents avant qu'ils n'atteignent le LLM.
Signaux
- Lexical / regex : marqueurs d'instruction connus (
ignore previous,system override,[INST],<|im_start|>, balises XML<system>, etc.). - Caractères de contrôle Unicode : zero-width joiners, RTL override, tag chars — généralement aucune raison légitime de les voir dans un prompt utilisateur.
- Encodages suspects : base64 long, hexa long, séquences ROT13 reconnues.
- Classifier ML : modèle dédié (DeBERTa, distilled BERT) entraîné sur corpus injection vs benin.
- LLM-as-judge : un LLM petit et rapide juge si le prompt courant tente une injection. Plus coûteux mais flexible.
Implémentation Python combinée
import re
from typing import Literal
DetectionResult = dict[str, str | float | bool]
# Marqueurs d'instruction connus
INSTRUCTION_MARKERS = [
r"ignore\s+(all\s+)?(previous|prior)\s+instructions",
r"disregard\s+(all\s+)?(previous|prior)",
r"system\s+(override|prompt|instructions)",
r"\[/?INST\]",
r"<\|im_(start|end)\|>",
r"###\s*SYSTEM\s*###",
r"DAN\s*(mode|prompt)",
r"developer\s+mode",
]
# Caractères Unicode à neutraliser
UNICODE_CONTROL = re.compile(r"[--0-F]")
def detect_input_injection(
user_input: str,
classifier=None,
judge_llm=None,
) -> DetectionResult:
signals = []
# Couche 1 — regex marqueurs
for pattern in INSTRUCTION_MARKERS:
if re.search(pattern, user_input, flags=re.IGNORECASE):
signals.append(("marker_regex", 0.7, pattern))
break
# Couche 2 — Unicode control chars
if UNICODE_CONTROL.search(user_input):
signals.append(("unicode_control", 0.6, "control_chars_present"))
# Couche 3 — base64 long
if re.search(r"[A-Za-z0-9+/]{60,}={0,2}", user_input):
signals.append(("encoded_payload", 0.4, "base64_suspect"))
# Couche 4 — classifier ML (Lakera, LLM Guard, custom)
if classifier is not None:
score = classifier.predict_proba(user_input)
if score > 0.6:
signals.append(("classifier_ml", score, "model_alert"))
# Couche 5 — LLM-as-judge (asynchrone si latence critique)
if judge_llm is not None:
verdict = judge_llm.classify(user_input)
if verdict.is_injection:
signals.append(("llm_judge", verdict.confidence, verdict.reason))
# Combinaison : score max + nombre de signaux
max_score = max((s[1] for s in signals), default=0.0)
return {
"is_suspicious": max_score >= 0.7 or len(signals) >= 2,
"score": max_score,
"signals": signals,
"action": _decide_action(max_score, len(signals)),
}
def _decide_action(score: float, n_signals: int) -> Literal["block", "flag", "allow"]:
if score >= 0.9 or n_signals >= 3:
return "block"
if score >= 0.6 or n_signals >= 2:
return "flag" # passer + alerter SOC
return "allow"Limites
- Faux positifs sur les développeurs qui parlent légitimement de prompts et d'instructions. Solution : seuil + contexte utilisateur.
- Faux négatifs sur payloads obfusqués (compositionnels, traduits) — d'où la nécessité des autres surfaces.
- Performance : un classifier custom CPU à 200-500ms peut devenir un bottleneck. Cibler GPU partagé ou produit managé.
Surface 2 — Détection en sortie
La sortie est souvent plus informative que l'entrée pour détecter une attaque réussie. Si la défense d'entrée a laissé passer un payload obfusqué, la sortie porte les traces de l'exécution.
Signaux
- DLP : présence d'identifiants sensibles dans la sortie (emails internes, IBAN, tokens API, clés). Outils : Microsoft Presidio, Google DLP, AWS Macie.
- Markdown image vers domaine externe : pattern
vers domaine hors allowlist — signal classique d'exfiltration. - Canary tokens : émission d'un token canari placé dans le system prompt ou dans des documents test. Signal certain.
- Anomalie de longueur : sortie 10× plus longue que la moyenne historique sur ce type de requête.
- Anomalie sémantique : sortie qui répond à autre chose que la question (le LLM a suivi une injection plutôt que la requête utilisateur).
Implémentation
from presidio_analyzer import AnalyzerEngine
import re
CANARY_TOKENS = {"CANARY_7f3a92b1", "CANARY_DOC_42", "CANARY_SYS_ALPHA"}
ALLOWED_OUTPUT_DOMAINS = {"yourcompany.com", "trusted-cdn.example"}
analyzer = AnalyzerEngine()
def detect_output_anomaly(llm_output: str, expected_intent: str) -> DetectionResult:
signals = []
# 1. Canary tokens
for canary in CANARY_TOKENS:
if canary in llm_output:
signals.append(("canary_emitted", 1.0, canary))
# 2. Markdown image vers domaine non autorisé
for match in re.finditer(r"!\[[^\]]*\]\((https?://[^)]+)\)", llm_output):
url = match.group(1)
domain = url.split("/")[2].lower()
if not any(domain.endswith(d) for d in ALLOWED_OUTPUT_DOMAINS):
signals.append(("exfil_markdown_image", 0.95, url))
# 3. DLP — Presidio
pii = analyzer.analyze(text=llm_output, language="fr")
sensitive = [r for r in pii if r.entity_type in {"EMAIL_ADDRESS", "IBAN_CODE", "API_KEY"}]
if sensitive:
signals.append(("dlp_pii_in_output", 0.8, [r.entity_type for r in sensitive]))
# 4. Anomalie de longueur (à calibrer sur historique)
if len(llm_output) > 8000 and "long_form" not in expected_intent:
signals.append(("anomaly_length", 0.5, len(llm_output)))
return {
"is_suspicious": any(s[1] >= 0.8 for s in signals),
"signals": signals,
}Patterns de blocage en sortie
- Strip des URLs vers domaines non autorisés avant rendu UI.
- Désactivation du rendu markdown des images côté client (mitigation EchoLeak-class).
- Réécriture : remplacer la sortie suspecte par un message d'erreur générique + log de l'incident.
- Approval HITL : pour les actions sortantes (envoi email, partage), ne jamais exécuter sans confirmation.
Surface 3 — Détection comportementale
S'applique aux agents IA, RAG et systèmes avec function calling. La détection se fait sur les patterns d'appels d'outils, l'embedding drift et les séquences anormales.
Signaux
- Tool call jamais vu : le modèle appelle un outil qu'il n'utilise jamais dans le contexte courant.
- Argument anormal : un outil d'envoi d'email appelé avec un destinataire externe inattendu.
- Séquence anormale :
read_user_data→send_external_requestdans la même chaîne, jamais observé en trafic normal. - Embedding drift : la requête utilisateur a une distance cosinus élevée par rapport au cluster historique de ce user.
- Volume / vélocité : explosion du nombre de tool calls dans une session.
Implémentation simplifiée
from collections import deque
import numpy as np
class BehavioralDetector:
"""Détecteur de pattern anormal sur tool calls."""
SUSPICIOUS_SEQUENCES = {
("read_documents", "send_email"),
("read_documents", "http_request"),
("get_user_data", "create_share_link"),
("retrieve_context", "post_to_webhook"),
}
def __init__(self, window_size: int = 10):
self.window: deque = deque(maxlen=window_size)
def check_tool_call(self, tool_name: str, args: dict) -> DetectionResult:
signals = []
# Séquence suspecte
if self.window:
seq = (self.window[-1], tool_name)
if seq in self.SUSPICIOUS_SEQUENCES:
signals.append(("suspicious_sequence", 0.85, seq))
# Argument suspect : URL hors allowlist
for key, val in args.items():
if isinstance(val, str) and val.startswith(("http://", "https://")):
domain = val.split("/")[2].lower()
if not domain.endswith(("yourcompany.com", "trusted.example")):
signals.append(("external_url_in_tool", 0.9, val))
# Volume anormal
if len(self.window) >= 8 and self.window.count(tool_name) >= 5:
signals.append(("tool_call_burst", 0.7, tool_name))
self.window.append(tool_name)
return {
"is_suspicious": any(s[1] >= 0.8 for s in signals),
"signals": signals,
}Embedding drift
Sur un utilisateur récurrent, on conserve l'embedding moyen de ses requêtes historiques. Une requête dont la distance cosinus dépasse un seuil (ex: > 2σ du cluster) est flaggée. Utile pour détecter une prise de contrôle de session ou un changement brutal d'intention.
Outils et produits du marché
| Outil | Type | Couverture | Latence | Self-hosted |
|---|---|---|---|---|
| Lakera Guard | API managée | Input + output, multi-langue | ~50-100ms p95 | Non (Cloud), Yes (Enterprise) |
| Protect AI Rebuff | Open source + cloud | Input, canary, vector DB poisoning | 30-300ms | Oui |
| LLM Guard (Laiyer) | Open source Python | Input + output, modulaire | 100-500ms CPU | Oui |
| NeMo Guardrails (NVIDIA) | Open source framework | Rails programmables, dialog flows | Variable | Oui |
| Microsoft Prompt Shields | Azure managé | Direct + indirect injection | ~50ms | Non |
| Microsoft Presidio | Open source | DLP / PII détection sortie | 50-200ms | Oui |
| Azure AI Content Safety | Azure managé | Toxicité + injection | ~100ms | Non |
| AWS Bedrock Guardrails | AWS managé | Input + output | Variable | Non |
| Google Model Armor | GCP managé | Multi-couche | Variable | Non |
| Mindgard | SaaS audit + runtime | Couverture audit large | Variable | Non |
Recommandation pratique : commencer par un produit managé (Lakera, Prompt Shields, Bedrock Guardrails) pour la couverture immédiate, ajouter LLM Guard ou règles maison pour le custom métier, et conserver Presidio dédié au DLP en sortie. Voir notre article guardrails pour la comparaison architecturale.
Métriques et calibration
Une détection sans métriques est inopérable. Suivre au minimum :
| Métrique | Définition | Cible typique |
|---|---|---|
| TPR (true positive rate) | détection / vraies attaques | > 90% sur corpus de test |
| FPR (false positive rate) | blocages / trafic légitime | < 1-2% |
| Latence p50 / p95 / p99 | overhead détection vs sans | + 50-100ms p95 |
| Coût par requête | classifier + LLM-judge | < 5% du coût LLM principal |
| Mean Time to Detect (MTTD) | délai entre attaque et alerte | < 1 minute |
| Mean Time to Respond (MTTR) | délai entre alerte et action | < 15 minutes |
Calibration des seuils
Procédure recommandée :
- Constituer un corpus : 500-2000 requêtes légitimes du domaine + 100-300 attaques (Top 20 techniques + variantes).
- Évaluer chaque détecteur isolément : TPR, FPR, latence.
- Combiner plusieurs signaux faibles via règle d'agrégation (AND/OR/score additif). Mesurer le delta TPR vs FPR.
- Calibrer le seuil de blocage par profil de risque : strict (banque, santé) vs permissif (chatbot conversationnel).
- Itérer : tous les mois, rejouer le corpus + nouveaux échantillons réels, ré-entraîner si dérive.
Tip — Utiliser une courbe ROC pour visualiser le tradeoff TPR/FPR. Le point opérationnel doit être choisi explicitement, pas accepté par défaut depuis le seuil par défaut de l'outil.
Intégration SOC et runbooks
La détection alimente le SOC comme n'importe quel autre signal. Trois étages :
Logging structuré
Format minimum (OpenTelemetry SemConv 1.27+ ou JSON propriétaire) :
{
"timestamp": "2026-04-29T11:23:45.123Z",
"request_id": "req_8a92...",
"user_id": "u_42",
"session_id": "sess_91fe...",
"model": "claude-sonnet-4-6",
"prompt_hash": "sha256:abc...",
"input_tokens": 412,
"output_tokens": 287,
"detection": {
"input_score": 0.82,
"input_signals": ["marker_regex", "classifier_ml"],
"output_score": 0.0,
"behavior_score": 0.0,
"action_taken": "block"
},
"tools_called": [],
"latency_ms": 87
}Émis vers le SIEM via OTLP, syslog, agent Splunk/Sentinel/Elastic.
Règles de corrélation
- Burst d'attaques sur un user : > 5 détections en 10 min → alerte + rate limit ciblé.
- Score élevé + canary émis : escalade immédiate, escalade pager.
- Pattern d'exfiltration : tool call externe + sortie contenant PII → alerte + blocage de session.
Runbook type
- Triage : alerte SOC reçue → vérifier prompt + sortie + contexte user.
- Catégorisation : faux positif ? attaque opportuniste ? attaque ciblée ?
- Action immédiate : rate limit, blocage IP/user, escalade engineering si exfiltration probable.
- Investigation : remonter les autres requêtes du user / IP / session sur 24h.
- Containment : si attaque ciblée → escalade RSSI, communication interne, vérification des données potentiellement exfiltrées.
- Lessons learned : ajouter le pattern au corpus de test, ré-entraîner classifier si nécessaire, partager threat intel.
Pour la mise en place complète d'observabilité LLM en production, voir notre guide auditer un LLM en production.
Points clés à retenir
- Bloquer ≠ détecter ≠ alerter ≠ logger : quatre actions distinctes, chacune nécessaire pour un système robuste.
- Quatre surfaces de détection : entrée, sortie, comportement (tool calls), télémétrie (logs/métriques). Aucune isolément ne suffit.
- En entrée : combiner regex + classifier + LLM-judge ; jamais un signal unique.
- En sortie : canary tokens + DLP + désactivation markdown image + allowlist domaines font la différence.
- En comportement : surveiller séquences de tool calls, arguments suspects, embedding drift sur les agents.
- Outils recommandés en stack : Lakera Guard / Microsoft Prompt Shields (entrée), LLM Guard / Presidio (sortie + DLP), NeMo Guardrails (orchestration), Langfuse / Phoenix Arize (télémétrie).
- Calibrer les seuils sur corpus métier, pas sur défauts vendeur. Cible : TPR > 90%, FPR < 1-2%.
- Intégration SOC obligatoire : logs structurés, règles de corrélation, runbook documenté, MTTD < 1 min.
La détection runtime n'est pas un nice-to-have. C'est la couche qui transforme une application LLM aveugle en système observable, défendable et améliorable dans le temps.







