scan_release_secrets.py 2.7 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
  1. #!/usr/bin/env python3
  2. """Fail closed when a release tree contains secret or binary payloads."""
  3. from __future__ import annotations
  4. import re
  5. import sys
  6. from pathlib import Path
  7. _FORBIDDEN_SUFFIXES = frozenset(
  8. {".der", ".jks", ".key", ".keystore", ".p12", ".pfx", ".pkcs12", ".pyc", ".pyo"}
  9. )
  10. _CONFIG_SUFFIXES = frozenset({".conf", ".env", ".ini", ".json", ".toml", ".txt", ".yaml", ".yml"})
  11. _PRIVATE_BLOCK = re.compile(
  12. "-----BEGIN " + r"(?:ENCRYPTED |RSA |EC |OPENSSH )?PRIVATE KEY-----",
  13. re.IGNORECASE,
  14. )
  15. _API_SECRET = re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b")
  16. _ASSIGNMENT = re.compile(
  17. r"(?im)^\s*(?:[A-Za-z0-9_]*_)?"
  18. r"(?:credential|password|passwd|secret|token|api_key|private_key)"
  19. r"\s*[:=]\s*['\"]?([^'\"\s#;,]{8,})"
  20. )
  21. _PLACEHOLDERS = ("${", "<", "change", "example", "placeholder", "replace-", "tbd")
  22. def _reject(message: str) -> int:
  23. print(f"release secret scan rejected: {message}", file=sys.stderr)
  24. return 1
  25. def scan(root: Path) -> int:
  26. if not root.is_absolute() or not root.is_dir() or root.is_symlink():
  27. return _reject("root must be an existing absolute directory")
  28. for path in sorted(root.rglob("*")):
  29. relative = path.relative_to(root)
  30. if path.is_symlink():
  31. return _reject(f"symbolic link {relative}")
  32. if path.is_dir():
  33. if path.name == "__pycache__":
  34. return _reject(f"cache directory {relative}")
  35. continue
  36. if not path.is_file() or path.suffix.casefold() in _FORBIDDEN_SUFFIXES:
  37. return _reject(f"forbidden file type {relative}")
  38. try:
  39. raw = path.read_bytes()
  40. if b"\x00" in raw:
  41. return _reject(f"binary file {relative}")
  42. text = raw.decode("utf-8")
  43. except (OSError, UnicodeDecodeError):
  44. return _reject(f"unreadable or non-UTF-8 file {relative}")
  45. if _PRIVATE_BLOCK.search(text) or _API_SECRET.search(text):
  46. return _reject(f"private key or API secret shape in {relative}")
  47. if path.suffix.casefold() in _CONFIG_SUFFIXES or path.name.startswith(".env"):
  48. for match in _ASSIGNMENT.finditer(text):
  49. value = match.group(1).casefold()
  50. if value in {"false", "none", "null", "true"}:
  51. continue
  52. if any(marker in value for marker in _PLACEHOLDERS):
  53. continue
  54. return _reject(f"literal credential shape in {relative}")
  55. return 0
  56. def main() -> int:
  57. if len(sys.argv) != 2:
  58. print("usage: scan_release_secrets.py ABSOLUTE_RELEASE_ROOT", file=sys.stderr)
  59. return 2
  60. return scan(Path(sys.argv[1]))
  61. if __name__ == "__main__":
  62. raise SystemExit(main())