redaction.py 3.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
  1. """Central redaction helpers for data-source requests, logs and responses."""
  2. import re
  3. from collections.abc import Mapping
  4. from urllib.parse import urlsplit, urlunsplit
  5. REDACTED = "[redacted]"
  6. SENSITIVE_KEYS = {
  7. "password",
  8. "passwd",
  9. "credential",
  10. "credentials",
  11. "credential_ref",
  12. "api_key",
  13. "apikey",
  14. "token",
  15. "authorization",
  16. "encrypted_payload",
  17. "nonce",
  18. "conn_str",
  19. "connection_string",
  20. "connection_url",
  21. }
  22. RESPONSE_SECRET_KEYS = SENSITIVE_KEYS | {"username"}
  23. _URL_IN_TEXT = re.compile(
  24. r"(?P<scheme>[a-zA-Z][a-zA-Z0-9+.-]*://)"
  25. r"(?P<userinfo>[^\s/@]+)@"
  26. )
  27. _NAMED_SECRET = re.compile(
  28. r"(?i)(password|passwd|api[_-]?key|token|authorization)"
  29. r"(\s*[=:]\s*)([^\s,;]+)"
  30. )
  31. def _redact_url(value: str) -> str:
  32. try:
  33. parsed = urlsplit(value)
  34. except (TypeError, ValueError):
  35. return value
  36. if not parsed.scheme or parsed.hostname is None or "@" not in parsed.netloc:
  37. return _URL_IN_TEXT.sub(r"\g<scheme>[redacted]@", value)
  38. host = parsed.hostname
  39. if ":" in host and not host.startswith("["):
  40. host = f"[{host}]"
  41. port = f":{parsed.port}" if parsed.port is not None else ""
  42. return urlunsplit(
  43. (
  44. parsed.scheme,
  45. f"{REDACTED}@{host}{port}",
  46. parsed.path,
  47. parsed.query,
  48. parsed.fragment,
  49. )
  50. )
  51. def redact_mapping(value):
  52. """Return a recursively redacted copy suitable for logs."""
  53. if isinstance(value, Mapping):
  54. redacted = {}
  55. for key, item in value.items():
  56. normalized = str(key).strip().lower()
  57. redacted[key] = (
  58. REDACTED
  59. if normalized in SENSITIVE_KEYS
  60. else redact_mapping(item)
  61. )
  62. return redacted
  63. if isinstance(value, list):
  64. return [redact_mapping(item) for item in value]
  65. if isinstance(value, tuple):
  66. return tuple(redact_mapping(item) for item in value)
  67. if isinstance(value, str):
  68. return _redact_url(value)
  69. return value
  70. def strip_sensitive_fields(value):
  71. """Remove secret field names and values from API response structures."""
  72. if isinstance(value, Mapping):
  73. return {
  74. key: strip_sensitive_fields(item)
  75. for key, item in value.items()
  76. if str(key).strip().lower() not in RESPONSE_SECRET_KEYS
  77. }
  78. if isinstance(value, list):
  79. return [strip_sensitive_fields(item) for item in value]
  80. if isinstance(value, tuple):
  81. return tuple(strip_sensitive_fields(item) for item in value)
  82. if isinstance(value, str):
  83. return _redact_url(value)
  84. return value
  85. def sanitize_exception(error: BaseException, limit: int = 1000) -> str:
  86. """Return a bounded error classification with credentials removed."""
  87. value = str(error)
  88. value = _URL_IN_TEXT.sub(r"\g<scheme>[redacted]@", value)
  89. value = _NAMED_SECRET.sub(
  90. lambda match: f"{match.group(1)}{match.group(2)}{REDACTED}",
  91. value,
  92. )
  93. return value[: max(0, int(limit))]