Skip to content

Commit 008d62b

Browse files
merge master
2 parents b068bb9 + 8a9684b commit 008d62b

69 files changed

Lines changed: 3568 additions & 3517 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agents/skills/security-review/languages/javascript.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,6 @@ path.join(base, userInput); // FLAG: ../../../ possible
143143

144144
// SSRF
145145
fetch(userUrl); // FLAG: Check URL validation
146-
axios.get(userUrl); // FLAG: Check URL validation
147146
http.get(userUrl); // FLAG: Check URL validation
148147

149148
// Prototype Pollution

.agents/skills/security-review/references/ssrf.md

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -259,8 +259,6 @@ class SafeRequests:
259259
### Node.js
260260

261261
```javascript
262-
const axios = require('axios');
263-
const url = require('url');
264262
const dns = require('dns').promises;
265263

266264
async function safeFetch(targetUrl) {
@@ -277,9 +275,9 @@ async function safeFetch(targetUrl) {
277275
throw new Error('Internal IP not allowed');
278276
}
279277

280-
return axios.get(targetUrl, {
281-
maxRedirects: 0,
282-
timeout: 30000
278+
return fetch(targetUrl, {
279+
redirect: 'error',
280+
signal: AbortSignal.timeout(30000)
283281
});
284282
}
285283
```

