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>
120 lines
4.5 KiB
Python
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))
|