security
Security Scan Patterns for Claude Code Plugins
Context-Aware File Classification
Before assigning severity to any finding, classify the file by its execution context:
| File Type | Examples | Can Execute? | Rule |
|---|---|---|---|
| Shell scripts | *.sh, *.bash |
Yes | Apply full severity table |
| Code files | *.py, *.js, *.mjs, *.ts |
Yes | Apply full severity table |
| Hook definitions | hooks/hooks.json |
Runs on every tool call | Apply full severity table |
| MCP configs | .mcp.json |
Yes (server launch) | Apply full severity table |
| Package manifests | package.json |
Via npm scripts | Apply full severity table |
| Documentation | *.md (SKILL.md, CLAUDE.md, README.md) |
No | Cap at Low — see rule below |
Documentation Files (*.md)
Patterns in .md files are instructional content, not executable code. A curl | bash in a README documents a user action the reader types manually — the plugin never runs it. Apply this rule universally:
Any Critical or High pattern found in a .md file → downgrade to Low (informational). Note it as "instructional content in documentation — not executable."
Examples:
curl https://... | bashin README.md → Low: install instruction for end userseval $varin SKILL.md → Low: pattern shown as example to avoidnew Function(...)in CLAUDE.md → Low: educational reference
Exception: if a .md file is explicitly referenced as a script via command: in hooks.json or executed via bash file.md, treat it as executable and apply full severity.
Scanning Workflow
- Classify files — categorize each file by execution context (see table above)
- Identify execution surfaces — map hooks, scripts, MCP configs, commands, and install scripts
- Scan each surface — apply pattern tables below, matching regex against file contents
- Apply context adjustments — downgrade documentation findings to Low per the markdown rule
- Validate findings — verify each Critical/High finding is in an executable context before finalizing
- Generate report — produce the structured report (see Report Format section)
Execution Surfaces
Claude Code plugins have five execution surfaces that must be scanned:
| Surface | Files | Risk Level | Why |
|---|---|---|---|
| Hooks | hooks/hooks.json, referenced scripts |
Critical | Runs on EVERY tool call automatically |
| Scripts | scripts/*.sh, *.py, *.js |
High | Executed by commands/agents |
| MCP Servers | .mcp.json |
High | Network access, data flow |
| Bash in commands | commands/*.md with Bash tool |
Medium | Shell execution via Claude |
| Install scripts | package.json postinstall, setup scripts |
Medium | Runs on install |
Dangerous Shell Patterns
Critical (immediate risk)
| Pattern | Regex | Why |
|---|---|---|
| Pipe to shell | curl.*|.*sh, wget.*|.*bash |
Remote code execution |
| Eval with variables | eval\s+["']?\$ |
Arbitrary code execution |
| Reverse shell | bash\s+-i\s+>&, /dev/tcp/ |
Backdoor |
| Base64 decode and exec | base64.*|.*sh, base64.*|.*python |
Obfuscated execution |
| SSH key exfiltration | cat.*\.ssh/, scp.*\.ssh/ |
Key theft |
| Token exfiltration | Secrets like GITHUB_TOKEN or API keys sent to curl/wget | Credential theft |
High (likely dangerous)
| Pattern | Regex | Why |
|---|---|---|
| Subprocess with shell=True | subprocess\.(call|run|Popen).*shell\s*=\s*True |
Unsanitized input reaches shell |
| OS system calls | os\.system\( |
No argument escaping; full shell interpretation |
| Dynamic require/import | require\(\s*\$, import\(\s*\$ |
Attacker-controlled module path |
| new Function with dynamic string | new Function\( with string concatenation or template literal |
Arbitrary code execution from string; often used to deserialize data that could be imported directly |
| File write outside repo | > ~/, > /etc/, > /tmp/.*\.sh |
System modification |
| Sudo usage | sudo\s+ |
Privilege escalation |
| PATH modification | Appending to bashrc, zshrc, or profile | Persistent system modification |
Medium (context-dependent)
| Pattern | Regex | Why |
|---|---|---|
| Network calls | curl\s+, wget\s+, fetch\(, requests\.(get|post) |
Could exfiltrate repo data to external host |
| Environment access | process\.env, os\.environ, shell variable expansion |
May leak tokens, keys, or secrets |
| File reads outside repo | Reading from home directory or system paths | Exposes credentials or configs outside project |
| Runtime package install | npm install, pip install, gem install |
Unvetted dependency pulled at runtime |
| Shell exec functions | Functions that execute strings as shell commands | String-to-shell boundary; injection risk |
MCP Configuration Risks
Scan .mcp.json for:
| Risk | Check | Severity |
|---|---|---|
| Remote servers | url field pointing to non-localhost |
High |
| Unknown domains | Domain not in known-safe list | High |
| Broad permissions | permissions with wildcard or extensive list |
Medium |
| File system access | Server with fs or filesystem capability |
Medium |
| Shell access | Server with shell or execution capability |
Critical |
| Missing auth | Remote server without auth field |
High |
Known-safe MCP domains: localhost, 127.0.0.1, modelcontextprotocol.io, github.com, api.anthropic.com
Hook Safety Rules
Scan hooks/hooks.json for:
| Risk | Check | Severity |
|---|---|---|
| Hook runs shell script | command field references .sh, .py, .js |
Medium (must scan the script) |
| Hook uses user input | Script receives prompt or input variables without sanitization | High |
| Hook on every event | Triggers on PreToolUse or PostToolUse without tool filter | Medium |
| Hook modifies files | Script writes to disk on every tool call | Medium |
| Hook makes network calls | Script contains network request commands | High |
Dependency Supply Chain
Scan package.json for:
| Risk | Check | Severity |
|---|---|---|
| postinstall scripts | scripts.postinstall exists |
High |
| preinstall scripts | scripts.preinstall exists |
High |
| Git URL dependencies | Deps pointing to git URLs | Medium |
| Unpinned versions | Wildcard or "latest" version (suppress if lockfile present: package-lock.json, bun.lock, yarn.lock, pnpm-lock.yaml) | Medium |
Scan requirements.txt / pyproject.toml for:
| Risk | Check | Severity |
|---|---|---|
| Git URL deps | git+https or git+ssh URLs | Medium |
| Unpinned | No version pin | Low |
| Direct URL | HTTP download URLs | High |
Prompt Injection Surfaces
| Risk | Check | Severity |
|---|---|---|
| Untrusted file content in prompts | Agent reads arbitrary file then uses content in Bash | High |
| User input passed to shell | Command takes arguments and passes to Bash without sanitization | Critical |
| Template expansion | Variable expansion in hook scripts with user-controlled values | High |
Severity Definitions
| Severity | Meaning | Action |
|---|---|---|
| Critical | Immediate exploitation risk: RCE, credential theft, backdoor | Block contribution, file security issue |
| High | Likely dangerous: shell injection, data exfil, privilege escalation | Block contribution, report in audit |
| Medium | Context-dependent: network calls, env access, runtime installs | Report in audit, flag for review |
| Low | Minor concern: unpinned deps, broad permissions | Report as informational |
Pre-Match Context Filter (apply BEFORE flagging)
Before generating ANY Critical or High finding from the pattern tables
above, verify the matched pattern is in executable position — not
quoted text being displayed, documented, echoed, or used as test data.
This filter applies universally to every Critical/High pattern in
this skill, not just curl | bash. The audit data has shown the same
class of false positives across SEC-curl-pipe-sh, SEC-new-function-eval,
SEC-eval-with-variables, and SEC-base64-decode-and-exec — pattern
syntactically present in the file, but in a string context where the
shell or interpreter never parses it as code.
Drop the finding silently if any of these apply:
| Filter | What to skip |
|---|---|
Inside echo/printf/cat arguments |
echo "curl X | bash", printf '%s' 'wget Y | sh' — the shell never executes the matched substring |
| Inside heredoc bodies fed to non-shell consumers | Anything between <<EOF / <<-EOF / <<'EOF' and the closing delimiter, when the heredoc is fed to cat, echo, a variable, or a usage function — only flag when fed to bash, sh, eval, or piped to a shell |
| Inside single- or double-quoted strings on RHS of assignment | MSG="run: curl X | bash", JS_CODE='const x = eval(input)', INSTRUCTIONS='see: wget Y | sh' — the string is data, not code |
| Inside object/dict literals as test/fixture data | {"jsCode": "eval(item.json.code)"} — the object value is a string sent to a remote system as workflow/test/fixture data, never parsed locally |
| Inside shell comments | Anything after # on a line (outside quoted strings) |
Inside usage() / help() / --help output functions |
Functions whose only effect is printing text to stderr/stdout |
Inside markdown code fences in .md files |
Already covered by the documentation-file rule above; reaffirm here |
A pattern is in executable position only when the shell or interpreter would actually parse it as a command — not when it is a string the program displays, returns, stores, or transmits. Apply this filter BEFORE confidence assignment, not after; once a Critical/High finding is emitted, the contribute path may ship it.
Per-pattern guidance
SEC-curl-pipe-sh / download-then-execute:
- Match
curl ... | (bash|sh)only when the curl invocation is at the start of a pipeline whose right-hand side is a shell, NOT when the pattern text appears as a quoted argument to another command. - A
chmod +x file && ./fileimmediately after acurl -o file ...IS executable; flag it. Achmod +xshown inside a usage heredoc is NOT; drop it.
SEC-new-function-eval / SEC-eval-with-variables:
- Match
eval(...),new Function(...),exec(...)only when the call is in executable position. Verify by reading the surrounding 5 lines: if the match is the value of an object property, the body of a string literal, or fixture/test data being passed to a remote system, drop it. - A
python3 -c "..."block where the-cargument interpolates variables IS executable when the script runs locally; flag it. - A string constant
jsCode: 'const result = eval(item.json.code);'defined in test data destined for an external workflow runtime is NOT executable in the audited repo; drop it.
SEC-base64-decode-and-exec:
- Match
base64 -d | sh,base64.decode(...) | execonly when the decoded output is fed to a local shell or interpreter. If the base64 is a transport encoding for code sent to a remote sandbox/container (e.g.,printf X | base64 -dwhere X is constructed locally and shipped via stdin to an E2B sandbox), the local audit has no exposure — drop it.
If a pattern is in executable position but is intentional and trusted
(e.g., a CI release script that pipes a known maintainer-controlled URL
to bash, or python3 -c interpolating values from mktemp/stat/internal
tools that cannot contain injection characters), mark it false_positive: true
with an fp_reason explaining the trust path. The reproduction gate at
the contribute step will drop it; the rule still gets the self-learning
signal.
Public-by-Design Identifiers (drop SEC-hardcoded-api-key matches)
Many "API keys" embedded in client-side code are PUBLIC BY DESIGN — they identify a project to a third-party SDK but carry no privileged access. Flagging them as hardcoded secrets is a category error: the maintainer cannot remove the value without breaking the integration, and the value is already visible to any browser that visits the site.
Drop SEC-hardcoded-api-key findings silently when ALL of these apply:
| Filter | What to drop |
|---|---|
File is under public/, static/, assets/, dist/, build/, _site/, or other published-output directories |
Anything served directly to browsers is, by construction, public. The maintainer can't make it private without redesigning the integration. |
| Key matches a known-public-by-design pattern | See list below. |
Filename indicates client-side initialization (posthog.js, gtag.js, analytics.js, mixpanel.js, sentry.js, clarity.js, etc.) |
Analytics SDKs require client-side identifiers to function. |
Known-public-by-design key patterns:
| Provider | Pattern | Example |
|---|---|---|
| PostHog | starts with phc_ (project key) |
phc_xxxxx... |
| PostHog | passed to posthog.init(KEY, ...) from a <script> tag |
any value |
| Google Analytics | G-XXXXXXX (GA4 Measurement ID) |
G-1A2B3C4D5E |
| Google Analytics | UA-XXXXXX-X (Universal Analytics) |
UA-12345-1 |
| Google Tag Manager | GTM-XXXXXXX |
GTM-ABCDE12 |
| Mixpanel | passed to mixpanel.init(TOKEN) from a <script> tag |
any 32-hex |
| Sentry | DSN with https:// prefix in browser-side code |
https://abc@o123.ingest.sentry.io/456 |
| Reo | reo.js clientID, passed to Reo.init |
any value |
| Clarity | passed to clarity.init or (c,l,a,r,i,t,y) snippet |
any value |
| Amplitude | passed to amplitude.init(API_KEY, ...) from <script> |
any value |
| Hotjar | numeric hjid in _hjSettings |
numeric |
| Segment | analytics.load(WRITE_KEY) from a <script> tag |
any value |
| LogRocket | LogRocket.init(APP_ID) from a <script> tag |
any value |
| Stripe | publishable key starts with pk_live_ or pk_test_ |
pk_live_xxxxx |
| Algolia | searchOnly key in client config (not admin key) |
any value |
Public DSN/CSP-safe identifiers in meta tags, <script src> URLs,
or ESM imports are also public by design.
What still IS a finding (never drop):
- Stripe secret keys (
sk_live_,sk_test_) - AWS access keys (
AKIA...,ASIA...) - GitHub PATs (
ghp_,gho_,ghu_,ghs_,ghr_) - OpenAI keys (
sk-...,sk-proj-...) - Anthropic keys (
sk-ant-...) - Database connection URLs with embedded credentials
- Private keys (
-----BEGIN ...PRIVATE KEY-----) - Twilio Auth Tokens, SendGrid API keys, etc. — server-side credentials
- Any key matched in a server-side path (
api/,server/,backend/,routes/,lib/server/, files NOT under public output dirs)
The discipline: ask "if this key were swapped tomorrow, would the end-user-visible product break?" If yes (analytics, tag managers, SDK identifiers), it's public-by-design — drop. If no (auth, write operations, admin endpoints), it's a real secret — flag.
Finding source: 2026-05-05 audit of wasp-lang/open-saas flagged 3
PostHog/Reo public keys in opensaas-sh/blog/public/scripts/. All 3
were correctly self-FP'd by the scorer. Adding this filter prevents
the audit cycle from being burned on the same false positive shape.
Finding Validation
After the pre-match filter, verify each surviving Critical or High result:
- Confirm the file is in an executable context (not documentation)
- Verify the pattern is reachable at runtime (not dead code behind a feature flag)
- Cross-reference with the project's test suite — a pattern in test fixtures is lower risk
Report Format
The security scan section in an audit report follows this structure:
## Security Scan
| Severity | Count |
|----------|-------|
| Critical | N |
| High | N |
| Medium | N |
| Low | N |
### Findings
| # | Severity | File | Line | Pattern | Description |
|---|----------|------|------|---------|-------------|
Risk Gate
If any Critical or High findings exist, the contribute-approved label must NOT be applied. The audit report must include a prominent warning and the tracking issue must link to the security findings.
Scope Note
This skill covers the security-pattern catalog and risk-gate logic used by
the security-scanner agent. For the schemas of executable artifacts the
scanner inspects (hooks, scripts, MCP configs), see nlpm:conventions.
For the broader anti-pattern catalog covering NL-quality issues that are
not security risks, see nlpm:patterns.
More from xiaolai/nlpm-for-claude
patterns
Use when writing or reviewing NL artifacts and need to check for anti-patterns — vague quantifiers, prohibitions without alternatives, oversized skills, write-on-read-only agents, monolithic prompts, or linter-duplicating rules.
2conventions
Use when writing, reviewing, or validating Claude Code plugin artifacts — check frontmatter schemas, hook event names, naming conventions, prompt structure, or reference syntax. Loaded by the NLPM scorer and checker agents for schema validation.
2writing-prompts
How to write effective system prompts for any LLM. Universal prompt engineering -- role clarity, structured output, injection resistance, few-shot examples. Use when writing prompts, system instructions, or AI configuration.
2scoring
Use when scoring NL artifact quality, applying penalties, or calibrating lint judgment — contains the 100-point rubric with penalty tables per artifact type and 4 worked calibration examples.
1testing
Use when writing test specs for NL artifacts, running /nlpm:test, or setting up TDD workflows for skills, agents, commands, rules, hooks, and prompts.
1rules
The 50 rules of natural language programming. Loaded when writing, reviewing, or improving any NL artifact — skills, agents, commands, rules, hooks, prompts, plugins, CLAUDE.md. The definitive style guide for NL code quality.
1