spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

claude-code / plugins / security-guidance / hooks / security_reminder_hook.py
316 lines · 11.8 KBRaw
1#!/usr/bin/env python3
2"""
3Security Reminder Hook for Claude Code
4This hook checks for security patterns in file edits and warns about potential vulnerabilities.
5"""
6
7import json
8import os
9import random
10import sys
11from datetime import datetime
12
13# Debug log file
14DEBUG_LOG_FILE = "/tmp/security-warnings-log.txt"
15
16
17def debug_log(message):
18 """Append debug message to log file with timestamp."""
19 try:
20 timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
21 with open(DEBUG_LOG_FILE, "a") as f:
22 f.write(f"[{timestamp}] {message}\n")
23 except Exception as e:
24 # Silently ignore logging errors to avoid disrupting the hook
25 pass
26
27
28# State file to track warnings shown (session-scoped using session ID)
29
30# Security patterns configuration
31SECURITY_PATTERNS = [
32 {
33 "ruleName": "github_actions_workflow",
34 "path_check": lambda path: ".github/workflows/" in path
35 and (path.endswith(".yml") or path.endswith(".yaml")),
36 "reminder": """You are editing a GitHub Actions workflow file. Be aware of these security risks:
37
381. **Command Injection**: Never use untrusted input (like issue titles, PR descriptions, commit messages) directly in run: commands without proper escaping
392. **Use environment variables**: Instead of ${{ github.event.issue.title }}, use env: with proper quoting
403. **Review the guide**: https://github.blog/security/vulnerability-research/how-to-catch-github-actions-workflow-injections-before-attackers-do/
41
42Example of UNSAFE pattern to avoid:
43run: echo "${{ github.event.issue.title }}"
44
45Example of SAFE pattern:
46env:
47 TITLE: ${{ github.event.issue.title }}
48run: echo "$TITLE"
49
50Other risky inputs to be careful with:
51- github.event.issue.body
52- github.event.pull_request.title
53- github.event.pull_request.body
54- github.event.comment.body
55- github.event.review.body
56- github.event.review_comment.body
57- github.event.pages.*.page_name
58- github.event.commits.*.message
59- github.event.head_commit.message
60- github.event.head_commit.author.email
61- github.event.head_commit.author.name
62- github.event.commits.*.author.email
63- github.event.commits.*.author.name
64- github.event.pull_request.head.ref
65- github.event.pull_request.head.label
66- github.event.pull_request.head.repo.default_branch
67- github.head_ref""",
68 },
69 {
70 "ruleName": "child_process_exec",
71 "substrings": ["child_process.exec", "exec(", "execSync("],
72 "reminder": """⚠️ Security Warning: Using child_process.exec() can lead to command injection vulnerabilities.
73
74This codebase provides a safer alternative: src/utils/execFileNoThrow.ts
75
76Instead of:
77 exec(`command ${userInput}`)
78
79Use:
80 import { execFileNoThrow } from '../utils/execFileNoThrow.js'
81 await execFileNoThrow('command', [userInput])
82
83The execFileNoThrow utility:
84- Uses execFile instead of exec (prevents shell injection)
85- Handles Windows compatibility automatically
86- Provides proper error handling
87- Returns structured output with stdout, stderr, and status
88
89Only use exec() if you absolutely need shell features and the input is guaranteed to be safe.""",
90 },
91 {
92 "ruleName": "new_function_injection",
93 "substrings": ["new Function"],
94 "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.",
95 },
96 {
97 "ruleName": "eval_injection",
98 "substrings": ["eval("],
99 "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.",
100 },
101 {
102 "ruleName": "react_dangerously_set_html",
103 "substrings": ["dangerouslySetInnerHTML"],
104 "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.",
105 },
106 {
107 "ruleName": "document_write_xss",
108 "substrings": ["document.write"],
109 "reminder": "⚠️ Security Warning: document.write() can be exploited for XSS attacks and has performance issues. Use DOM manipulation methods like createElement() and appendChild() instead.",
110 },
111 {
112 "ruleName": "innerHTML_xss",
113 "substrings": [".innerHTML =", ".innerHTML="],
114 "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.",
115 },
116 {
117 "ruleName": "pickle_deserialization",
118 "substrings": ["pickle"],
119 "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.",
120 },
121 {
122 "ruleName": "os_system_injection",
123 "substrings": ["os.system", "from os import system"],
124 "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.",
125 },
126]
127
128
129def get_state_file(session_id):
130 """Get session-specific state file path."""
131 return os.path.expanduser(f"~/.claude/security_warnings_state_{session_id}.json")
132
133
134def cleanup_old_state_files():
135 """Remove state files older than 30 days."""
136 try:
137 state_dir = os.path.expanduser("~/.claude")
138 if not os.path.exists(state_dir):
139 return
140
141 current_time = datetime.now().timestamp()
142 thirty_days_ago = current_time - (30 * 24 * 60 * 60)
143
144 for filename in os.listdir(state_dir):
145 if filename.startswith("security_warnings_state_") and filename.endswith(
146 ".json"
147 ):
148 file_path = os.path.join(state_dir, filename)
149 try:
150 file_mtime = os.path.getmtime(file_path)
151 if file_mtime < thirty_days_ago:
152 os.remove(file_path)
153 except (OSError, IOError):
154 pass # Ignore errors for individual file cleanup
155 except Exception:
156 pass # Silently ignore cleanup errors
157
158
159def load_state(session_id):
160 """Load the state of shown warnings from file."""
161 state_file = get_state_file(session_id)
162 if os.path.exists(state_file):
163 try:
164 with open(state_file, "r") as f:
165 return set(json.load(f))
166 except (json.JSONDecodeError, IOError):
167 return set()
168 return set()
169
170
171def save_state(session_id, shown_warnings):
172 """Save the state of shown warnings to file."""
173 state_file = get_state_file(session_id)
174 try:
175 os.makedirs(os.path.dirname(state_file), exist_ok=True)
176 with open(state_file, "w") as f:
177 json.dump(list(shown_warnings), f)
178 except IOError as e:
179 debug_log(f"Failed to save state file: {e}")
180 pass # Fail silently if we can't save state
181
182
183def resolve_symlink_path(file_path):
184 """Resolve symlinks in file path to get canonical path.
185
186 Security fix for CVE-2025-59829: Deny rules could be bypassed by creating
187 a symlink to a restricted file. This method resolves the symlink to its
188 target path so that security patterns are checked against the actual file.
189
190 Args:
191 file_path: The file path that may contain symlinks
192
193 Returns:
194 The canonical path with symlinks resolved, or original path if
195 resolution fails (e.g., file doesn't exist yet)
196 """
197 if not file_path:
198 return file_path
199
200 try:
201 # Expand user home directory first
202 expanded_path = os.path.expanduser(file_path)
203
204 # Use realpath to resolve all symlinks and get canonical path
205 # This handles nested symlinks and relative path components
206 resolved = os.path.realpath(expanded_path)
207
208 return resolved
209 except (OSError, ValueError):
210 # If resolution fails (e.g., permission denied, invalid path),
211 # return the original path to avoid blocking legitimate operations
212 return file_path
213
214
215def check_patterns(file_path, content):
216 """Check if file path or content matches any security patterns."""
217 # Security fix: resolve symlinks before checking patterns
218 # CVE-2025-59829: Security patterns could be bypassed via symlinks
219 resolved_path = resolve_symlink_path(file_path)
220
221 # Normalize path by removing leading slashes
222 normalized_path = resolved_path.lstrip("/")
223
224 for pattern in SECURITY_PATTERNS:
225 # Check path-based patterns
226 if "path_check" in pattern and pattern["path_check"](normalized_path):
227 return pattern["ruleName"], pattern["reminder"]
228
229 # Check content-based patterns
230 if "substrings" in pattern and content:
231 for substring in pattern["substrings"]:
232 if substring in content:
233 return pattern["ruleName"], pattern["reminder"]
234
235 return None, None
236
237
238def extract_content_from_input(tool_name, tool_input):
239 """Extract content to check from tool input based on tool type."""
240 if tool_name == "Write":
241 return tool_input.get("content", "")
242 elif tool_name == "Edit":
243 return tool_input.get("new_string", "")
244 elif tool_name == "MultiEdit":
245 edits = tool_input.get("edits", [])
246 if edits:
247 return " ".join(edit.get("new_string", "") for edit in edits)
248 return ""
249
250 return ""
251
252
253def main():
254 """Main hook function."""
255 # Check if security reminders are enabled
256 security_reminder_enabled = os.environ.get("ENABLE_SECURITY_REMINDER", "1")
257
258 # Only run if security reminders are enabled
259 if security_reminder_enabled == "0":
260 sys.exit(0)
261
262 # Periodically clean up old state files (10% chance per run)
263 if random.random() < 0.1:
264 cleanup_old_state_files()
265
266 # Read input from stdin
267 try:
268 raw_input = sys.stdin.read()
269 input_data = json.loads(raw_input)
270 except json.JSONDecodeError as e:
271 debug_log(f"JSON decode error: {e}")
272 sys.exit(0) # Allow tool to proceed if we can't parse input
273
274 # Extract session ID and tool information from the hook input
275 session_id = input_data.get("session_id", "default")
276 tool_name = input_data.get("tool_name", "")
277 tool_input = input_data.get("tool_input", {})
278
279 # Check if this is a relevant tool
280 if tool_name not in ["Edit", "Write", "MultiEdit", "Read"]:
281 sys.exit(0) # Allow non-file tools to proceed
282
283 # Extract file path from tool_input
284 file_path = tool_input.get("file_path", "")
285 if not file_path:
286 sys.exit(0) # Allow if no file path
287
288 # Extract content to check
289 content = extract_content_from_input(tool_name, tool_input)
290
291 # Check for security patterns
292 rule_name, reminder = check_patterns(file_path, content)
293
294 if rule_name and reminder:
295 # Create unique warning key
296 warning_key = f"{file_path}-{rule_name}"
297
298 # Load existing warnings for this session
299 shown_warnings = load_state(session_id)
300
301 # Check if we've already shown this warning in this session
302 if warning_key not in shown_warnings:
303 # Add to shown warnings and save
304 shown_warnings.add(warning_key)
305 save_state(session_id, shown_warnings)
306
307 # Output the warning to stderr and block execution
308 print(reminder, file=sys.stderr)
309 sys.exit(2) # Block tool execution (exit code 2 for PreToolUse hooks)
310
311 # Allow tool to proceed
312 sys.exit(0)
313
314
315if __name__ == "__main__":
316 main()