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