"""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. Les secrets sont appliqués du plus long au plus court afin qu'un secret qui est une sous-chaîne d'un autre soit remplacé en premier, sans être corrompu. :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) values: list[str] = [] for secret in extra_secrets: value: str | None = secret.get_secret_value() if isinstance(secret, SecretStr) else secret if not value: continue values.append(value) for value in sorted(values, key=len, reverse=True): redacted = redacted.replace(value, _REDACTED) return redacted def redact_exception(exc: Exception, extra_secrets: Iterable[SecretStr | str] = ()) -> str: """Masque les secrets dans la représentation textuelle d'une exception. :param exc: Exception dont le message doit être rédigé. :param extra_secrets: Itérable de secrets bruts (``str`` ou :class:`pydantic.SecretStr`) à masquer, transmis à :func:`redact_secrets`. Les valeurs vides ou ``None`` sont ignorées. :return: Représentation textuelle de l'exception avec les secrets masqués. :rtype: str """ return redact_secrets(str(exc), extra_secrets)