Findings from the 2026-06-24 security review, now mandatory before this platform leaves the LAN for a public VPS (see budget/externalization note): - Timing attack on admin password: hmac.compare_digest instead of != - CORS wildcard removed, restricted to ALLOWED_ORIGINS (needed for credentials) - hCaptcha now fails closed on network error instead of open - HMAC signatures use the full SHA-256 digest instead of a 64-bit truncation - Consultation-scoped submissions were missing the consent/cooldown checks the global submission route already had - Admin search capped at 200 chars (ILIKE cost) - Webhook URLs validated against SSRF (private/loopback/link-local/metadata IPs rejected, re-checked at trigger time too, redirects not followed) - Flood tracker backed by Redis when REDIS_URL is set (shared across Gunicorn workers, survives restarts), falls back to in-memory otherwise - Admin session moved from sessionStorage/Bearer token to an httpOnly+Secure+ SameSite=Strict cookie; new /api/admin/logout and /api/admin/session routes Also found while wiring ProxyFix for the cookie's Secure flag: the app never told Flask about the Nginx reverse proxy, so request.remote_addr saw Nginx's own IP for every request in production — silently breaking rate limiting, flood detection, and IP blacklist (everyone would collapse onto one shared key). Added ProxyFix (1 hop). CSV exports now stream instead of loading everything into memory. AI prompts delimit citizen-submitted content explicitly and instruct the model to treat it as inert data, never instructions, to reduce prompt injection surface. Docs (DEX.md, SECURITE_ANTI_ABUS.md, README.md, DEPLOIEMENT.md) and dev-local.sh updated to match.
189 lines
7.7 KiB
Python
189 lines
7.7 KiB
Python
"""
|
|
La Voix du Peuple — Agent IA
|
|
Copyright (C) 2026 billisdead — Licence EUPL-1.2
|
|
|
|
Agent IA pour le filtrage éthique et la synthèse démocratique.
|
|
Supporte Mistral AI (par défaut) et tout fournisseur compatible OpenAI.
|
|
"""
|
|
import json
|
|
import os
|
|
import logging
|
|
from openai import OpenAI, BadRequestError
|
|
from legal_framework import LEGAL_FILTER_PROMPT, SYNTHESIS_PROMPT, LAW_CHECK_PROMPT, build_filter_prompt
|
|
from legifrance_client import get_legifrance_client
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
MISTRAL_BASE_URL = "https://api.mistral.ai/v1"
|
|
_LEGAL_REFS_PATH = os.path.join(os.path.dirname(__file__), "legal_refs.yaml")
|
|
|
|
_client: OpenAI | None = None
|
|
|
|
|
|
def _get_live_legal_texts() -> dict[str, str]:
|
|
"""
|
|
Récupère les textes consolidés des articles critiques depuis Légifrance (PISTE).
|
|
Retourne {} si PISTE n'est pas configuré ou en cas d'erreur — sans bloquer le filtrage.
|
|
Les résultats sont mis en cache dans Redis (24h).
|
|
"""
|
|
try:
|
|
lf = get_legifrance_client()
|
|
if not lf.available:
|
|
return {}
|
|
return lf.fetch_tracked_articles(_LEGAL_REFS_PATH)
|
|
except Exception:
|
|
logger.warning("Légifrance: impossible de récupérer les textes live — mode statique")
|
|
return {}
|
|
|
|
|
|
def get_client() -> OpenAI:
|
|
"""
|
|
Supporte deux modes (par ordre de priorité) :
|
|
1. Mistral AI : MISTRAL_API_KEY (+ MISTRAL_BASE_URL optionnel)
|
|
2. OpenAI-compatible : OPENAI_API_KEY (+ OPENAI_BASE_URL optionnel)
|
|
"""
|
|
global _client
|
|
if _client is None:
|
|
mistral_key = os.environ.get("MISTRAL_API_KEY")
|
|
mistral_base = os.environ.get("MISTRAL_BASE_URL", MISTRAL_BASE_URL)
|
|
std_key = os.environ.get("OPENAI_API_KEY")
|
|
std_base = os.environ.get("OPENAI_BASE_URL")
|
|
|
|
if mistral_key:
|
|
logger.info("Utilisation de l'API Mistral AI (%s)", mistral_base)
|
|
_client = OpenAI(base_url=mistral_base, api_key=mistral_key)
|
|
elif std_key:
|
|
logger.info("Utilisation d'une API compatible OpenAI")
|
|
kwargs: dict = {"api_key": std_key}
|
|
if std_base:
|
|
kwargs["base_url"] = std_base
|
|
_client = OpenAI(**kwargs)
|
|
else:
|
|
raise RuntimeError(
|
|
"Aucune clé IA configurée. "
|
|
"Définissez MISTRAL_API_KEY (recommandé) ou OPENAI_API_KEY dans le fichier .env."
|
|
)
|
|
return _client
|
|
|
|
|
|
def filter_idea(content: str) -> dict:
|
|
"""
|
|
Filtre une idée selon le cadre légal international des droits humains
|
|
et le droit pénal français (Code pénal, Loi du 29 juillet 1881, LCEN,
|
|
Loi SREN 2024, RGPD, Code de la santé publique).
|
|
Retourne : {"accepted": bool, "reason"?: str, "legal_basis"?: str}
|
|
"""
|
|
try:
|
|
client = get_client()
|
|
filter_model = os.environ.get("FILTER_MODEL", os.environ.get("OPENAI_FILTER_MODEL", "mistral-small-latest"))
|
|
live_texts = _get_live_legal_texts()
|
|
system_prompt = build_filter_prompt(live_texts)
|
|
if live_texts:
|
|
logger.debug("Légifrance: %d article(s) injecté(s) dans le prompt", len(live_texts))
|
|
response = client.chat.completions.create(
|
|
model=filter_model,
|
|
max_tokens=300,
|
|
response_format={"type": "json_object"},
|
|
messages=[
|
|
{"role": "system", "content": system_prompt},
|
|
{"role": "user", "content": f"Idée soumise :\n<<<DEBUT_SOUMISSION>>>\n{content}\n<<<FIN_SOUMISSION>>>"},
|
|
],
|
|
)
|
|
raw = (
|
|
response.choices[0].message.content
|
|
or '{"accepted": false, "reason": "Contenu non conforme aux principes démocratiques."}'
|
|
)
|
|
raw = raw.strip()
|
|
if raw.startswith("```"):
|
|
raw = raw.split("```")[1]
|
|
if raw.startswith("json"):
|
|
raw = raw[4:]
|
|
raw = raw.strip()
|
|
start = raw.find("{")
|
|
end = raw.rfind("}") + 1
|
|
if start != -1 and end > start:
|
|
raw = raw[start:end]
|
|
result = json.loads(raw)
|
|
return result
|
|
except json.JSONDecodeError:
|
|
logger.warning("Impossible de parser la réponse JSON du filtre, raw=%r", raw if 'raw' in locals() else 'N/A')
|
|
return {"accepted": False, "reason": "Erreur interne de filtrage"}
|
|
except BadRequestError as e:
|
|
if "content_filter" in str(e) or "content management policy" in str(e):
|
|
logger.warning("Contenu bloqué par le filtre de sécurité du proxy IA — rejet automatique")
|
|
return {
|
|
"accepted": False,
|
|
"reason": (
|
|
"Ce contenu a été automatiquement bloqué car il contient des propos "
|
|
"haineux ou violents graves, contraires à la dignité humaine."
|
|
),
|
|
"legal_basis": (
|
|
"DUDH Art. 1 (dignité humaine), DUDH Art. 20 (interdiction de la haine), "
|
|
"PIDCP Art. 20, CEDH Art. 17 (abus de droit)"
|
|
),
|
|
}
|
|
logger.exception("Erreur API lors du filtrage")
|
|
return {"accepted": False, "reason": "Service temporairement indisponible"}
|
|
except Exception:
|
|
logger.exception("Erreur lors du filtrage de l'idée")
|
|
return {"accepted": False, "reason": "Service temporairement indisponible"}
|
|
|
|
|
|
def check_existing_law(content: str) -> str:
|
|
"""
|
|
Vérifie si une proposition citoyenne est déjà couverte par une loi
|
|
française ou un règlement européen en vigueur.
|
|
Retourne une phrase informative (non bloquante).
|
|
"""
|
|
try:
|
|
client = get_client()
|
|
model = os.environ.get("FILTER_MODEL", os.environ.get("OPENAI_FILTER_MODEL", "mistral-small-latest"))
|
|
response = client.chat.completions.create(
|
|
model=model,
|
|
max_tokens=200,
|
|
messages=[
|
|
{"role": "system", "content": LAW_CHECK_PROMPT},
|
|
{"role": "user", "content": f"Proposition :\n<<<DEBUT_SOUMISSION>>>\n{content}\n<<<FIN_SOUMISSION>>>"},
|
|
],
|
|
)
|
|
return (response.choices[0].message.content or "").strip()
|
|
except Exception:
|
|
logger.exception("Erreur lors de la vérification du cadre légal existant")
|
|
return ""
|
|
|
|
|
|
def synthesize_ideas(ideas: list[str]) -> str:
|
|
"""
|
|
Synthétise une liste d'idées acceptées en un texte collectif
|
|
ancré dans les valeurs démocratiques et les droits humains.
|
|
"""
|
|
if not ideas:
|
|
return (
|
|
"Aucune idée n'a encore été soumise. "
|
|
"Soyez le premier à partager votre vision pour une société meilleure, "
|
|
"fondée sur la Déclaration universelle des droits de l'homme."
|
|
)
|
|
try:
|
|
client = get_client()
|
|
synthesis_model = os.environ.get("SYNTHESIS_MODEL", os.environ.get("OPENAI_SYNTHESIS_MODEL", "mistral-large-latest"))
|
|
ideas_text = "\n".join(f"{i + 1}. {idea}" for i, idea in enumerate(ideas))
|
|
response = client.chat.completions.create(
|
|
model=synthesis_model,
|
|
max_tokens=1200,
|
|
messages=[
|
|
{"role": "system", "content": SYNTHESIS_PROMPT},
|
|
{
|
|
"role": "user",
|
|
"content": (
|
|
f"Voici les {len(ideas)} idée(s) citoyenne(s) validées, entre les balises :\n\n"
|
|
f"<<<DEBUT_SOUMISSIONS>>>\n{ideas_text}\n<<<FIN_SOUMISSIONS>>>\n\n"
|
|
"Rédige La Voix du Peuple à partir de ce contenu."
|
|
),
|
|
},
|
|
],
|
|
)
|
|
return response.choices[0].message.content or "Synthèse en cours..."
|
|
except Exception:
|
|
logger.exception("Erreur lors de la synthèse des idées")
|
|
return "La synthèse est temporairement indisponible. Vos idées ont bien été enregistrées."
|