security_reminder_hook.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. #!/usr/bin/env python3
  2. """
  3. Security Reminder Hook for Claude Code
  4. This hook checks for security patterns in file edits and warns about potential vulnerabilities.
  5. """
  6. import json
  7. import os
  8. import random
  9. import sys
  10. from datetime import datetime
  11. # Debug log file
  12. DEBUG_LOG_FILE = "/tmp/security-warnings-log.txt"
  13. def debug_log(message):
  14. """Append debug message to log file with timestamp."""
  15. try:
  16. timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
  17. with open(DEBUG_LOG_FILE, "a") as f:
  18. f.write(f"[{timestamp}] {message}\n")
  19. except Exception as e:
  20. # Silently ignore logging errors to avoid disrupting the hook
  21. pass
  22. # State file to track warnings shown (session-scoped using session ID)
  23. # Security patterns configuration
  24. SECURITY_PATTERNS = [
  25. {
  26. "ruleName": "github_actions_workflow",
  27. "path_check": lambda path: ".github/workflows/" in path
  28. and (path.endswith(".yml") or path.endswith(".yaml")),
  29. "reminder": """You are editing a GitHub Actions workflow file. Be aware of these security risks:
  30. 1. **Command Injection**: Never use untrusted input (like issue titles, PR descriptions, commit messages) directly in run: commands without proper escaping
  31. 2. **Use environment variables**: Instead of ${{ github.event.issue.title }}, use env: with proper quoting
  32. 3. **Review the guide**: https://github.blog/security/vulnerability-research/how-to-catch-github-actions-workflow-injections-before-attackers-do/
  33. Example of UNSAFE pattern to avoid:
  34. run: echo "${{ github.event.issue.title }}"
  35. Example of SAFE pattern:
  36. env:
  37. TITLE: ${{ github.event.issue.title }}
  38. run: echo "$TITLE"
  39. Other risky inputs to be careful with:
  40. - github.event.issue.body
  41. - github.event.pull_request.title
  42. - github.event.pull_request.body
  43. - github.event.comment.body
  44. - github.event.review.body
  45. - github.event.review_comment.body
  46. - github.event.pages.*.page_name
  47. - github.event.commits.*.message
  48. - github.event.head_commit.message
  49. - github.event.head_commit.author.email
  50. - github.event.head_commit.author.name
  51. - github.event.commits.*.author.email
  52. - github.event.commits.*.author.name
  53. - github.event.pull_request.head.ref
  54. - github.event.pull_request.head.label
  55. - github.event.pull_request.head.repo.default_branch
  56. - github.head_ref""",
  57. },
  58. {
  59. "ruleName": "child_process_exec",
  60. "substrings": ["child_process.exec", "exec(", "execSync("],
  61. "reminder": """⚠️ Security Warning: Using child_process.exec() can lead to command injection vulnerabilities.
  62. This codebase provides a safer alternative: src/utils/execFileNoThrow.ts
  63. Instead of:
  64. exec(`command ${userInput}`)
  65. Use:
  66. import { execFileNoThrow } from '../utils/execFileNoThrow.js'
  67. await execFileNoThrow('command', [userInput])
  68. The execFileNoThrow utility:
  69. - Uses execFile instead of exec (prevents shell injection)
  70. - Handles Windows compatibility automatically
  71. - Provides proper error handling
  72. - Returns structured output with stdout, stderr, and status
  73. Only use exec() if you absolutely need shell features and the input is guaranteed to be safe.""",
  74. },
  75. {
  76. "ruleName": "new_function_injection",
  77. "substrings": ["new Function"],
  78. "reminder": "⚠️ Security Warning: Using new Function() with dynamic strings can lead to code injection vulnerabilities. Consider alternative approaches that don't evaluate arbitrary code. Only use new Function() if you truly need to evaluate arbitrary dynamic code.",
  79. },
  80. {
  81. "ruleName": "eval_injection",
  82. "substrings": ["eval("],
  83. "reminder": "⚠️ Security Warning: eval() executes arbitrary code and is a major security risk. Consider using JSON.parse() for data parsing or alternative design patterns that don't require code evaluation. Only use eval() if you truly need to evaluate arbitrary code.",
  84. },
  85. {
  86. "ruleName": "react_dangerously_set_html",
  87. "substrings": ["dangerouslySetInnerHTML"],
  88. "reminder": "⚠️ Security Warning: dangerouslySetInnerHTML can lead to XSS vulnerabilities if used with untrusted content. Ensure all content is properly sanitized using an HTML sanitizer library like DOMPurify, or use safe alternatives.",
  89. },
  90. {
  91. "ruleName": "document_write_xss",
  92. "substrings": ["document.write"],
  93. "reminder": "⚠️ Security Warning: document.write() can be exploited for XSS attacks and has performance issues. Use DOM manipulation methods like createElement() and appendChild() instead.",
  94. },
  95. {
  96. "ruleName": "innerHTML_xss",
  97. "substrings": [".innerHTML =", ".innerHTML="],
  98. "reminder": "⚠️ Security Warning: Setting innerHTML with untrusted content can lead to XSS vulnerabilities. Use textContent for plain text or safe DOM methods for HTML content. If you need HTML support, consider using an HTML sanitizer library such as DOMPurify.",
  99. },
  100. {
  101. "ruleName": "pickle_deserialization",
  102. "substrings": ["pickle"],
  103. "reminder": "⚠️ Security Warning: Using pickle with untrusted content can lead to arbitrary code execution. Consider using JSON or other safe serialization formats instead. Only use pickle if it is explicitly needed or requested by the user.",
  104. },
  105. {
  106. "ruleName": "os_system_injection",
  107. "substrings": ["os.system", "from os import system"],
  108. "reminder": "⚠️ Security Warning: This code appears to use os.system. This should only be used with static arguments and never with arguments that could be user-controlled.",
  109. },
  110. ]
  111. def get_state_file(session_id):
  112. """Get session-specific state file path."""
  113. return os.path.expanduser(f"~/.claude/security_warnings_state_{session_id}.json")
  114. def cleanup_old_state_files():
  115. """Remove state files older than 30 days."""
  116. try:
  117. state_dir = os.path.expanduser("~/.claude")
  118. if not os.path.exists(state_dir):
  119. return
  120. current_time = datetime.now().timestamp()
  121. thirty_days_ago = current_time - (30 * 24 * 60 * 60)
  122. for filename in os.listdir(state_dir):
  123. if filename.startswith("security_warnings_state_") and filename.endswith(
  124. ".json"
  125. ):
  126. file_path = os.path.join(state_dir, filename)
  127. try:
  128. file_mtime = os.path.getmtime(file_path)
  129. if file_mtime < thirty_days_ago:
  130. os.remove(file_path)
  131. except (OSError, IOError):
  132. pass # Ignore errors for individual file cleanup
  133. except Exception:
  134. pass # Silently ignore cleanup errors
  135. def load_state(session_id):
  136. """Load the state of shown warnings from file."""
  137. state_file = get_state_file(session_id)
  138. if os.path.exists(state_file):
  139. try:
  140. with open(state_file, "r") as f:
  141. return set(json.load(f))
  142. except (json.JSONDecodeError, IOError):
  143. return set()
  144. return set()
  145. def save_state(session_id, shown_warnings):
  146. """Save the state of shown warnings to file."""
  147. state_file = get_state_file(session_id)
  148. try:
  149. os.makedirs(os.path.dirname(state_file), exist_ok=True)
  150. with open(state_file, "w") as f:
  151. json.dump(list(shown_warnings), f)
  152. except IOError as e:
  153. debug_log(f"Failed to save state file: {e}")
  154. pass # Fail silently if we can't save state
  155. def check_patterns(file_path, content):
  156. """Check if file path or content matches any security patterns."""
  157. # Normalize path by removing leading slashes
  158. normalized_path = file_path.lstrip("/")
  159. for pattern in SECURITY_PATTERNS:
  160. # Check path-based patterns
  161. if "path_check" in pattern and pattern["path_check"](normalized_path):
  162. return pattern["ruleName"], pattern["reminder"]
  163. # Check content-based patterns
  164. if "substrings" in pattern and content:
  165. for substring in pattern["substrings"]:
  166. if substring in content:
  167. return pattern["ruleName"], pattern["reminder"]
  168. return None, None
  169. def extract_content_from_input(tool_name, tool_input):
  170. """Extract content to check from tool input based on tool type."""
  171. if tool_name == "Write":
  172. return tool_input.get("content", "")
  173. elif tool_name == "Edit":
  174. return tool_input.get("new_string", "")
  175. elif tool_name == "MultiEdit":
  176. edits = tool_input.get("edits", [])
  177. if edits:
  178. return " ".join(edit.get("new_string", "") for edit in edits)
  179. return ""
  180. return ""
  181. def main():
  182. """Main hook function."""
  183. # Check if security reminders are enabled
  184. security_reminder_enabled = os.environ.get("ENABLE_SECURITY_REMINDER", "1")
  185. # Only run if security reminders are enabled
  186. if security_reminder_enabled == "0":
  187. sys.exit(0)
  188. # Periodically clean up old state files (10% chance per run)
  189. if random.random() < 0.1:
  190. cleanup_old_state_files()
  191. # Read input from stdin
  192. try:
  193. raw_input = sys.stdin.read()
  194. input_data = json.loads(raw_input)
  195. except json.JSONDecodeError as e:
  196. debug_log(f"JSON decode error: {e}")
  197. sys.exit(0) # Allow tool to proceed if we can't parse input
  198. # Extract session ID and tool information from the hook input
  199. session_id = input_data.get("session_id", "default")
  200. tool_name = input_data.get("tool_name", "")
  201. tool_input = input_data.get("tool_input", {})
  202. # Check if this is a relevant tool
  203. if tool_name not in ["Edit", "Write", "MultiEdit"]:
  204. sys.exit(0) # Allow non-file tools to proceed
  205. # Extract file path from tool_input
  206. file_path = tool_input.get("file_path", "")
  207. if not file_path:
  208. sys.exit(0) # Allow if no file path
  209. # Extract content to check
  210. content = extract_content_from_input(tool_name, tool_input)
  211. # Check for security patterns
  212. rule_name, reminder = check_patterns(file_path, content)
  213. if rule_name and reminder:
  214. # Create unique warning key
  215. warning_key = f"{file_path}-{rule_name}"
  216. # Load existing warnings for this session
  217. shown_warnings = load_state(session_id)
  218. # Check if we've already shown this warning in this session
  219. if warning_key not in shown_warnings:
  220. # Add to shown warnings and save
  221. shown_warnings.add(warning_key)
  222. save_state(session_id, shown_warnings)
  223. # Output the warning to stderr and block execution
  224. print(reminder, file=sys.stderr)
  225. sys.exit(2) # Block tool execution (exit code 2 for PreToolUse hooks)
  226. # Allow tool to proceed
  227. sys.exit(0)
  228. if __name__ == "__main__":
  229. main()