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
280 lines · 10.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# 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 check_patterns(file_path, content):
184 """Check if file path or content matches any security patterns."""
185 # Normalize path by removing leading slashes
186 normalized_path = file_path.lstrip("/")
187
188 for pattern in SECURITY_PATTERNS:
189 # Check path-based patterns
190 if "path_check" in pattern and pattern["path_check"](normalized_path):
191 return pattern["ruleName"], pattern["reminder"]
192
193 # Check content-based patterns
194 if "substrings" in pattern and content:
195 for substring in pattern["substrings"]:
196 if substring in content:
197 return pattern["ruleName"], pattern["reminder"]
198
199 return None, None
200
201
202def extract_content_from_input(tool_name, tool_input):
203 """Extract content to check from tool input based on tool type."""
204 if tool_name == "Write":
205 return tool_input.get("content", "")
206 elif tool_name == "Edit":
207 return tool_input.get("new_string", "")
208 elif tool_name == "MultiEdit":
209 edits = tool_input.get("edits", [])
210 if edits:
211 return " ".join(edit.get("new_string", "") for edit in edits)
212 return ""
213
214 return ""
215
216
217def main():
218 """Main hook function."""
219 # Check if security reminders are enabled
220 security_reminder_enabled = os.environ.get("ENABLE_SECURITY_REMINDER", "1")
221
222 # Only run if security reminders are enabled
223 if security_reminder_enabled == "0":
224 sys.exit(0)
225
226 # Periodically clean up old state files (10% chance per run)
227 if random.random() < 0.1:
228 cleanup_old_state_files()
229
230 # Read input from stdin
231 try:
232 raw_input = sys.stdin.read()
233 input_data = json.loads(raw_input)
234 except json.JSONDecodeError as e:
235 debug_log(f"JSON decode error: {e}")
236 sys.exit(0) # Allow tool to proceed if we can't parse input
237
238 # Extract session ID and tool information from the hook input
239 session_id = input_data.get("session_id", "default")
240 tool_name = input_data.get("tool_name", "")
241 tool_input = input_data.get("tool_input", {})
242
243 # Check if this is a relevant tool
244 if tool_name not in ["Edit", "Write", "MultiEdit"]:
245 sys.exit(0) # Allow non-file tools to proceed
246
247 # Extract file path from tool_input
248 file_path = tool_input.get("file_path", "")
249 if not file_path:
250 sys.exit(0) # Allow if no file path
251
252 # Extract content to check
253 content = extract_content_from_input(tool_name, tool_input)
254
255 # Check for security patterns
256 rule_name, reminder = check_patterns(file_path, content)
257
258 if rule_name and reminder:
259 # Create unique warning key
260 warning_key = f"{file_path}-{rule_name}"
261
262 # Load existing warnings for this session
263 shown_warnings = load_state(session_id)
264
265 # Check if we've already shown this warning in this session
266 if warning_key not in shown_warnings:
267 # Add to shown warnings and save
268 shown_warnings.add(warning_key)
269 save_state(session_id, shown_warnings)
270
271 # Output the warning to stderr and block execution
272 print(reminder, file=sys.stderr)
273 sys.exit(2) # Block tool execution (exit code 2 for PreToolUse hooks)
274
275 # Allow tool to proceed
276 sys.exit(0)
277
278
279if __name__ == "__main__":
280 main()