.agents/skills/security-review/references/supply-chain.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ yarn.lock
5555
"dependencies": {
5656
"lodash": "^4.0.0", // Could get 4.999.0
5757
"express": "*", // Any version
58-
"axios": "latest" // Always latest
58+
"left-pad": "latest" // Always latest
5959
}
6060
}
6161
```

.agents/skills/skill-scanner/SKILL.md

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ allowed-tools: Read, Grep, Glob, Bash
1111

1212
Scan agent skills for security issues before adoption. Detects prompt injection, malicious code, excessive permissions, secret exposure, and supply chain risks.
1313

14-
**Important**: Run all scripts from the repository root using the full path via `${CLAUDE_SKILL_ROOT}`.
14+
**Requires**: The `uv` CLI for python package management, install guide at https://docs.astral.sh/uv/getting-started/installation/
15+
16+
**Important**: Run all scripts from the repository root. Script paths like `scripts/scan_skill.py` are relative to this skill's root directory (the directory containing this SKILL.md), not relative to the target repository.
1517

1618
## Bundled Script
1719

@@ -20,7 +22,7 @@ Scan agent skills for security issues before adoption. Detects prompt injection,
2022
Static analysis scanner that detects deterministic patterns. Outputs structured JSON.
2123

2224
```bash
23-
uv run ${CLAUDE_SKILL_ROOT}/scripts/scan_skill.py <skill-directory>
25+
uv run scripts/scan_skill.py <skill-directory>
2426
```
2527

2628
Returns JSON with findings, URLs, structure info, and severity counts. The script catches patterns mechanically — your job is to evaluate intent and filter false positives.
@@ -32,7 +34,7 @@ Returns JSON with findings, URLs, structure info, and severity counts. The scrip
3234
Determine the scan target:
3335

3436
- If the user provides a skill directory path, use it directly
35-
- If the user names a skill, look for it under `plugins/*/skills/<name>/` or `.claude/skills/<name>/`
37+
- If the user names a skill, look for it under `.agents/skills/<name>/` first, then other established layouts such as `skills/<name>/` when the repo uses a canonical root skill tree, `.claude/skills/<name>/`, `plugins/*/skills/<name>/`, or another repo-managed skill root with clear prior art
3638
- If the user says "scan all skills", discover all `*/SKILL.md` files and scan each
3739

3840
Validate the target contains a `SKILL.md` file. List the skill structure:
@@ -48,7 +50,7 @@ ls <skill-directory>/scripts/ 2>/dev/null
4850
Run the bundled scanner:
4951

5052
```bash
51-
uv run ${CLAUDE_SKILL_ROOT}/scripts/scan_skill.py <skill-directory>
53+
uv run scripts/scan_skill.py <skill-directory>
5254
```
5355

5456
Parse the JSON output. The script produces findings with severity levels, URL analysis, and structure information. Use these as leads for deeper analysis.
@@ -67,7 +69,7 @@ Read the SKILL.md and check:
6769

6870
### Phase 4: Prompt Injection Analysis
6971

70-
Load `${CLAUDE_SKILL_ROOT}/references/prompt-injection-patterns.md` for context.
72+
Load `references/prompt-injection-patterns.md` for context.
7173

7274
Review scanner findings in the "Prompt Injection" category. For each finding:
7375

@@ -88,7 +90,8 @@ This phase is agent-only — no pattern matching. Read the full SKILL.md instruc
8890
**Config/memory poisoning**:
8991
- Instructions to modify `CLAUDE.md`, `MEMORY.md`, `settings.json`, `.mcp.json`, or hook configurations
9092
- Instructions to add itself to allowlists or auto-approve permissions
91-
- Writing to `~/.claude/` or any agent configuration directory
93+
- Writing to `~/.claude/`, `~/.agents/`, or any agent configuration directory
94+
- Scripts that append to global config files — the poisoned instructions persist after skill removal
9295

9396
**Scope creep**:
9497
- Instructions that exceed the skill's stated purpose
@@ -100,11 +103,19 @@ This phase is agent-only — no pattern matching. Read the full SKILL.md instruc
100103
- Listing directory contents outside the skill's scope
101104
- Accessing git history, credentials, or user data unnecessarily
102105

106+
**Structural attacks** (check scanner output for these):
107+
- **Symlinks**: Files that resolve outside the skill directory — can disguise reads of `~/.ssh/id_rsa`, `~/.aws/credentials`, etc. as "example" files
108+
- **Frontmatter hooks**: `PostToolUse`/`PreToolUse` hooks in YAML — execute shell commands automatically, the model cannot prevent it
109+
- **`!`command`` syntax**: Runs shell commands at skill load time during template expansion, before the model sees the prompt
110+
- **Test files**: `conftest.py`, `test_*.py`, `*.test.js` — test runners auto-discover and execute these as side effects of `pytest` or `npm test`
111+
- **npm lifecycle hooks**: `postinstall` scripts in bundled `package.json` — run automatically on `npm install`
112+
- **Image metadata**: PNG files with text in metadata chunks (tEXt/iTXt) — multimodal LLMs can read hidden instructions from image metadata
113+
103114
### Phase 6: Script Analysis
104115

105116
If the skill has a `scripts/` directory:
106117

107-
1. Load `${CLAUDE_SKILL_ROOT}/references/dangerous-code-patterns.md` for context
118+
1. Load `references/dangerous-code-patterns.md` for context
108119
2. Read each script file fully (do not skip any)
109120
3. Check scanner findings in the "Malicious Code" category
110121
4. For each finding, evaluate:
@@ -130,7 +141,7 @@ Review URLs from the scanner output and any additional URLs found in scripts:
130141

131142
### Phase 8: Permission Analysis
132143

133-
Load `${CLAUDE_SKILL_ROOT}/references/permission-analysis.md` for the tool risk matrix.
144+
Load `references/permission-analysis.md` for the tool risk matrix.
134145

135146
Evaluate:
136147

.agents/skills/skill-scanner/references/dangerous-code-patterns.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,25 @@ cmd = chr(99)+chr(117)+chr(114)+chr(108) # "curl"
157157
os.system(cmd + " evil.com")
158158
```
159159

160+
## Structural Attack Patterns
161+
162+
These don't require malicious code content — the attack is in the file structure itself.
163+
164+
### Symlinks
165+
Files that resolve outside the skill directory. A file named `examples/id_rsa.example` that is actually a symlink to `~/.ssh/id_rsa` tricks the agent into reading real credentials when it reads the "example."
166+
167+
### Test File Auto-Discovery
168+
`conftest.py` is auto-imported by pytest at collection time. `*.test.js` files may be auto-discovered by Jest/Vitest. These execute as side effects of `pytest` or `npm test` — the agent just runs tests, the malicious code runs automatically.
169+
170+
### npm Lifecycle Hooks
171+
`package.json` files with `postinstall` (or `preinstall`, `install`) scripts execute automatically on `npm install`. A skill that bundles a local package with a postinstall hook gets code execution whenever the agent installs dependencies.
172+
173+
### Frontmatter Hooks (Claude Code)
174+
YAML frontmatter in SKILL.md can define `PostToolUse`, `PreToolUse`, etc. hooks that execute shell commands on lifecycle events. The model cannot prevent this — the harness runs hooks automatically.
175+
176+
### `!`command`` Pre-prompt Injection (Claude Code)
177+
The `!`command`` syntax in SKILL.md runs shell commands at template expansion time, before the model sees the prompt. Requires `allowed-tools: Bash(...)` or permissive settings.
178+
160179
## Legitimate Patterns
161180

