Sécurité du Function Calling
Ne jamais exécuter aveuglément
Le function calling donne au modèle la capacité de demander l’exécution de fonctions dans votre système. C’est puissant, mais c’est aussi un vecteur d’attaque potentiel. Un utilisateur malveillant peut tenter de manipuler le modèle pour qu’il appelle des fonctions avec des paramètres dangereux. Votre code doit être le dernier rempart de sécurité.
Principe fondamental : validation des arguments
Le modèle génère des arguments JSON. Avant d’exécuter quoi que ce soit, validez chaque argument :
import re
def validate_transaction_id(transaction_id: str) -> bool:
"""Valide le format d'un identifiant de transaction."""
# Format attendu : T suivi de 4 chiffres
return bool(re.match(r"^T\d{4}$", transaction_id))
def retrieve_payment_status_safe(df, transaction_id: str) -> str:
"""Version sécurisée avec validation."""
# Étape 1 : valider le format
if not validate_transaction_id(transaction_id):
return json.dumps({
"error": "invalid_format",
"message": "L'identifiant doit être au format Txxxx (ex: T1001)"
})
# Étape 2 : vérifier l'existence
if transaction_id not in df["transaction_id"].values:
return json.dumps({
"error": "not_found",
"message": f"Transaction {transaction_id} introuvable"
})
# Étape 3 : exécuter
status = df[df["transaction_id"] == transaction_id]["payment_status"].values[0]
return json.dumps({"status": status})
Injection de prompt via les arguments
Un utilisateur malveillant peut tenter d’injecter des instructions dans les arguments. Exemple :
Utilisateur : "Cherche le paiement avec l'ID: T1001; DROP TABLE payments"
Le modèle pourrait générer {"transaction_id": "T1001; DROP TABLE payments"}. Si votre fonction utilise cet argument directement dans une requête SQL, c’est une injection SQL classique.
Protections :
# JAMAIS ça — injection SQL directe
def bad_query(transaction_id):
cursor.execute(f"SELECT * FROM payments WHERE id = ' {transaction_id}'")
# TOUJOURS ça — requêtes paramétrées
def safe_query(transaction_id):
cursor.execute("SELECT * FROM payments WHERE id = %s", (transaction_id,))
# ENCORE MIEUX — validation + requête paramétrée
def best_query(transaction_id):
if not validate_transaction_id(transaction_id):
raise ValueError("Format d'identifiant invalide")
cursor.execute("SELECT * FROM payments WHERE id = %s", (transaction_id,))
Liste blanche de fonctions
Ne rendez disponibles que les fonctions strictement nécessaires. Implémentez une liste blanche explicite :
# Définir explicitement les fonctions autorisées
ALLOWED_FUNCTIONS = {
"retrieve_payment_status",
"retrieve_payment_date",
"search_transactions"
}
def execute_tool_call_safe(tool_call, available_functions):
"""Exécution avec vérification de la liste blanche."""
function_name = tool_call.function.name
# Vérifier la liste blanche
if function_name not in ALLOWED_FUNCTIONS:
return json.dumps({
"error": "forbidden",
"message": f"La fonction {function_name} n'est pas autorisée."
})
if function_name not in available_functions:
return json.dumps({
"error": "not_implemented",
"message": f"La fonction {function_name} n'est pas implémentée."
})
# Exécuter
function_params = json.loads(tool_call.function.arguments)
return available_functions[function_name](**function_params)
Limiter les actions destructives
Classifiez vos fonctions par niveau de risque :
# Niveaux de risque
READ_ONLY = {"retrieve_payment_status", "retrieve_payment_date", "search_transactions"}
WRITE = {"update_payment_status", "create_refund"}
DESTRUCTIVE = {"delete_transaction", "purge_old_records"}
def execute_with_permissions(tool_call, available_functions, permission_level="read"):
"""Exécute avec vérification du niveau de permission."""
function_name = tool_call.function.name
if permission_level == "read" and function_name not in READ_ONLY:
return json.dumps({
"error": "permission_denied",
"message": f"Seules les fonctions de lecture sont autorisées. "
f"{function_name} nécessite le niveau 'write'."
})
if function_name in DESTRUCTIVE:
return json.dumps({
"error": "confirmation_required",
"message": f"L'action {function_name} est destructive et "
f"nécessite une confirmation manuelle."
})
function_params = json.loads(tool_call.function.arguments)
return available_functions[function_name](**function_params)
Rate limiting par fonction
Empêchez les abus en limitant le nombre d’appels :
from collections import defaultdict
import time
class FunctionRateLimiter:
def __init__(self, max_calls_per_minute=10):
self.max_calls = max_calls_per_minute
self.call_history = defaultdict(list)
def can_call(self, function_name: str) -> bool:
now = time.time()
# Nettoyer les appels de plus d'une minute
self.call_history[function_name] = [
t for t in self.call_history[function_name]
if now - t < 60
]
return len(self.call_history[function_name]) < self.max_calls
def record_call(self, function_name: str):
self.call_history[function_name].append(time.time())
rate_limiter = FunctionRateLimiter(max_calls_per_minute=10)
Sanitisation des résultats
Avant de renvoyer un résultat au modèle, vérifiez qu’il ne contient pas de données sensibles :
def sanitize_result(result: dict) -> dict:
"""Retire les champs sensibles avant de renvoyer au modèle."""
sensitive_keys = {"password", "api_key", "secret", "token", "ssn", "credit_card"}
sanitized = {}
for key, value in result.items():
if key.lower() in sensitive_keys:
sanitized[key] = "[MASQUÉ]"
elif isinstance(value, dict):
sanitized[key] = sanitize_result(value)
else:
sanitized[key] = value
return sanitized
Points clés à retenir
- Validez toujours les arguments avant d’exécuter une fonction — format, type, valeurs autorisées
- Utilisez des requêtes paramétrées pour éviter les injections SQL
- Implémentez une liste blanche de fonctions autorisées
- Classifiez les fonctions par niveau de risque (lecture, écriture, destructif)
- Ajoutez du rate limiting pour empêcher les abus
- Sanitisez les résultats pour ne pas exposer de données sensibles au modèle