redaction.py 3.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  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. _BEARER_SECRET = re.compile(r"(?i)(bearer)(\s+)([^\s,;]+)")
  32. def _redact_url(value: str) -> str:
  33. try:
  34. parsed = urlsplit(value)
  35. except (TypeError, ValueError):
  36. return value
  37. if not parsed.scheme or parsed.hostname is None or "@" not in parsed.netloc:
  38. return _URL_IN_TEXT.sub(r"\g<scheme>[redacted]@", value)
  39. host = parsed.hostname
  40. if ":" in host and not host.startswith("["):
  41. host = f"[{host}]"
  42. port = f":{parsed.port}" if parsed.port is not None else ""
  43. return urlunsplit(
  44. (
  45. parsed.scheme,
  46. f"{REDACTED}@{host}{port}",
  47. parsed.path,
  48. parsed.query,
  49. parsed.fragment,
  50. )
  51. )
  52. def redact_mapping(value):
  53. """Return a recursively redacted copy suitable for logs."""
  54. if isinstance(value, Mapping):
  55. redacted = {}
  56. for key, item in value.items():
  57. normalized = str(key).strip().lower()
  58. redacted[key] = (
  59. REDACTED
  60. if normalized in SENSITIVE_KEYS
  61. else redact_mapping(item)
  62. )
  63. return redacted
  64. if isinstance(value, list):
  65. return [redact_mapping(item) for item in value]
  66. if isinstance(value, tuple):
  67. return tuple(redact_mapping(item) for item in value)
  68. if isinstance(value, str):
  69. return _redact_url(value)
  70. return value
  71. def strip_sensitive_fields(value):
  72. """Remove secret field names and values from API response structures."""
  73. if isinstance(value, Mapping):
  74. return {
  75. key: strip_sensitive_fields(item)
  76. for key, item in value.items()
  77. if str(key).strip().lower() not in RESPONSE_SECRET_KEYS
  78. }
  79. if isinstance(value, list):
  80. return [strip_sensitive_fields(item) for item in value]
  81. if isinstance(value, tuple):
  82. return tuple(strip_sensitive_fields(item) for item in value)
  83. if isinstance(value, str):
  84. return _redact_url(value)
  85. return value
  86. def sanitize_exception(error: BaseException, limit: int = 1000) -> str:
  87. """Return a bounded error classification with credentials removed."""
  88. value = str(error)
  89. value = _URL_IN_TEXT.sub(r"\g<scheme>[redacted]@", value)
  90. value = _NAMED_SECRET.sub(
  91. lambda match: f"{match.group(1)}{match.group(2)}{REDACTED}",
  92. value,
  93. )
  94. value = _BEARER_SECRET.sub(
  95. lambda match: f"{match.group(1)}{match.group(2)}{REDACTED}",
  96. value,
  97. )
  98. return value[: max(0, int(limit))]