LLM Security

Comment détecter une tentative de prompt injection en temps réel

Détection runtime de prompt injection : classifiers, signaux output, anomalies comportementales, outils (Lakera, Rebuff, LLM Guard), métriques SOC.

Naim Aouaichia
12 min de lecture
  • détection
  • monitoring
  • SOC
  • runtime
  • prompt injection
  • LLM security

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 :

  1. 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.
  2. 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.
  3. 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

SurfaceQuandLatence acceptableCoûtCouverture
Entrée (input)Avant appel LLMSynchrone, 30-100msFaibleInjection directe + une partie de l'indirecte
Sortie (output)Après réponse LLMSynchrone ou asyncMoyenExfiltration, fuite de données, comportement anormal
Comportement (tool calls + embedding)Pendant orchestrationSynchrone sur tool callsMoyen-élevéAgents, RAG, function calling
Télémétrie (logs + métriques)AsynchroneAsync purFaiblePatterns 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 ![...](https://...) 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_datasend_external_request dans 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é

OutilTypeCouvertureLatenceSelf-hosted
Lakera GuardAPI managéeInput + output, multi-langue~50-100ms p95Non (Cloud), Yes (Enterprise)
Protect AI RebuffOpen source + cloudInput, canary, vector DB poisoning30-300msOui
LLM Guard (Laiyer)Open source PythonInput + output, modulaire100-500ms CPUOui
NeMo Guardrails (NVIDIA)Open source frameworkRails programmables, dialog flowsVariableOui
Microsoft Prompt ShieldsAzure managéDirect + indirect injection~50msNon
Microsoft PresidioOpen sourceDLP / PII détection sortie50-200msOui
Azure AI Content SafetyAzure managéToxicité + injection~100msNon
AWS Bedrock GuardrailsAWS managéInput + outputVariableNon
Google Model ArmorGCP managéMulti-coucheVariableNon
MindgardSaaS audit + runtimeCouverture audit largeVariableNon

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étriqueDéfinitionCible 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 / p99overhead détection vs sans+ 50-100ms p95
Coût par requêteclassifier + 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 :

  1. Constituer un corpus : 500-2000 requêtes légitimes du domaine + 100-300 attaques (Top 20 techniques + variantes).
  2. Évaluer chaque détecteur isolément : TPR, FPR, latence.
  3. Combiner plusieurs signaux faibles via règle d'agrégation (AND/OR/score additif). Mesurer le delta TPR vs FPR.
  4. Calibrer le seuil de blocage par profil de risque : strict (banque, santé) vs permissif (chatbot conversationnel).
  5. 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

  1. Triage : alerte SOC reçue → vérifier prompt + sortie + contexte user.
  2. Catégorisation : faux positif ? attaque opportuniste ? attaque ciblée ?
  3. Action immédiate : rate limit, blocage IP/user, escalade engineering si exfiltration probable.
  4. Investigation : remonter les autres requêtes du user / IP / session sur 24h.
  5. Containment : si attaque ciblée → escalade RSSI, communication interne, vérification des données potentiellement exfiltrées.
  6. 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.

Questions fréquentes

  • Bloquer un prompt suspect suffit-il ou faut-il aussi le détecter et le logger ?
    Bloquer sans détecter laisse l'attaquant itérer en aveugle jusqu'à trouver une variante qui passe. Détecter sans bloquer laisse l'attaque réussir. La bonne posture est : bloquer + logger + alerter. La détection produit le signal qui alimente le SOC, déclenche les contre-mesures (rate limit ciblé, blocage IP, escalade) et alimente la threat intelligence interne. C'est le même pattern qu'un WAF web : refuser n'est qu'une partie du travail.
  • Quelle latence ajoute un classifier de détection à un appel LLM ?
    Un classifier dédié type DeBERTa-base ou similaire ajoute typiquement 30-100ms en GPU et 200-500ms en CPU. Sur un appel LLM dont la latence totale dépasse souvent 2 secondes, c'est négligeable. Les solutions managées (Lakera Guard, Microsoft Prompt Shields) sont autour de 50-100ms p95. Le pattern recommandé : détection en entrée synchrone (bloquant), détection en sortie asynchrone si elle est coûteuse, télémétrie 100% asynchrone.
  • Faut-il faire son propre classifier ou utiliser un produit du marché ?
    Pour démarrer : produit managé (Lakera Guard, Protect AI Rebuff, NeMo Guardrails, Microsoft Prompt Shields) — couverture immédiate, mises à jour gérées, ~0.5-2 ms de latence ajoutée pour les solutions API. À maturité : combiner produit managé + signaux maison spécifiques au domaine (regex métier, canary tokens, intent classifier interne). Refaire un classifier de bout en bout est rentable seulement à très grande échelle ou pour des contraintes data-residency strictes.
  • Comment réduire les faux positifs sans rendre le système vulnérable ?
    Trois leviers. (1) Calibrer les seuils par profil de risque : un assistant juridique tolère plus de friction qu'un chatbot conversationnel grand public. (2) Combiner plusieurs signaux faibles (classifier + regex + comportement) plutôt qu'un seul fort — un signal isolé doit avoir un seuil élevé, une combinaison peut avoir un seuil bas. (3) Boucle de feedback : reviewer humain sur les blocages contestés, ré-entraînement périodique du classifier. Cible : FPR < 1-2% sur le trafic légitime.
  • Les canary tokens sont-ils utiles pour détecter une exfiltration LLM ?
    Oui, c'est un des signaux les plus efficaces et les moins coûteux. On insère dans le system prompt ou dans des documents test des tokens uniques sans utilité fonctionnelle (ex: identifiants random base64). Si le LLM les émet en sortie ou les inclut dans un appel d'outil, c'est un signal certain de fuite ou d'injection réussie. Outils : Canarytokens.org, services internes maison, ou directement dans Langfuse/Phoenix via tags.
  • Comment intégrer la détection LLM au SIEM existant (Splunk, Sentinel, Elastic) ?
    Format des logs : OpenTelemetry semantic conventions pour LLM (SemConv 1.27+) ou format structuré JSON propriétaire avec champs standards (request_id, model, prompt_hash, detection_score, detection_class, user_id, action_taken). Émettre vers le collecteur centralisé via OTLP, syslog ou agent SIEM. Construire ensuite des règles de corrélation : pic de détections sur un user, scores élevés répétés, canary tokens émis. Voir notre article sur l'audit LLM en production pour le pattern d'observabilité complet.

Écrit par

Naim Aouaichia

Expert cybersécurité et fondateur de Zeroday Cyber Academy

Expert cybersécurité avec un master spécialisé et un parcours hybride : développement, DevOps, DevSecOps, SOC, GRC. Fondateur de Hash24Security et Zeroday Cyber Academy. Formateur et créateur de contenu technique sur la cybersécurité appliquée, la sécurité des LLM et le DevSecOps.