"""Central redaction helpers for data-source requests, logs and responses.""" import re from collections.abc import Mapping from urllib.parse import urlsplit, urlunsplit REDACTED = "[redacted]" SENSITIVE_KEYS = { "password", "passwd", "credential", "credentials", "credential_ref", "api_key", "apikey", "token", "authorization", "encrypted_payload", "nonce", "conn_str", "connection_string", "connection_url", } RESPONSE_SECRET_KEYS = SENSITIVE_KEYS | {"username"} _URL_IN_TEXT = re.compile( r"(?P[a-zA-Z][a-zA-Z0-9+.-]*://)" r"(?P[^\s/@]+)@" ) _NAMED_SECRET = re.compile( r"(?i)(password|passwd|api[_-]?key|token|authorization)" r"(\s*[=:]\s*)([^\s,;]+)" ) def _redact_url(value: str) -> str: try: parsed = urlsplit(value) except (TypeError, ValueError): return value if not parsed.scheme or parsed.hostname is None or "@" not in parsed.netloc: return _URL_IN_TEXT.sub(r"\g[redacted]@", value) host = parsed.hostname if ":" in host and not host.startswith("["): host = f"[{host}]" port = f":{parsed.port}" if parsed.port is not None else "" return urlunsplit( ( parsed.scheme, f"{REDACTED}@{host}{port}", parsed.path, parsed.query, parsed.fragment, ) ) def redact_mapping(value): """Return a recursively redacted copy suitable for logs.""" if isinstance(value, Mapping): redacted = {} for key, item in value.items(): normalized = str(key).strip().lower() redacted[key] = ( REDACTED if normalized in SENSITIVE_KEYS else redact_mapping(item) ) return redacted if isinstance(value, list): return [redact_mapping(item) for item in value] if isinstance(value, tuple): return tuple(redact_mapping(item) for item in value) if isinstance(value, str): return _redact_url(value) return value def strip_sensitive_fields(value): """Remove secret field names and values from API response structures.""" if isinstance(value, Mapping): return { key: strip_sensitive_fields(item) for key, item in value.items() if str(key).strip().lower() not in RESPONSE_SECRET_KEYS } if isinstance(value, list): return [strip_sensitive_fields(item) for item in value] if isinstance(value, tuple): return tuple(strip_sensitive_fields(item) for item in value) if isinstance(value, str): return _redact_url(value) return value def sanitize_exception(error: BaseException, limit: int = 1000) -> str: """Return a bounded error classification with credentials removed.""" value = str(error) value = _URL_IN_TEXT.sub(r"\g[redacted]@", value) value = _NAMED_SECRET.sub( lambda match: f"{match.group(1)}{match.group(2)}{REDACTED}", value, ) return value[: max(0, int(limit))]