from __future__ import annotations import ipaddress import re from typing import Iterable from urllib.parse import urlsplit REDACTION = "[REDACTED]" _URL_RE = re.compile(r"https?://[^\s<>\]\[\)\(\"']+", re.IGNORECASE) _EMAIL_RE = re.compile(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.IGNORECASE) _IPV4_RE = re.compile(r"(? set[str]: return {host.strip().lower().rstrip(".") for host in hosts if host.strip()} def _redact_url(match: re.Match[str], allowed_hosts: set[str]) -> str: value = match.group(0) host = (urlsplit(value).hostname or "").lower().rstrip(".") return value if host in allowed_hosts else REDACTION def _redact_ipv4(match: re.Match[str]) -> str: value = match.group(0) try: ipaddress.IPv4Address(value) except ipaddress.AddressValueError: return value return REDACTION def _redact_ipv6(match: re.Match[str]) -> str: value = match.group(0) try: ipaddress.IPv6Address(value) except ipaddress.AddressValueError: return value return REDACTION def _redact_fqdn(match: re.Match[str], allowed_hosts: set[str]) -> str: host = match.group(0) return host if host.lower().rstrip(".") in allowed_hosts else REDACTION def sanitize_text(text: str, *, allowed_hosts: Iterable[str]) -> str: hosts = _normalized_hosts(allowed_hosts) cleaned = _URL_RE.sub(lambda match: _redact_url(match, hosts), text) cleaned = _CREDENTIAL_RE.sub(REDACTION, cleaned) cleaned = _EMAIL_RE.sub(REDACTION, cleaned) cleaned = _PATH_RE.sub(REDACTION, cleaned) cleaned = _IPV4_RE.sub(_redact_ipv4, cleaned) cleaned = _IPV6_CANDIDATE_RE.sub(_redact_ipv6, cleaned) cleaned = _PHONE_RE.sub(REDACTION, cleaned) cleaned = _PRIVATE_HOST_RE.sub(REDACTION, cleaned) cleaned = _FQDN_RE.sub(lambda match: _redact_fqdn(match, hosts), cleaned) return cleaned def assert_publishable(text: str, *, allowed_hosts: Iterable[str]) -> None: sanitized = sanitize_text(text, allowed_hosts=allowed_hosts) if sanitized != text: raise ValueError("content still contains private or unapproved data")