redaction.py 4.1 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
  1. """日志、请求头与运行记录脱敏(任务书 P1-B 安全要求)。
  2. 规则:
  3. - 已知 Secret / 密码 / Token 值出现即替换为 ***REDACTED***;
  4. - 敏感 Header 值只保留 AccessKey 后四位,其余整体遮蔽;
  5. - 连接串中 pwd=/password= 段遮蔽;
  6. - 裸 JWT(eyJ…)与 URL 内嵌凭据(scheme://user:pwd@host)遮蔽;
  7. - 已知 AccessKey 值出现在自由文本时只保留后四位(任务书 §24)。
  8. """
  9. from __future__ import annotations
  10. import re
  11. REDACTED = "***REDACTED***"
  12. SENSITIVE_HEADERS = {
  13. "authorization", "x-signature", "x-access-key", "cookie", "set-cookie",
  14. "proxy-authorization", "x-csrf-token",
  15. }
  16. # 键名命中即整体遮蔽(用于运行记录/报告中的嵌套字段)
  17. SENSITIVE_KEY_RE = re.compile(
  18. r"(password|passwd|pwd|secret|token|signature|access[-_]?key|credential|jwt)", re.IGNORECASE)
  19. _CONN_PWD_RE = re.compile(r"(pwd|password)\s*=\s*[^;]+", re.IGNORECASE)
  20. # 中文自由文本口令:密码/口令 + 值(仅 ASCII 值,避免误伤普通描述)
  21. _CN_PWD_RE = re.compile(r"(密码|口令)\s*[::=]?\s*[A-Za-z0-9_\-@#$%!*]{4,}")
  22. _BEARER_RE = re.compile(r"(Bearer|Basic)\s+[A-Za-z0-9\-._~+/=]+", re.IGNORECASE)
  23. # 裸 JWT:header.payload.signature 三段 Base64URL(即使没有 Bearer 前缀)
  24. _JWT_RE = re.compile(r"eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}")
  25. # URL 内嵌凭据:mysql://user:pwd@host / https://user:pass@host
  26. _URL_CRED_RE = re.compile(
  27. r"(?P<scheme>[a-zA-Z][a-zA-Z0-9+.-]*://)(?P<user>[^:/\s@]+):(?P<pwd>[^@/\s]+)@")
  28. def mask_access_key(access_key: str | None) -> str:
  29. """报告中 AccessKey 只保留后四位。"""
  30. if not access_key:
  31. return ""
  32. return f"****{access_key[-4:]}" if len(access_key) > 4 else "****"
  33. def redact_headers(headers: dict, secrets: list[str] | None = None,
  34. access_keys: list[str] | None = None) -> dict:
  35. out = {}
  36. for k, v in headers.items():
  37. if str(k).lower() in SENSITIVE_HEADERS:
  38. out[k] = mask_access_key(str(v)) if str(k).lower() == "x-access-key" else REDACTED
  39. else:
  40. out[k] = redact_text(str(v), secrets, access_keys)
  41. return out
  42. def redact_text(text: str, secrets: list[str] | None = None,
  43. access_keys: list[str] | None = None) -> str:
  44. if not text:
  45. return text
  46. result = text
  47. for secret in secrets or []:
  48. if secret and secret in result:
  49. result = result.replace(secret, REDACTED)
  50. result = _CONN_PWD_RE.sub(lambda m: f"{m.group(1)}={REDACTED}", result)
  51. result = _CN_PWD_RE.sub(lambda m: f"{m.group(1)} {REDACTED}", result)
  52. result = _BEARER_RE.sub(lambda m: f"{m.group(1)} {REDACTED}", result)
  53. result = _JWT_RE.sub(REDACTED, result)
  54. result = _URL_CRED_RE.sub(
  55. lambda m: f"{m.group('scheme')}{m.group('user')}:{REDACTED}@", result)
  56. # 已知 AccessKey 值:只保留后四位(优先于全量遮蔽)
  57. for access_key in access_keys or []:
  58. if access_key and access_key in result:
  59. result = result.replace(access_key, mask_access_key(access_key))
  60. return result
  61. def redact_obj(obj, secrets: list[str] | None = None,
  62. access_keys: list[str] | None = None):
  63. """递归脱敏 dict/list/str,用于运行记录与报告。
  64. - 敏感 Header 名:Authorization/签名类整体遮蔽,AccessKey 只留后四位;
  65. - 键名命中 SENSITIVE_KEY_RE 的字段整体遮蔽;
  66. - 其余字符串按已知 Secret 值、连接串/Bearer/JWT/URL 凭据模式遮蔽,
  67. 已知 AccessKey 值只保留后四位。
  68. """
  69. if isinstance(obj, dict):
  70. out = {}
  71. for k, v in obj.items():
  72. key = str(k).lower()
  73. if key in SENSITIVE_HEADERS or SENSITIVE_KEY_RE.search(key):
  74. out[k] = mask_access_key(str(v)) if "access" in key and "key" in key else REDACTED
  75. else:
  76. out[k] = redact_obj(v, secrets, access_keys)
  77. return out
  78. if isinstance(obj, list):
  79. return [redact_obj(v, secrets, access_keys) for v in obj]
  80. if isinstance(obj, str):
  81. return redact_text(obj, secrets, access_keys)
  82. return obj