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://... | bash in README.md → Low: install instruction for end users
eval $var in SKILL.md → Low: pattern shown as example to avoid
new 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 && ./file immediately after a curl -o file ... IS
executable; flag it. A chmod +x shown 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 -c argument 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(...) | exec only 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 -d where 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 self-marked false_positive: true 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 findings that are
not security risks, see nlpm:patterns.
1---2name: security3description: Detects execution surface risks, supply chain vulnerabilities, data exfiltration vectors, and prompt injection patterns in Claude Code plugins. Use when auditing plugins for security risks, reviewing MCP server configurations, scanning hooks and scripts for vulnerabilities, or checking extensions before installation.4---56# Security Scan Patterns for Claude Code Plugins78## Context-Aware File Classification910Before assigning severity to any finding, classify the file by its execution context:1112| File Type | Examples | Can Execute? | Rule |13|-----------|----------|--------------|------|14| Shell scripts | `*.sh`, `*.bash` | Yes | Apply full severity table |15| Code files | `*.py`, `*.js`, `*.mjs`, `*.ts` | Yes | Apply full severity table |16| Hook definitions | `hooks/hooks.json` | Runs on every tool call | Apply full severity table |17| MCP configs | `.mcp.json` | Yes (server launch) | Apply full severity table |18| Package manifests | `package.json` | Via npm scripts | Apply full severity table |19| Documentation | `*.md` (SKILL.md, CLAUDE.md, README.md) | **No** | Cap at Low — see rule below |2021### Documentation Files (*.md)2223Patterns 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:2425**Any Critical or High pattern found in a `.md` file → downgrade to Low (informational).** Note it as "instructional content in documentation — not executable."2627Examples:28- `curl https://... | bash` in README.md → Low: install instruction for end users29- `eval $var` in SKILL.md → Low: pattern shown as example to avoid30- `new Function(...)` in CLAUDE.md → Low: educational reference3132Exception: 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.3334## Scanning Workflow35361. **Classify files** — categorize each file by execution context (see table above)372. **Identify execution surfaces** — map hooks, scripts, MCP configs, commands, and install scripts383. **Scan each surface** — apply pattern tables below, matching regex against file contents394. **Apply context adjustments** — downgrade documentation findings to Low per the markdown rule405. **Validate findings** — verify each Critical/High finding is in an executable context before finalizing416. **Generate report** — produce the structured report (see Report Format section)4243## Execution Surfaces4445Claude Code plugins have five execution surfaces that must be scanned:4647| Surface | Files | Risk Level | Why |48|---------|-------|------------|-----|49| Hooks | `hooks/hooks.json`, referenced scripts | Critical | Runs on EVERY tool call automatically |50| Scripts | `scripts/*.sh`, `*.py`, `*.js` | High | Executed by commands/agents |51| MCP Servers | `.mcp.json` | High | Network access, data flow |52| Bash in commands | `commands/*.md` with Bash tool | Medium | Shell execution via Claude |53| Install scripts | `package.json` postinstall, setup scripts | Medium | Runs on install |5455## Dangerous Shell Patterns5657### Critical (immediate risk)5859| Pattern | Regex | Why |60|---------|-------|-----|61| Pipe to shell | `curl.*\|.*sh`, `wget.*\|.*bash` | Remote code execution |62| Eval with variables | `eval\s+["']?\$` | Arbitrary code execution |63| Reverse shell | `bash\s+-i\s+>&`, `/dev/tcp/` | Backdoor |64| Base64 decode and exec | `base64.*\|.*sh`, `base64.*\|.*python` | Obfuscated execution |65| SSH key exfiltration | `cat.*\.ssh/`, `scp.*\.ssh/` | Key theft |66| Token exfiltration | Secrets like GITHUB_TOKEN or API keys sent to curl/wget | Credential theft |6768### High (likely dangerous)6970| Pattern | Regex | Why |71|---------|-------|-----|72| Subprocess with shell=True | `subprocess\.(call\|run\|Popen).*shell\s*=\s*True` | Unsanitized input reaches shell |73| OS system calls | `os\.system\(` | No argument escaping; full shell interpretation |74| Dynamic require/import | `require\(\s*\$`, `import\(\s*\$` | Attacker-controlled module path |75| 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 |76| File write outside repo | `> ~/`, `> /etc/`, `> /tmp/.*\.sh` | System modification |77| Sudo usage | `sudo\s+` | Privilege escalation |78| PATH modification | Appending to bashrc, zshrc, or profile | Persistent system modification |7980### Medium (context-dependent)8182| Pattern | Regex | Why |83|---------|-------|-----|84| Network calls | `curl\s+`, `wget\s+`, `fetch\(`, `requests\.(get\|post)` | Could exfiltrate repo data to external host |85| Environment access | `process\.env`, `os\.environ`, shell variable expansion | May leak tokens, keys, or secrets |86| File reads outside repo | Reading from home directory or system paths | Exposes credentials or configs outside project |87| Runtime package install | `npm install`, `pip install`, `gem install` | Unvetted dependency pulled at runtime |88| Shell exec functions | Functions that execute strings as shell commands | String-to-shell boundary; injection risk |8990## MCP Configuration Risks9192Scan `.mcp.json` for:9394| Risk | Check | Severity |95|------|-------|----------|96| Remote servers | `url` field pointing to non-localhost | High |97| Unknown domains | Domain not in known-safe list | High |98| Broad permissions | `permissions` with wildcard or extensive list | Medium |99| File system access | Server with `fs` or `filesystem` capability | Medium |100| Shell access | Server with `shell` or execution capability | Critical |101| Missing auth | Remote server without `auth` field | High |102103Known-safe MCP domains: `localhost`, `127.0.0.1`, `modelcontextprotocol.io`, `github.com`, `api.anthropic.com`104105## Hook Safety Rules106107Scan `hooks/hooks.json` for:108109| Risk | Check | Severity |110|------|-------|----------|111| Hook runs shell script | `command` field references `.sh`, `.py`, `.js` | Medium (must scan the script) |112| Hook uses user input | Script receives prompt or input variables without sanitization | High |113| Hook on every event | Triggers on PreToolUse or PostToolUse without tool filter | Medium |114| Hook modifies files | Script writes to disk on every tool call | Medium |115| Hook makes network calls | Script contains network request commands | High |116117## Dependency Supply Chain118119Scan `package.json` for:120121| Risk | Check | Severity |122|------|-------|----------|123| postinstall scripts | `scripts.postinstall` exists | High |124| preinstall scripts | `scripts.preinstall` exists | High |125| Git URL dependencies | Deps pointing to git URLs | Medium |126| Unpinned versions | Wildcard or "latest" version (suppress if lockfile present: package-lock.json, bun.lock, yarn.lock, pnpm-lock.yaml) | Medium |127128Scan `requirements.txt` / `pyproject.toml` for:129130| Risk | Check | Severity |131|------|-------|----------|132| Git URL deps | git+https or git+ssh URLs | Medium |133| Unpinned | No version pin | Low |134| Direct URL | HTTP download URLs | High |135136## Prompt Injection Surfaces137138| Risk | Check | Severity |139|------|-------|----------|140| Untrusted file content in prompts | Agent reads arbitrary file then uses content in Bash | High |141| User input passed to shell | Command takes arguments and passes to Bash without sanitization | Critical |142| Template expansion | Variable expansion in hook scripts with user-controlled values | High |143144## Severity Definitions145146| Severity | Meaning | Action |147|----------|---------|--------|148| Critical | Immediate exploitation risk: RCE, credential theft, backdoor | Block contribution, file security issue |149| High | Likely dangerous: shell injection, data exfil, privilege escalation | Block contribution, report in audit |150| Medium | Context-dependent: network calls, env access, runtime installs | Report in audit, flag for review |151| Low | Minor concern: unpinned deps, broad permissions | Report as informational |152153## Pre-Match Context Filter (apply BEFORE flagging)154155Before generating ANY Critical or High finding from the pattern tables156above, verify the matched pattern is in **executable position** — not157quoted text being displayed, documented, echoed, or used as test data.158This filter applies universally to **every** Critical/High pattern in159this skill, not just `curl | bash`. The audit data has shown the same160class of false positives across `SEC-curl-pipe-sh`, `SEC-new-function-eval`,161`SEC-eval-with-variables`, and `SEC-base64-decode-and-exec` — pattern162syntactically present in the file, but in a string context where the163shell or interpreter never parses it as code.164165**Drop the finding silently** if any of these apply:166167| Filter | What to skip |168|--------|--------------|169| Inside `echo`/`printf`/`cat` arguments | `echo "curl X \| bash"`, `printf '%s' 'wget Y \| sh'` — the shell never executes the matched substring |170| 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 |171| 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 |172| 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 |173| Inside shell comments | Anything after `#` on a line (outside quoted strings) |174| Inside `usage()` / `help()` / `--help` output functions | Functions whose only effect is printing text to stderr/stdout |175| Inside markdown code fences in `.md` files | Already covered by the documentation-file rule above; reaffirm here |176177A pattern is in executable position only when the shell or interpreter178would actually parse it as a command — not when it is a string the179program displays, returns, stores, or transmits. Apply this filter180BEFORE confidence assignment, not after; once a Critical/High finding181is emitted, the contribute path may ship it.182183### Per-pattern guidance184185**`SEC-curl-pipe-sh` / `download-then-execute`**:186- Match `curl ... | (bash|sh)` only when the curl invocation is at the187 start of a pipeline whose right-hand side is a shell, NOT when the188 pattern text appears as a quoted argument to another command.189- A `chmod +x file && ./file` immediately after a `curl -o file ...` IS190 executable; flag it. A `chmod +x` shown inside a usage heredoc is NOT;191 drop it.192193**`SEC-new-function-eval` / `SEC-eval-with-variables`**:194- Match `eval(...)`, `new Function(...)`, `exec(...)` only when the call195 is in executable position. Verify by reading the surrounding 5 lines:196 if the match is the value of an object property, the body of a string197 literal, or fixture/test data being passed to a remote system, drop it.198- A `python3 -c "..."` block where the `-c` argument interpolates199 variables IS executable when the script runs locally; flag it.200- A string constant `jsCode: 'const result = eval(item.json.code);'`201 defined in test data destined for an external workflow runtime is NOT202 executable in the audited repo; drop it.203204**`SEC-base64-decode-and-exec`**:205- Match `base64 -d | sh`, `base64.decode(...) | exec` only when the206 decoded output is fed to a local shell or interpreter. If the base64207 is a transport encoding for code sent to a remote sandbox/container208 (e.g., `printf X | base64 -d` where X is constructed locally and209 shipped via stdin to an E2B sandbox), the local audit has no exposure210 — drop it.211212If a pattern is in executable position but is intentional and trusted213(e.g., a CI release script that pipes a known maintainer-controlled URL214to bash, or `python3 -c` interpolating values from `mktemp`/`stat`/internal215tools that cannot contain injection characters), mark it `false_positive: true`216with an `fp_reason` explaining the trust path. The reproduction gate at217the contribute step will drop it; the rule still gets the self-learning218signal.219220## Public-by-Design Identifiers (drop SEC-hardcoded-api-key matches)221222Many "API keys" embedded in client-side code are PUBLIC BY DESIGN —223they identify a project to a third-party SDK but carry no privileged224access. Flagging them as hardcoded secrets is a category error: the225maintainer cannot remove the value without breaking the integration,226and the value is already visible to any browser that visits the site.227228**Drop `SEC-hardcoded-api-key` findings silently when ALL of these apply:**229230| Filter | What to drop |231|--------|--------------|232| 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. |233| Key matches a known-public-by-design pattern | See list below. |234| 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. |235236**Known-public-by-design key patterns:**237238| Provider | Pattern | Example |239|----------|---------|---------|240| PostHog | starts with `phc_` (project key) | `phc_xxxxx...` |241| PostHog | passed to `posthog.init(KEY, ...)` from a `<script>` tag | any value |242| Google Analytics | `G-XXXXXXX` (GA4 Measurement ID) | `G-1A2B3C4D5E` |243| Google Analytics | `UA-XXXXXX-X` (Universal Analytics) | `UA-12345-1` |244| Google Tag Manager | `GTM-XXXXXXX` | `GTM-ABCDE12` |245| Mixpanel | passed to `mixpanel.init(TOKEN)` from a `<script>` tag | any 32-hex |246| Sentry | DSN with `https://` prefix in browser-side code | `https://abc@o123.ingest.sentry.io/456` |247| Reo | `reo.js` clientID, passed to `Reo.init` | any value |248| Clarity | passed to `clarity.init` or `(c,l,a,r,i,t,y)` snippet | any value |249| Amplitude | passed to `amplitude.init(API_KEY, ...)` from `<script>` | any value |250| Hotjar | numeric `hjid` in `_hjSettings` | numeric |251| Segment | `analytics.load(WRITE_KEY)` from a `<script>` tag | any value |252| LogRocket | `LogRocket.init(APP_ID)` from a `<script>` tag | any value |253| Stripe | publishable key starts with `pk_live_` or `pk_test_` | `pk_live_xxxxx` |254| Algolia | `searchOnly` key in client config (not admin key) | any value |255256**Public DSN/CSP-safe identifiers** in `meta` tags, `<script src>` URLs,257or ESM imports are also public by design.258259**What still IS a finding** (never drop):260261- Stripe **secret** keys (`sk_live_`, `sk_test_`)262- AWS access keys (`AKIA...`, `ASIA...`)263- GitHub PATs (`ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_`)264- OpenAI keys (`sk-...`, `sk-proj-...`)265- Anthropic keys (`sk-ant-...`)266- Database connection URLs with embedded credentials267- Private keys (`-----BEGIN ...PRIVATE KEY-----`)268- Twilio Auth Tokens, SendGrid API keys, etc. — server-side credentials269- Any key matched in a server-side path (`api/`, `server/`, `backend/`,270 `routes/`, `lib/server/`, files NOT under public output dirs)271272The discipline: ask "if this key were swapped tomorrow, would the273end-user-visible product break?" If yes (analytics, tag managers,274SDK identifiers), it's public-by-design — drop. If no (auth, write275operations, admin endpoints), it's a real secret — flag.276277Finding source: 2026-05-05 audit of `wasp-lang/open-saas` flagged 3278PostHog/Reo public keys in `opensaas-sh/blog/public/scripts/`. All 3279were self-marked `false_positive: true` by the scorer. Adding this filter prevents280the audit cycle from being burned on the same false positive shape.281282## Finding Validation283284After the pre-match filter, verify each surviving Critical or High result:285- Confirm the file is in an executable context (not documentation)286- Verify the pattern is reachable at runtime (not dead code behind a feature flag)287- Cross-reference with the project's test suite — a pattern in test fixtures is lower risk288289## Report Format290291The security scan section in an audit report follows this structure:292293```294## Security Scan295296| Severity | Count |297|----------|-------|298| Critical | N |299| High | N |300| Medium | N |301| Low | N |302303### Findings304305| # | Severity | File | Line | Pattern | Description |306|---|----------|------|------|---------|-------------|307```308309## Risk Gate310311If 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.312313## Scope Note314315This skill covers the security-pattern catalog and risk-gate logic used by316the `security-scanner` agent. For the schemas of executable artifacts the317scanner inspects (hooks, scripts, MCP configs), see `nlpm:conventions`.318For the broader anti-pattern catalog covering NL-quality findings that are319not security risks, see `nlpm:patterns`.