Files
college-infos/pronote_sync/utils/redaction.py
Antoine Van Elstraete 19cbf8f13f fix(M9): corrections d'audit FIXME_M9 — secrets, messages, validation, tests
Cinq corrections de l'audit FIXME_M9 :
- redact_secrets() étendue avec extra_secrets pour masquer les clés brutes ;
  providers stockent SecretStr jusqu'à l'appel SDK.
- _build_prompt() inclut le contenu des messages (tronqué à 500 car.) ;
  prompt système renforcé contre l'injection.
- _validate_output() supprime les emojis et rejette titre/liste/HTML → None.
- Tests litellm utilisent importorskip + LITELLM_LOCAL_MODEL_COST_MAP=true.
- .env.example désactive l'IA par défaut (AI_ENABLED=false).
- Documentation §9.2-§9.5 alignée avec l'implémentation (SDK openai, SecretStr,
  factory réelle, validation sortie, politique hors réseau).

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
Co-authored-by: opencode/tech-writer <tech-writer@agents.invalid>
2026-09-07 19:01:25 +02:00

120 lines
4.5 KiB
Python

"""Utilitaires de masquage des secrets dans les URLs, textes et exceptions.
Ce module centralise la rédaction des données sensibles (tokens, mots de
passe, clés d'accès) afin qu'aucun secret ne soit exposé dans les logs,
les messages d'erreur ou les traces du pipeline ``pronote-sync``.
"""
from __future__ import annotations
import re
from collections.abc import Iterable
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from pydantic import SecretStr
_SENSITIVE_QUERY_KEYS = frozenset(
{
"icalsecurise",
"token",
"key",
"password",
"secret",
"api_key",
"apikey",
"access_token",
"auth",
"authorization",
}
)
_URL_PATTERN = re.compile(r"https?://[^\s]+", re.IGNORECASE)
_AUTH_HEADER_PATTERN = re.compile(
r"((?:Proxy-)?Authorization)\s*[:=]\s*\S[^\r\n]*",
re.IGNORECASE,
)
_ISOLATED_SECRET_PATTERN = re.compile(
r"\b(icalsecurise|access_token|api_key|apikey|authorization|token|password|secret|key|auth)"
r"(\s*['\"]?\s*[:=]\s*)"
r"(['\"]?)"
r"([^\s&'\"]+)",
re.IGNORECASE,
)
_REDACTED = "REDACTED"
_REDACTED_URL = "REDACTED_URL"
def redact_url(url: str) -> str:
"""Masque les identifiants et les paramètres sensibles d'une URL.
Les informations d'authentification du netloc (``utilisateur:motdepasse@hôte``)
sont entièrement masquées (utilisateur et mot de passe), ainsi que les
paramètres sensibles de la requête (ex: ``icalsecurise``).
:param url: URL pouvant contenir des informations sensibles (ex: ``icalsecurise``).
:return: URL avec les éléments sensibles remplacés par ``REDACTED``,
ou ``REDACTED_URL`` si le traitement échoue.
:rtype: str
"""
try:
parts = urlsplit(url)
if parts.username is not None or parts.password is not None:
# Netloc sûr : REDACTED@hôte:port. L'utilisateur et le mot de
# passe sont entièrement masqués.
host = parts.hostname or ""
if parts.port is not None:
netloc = f"{_REDACTED}@{host}:{parts.port}"
else:
netloc = f"{_REDACTED}@{host}"
parts = parts._replace(netloc=netloc)
query: list[tuple[str, str]] = parse_qsl(parts.query, keep_blank_values=True)
redacted_query = [
(key, _REDACTED if key.lower() in _SENSITIVE_QUERY_KEYS else value)
for key, value in query
]
return urlunsplit(parts._replace(query=urlencode(redacted_query, doseq=True)))
except Exception:
return _REDACTED_URL
def redact_secrets(text: str, extra_secrets: Iterable[SecretStr | str] = ()) -> str:
"""Masque les secrets présents dans un texte arbitraire.
Les URLs sont d'abord traitées par :func:`redact_url`, puis les en-têtes
d'authentification (``Authorization``, ``Proxy-Authorization``) et les
affectations isolées de type ``cle=valeur`` ou ``cle:valeur``
(ex: ``icalsecurise=XXX``, ``"token": "XXX"``) sont masquées, sans
distinction de casse.
Les valeurs sensibles additionnelles fournies via ``extra_secrets``
(clés API brutes, jetons, mots de passe, etc.) sont ensuite remplacées
littéralement, par ``str.replace``, par ``REDACTED`` dans le texte, y
compris lorsqu'elles n'apparaissent pas sous une forme ``cle=valeur``
reconnue. Une valeur vide ou ``None`` est ignorée.
:param text: Texte pouvant contenir des URLs ou des secrets en clair.
:param extra_secrets: Itérable de secrets bruts (``str`` ou
:class:`pydantic.SecretStr`) à masquer. Les valeurs vides ou
``None`` sont ignorées.
:return: Texte avec les secrets remplacés par ``REDACTED``.
:rtype: str
"""
redacted = _URL_PATTERN.sub(lambda match: redact_url(match.group(0)), text)
redacted = _AUTH_HEADER_PATTERN.sub(r"\1: REDACTED", redacted)
redacted = _ISOLATED_SECRET_PATTERN.sub(r"\1\2\3REDACTED", redacted)
for secret in extra_secrets:
value: str | None = secret.get_secret_value() if isinstance(secret, SecretStr) else secret
if not value:
continue
redacted = redacted.replace(value, _REDACTED)
return redacted
def redact_exception(exc: Exception) -> str:
"""Masque les secrets dans la représentation textuelle d'une exception.
:param exc: Exception dont le message doit être rédigé.
:return: Représentation textuelle de l'exception avec les secrets masqués.
:rtype: str
"""
return redact_secrets(str(exc))