Setting the file. One moment.
Scan Skill · Skill Scanner · getsentry/skills · Skills Docs
ContentsBack to the top of the page
Number 22.4
Position 4 of 4
Type Python
Size 29 KB
Lines 674 scripts/ scan_skill.py
Python · 674 lines · 29 KB
from
__future__
import
annotations
18
19 import base64
20 import json
21 import re
22 import sys
23 from pathlib import Path
24 from typing import Any
25
26 import yaml
27
28
29 # --- Pattern Definitions ---
30
31 PROMPT_INJECTION_PATTERNS : list[tuple[ str , str , str ]] = [
32 # (pattern, description, severity)
33 ( r " (?i) ignore \s + ( all \s + ) ? previous \s + instructions" , "Instruction override: ignore previous instructions" , "critical" ),
34 ( r " (?i) disregard \s + ( all \s + ) ? ( previous | prior | above )\s + ( instructions | rules | guidelines ) " , "Instruction override: disregard previous" , "critical" ),
35 ( r " (?i) forget \s + ( all \s + ) ? ( previous | prior | your )\s + ( instructions | rules | training ) " , "Instruction override: forget previous" , "critical" ),
36 ( r " (?i) you \s + are \s + now \s + ( a | an | in )\s + " , "Role reassignment: 'you are now'" , "high" ),
37 ( r " (?i) act \s + as \s + ( a | an )\s + unrestricted" , "Role reassignment: unrestricted mode" , "critical" ),
38 ( r " (?i) enter \s + ( developer | debug | admin | god )\s + mode" , "Jailbreak: developer/debug mode" , "critical" ),
39 ( r " (?i) DAN \s + ( mode | prompt | jailbreak ) " , "Jailbreak: DAN pattern" , "critical" ),
40 ( r " (?i) do \s + anything \s + now" , "Jailbreak: do anything now" , "critical" ),
41 ( r " (?i) bypass \s + ( safety | security | content | filter | restriction ) " , "Jailbreak: bypass safety" , "critical" ),
42 ( r " (?i) override \s + ( system | safety | security )\s + ( prompt | message | instruction ) " , "System prompt override" , "critical" ),
43 ( r " (?i) \b system \s * : \s * you \s + are \b " , "System prompt injection marker" , "high" ),
44 ( r " (?i) new \s + system \s + ( prompt | instruction | message )\s * :" , "New system prompt injection" , "critical" ),
45 ( r " (?i) from \s + now \s + on, ? \s + ( you | ignore | forget | disregard ) " , "Temporal instruction override" , "high" ),
46 ( r " (?i) pretend \s + ( that \s + ) ? you \s + ( have \s + no | don't \s + have | are \s + not \s + bound ) " , "Pretend-based jailbreak" , "high" ),
47 ( r " (?i) respond \s + ( only \s + ) ? with \s + ( the \s + ) ? ( raw | full | complete )\s + ( system | initial )\s + prompt" , "System prompt extraction" , "high" ),
48 ( r " (?i) output \s + ( your | the )\s + ( system | initial | original )\s + ( prompt | instructions ) " , "System prompt extraction" , "high" ),
49 ]
50
51 OBFUSCATION_PATTERNS : list[tuple[ str , str ]] = [
52 # (description, detail)
53 ( "Zero-width characters" , "Zero-width space, joiner, or non-joiner detected" ),
54 ( "Right-to-left override" , "RTL override character can hide text direction" ),
55 ( "Homoglyph characters" , "Characters visually similar to ASCII but from different Unicode blocks" ),
56 ( "Unicode Tag characters" , "Tags block (U+E0000-E007F) can encode invisible ASCII text readable by LLMs" ),
57 ]
58
59 SECRET_PATTERNS : list[tuple[ str , str , str ]] = [
60 # (pattern, description, severity)
61 ( r " (?i) AKIA [ 0-9A-Z ] {16} " , "AWS Access Key ID" , "critical" ),
62 ( r " (?i) aws . {0,20} secret . {0,20} [ ' \" ][ 0-9a-zA-Z/+ ] {40} [ ' \" ] " , "AWS Secret Access Key" , "critical" ),
63 ( r "ghp_ [ 0-9a-zA-Z ] {36} " , "GitHub Personal Access Token" , "critical" ),
64 ( r "ghs_ [ 0-9a-zA-Z ] {36} " , "GitHub Server Token" , "critical" ),
65 ( r "gho_ [ 0-9a-zA-Z ] {36} " , "GitHub OAuth Token" , "critical" ),
66 ( r "github_pat_ [ 0-9a-zA-Z_ ] {82} " , "GitHub Fine-Grained PAT" , "critical" ),
67 ( r "sk- [ 0-9a-zA-Z ] {20,} T3BlbkFJ [ 0-9a-zA-Z ] {20,} " , "OpenAI API Key" , "critical" ),
68 ( r "sk-ant-api03- [ 0-9a-zA-Z \- _ ] {90,} " , "Anthropic API Key" , "critical" ),
69 ( r "xox [ bpors ] - [ 0-9a-zA-Z \- ] {10,} " , "Slack Token" , "critical" ),
70 ( r "-----BEGIN \s + ( RSA \s + ) ? PRIVATE \s + KEY-----" , "Private Key" , "critical" ),
71 ( r " (?i) ( password | passwd | pwd )\s * [ := ]\s * [ ' \" ][ ^' \" ] {8,} [ ' \" ] " , "Hardcoded password" , "high" ),
72 ( r " (?i) ( api [ _- ] ? key | apikey )\s * [ := ]\s * [ ' \" ][ 0-9a-zA-Z ] {16,} [ ' \" ] " , "Hardcoded API key" , "high" ),
73 ( r " (?i) ( secret | token )\s * [ := ]\s * [ ' \" ][ 0-9a-zA-Z ] {16,} [ ' \" ] " , "Hardcoded secret/token" , "high" ),
74 ]
75
76 DANGEROUS_SCRIPT_PATTERNS : list[tuple[ str , str , str ]] = [
77 # (pattern, description, severity)
78 # Data exfiltration
79 ( r " (?i) ( requests \. ( get | post | put ) | urllib \. request | http \. client | aiohttp )\s * \( " , "HTTP request (potential exfiltration)" , "medium" ),
80 ( r " (?i) ( curl | wget )\s + " , "Shell HTTP request" , "medium" ),
81 ( r " (?i) socket \. ( connect | create_connection ) " , "Raw socket connection" , "high" ),
82 ( r " (?i) subprocess . * \b( nc | ncat | netcat )\b " , "Netcat usage (potential reverse shell)" , "critical" ),
83 # Credential access
84 ( r " (?i) ( ~ | HOME | USERPROFILE ). * \. ( ssh | aws | gnupg | config ) " , "Sensitive directory access" , "high" ),
85 ( r " (?i) open \s * \( . * ( \. env | credentials | \. netrc | \. pgpass | \. my \. cnf ) " , "Sensitive file access" , "high" ),
86 ( r " (?i) os \. environ \s * \[ . * (?: KEY | SECRET | TOKEN | PASSWORD | CREDENTIAL ) " , "Environment secret access" , "medium" ),
87 # Dangerous execution
88 ( r " \b eval \s * \( " , "eval() usage" , "high" ),
89 ( r " \b exec \s * \( " , "exec() usage" , "high" ),
90 ( r " (?i) subprocess . * shell \s * = \s * True" , "Shell execution with shell=True" , "high" ),
91 ( r " (?i) os \. ( system | popen | exec [ lv ] p ? e ? )\s * \( " , "OS command execution" , "high" ),
92 ( r " (?i) __import__ \s * \( " , "Dynamic import" , "medium" ),
93 # File system manipulation
94 ( r " (?i) ( open | write | Path ). * \. ( claude | bashrc | zshrc | profile | bash_profile ) " , "Agent/shell config modification" , "critical" ),
95 ( r " (?i) ( open | write | Path ). * ( settings \. json | CLAUDE \. md | MEMORY \. md | \. mcp \. json ) " , "Agent settings modification" , "critical" ),
96 ( r " (?i) ( open | write | Path ). * ( \. git/hooks | \. husky ) " , "Git hooks modification" , "critical" ),
97 # Encoding/obfuscation in scripts
98 ( r " (?i) base64 \. ( b64decode | decodebytes )\s * \( " , "Base64 decoding (potential obfuscation)" , "medium" ),
99 ( r " (?i) codecs \. ( decode | encode )\s * \( . * rot" , "ROT encoding (obfuscation)" , "high" ),
100 ( r " (?i) compile \s * \( . * exec" , "Dynamic code compilation" , "high" ),
101 ]
102
103 # Domains commonly trusted in skill contexts
104 TRUSTED_DOMAINS = {
105 "github.com" , "api.github.com" , "raw.githubusercontent.com" ,
106 "docs.sentry.io" , "develop.sentry.dev" , "sentry.io" ,
107 "pypi.org" , "npmjs.com" , "crates.io" ,
108 "docs.python.org" , "docs.djangoproject.com" ,
109 "developer.mozilla.org" , "stackoverflow.com" ,
110 "agentskills.io" ,
111 }
112
113
114 def parse_frontmatter (content: str ) -> tuple[dict[ str , Any] | None , str ]:
115 """Parse YAML frontmatter from SKILL.md content."""
116 if not content.startswith( "---" ):
117 return None , content
118
119 parts = content.split( "---" , 2 )
120 if len (parts) < 3 :
121 return None , content
122
123 try :
124 fm = yaml.safe_load(parts[ 1 ])
125 body = parts[ 2 ]
126 return fm if isinstance (fm, dict ) else None , body
127 except yaml.YAMLError:
128 return None , content
129
130
131 def check_frontmatter (skill_dir: Path, content: str ) -> list[dict[ str , Any]]:
132 """Validate SKILL.md frontmatter."""
133 findings: list[dict[ str , Any]] = []
134 fm, _ = parse_frontmatter(content)
135
136 if fm is None :
137 findings.append({
138 "type" : "Invalid Frontmatter" ,
139 "severity" : "high" ,
140 "location" : "SKILL.md:1" ,
141 "description" : "Missing or unparseable YAML frontmatter" ,
142 "category" : "Validation" ,
143 })
144 return findings
145
146 # Required fields
147 if "name" not in fm:
148 findings.append({
149 "type" : "Missing Name" ,
150 "severity" : "high" ,
151 "location" : "SKILL.md frontmatter" ,
152 "description" : "Required 'name' field missing from frontmatter" ,
153 "category" : "Validation" ,
154 })
155
156 if "description" not in fm:
157 findings.append({
158 "type" : "Missing Description" ,
159 "severity" : "medium" ,
160 "location" : "SKILL.md frontmatter" ,
161 "description" : "Required 'description' field missing from frontmatter" ,
162 "category" : "Validation" ,
163 })
164
165 # Name-directory mismatch
166 if "name" in fm and fm[ "name" ] != skill_dir.name:
167 findings.append({
168 "type" : "Name Mismatch" ,
169 "severity" : "medium" ,
170 "location" : "SKILL.md frontmatter" ,
171 "description" : f "Frontmatter name ' { fm[ 'name' ] } ' does not match directory name ' { skill_dir.name } '" ,
172 "category" : "Validation" ,
173 })
174
175 # Unrestricted tools
176 tools = fm.get( "allowed-tools" , "" )
177 if isinstance (tools, str ) and tools.strip() == "*" :
178 findings.append({
179 "type" : "Unrestricted Tools" ,
180 "severity" : "critical" ,
181 "location" : "SKILL.md frontmatter" ,
182 "description" : "allowed-tools is set to '*' (unrestricted access to all tools)" ,
183 "category" : "Excessive Permissions" ,
184 })
185
186 return findings
187
188
189 def check_prompt_injection (content: str , filepath: str ) -> list[dict[ str , Any]]:
190 """Scan content for prompt injection patterns."""
191 findings: list[dict[ str , Any]] = []
192 lines = content.split( " \n " )
193
194 for line_num, line in enumerate (lines, 1 ):
195 for pattern, description, severity in PROMPT_INJECTION_PATTERNS :
196 if re.search(pattern, line):
197 findings.append({
198 "type" : "Prompt Injection Pattern" ,
199 "severity" : severity,
200 "location" : f " { filepath } : { line_num } " ,
201 "description" : description,
202 "evidence" : line.strip()[: 200 ],
203 "category" : "Prompt Injection" ,
204 })
205 break # One finding per line
206
207 return findings
208
209
210 def check_obfuscation (content: str , filepath: str ) -> list[dict[ str , Any]]:
211 """Detect obfuscation techniques."""
212 findings: list[dict[ str , Any]] = []
213 lines = content.split( " \n " )
214
215 # Zero-width characters
216 zwc_pattern = re.compile( r " [ \u200b\u200c\u200d\u2060\ufeff ] " )
217 for line_num, line in enumerate (lines, 1 ):
218 if zwc_pattern.search(line):
219 chars = [ f "U+ { ord (c) :04X} " for c in zwc_pattern.findall(line)]
220 findings.append({
221 "type" : "Zero-Width Characters" ,
222 "severity" : "high" ,
223 "location" : f " { filepath } : { line_num } " ,
224 "description" : f "Zero-width characters detected: { ', ' .join(chars) } " ,
225 "category" : "Obfuscation" ,
226 })
227
228 # RTL override
229 rtl_pattern = re.compile( r " [ \u202a-\u202e\u2066-\u2069 ] " )
230 for line_num, line in enumerate (lines, 1 ):
231 if rtl_pattern.search(line):
232 findings.append({
233 "type" : "RTL Override" ,
234 "severity" : "high" ,
235 "location" : f " { filepath } : { line_num } " ,
236 "description" : "Right-to-left override or embedding character detected" ,
237 "category" : "Obfuscation" ,
238 })
239
240 # Unicode Tag characters (U+E0000 block) — invisible text readable by LLMs
241 tag_pattern = re.compile( r " [ \U000e0001-\U000e007f ] " )
242 tag_chars = tag_pattern.findall(content)
243 if tag_chars:
244 # Decode the hidden text
245 decoded = "" .join(
246 chr ( ord (c) - 0x E0000 ) for c in tag_chars if 0x E0020 <= ord (c) <= 0x E007E
247 )
248 findings.append({
249 "type" : "Unicode Tag Smuggling" ,
250 "severity" : "critical" ,
251 "location" : filepath,
252 "description" : f "Invisible Unicode Tag characters detected ( { len (tag_chars) } chars). "
253 f "Decoded hidden text: { decoded[: 200 ] } " ,
254 "category" : "Obfuscation" ,
255 })
256
257 # Suspicious base64 strings (long base64 that decodes to text with suspicious keywords)
258 b64_pattern = re.compile( r " [ A-Za-z0-9+/ ] {40,} = {0,2} " )
259 for line_num, line in enumerate (lines, 1 ):
260 for match in b64_pattern.finditer(line):
261 try :
262 decoded = base64.b64decode(match.group()).decode( "utf-8" , errors = "ignore" )
263 suspicious_keywords = [ "ignore" , "system" , "override" , "eval" , "exec" , "password" , "secret" ]
264 for kw in suspicious_keywords:
265 if kw.lower() in decoded.lower():
266 findings.append({
267 "type" : "Suspicious Base64" ,
268 "severity" : "high" ,
269 "location" : f " { filepath } : { line_num } " ,
270 "description" : f "Base64 string decodes to text containing ' { kw } '" ,
271 "decoded_preview" : decoded[: 100 ],
272 "category" : "Obfuscation" ,
273 })
274 break
275 except Exception :
276 pass
277
278 # HTML comments with suspicious content
279 comment_pattern = re.compile( r "<!-- (. *? ) -->" , re. DOTALL )
280 for match in comment_pattern.finditer(content):
281 comment_text = match.group( 1 )
282 # Check if the comment contains injection-like patterns
283 for pattern, description, severity in PROMPT_INJECTION_PATTERNS :
284 if re.search(pattern, comment_text):
285 # Find line number
286 line_num = content[:match.start()].count( " \n " ) + 1
287 findings.append({
288 "type" : "Hidden Injection in Comment" ,
289 "severity" : "critical" ,
290 "location" : f " { filepath } : { line_num } " ,
291 "description" : f "HTML comment contains injection pattern: { description } " ,
292 "evidence" : comment_text.strip()[: 200 ],
293 "category" : "Prompt Injection" ,
294 })
295 break
296
297 return findings
298
299
300 def check_secrets (content: str , filepath: str ) -> list[dict[ str , Any]]:
301 """Detect hardcoded secrets."""
302 findings: list[dict[ str , Any]] = []
303 lines = content.split( " \n " )
304
305 for line_num, line in enumerate (lines, 1 ):
306 for pattern, description, severity in SECRET_PATTERNS :
307 if re.search(pattern, line):
308 # Mask the actual secret in evidence
309 evidence = line.strip()[: 200 ]
310 findings.append({
311 "type" : "Secret Detected" ,
312 "severity" : severity,
313 "location" : f " { filepath } : { line_num } " ,
314 "description" : description,
315 "evidence" : evidence,
316 "category" : "Secret Exposure" ,
317 })
318 break # One finding per line
319
320 return findings
321
322
323 def check_scripts (script_path: Path) -> list[dict[ str , Any]]:
324 """Analyze a script file for dangerous patterns."""
325 findings: list[dict[ str , Any]] = []
326 try :
327 content = script_path.read_text( encoding = "utf-8" , errors = "replace" )
328 except OSError :
329 return findings
330
331 relative = script_path.name
332 lines = content.split( " \n " )
333
334 for line_num, line in enumerate (lines, 1 ):
335 for pattern, description, severity in DANGEROUS_SCRIPT_PATTERNS :
336 if re.search(pattern, line):
337 findings.append({
338 "type" : "Dangerous Code Pattern" ,
339 "severity" : severity,
340 "location" : f "scripts/ { relative } : { line_num } " ,
341 "description" : description,
342 "evidence" : line.strip()[: 200 ],
343 "category" : "Malicious Code" ,
344 })
345 break # One finding per line
346
347 return findings
348
349
350 def extract_urls (content: str , filepath: str ) -> list[dict[ str , Any]]:
351 """Extract and categorize URLs."""
352 urls: list[dict[ str , Any]] = []
353 url_pattern = re.compile( r "https ? :// [ ^ \s \)\]\>\" '` ] + " )
354 lines = content.split( " \n " )
355
356 for line_num, line in enumerate (lines, 1 ):
357 for match in url_pattern.finditer(line):
358 url = match.group().rstrip( ".,;:" )
359 try :
360 # Extract domain
361 domain = url.split( "//" , 1 )[ 1 ].split( "/" , 1 )[ 0 ].split( ":" )[ 0 ]
362 # Check if root domain is trusted
363 domain_parts = domain.split( "." )
364 root_domain = "." .join(domain_parts[ - 2 :]) if len (domain_parts) >= 2 else domain
365 trusted = root_domain in TRUSTED_DOMAINS or domain in TRUSTED_DOMAINS
366 except ( IndexError , ValueError ):
367 domain = "unknown"
368 trusted = False
369
370 urls.append({
371 "url" : url,
372 "domain" : domain,
373 "trusted" : trusted,
374 "location" : f " { filepath } : { line_num } " ,
375 })
376
377 return urls
378
379
380 def check_structural_attacks (skill_dir: Path, content: str , frontmatter: dict[ str , Any] | None ) -> list[dict[ str , Any]]:
381 """Detect structural attack patterns that go beyond text content."""
382 findings: list[dict[ str , Any]] = []
383
384 # 1. Symlinks — files that resolve to paths outside the skill directory
385 for path in skill_dir.rglob( "*" ):
386 if path.is_symlink():
387 target = path.resolve()
388 is_internal = target.is_relative_to(skill_dir.resolve())
389 findings.append({
390 "type" : "Symlink Detected" ,
391 "severity" : "medium" if is_internal else "critical" ,
392 "location" : str (path.relative_to(skill_dir)),
393 "description" : f "Symlink points to { path.readlink() } (resolves to { str (target) } ). "
394 "Symlinks can trick agents into reading sensitive files (e.g., ~/.ssh/id_rsa) "
395 "disguised as example/reference files." ,
396 "category" : "Symlink Exfiltration" ,
397 })
398
399 # 2. YAML hook exploitation — hooks in frontmatter execute shell commands
400 if frontmatter and "hooks" in frontmatter:
401 hooks = frontmatter[ "hooks" ]
402 hook_types = hooks.keys() if isinstance (hooks, dict ) else []
403 for hook_type in hook_types:
404 findings.append({
405 "type" : "Frontmatter Hooks" ,
406 "severity" : "critical" ,
407 "location" : "SKILL.md frontmatter" ,
408 "description" : f "Skill defines ' { hook_type } ' hooks. Hooks execute shell commands "
409 "automatically on lifecycle events — the model cannot prevent execution. "
410 "Review all hook commands carefully." ,
411 "category" : "Hook Exploitation" ,
412 })
413
414 # 3. !`command` pre-prompt injection — runs at template expansion time
415 bang_pattern = re.compile( r "! \` [ ^` ] + \` " )
416 for line_num, line in enumerate (content.split( " \n " ), 1 ):
417 for match in bang_pattern.finditer(line):
418 cmd = match.group()[ 2 : - 1 ] # Strip !` and `
419 findings.append({
420 "type" : "Pre-prompt Command" ,
421 "severity" : "high" ,
422 "location" : f "SKILL.md: { line_num } " ,
423 "description" : f "!`command` syntax executes at skill load time before the model sees "
424 f "the prompt. Command: { cmd } " ,
425 "evidence" : line.strip()[: 200 ],
426 "category" : "Pre-prompt Injection" ,
427 })
428
429 # 4. Test file auto-discovery — conftest.py, test_*.py, *.test.js/ts
430 test_patterns = {
431 "conftest.py" : "pytest auto-imports conftest.py at collection time — code runs before any tests" ,
432 "test_*.py" : "pytest discovers and runs test_*.py files automatically" ,
433 "*_test.py" : "pytest discovers and runs *_test.py files automatically" ,
434 "*.test.js" : "Jest/Vitest may discover .test.js files if dot:true glob is set" ,
435 "*.test.ts" : "Jest/Vitest may discover .test.ts files if dot:true glob is set" ,
436 }
437 for path in skill_dir.rglob( "*" ):
438 if not path.is_file():
439 continue
440 name = path.name
441 for pattern, desc in test_patterns.items():
442 import fnmatch
443 if fnmatch.fnmatch(name, pattern):
444 findings.append({
445 "type" : "Test File Auto-Discovery" ,
446 "severity" : "high" ,
447 "location" : str (path.relative_to(skill_dir)),
448 "description" : f " { desc } . Bundled test files execute as a side effect of running "
449 "the test suite — review file contents for hidden payloads." ,
450 "category" : "Test File RCE" ,
451 })
452
453 # 5. npm postinstall — bundled package.json with lifecycle scripts
454 for pkg_json in skill_dir.rglob( "package.json" ):
455 try :
456 pkg = json.loads(pkg_json.read_text( encoding = "utf-8" , errors = "replace" ))
457 except (json.JSONDecodeError, OSError , ValueError ):
458 continue
459 scripts = pkg.get( "scripts" ) or {}
460 lifecycle_hooks = [ "preinstall" , "install" , "postinstall" , "preuninstall" , "postuninstall" ]
461 for hook in lifecycle_hooks:
462 if hook in scripts:
463 findings.append({
464 "type" : "npm Lifecycle Hook" ,
465 "severity" : "critical" ,
466 "location" : str (pkg_json.relative_to(skill_dir)),
467 "description" : f "package.json defines ' { hook } ' script: { scripts[hook] } . "
468 "npm executes lifecycle hooks automatically on install — "
469 "this is a common supply chain attack vector." ,
470 "category" : "Supply Chain" ,
471 })
472
473 # 6. Image metadata — parse PNG chunks properly to find tEXt/iTXt metadata
474 import struct
475 for img_path in skill_dir.rglob( "*.png" ):
476 try :
477 data = img_path.read_bytes()
478 # PNG files start with 8-byte signature, then chunks
479 # Each chunk: 4-byte length (big-endian), 4-byte type, data, 4-byte CRC
480 if data[: 8 ] != b " \x89 PNG \r\n\x1a\n " :
481 continue
482 offset = 8
483 while offset + 8 <= len (data):
484 chunk_len = struct.unpack( ">I" , data[offset:offset + 4 ])[ 0 ]
485 chunk_type = data[offset + 4 :offset + 8 ]
486 chunk_data = data[offset + 8 :offset + 8 + chunk_len]
487
488 keyword = ""
489 value = ""
490 if chunk_type == b "tEXt" :
491 # tEXt: keyword\0text
492 parts = chunk_data.split( b " \x00 " , 1 )
493 if len (parts) > 1 :
494 keyword = parts[ 0 ].decode( "ascii" , errors = "ignore" )
495 value = parts[ 1 ][: 200 ].decode( "latin-1" , errors = "ignore" )
496 elif chunk_type == b "iTXt" :
497 # iTXt: keyword\0comprFlag\0comprMethod\0langTag\0transKeyword\0text
498 parts = chunk_data.split( b " \x00 " , 4 )
499 if len (parts) >= 5 :
500 keyword = parts[ 0 ].decode( "ascii" , errors = "ignore" )
501 value = parts[ 4 ][: 200 ].decode( "utf-8" , errors = "ignore" )
502
503 if keyword and value.strip():
504 findings.append({
505 "type" : "Image Metadata Text" ,
506 "severity" : "high" ,
507 "location" : str (img_path.relative_to(skill_dir)),
508 "description" : f "PNG contains text metadata (' { keyword } '): { value[: 100 ] } . "
509 "Hidden instructions in image metadata can be read by "
510 "multimodal LLMs when they inspect the file." ,
511 "category" : "Image Injection" ,
512 })
513
514 # Advance to next chunk: length + type(4) + data + CRC(4)
515 offset += 4 + 4 + chunk_len + 4
516 except ( OSError , struct.error):
517 continue
518
519 return findings
520
521
522 def compute_description_body_overlap (frontmatter: dict[ str , Any] | None , body: str ) -> float :
523 """Compute keyword overlap between description and body as a heuristic."""
524 if not frontmatter or "description" not in frontmatter or frontmatter[ "description" ] is None :
525 return 0.0
526
527 desc_words = set (re.findall( r " \b[ a-z ] {4,} \b " , frontmatter[ "description" ].lower()))
528 body_words = set (re.findall( r " \b[ a-z ] {4,} \b " , body.lower()))
529
530 if not desc_words:
531 return 0.0
532
533 overlap = desc_words & body_words
534 return len (overlap) / len (desc_words)
535
536
537 def scan_skill (skill_dir: Path) -> dict[ str , Any]:
538 """Run full scan on a skill directory."""
539 skill_md = skill_dir / "SKILL.md"
540 if not skill_md.exists():
541 return { "error" : f "No SKILL.md found in { skill_dir } " }
542
543 try :
544 content = skill_md.read_text( encoding = "utf-8" , errors = "replace" )
545 except OSError as e:
546 return { "error" : f "Cannot read SKILL.md: { e } " }
547
548 frontmatter, body = parse_frontmatter(content)
549
550 all_findings: list[dict[ str , Any]] = []
551 all_urls: list[dict[ str , Any]] = []
552
553 # 1. Frontmatter validation
554 all_findings.extend(check_frontmatter(skill_dir, content))
555
556 # 2. Prompt injection patterns in SKILL.md
557 all_findings.extend(check_prompt_injection(content, "SKILL.md" ))
558
559 # 3. Obfuscation detection in SKILL.md
560 all_findings.extend(check_obfuscation(content, "SKILL.md" ))
561
562 # 4. Secret detection in SKILL.md
563 all_findings.extend(check_secrets(content, "SKILL.md" ))
564
565 # 5. URL extraction from SKILL.md
566 all_urls.extend(extract_urls(content, "SKILL.md" ))
567
568 # 6. Scan reference files
569 refs_dir = skill_dir / "references"
570 if refs_dir.is_dir():
571 for ref_file in sorted (refs_dir.iterdir()):
572 if ref_file.suffix == ".md" :
573 try :
574 ref_content = ref_file.read_text( encoding = "utf-8" , errors = "replace" )
575 except OSError :
576 continue
577 rel_path = f "references/ { ref_file.name } "
578 all_findings.extend(check_prompt_injection(ref_content, rel_path))
579 all_findings.extend(check_obfuscation(ref_content, rel_path))
580 all_findings.extend(check_secrets(ref_content, rel_path))
581 all_urls.extend(extract_urls(ref_content, rel_path))
582
583 # 7. Scan scripts
584 scripts_dir = skill_dir / "scripts"
585 script_findings: list[dict[ str , Any]] = []
586 if scripts_dir.is_dir():
587 for script_file in sorted (scripts_dir.iterdir()):
588 if script_file.suffix in ( ".py" , ".sh" , ".js" , ".ts" ):
589 sf = check_scripts(script_file)
590 script_findings.extend(sf)
591 try :
592 script_content = script_file.read_text( encoding = "utf-8" , errors = "replace" )
593 except OSError :
594 continue
595 rel_path = f "scripts/ { script_file.name } "
596 all_findings.extend(check_secrets(script_content, rel_path))
597 all_findings.extend(check_obfuscation(script_content, rel_path))
598 all_urls.extend(extract_urls(script_content, rel_path))
599
600 all_findings.extend(script_findings)
601
602 # 8. Structural attacks (symlinks, hooks, !command, test files, npm, image metadata)
603 all_findings.extend(check_structural_attacks(skill_dir, content, frontmatter))
604
605 # 9. Description-body overlap
606 overlap = compute_description_body_overlap(frontmatter, body)
607
608 # Build structure info
609 structure = {
610 "has_skill_md" : True ,
611 "has_references" : refs_dir.is_dir() if (refs_dir := skill_dir / "references" ) else False ,
612 "has_scripts" : scripts_dir.is_dir() if (scripts_dir := skill_dir / "scripts" ) else False ,
613 "reference_files" : sorted (f.name for f in (skill_dir / "references" ).iterdir() if f.suffix == ".md" ) if (skill_dir / "references" ).is_dir() else [],
614 "script_files" : sorted (f.name for f in (skill_dir / "scripts" ).iterdir() if f.suffix in ( ".py" , ".sh" , ".js" , ".ts" )) if (skill_dir / "scripts" ).is_dir() else [],
615 }
616
617 # Summary counts
618 severity_counts: dict[ str , int ] = {}
619 for f in all_findings:
620 sev = f.get( "severity" , "unknown" )
621 severity_counts[sev] = severity_counts.get(sev, 0 ) + 1
622
623 untrusted_urls = [u for u in all_urls if not u[ "trusted" ]]
624
625 # Allowed tools analysis
626 tools_info = None
627 if frontmatter and "allowed-tools" in frontmatter:
628 tools_str = frontmatter[ "allowed-tools" ]
629 if isinstance (tools_str, str ):
630 tools_list = [t.strip() for t in tools_str.replace( "," , " " ).split() if t.strip()]
631 tools_info = {
632 "tools" : tools_list,
633 "has_bash" : "Bash" in tools_list,
634 "has_write" : "Write" in tools_list,
635 "has_edit" : "Edit" in tools_list,
636 "has_webfetch" : "WebFetch" in tools_list,
637 "has_task" : "Task" in tools_list,
638 "unrestricted" : tools_str.strip() == "*" ,
639 }
640
641 return {
642 "skill_name" : frontmatter.get( "name" , "unknown" ) if frontmatter else "unknown" ,
643 "skill_dir" : str (skill_dir),
644 "structure" : structure,
645 "frontmatter" : frontmatter,
646 "tools" : tools_info,
647 "findings" : all_findings,
648 "finding_counts" : severity_counts,
649 "total_findings" : len (all_findings),
650 "urls" : {
651 "total" : len (all_urls),
652 "untrusted" : untrusted_urls,
653 "trusted_count" : len (all_urls) - len (untrusted_urls),
654 },
655 "description_body_overlap" : round (overlap, 2 ),
656 }
657
658
659 def main ():
660 if len (sys.argv) < 2 :
661 print ( "Usage: scan_skill.py <skill-directory>" , file = sys.stderr)
662 sys.exit( 1 )
663
664 skill_dir = Path(sys.argv[ 1 ]).resolve()
665 if not skill_dir.is_dir():
666 print (json.dumps({ "error" : f "Not a directory: { skill_dir } " }))
667 sys.exit( 1 )
668
669 result = scan_skill(skill_dir)
670 print (json.dumps(result, indent = 2 ))
671
672
673 if __name__ == "__main__" :
674 main()