feat: durcit l'exécution SQL IA, borne les uploads et ajoute des tests

- sql_executor: remplace le filtre regex fragile par une autorisation SQLite
  (set_authorizer) en complément de mode=ro ; rejette les instructions multiples
- uploads: lecture bornée des PDF (helper read_upload_limited, limite 20 Mo,
  HTTP 413) branchée sur /extract et /save-with-pdf
- tests: suite pytest (54 tests) couvrant amounts, dates, storage, sql_executor,
  uploads et DatabaseService.save_document ; pytest ajouté en dépendance dev

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 10:11:07 +02:00
parent c15b65f0d9
commit 635a094591
13 changed files with 646 additions and 22 deletions

View File

@@ -1,49 +1,73 @@
"""Exécution SQL read-only sécurisée pour l'assistant IA."""
"""Exécution SQL read-only sécurisée pour l'assistant IA.
Trois niveaux de défense, du plus fort au plus faible :
1. Connexion SQLite ouverte en ``mode=ro`` — garantie réelle au niveau du fichier.
2. Autorisation SQLite (``set_authorizer``) qui refuse toute opération non
read-only (writes, DDL, ATTACH…) au niveau du moteur, sans faux positifs.
3. Garde lexicale légère : une seule instruction, débutant par SELECT/WITH/PRAGMA,
uniquement pour produire des messages d'erreur clairs en amont.
"""
import re
import sqlite3
from ..database.connection import get_db_path
# Requêtes interdites (défense en profondeur)
_FORBIDDEN_PATTERN = re.compile(
r"\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|ATTACH|DETACH|REPLACE|GRANT|REVOKE)\b",
re.IGNORECASE,
)
# PRAGMA autorisés
# PRAGMA autorisés (lecture de métadonnées uniquement)
_ALLOWED_PRAGMAS = {"table_info", "database_list", "table_list"}
# Limite de résultats par défaut
MAX_ROWS = 500
# Codes d'action de l'autorisation SQLite (stables dans l'ABI SQLite ; définis
# en dur car les constantes sqlite3.SQLITE_* ne sont disponibles qu'à partir de
# Python 3.11 alors que le projet cible >= 3.10).
_SQLITE_OK = 0
_SQLITE_DENY = 1
_SQLITE_READ = 20
_SQLITE_SELECT = 21
_SQLITE_PRAGMA = 19
_SQLITE_FUNCTION = 31
_SQLITE_RECURSIVE = 33
_READONLY_ACTIONS = {_SQLITE_READ, _SQLITE_SELECT, _SQLITE_FUNCTION, _SQLITE_RECURSIVE}
def _authorizer(action: int, arg1, arg2, db_name, trigger) -> int:
"""Callback d'autorisation SQLite : n'autorise que les opérations de lecture."""
if action in _READONLY_ACTIONS:
return _SQLITE_OK
if action == _SQLITE_PRAGMA:
# arg1 = nom du PRAGMA
if arg1 and arg1.lower() in _ALLOWED_PRAGMAS:
return _SQLITE_OK
return _SQLITE_DENY
return _SQLITE_DENY
def _validate_sql(query: str) -> None:
"""Valide qu'une requête SQL est read-only.
"""Garde lexicale légère pour messages d'erreur clairs (pas la garde principale).
Raises:
ValueError: si la requête n'est pas autorisée.
ValueError: si la requête n'est manifestement pas une lecture.
"""
stripped = query.strip().rstrip(";").strip()
# Refuser les instructions multiples (stacked queries)
if ";" in stripped:
raise ValueError("Une seule instruction SQL est autorisée")
upper = stripped.upper()
# Autoriser les PRAGMA spécifiques
if upper.startswith("PRAGMA"):
pragma_name = stripped.split("(")[0].split()[-1].lower().strip()
if pragma_name not in _ALLOWED_PRAGMAS:
raise ValueError(f"PRAGMA '{pragma_name}' non autorisé")
return
# La requête doit commencer par SELECT ou WITH
if not (upper.startswith("SELECT") or upper.startswith("WITH")):
raise ValueError("Seules les requêtes SELECT ou WITH sont autorisées")
# Vérifier l'absence de mots-clés dangereux
match = _FORBIDDEN_PATTERN.search(stripped)
if match:
raise ValueError(f"Mot-clé SQL interdit détecté : {match.group()}")
def _ensure_limit(query: str) -> str:
"""Ajoute LIMIT si absent."""
@@ -64,7 +88,7 @@ def execute_readonly_sql(query: str) -> dict:
Raises:
ValueError: si la requête n'est pas autorisée.
sqlite3.Error: si l'exécution échoue.
sqlite3.Error: si l'exécution échoue (y compris refus de l'autorisation).
"""
_validate_sql(query)
query = _ensure_limit(query)
@@ -73,6 +97,7 @@ def execute_readonly_sql(query: str) -> dict:
uri = f"file:{db_path}?mode=ro"
conn = sqlite3.connect(uri, uri=True)
try:
conn.set_authorizer(_authorizer)
cursor = conn.execute(query)
columns = [desc[0] for desc in cursor.description] if cursor.description else []
rows = cursor.fetchall()