162181
Not all matches are malicious. These are normal in skill scripts:

.agents/skills/skill-scanner/references/permission-analysis.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ Expected tool sets by skill type:
4747
### Workflow Automation Skills
4848
- **Expected**: `Read, Grep, Glob, Bash`
4949
- **Bash justification**: Git operations, CI commands, gh CLI
50-
- **Examples**: commit, create-pr, iterate-pr
50+
- **Examples**: commit, pr-writer, iterate-pr
5151

5252
### Content Generation Skills
5353
- **Expected**: `Read, Grep, Glob, Write` or `Read, Grep, Glob, Bash, Write, Edit`

.agents/skills/skill-scanner/references/prompt-injection-patterns.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,19 @@ Used to make malicious instructions look like normal text while bypassing keywor
8181
### RTL Override
8282
Unicode bidirectional override characters (`U+202E`) can reverse displayed text direction, hiding the true content from visual review.
8383

84+
### Unicode Tag Characters (U+E0000 block)
85+
The Tags Unicode block (U+E0001–U+E007F) provides invisible representations of every ASCII character. These are:
86+
- Invisible in all text editors, GitHub, and terminal output
87+
- Processed normally by LLM tokenizers
88+
- Mapping: `ASCII code point + 0xE0000 = invisible tag character`
89+
90+
Detection: `cat -v` shows escape sequences, or check file size vs visible content (large discrepancy = suspicious). The scanner decodes these automatically.
91+
92+
### PNG/Image Metadata Injection
93+
Hidden instructions embedded in PNG metadata chunks (tEXt, iTXt, Description, Comment fields). The image renders normally but metadata contains prompt injection text. Multimodal LLMs that inspect image files can read and follow these instructions.
94+
95+
Detection: `exiftool <image>` or check for tEXt/iTXt chunks in PNG binary data.
96+
8497
### Whitespace and Formatting
8598
- Injection patterns hidden in trailing whitespace
8699
- Instructions placed in markdown that renders as invisible (e.g., empty links, reference-style links that aren't displayed)

.agents/skills/skill-scanner/scripts/scan_skill.py

Lines changed: 165 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,8 @@
1818

1919
import base64
2020
import json
21-
import os
2221
import re
2322
import sys
24-
import unicodedata
2523
from pathlib import Path
2624
from typing import Any
2725

@@ -55,6 +53,7 @@
5553
("Zero-width characters", "Zero-width space, joiner, or non-joiner detected"),
5654
("Right-to-left override", "RTL override character can hide text direction"),
5755
("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"),
5857
]
5958

6059
SECRET_PATTERNS: list[tuple[str, str, str]] = [
@@ -238,6 +237,23 @@ def check_obfuscation(content: str, filepath: str) -> list[dict[str, Any]]:
238237
"category": "Obfuscation",
239238
})
240239

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) - 0xE0000) for c in tag_chars if 0xE0020 <= ord(c) <= 0xE007E
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+
241257
# Suspicious base64 strings (long base64 that decodes to text with suspicious keywords)
242258
b64_pattern = re.compile(r"[A-Za-z0-9+/]{40,}={0,2}")
243259
for line_num, line in enumerate(lines, 1):
@@ -361,9 +377,151 @@ def extract_urls(content: str, filepath: str) -> list[dict[str, Any]]:
361377
return urls
362378

363379

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"\x89PNG\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+
364522
def compute_description_body_overlap(frontmatter: dict[str, Any] | None, body: str) -> float:
365523
"""Compute keyword overlap between description and body as a heuristic."""
366-
if not frontmatter or "description" not in frontmatter:
524+
if not frontmatter or "description" not in frontmatter or frontmatter["description"] is None:
367525
return 0.0
368526

369527
desc_words = set(re.findall(r"\b[a-z]{4,}\b", frontmatter["description"].lower()))
@@ -441,7 +599,10 @@ def scan_skill(skill_dir: Path) -> dict[str, Any]:
441599

442600
all_findings.extend(script_findings)
443601

444-
# 8. Description-body overlap
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
445606
overlap = compute_description_body_overlap(frontmatter, body)
446607

447608
# Build structure info

0 commit comments

Comments
 (0)