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 / patterns.py
360 lines · 18.0 KBRaw
1"""
2Regex-based security pattern definitions for the security-guidance plugin.
3
4Pure data + one pure helper. No env-var reads, no I/O, no debug_log — kept
5side-effect-free so it can be imported in isolation.
6"""
7from enum import IntEnum
8
9
10_JS_EXTS = (".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".svelte")
11_PY_EXTS = (".py", ".pyi", ".ipynb")
12_DOC_EXTS = (".md", ".mdx", ".txt", ".rst", ".json", ".yaml", ".yml")
13
14
15_UNSAFE_DESERIALIZATION_REMINDER = """⚠️ Security Warning: Loading pickle data (or equivalents: cPickle, cloudpickle, dill, marshal, shelve, joblib, pandas.read_pickle, numpy with allow_pickle=True) from untrusted sources allows arbitrary code execution.
16
17For simple data, prefer JSON or msgspec. For typed objects, prefer a schema-validated deserializer (msgspec.Struct, pydantic, marshmallow) that constructs only declared types.
18
19If this is safe or is explicitly needed, briefly document that in a comment before continuing."""
20
21_UNSAFE_YAML_LOAD_REMINDER = """⚠️ Security Warning: yaml.load() / yaml.unsafe_load() execute arbitrary Python via !!python/object tags.
22
23Use yaml.safe_load() if the file only contains simple data structures (dicts, lists, strings, numbers). If you need typed objects, parse with safe_load and validate the result against a schema (pydantic, msgspec, marshmallow) — never use a custom Loader that constructs arbitrary types."""
24
25_UNSAFE_TORCH_LOAD_REMINDER = """⚠️ Security Warning: torch.load() defaults to weights_only=False, which unpickles arbitrary Python objects and allows arbitrary code execution.
26
27If the file only contains tensors and simple data structures, pass weights_only=True (or set TORCH_FORCE_WEIGHTS_ONLY_LOAD=1)."""
28
29# Security patterns configuration
30SECURITY_PATTERNS = [
31 {
32 "ruleName": "github_actions_workflow",
33 "path_check": lambda path: ".github/workflows/" in path
34 and (path.endswith(".yml") or path.endswith(".yaml")),
35 "reminder": """⚠️ Security Warning: You are editing a GitHub Actions workflow file. Be aware of these security risks:
36
371. **Command Injection**: Never use untrusted input (like issue titles, PR descriptions, commit messages) directly in run: commands without proper escaping
382. **Use environment variables**: Instead of ${{ github.event.issue.title }}, use env: with proper quoting
393. **Review the guide**: https://github.blog/security/vulnerability-research/how-to-catch-github-actions-workflow-injections-before-attackers-do/
40
41Example of UNSAFE pattern to avoid:
42run: echo "${{ github.event.issue.title }}"
43
44Example of SAFE pattern:
45env:
46 TITLE: ${{ github.event.issue.title }}
47run: echo "$TITLE"
48
49Other risky inputs to be careful with:
50- github.event.issue.body
51- github.event.pull_request.title
52- github.event.pull_request.body
53- github.event.comment.body
54- github.event.review.body
55- github.event.review_comment.body
56- github.event.pages.*.page_name
57- github.event.commits.*.message
58- github.event.head_commit.message
59- github.event.head_commit.author.email
60- github.event.head_commit.author.name
61- github.event.commits.*.author.email
62- github.event.commits.*.author.name
63- github.event.pull_request.head.ref
64- github.event.pull_request.head.label
65- github.event.pull_request.head.repo.default_branch
66- github.event.client_payload.* (repository_dispatch events — attacker can set any field)
67
684. **Ref injection**: Never use untrusted input in `ref:` parameters of `actions/checkout`. For `client_payload.pr_number`, validate it matches `^[0-9]+$` before using in `ref: refs/pull/${{ ... }}/head`
69- github.head_ref""",
70 },
71 {
72 "ruleName": "child_process_exec",
73 # Gate to JS/TS files — bare `exec(` otherwise fires on Python's
74 # exec() and on prose/docstrings mentioning exec.
75 "path_filter": lambda p: p.endswith(_JS_EXTS),
76 "substrings": ["child_process.exec", "execSync("],
77 "regex": r"(?<![a-zA-Z0-9_\.])exec\(",
78 "reminder": """⚠️ Security Warning: Using child_process.exec() can lead to command injection vulnerabilities.
79
80exec() runs the command string through a shell, so any user input interpolated into it can inject arbitrary commands. Prefer child_process.execFile() (or spawn()) with an argument array instead of building a shell string.
81
82Instead of:
83 exec(`command ${userInput}`)
84
85Use:
86 import { execFile } from 'node:child_process'
87 execFile('command', [userInput], callback)
88
89Why execFile/spawn with an argument array is safer:
90- No shell is involved, so shell metacharacters in arguments are not interpreted
91- Arguments are passed directly to the program rather than interpolated into a command string
92
93Only use exec() if you absolutely need shell features and the input is guaranteed to be safe.""",
94 },
95 {
96 "ruleName": "new_function_injection",
97 # JS-only construct: gate to JS/TS files so docs/.md and other prose
98 # mentioning "new Function" don't trip the warning.
99 "path_filter": lambda p: p.endswith(_JS_EXTS),
100 "substrings": ["new Function"],
101 "reminder": "\u26a0\ufe0f Security Warning: Using new Function() with string interpolation is a CODE INJECTION vulnerability. If any variable is concatenated or interpolated into the function body string, an attacker controlling that variable can execute arbitrary code. Use safe alternatives: for property access use obj[key] or array.reduce((o, k) => o[k], root); for computation use a safe expression parser. NEVER interpolate untrusted strings into new Function() bodies.",
102 },
103 {
104 "ruleName": "eval_injection",
105 # Lookbehind excludes `.` so method calls like PyTorch model.eval(),
106 # redis.eval(), spec.eval() don't match. Skip doc/prose files.
107 "path_filter": lambda p: not p.endswith(_DOC_EXTS),
108 "regex": r"(?<![a-zA-Z0-9_\.])eval\(",
109 "reminder": "⚠️ Security Warning: eval() executes arbitrary code and is a major security risk. Use JSON.parse() for data, ast.literal_eval() for Python literals, or a safe expression parser. If this is safe or is explicitly needed, briefly document that in a comment before continuing.",
110 },
111 {
112 "ruleName": "react_dangerously_set_html",
113 # JS/TS-only (React); gate so .md docs / .py / .go files don't trip.
114 "path_filter": lambda p: p.endswith(_JS_EXTS),
115 "substrings": ["dangerouslySetInnerHTML"],
116 "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.",
117 },
118 {
119 "ruleName": "document_write_xss",
120 # Browser DOM API: only meaningful in JS/TS source.
121 "path_filter": lambda p: p.endswith(_JS_EXTS),
122 "substrings": ["document.write"],
123 "reminder": "⚠️ Security Warning: document.write() can be exploited for XSS attacks and has performance issues. Use DOM manipulation methods like createElement() and appendChild() instead.",
124 },
125 {
126 "ruleName": "innerHTML_xss",
127 # Browser DOM API: only meaningful in JS/TS source. Closes FPs like
128 # docs/example HTML, playground/self-contained skills that hardcode
129 # innerHTML strings with zero user input (#410).
130 "path_filter": lambda p: p.endswith(_JS_EXTS),
131 "substrings": [".innerHTML =", ".innerHTML="],
132 "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.",
133 },
134 {
135 "ruleName": "pickle_deserialization",
136 # Match deserialization only (load/loads/Unpickler). pickle.dump is
137 # not the RCE surface. `pkl_load` needs a word boundary so similarly
138 # named safe loaders don't match.
139 "path_filter": lambda p: p.endswith(_PY_EXTS),
140 "regex": r"(?<![a-zA-Z0-9_])pickle\.(loads?|Unpickler)\b|(?<![a-zA-Z0-9_])pkl_load\(",
141 "reminder": _UNSAFE_DESERIALIZATION_REMINDER,
142 },
143 {
144 "ruleName": "os_system_injection",
145 "path_filter": lambda p: p.endswith(_PY_EXTS),
146 "regex": r"\bos\.system\s*\(",
147 "substrings": ["from os import system"],
148 "reminder": "⚠️ Security Warning: os.system() runs a shell and is a command-injection sink. Use subprocess.run([...]) with a list of arguments instead. If this is safe or is explicitly needed, briefly document that in a comment before continuing.",
149 },
150 {
151 "ruleName": "python_subprocess_shell",
152 "regex": r"subprocess\.(?:run|call|Popen|check_output|check_call)\(.*shell\s*=\s*True",
153 "reminder": """⚠️ Security Warning: Using subprocess with shell=True enables command injection.
154
155UNSAFE:
156 subprocess.run(f"ls {user_input}", shell=True)
157 subprocess.call("grep " + pattern, shell=True)
158
159SAFE - pass arguments as a list without shell:
160 subprocess.run(["ls", user_input])
161 subprocess.call(["grep", pattern])
162
163When arguments are passed as a list without shell=True, special characters cannot be interpreted as shell metacharacters.""",
164 },
165 # =====================================================================
166 # Go-specific security patterns
167 # =====================================================================
168 {
169 "ruleName": "go_exec_shell_injection",
170 # Detect exec.Command with shell invocation (sh, bash, /bin/sh, /bin/bash)
171 "regex": r'exec\.Command\(\s*"(?:sh|bash|/bin/sh|/bin/bash)"',
172 "reminder": """⚠️ Security Warning: Using exec.Command with a shell interpreter (sh/bash) enables command injection.
173
174UNSAFE:
175 exec.Command("sh", "-c", "ping -c 1 " + host)
176 exec.Command("bash", "-c", fmt.Sprintf("df -h %s", path))
177
178SAFE - pass arguments directly without a shell:
179 exec.Command("ping", "-c", "1", host)
180 exec.Command("df", "-h", path)
181
182When arguments are passed directly (not through a shell), special characters in user input cannot be interpreted as shell metacharacters. This prevents command injection entirely.
183
184Additionally, validate user inputs:
185- For hostnames/IPs: use net.ParseIP() or a hostname regex
186- For file paths: use filepath.Clean() and verify the result is within an allowed directory
187- For numeric values: parse to int/float first""",
188 },
189 {
190 "ruleName": "unsafe_yaml_load",
191 "regex": r"\byaml\.load\s*\((?![^)\n]{0,80}\bSafe)",
192 "reminder": _UNSAFE_YAML_LOAD_REMINDER,
193 },
194 {
195 "ruleName": "node_createcipher_no_iv",
196 "regex": r"\bcrypto\.(createCipher|createDecipher)\b",
197 "reminder": "⚠️ Security Warning: Use crypto.createCipheriv() / createDecipheriv(). createCipher was removed in Node 22 and derives the key insecurely (no IV, MD5-based KDF).",
198 },
199 {
200 "ruleName": "aes_ecb_mode",
201 "regex": r"\bAES\.MODE_ECB\b|\bmodes\.ECB\s*\(|[\x22\x27]aes-\d+-ecb[\x22\x27]",
202 "reminder": "⚠️ Security Warning: Use AES-GCM or AES-CBC with HMAC. ECB mode leaks plaintext structure (identical blocks encrypt to identical ciphertext).",
203 },
204 {
205 "ruleName": "tls_verification_disabled",
206 "regex": r"\bverify\s*=\s*False\b|rejectUnauthorized\s*:\s*false|InsecureSkipVerify\s*:\s*true|NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*[\x22\x27]?0|ssl\._create_unverified_context|check_hostname\s*=\s*False",
207 "reminder": "⚠️ Security Warning: Don't disable TLS verification. This allows MITM attacks. For self-signed dev certs, add the CA to your trust store or use a properly-issued cert.",
208 },
209 {
210 "ruleName": "marshal_loads",
211 "regex": r"\bmarshal\.loads?\s*\(",
212 "reminder": _UNSAFE_DESERIALIZATION_REMINDER,
213 },
214 {
215 "ruleName": "shelve_open",
216 "regex": r"\bshelve\.open\s*\(",
217 "reminder": _UNSAFE_DESERIALIZATION_REMINDER,
218 },
219 {
220 "ruleName": "xml_unsafe_parse",
221 "regex": r"\b(xml\.etree\.ElementTree|ElementTree|ET)\.(parse|fromstring|XML)\s*\(|\bminidom\.(parse|parseString)\s*\(|\bxml\.sax\.(parse|make_parser)\b",
222 "reminder": "⚠️ Security Warning: Use defusedxml.ElementTree. Python's stdlib XML parsers are vulnerable to XXE (external entity) and billion-laughs attacks by default.",
223 },
224 {
225 "ruleName": "pickle_variants_load",
226 "regex": r"\b(cPickle|cloudpickle|dill)\.(load|loads)\s*\(",
227 "reminder": _UNSAFE_DESERIALIZATION_REMINDER,
228 },
229 {
230 "ruleName": "outerHTML_xss",
231 # Browser DOM API: only meaningful in JS/TS source.
232 "path_filter": lambda p: p.endswith(_JS_EXTS),
233 "substrings": [".outerHTML =", ".outerHTML="],
234 "reminder": "⚠️ Security Warning: Use textContent or sanitize with DOMPurify. outerHTML assignment is an XSS sink equivalent to innerHTML.",
235 },
236 {
237 "ruleName": "insertAdjacentHTML_xss",
238 # Browser DOM API: only meaningful in JS/TS source.
239 "path_filter": lambda p: p.endswith(_JS_EXTS),
240 "substrings": [".insertAdjacentHTML("],
241 "reminder": "⚠️ Security Warning: Use insertAdjacentText() or sanitize with DOMPurify. insertAdjacentHTML is an XSS sink.",
242 },
243 {
244 "ruleName": "script_src_without_sri",
245 # Detect remote code execution via dynamic import/eval of fetched content.
246 # Negative lookahead after src checks for integrity= anywhere in the remaining tag.
247 "regex": (
248 r"<script\s+(?![^>]{0,400}integrity\s*=)"
249 r"[^>]{0,200}src\s*=\s*[\x22\x27](?:https?:)?//"
250 r"[^\x22\x27]{1,300}[\x22\x27]"
251 r"[^>]{0,100}>"
252 ),
253 "reminder": '⚠️ Security Warning: Add integrity="sha384-..." crossorigin="anonymous" to external script tags. Loading scripts without Subresource Integrity exposes you to CDN compromise.',
254 },
255 {
256 "ruleName": "torch_unsafe_load",
257 # Suppressed by weights_only=True on the same line (within 200 chars). weights_only=False
258 # still triggers. Multi-line calls false-positive — same known limitation as unsafe_yaml_load.
259 "regex": r"(?:\btorch\.load|\.torch_load)\s*\((?![^)\n]{0,200}weights_only\s*=\s*True)",
260 "reminder": _UNSAFE_TORCH_LOAD_REMINDER,
261 },
262 {
263 "ruleName": "yaml_unsafe_load_variants",
264 # yaml.unsafe_load (stdlib alias) plus unsafe wrapper method names seen in the wild.
265 # Bare yaml.load() is unsafe_yaml_load's job (RuleId 12).
266 "regex": r"(?:\byaml\.unsafe_load|\.yaml_unsafe_load)\s*\(",
267 "reminder": _UNSAFE_YAML_LOAD_REMINDER,
268 },
269 {
270 "ruleName": "pickle_wrapper_load",
271 # Library APIs that unpickle without saying "pickle". numpy.load only triggers
272 # when allow_pickle=True is explicit (defaults to False since numpy 1.16.3).
273 "regex": r"\bjoblib\.load\s*\(|\b(?:pd|pandas)\.read_pickle\s*\(|\.cloudpickle_load\s*\(|\b(?:np|numpy)\.load\s*\([^)\n]{0,200}allow_pickle\s*=\s*True",
274 "reminder": _UNSAFE_DESERIALIZATION_REMINDER,
275 },
276]
277
278
279class RuleId(IntEnum):
280 """
281 Stable numeric IDs for SECURITY_PATTERNS rules, emitted via the PostToolUse
282 metrics field so telemetry can attribute pattern-warning events to
283 specific checks. The metrics schema only allows bool|number values (no
284 strings), so rule names can't be sent directly.
285
286 Values are frozen: do not renumber existing entries. Append new ones.
287 """
288 GITHUB_ACTIONS_WORKFLOW = 1
289 CHILD_PROCESS_EXEC = 2
290 NEW_FUNCTION_INJECTION = 3
291 EVAL_INJECTION = 4
292 REACT_DANGEROUSLY_SET_HTML = 5
293 DOCUMENT_WRITE_XSS = 6
294 INNERHTML_XSS = 7
295 PICKLE_DESERIALIZATION = 8
296 OS_SYSTEM_INJECTION = 9
297 PYTHON_SUBPROCESS_SHELL = 10
298 GO_EXEC_SHELL_INJECTION = 11
299 UNSAFE_YAML_LOAD = 12
300 NODE_CREATECIPHER_NO_IV = 13
301 AES_ECB_MODE = 14
302 TLS_VERIFICATION_DISABLED = 15
303 MARSHAL_LOADS = 16
304 SHELVE_OPEN = 17
305 XML_UNSAFE_PARSE = 18
306 PICKLE_VARIANTS_LOAD = 19
307 OUTERHTML_XSS = 20
308 INSERTADJACENTHTML_XSS = 21
309 SCRIPT_SRC_WITHOUT_SRI = 22
310 TORCH_UNSAFE_LOAD = 23
311 YAML_UNSAFE_LOAD_VARIANTS = 24
312 PICKLE_WRAPPER_LOAD = 25
313
314
315_RULE_NAME_TO_ID = {
316 "github_actions_workflow": RuleId.GITHUB_ACTIONS_WORKFLOW,
317 "child_process_exec": RuleId.CHILD_PROCESS_EXEC,
318 "new_function_injection": RuleId.NEW_FUNCTION_INJECTION,
319 "eval_injection": RuleId.EVAL_INJECTION,
320 "react_dangerously_set_html": RuleId.REACT_DANGEROUSLY_SET_HTML,
321 "document_write_xss": RuleId.DOCUMENT_WRITE_XSS,
322 "innerHTML_xss": RuleId.INNERHTML_XSS,
323 "pickle_deserialization": RuleId.PICKLE_DESERIALIZATION,
324 "os_system_injection": RuleId.OS_SYSTEM_INJECTION,
325 "python_subprocess_shell": RuleId.PYTHON_SUBPROCESS_SHELL,
326 "go_exec_shell_injection": RuleId.GO_EXEC_SHELL_INJECTION,
327 "unsafe_yaml_load": RuleId.UNSAFE_YAML_LOAD,
328 "node_createcipher_no_iv": RuleId.NODE_CREATECIPHER_NO_IV,
329 "aes_ecb_mode": RuleId.AES_ECB_MODE,
330 "tls_verification_disabled": RuleId.TLS_VERIFICATION_DISABLED,
331 "marshal_loads": RuleId.MARSHAL_LOADS,
332 "shelve_open": RuleId.SHELVE_OPEN,
333 "xml_unsafe_parse": RuleId.XML_UNSAFE_PARSE,
334 "pickle_variants_load": RuleId.PICKLE_VARIANTS_LOAD,
335 "outerHTML_xss": RuleId.OUTERHTML_XSS,
336 "insertAdjacentHTML_xss": RuleId.INSERTADJACENTHTML_XSS,
337 "script_src_without_sri": RuleId.SCRIPT_SRC_WITHOUT_SRI,
338 "torch_unsafe_load": RuleId.TORCH_UNSAFE_LOAD,
339 "yaml_unsafe_load_variants": RuleId.YAML_UNSAFE_LOAD_VARIANTS,
340 "pickle_wrapper_load": RuleId.PICKLE_WRAPPER_LOAD,
341}
342
343# Fail loudly at import time if a pattern is added without a RuleId.
344# This fires in pytest on every PR, so desync is caught before merge.
345assert set(_RULE_NAME_TO_ID) == {p["ruleName"] for p in SECURITY_PATTERNS}, (
346 f"RuleId enum out of sync with SECURITY_PATTERNS: "
347 f"missing={set(p['ruleName'] for p in SECURITY_PATTERNS) - set(_RULE_NAME_TO_ID)}, "
348 f"extra={set(_RULE_NAME_TO_ID) - set(p['ruleName'] for p in SECURITY_PATTERNS)}"
349)
350
351
352def rule_names_to_mask(rule_names):
353 """Pack a set of rule names into a bitmask. Bit N set means RuleId(N) matched.
354 User-defined patterns (rule_name starting with "user:") have no static
355 RuleId and are excluded from the mask."""
356 mask = 0
357 for name in rule_names:
358 if name in _RULE_NAME_TO_ID:
359 mask |= 1 << _RULE_NAME_TO_ID[name]
360 return mask