Semgrep Rules skill for Claude Code
Custom rules with tests, tuned to zero noise. Claude turns a real bug or banned API into a Semgrep rule that ships with a test file, passes semgrep --test, and produces no unexplained hits on the repo. It picks search or taint mode deliberately, tunes false positives with pattern-not-inside instead of deleting tests, and gates CI on new findings so legacy hits never block a merge.
- Name
semgrep-rules- Size
- 282 lines · ≈3,000 tokens
- Files
- 2
- License
- MIT
- Updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Writes, tests, and tunes custom Semgrep rules for a specific codebase - structural patterns with metavariables and ellipsis, pattern-inside and pattern-not exclusions, metavariable-regex filters, taint mode with sources, sinks, and sanitizers, ruleid/ok test fixtures, autofix, and CI gating on new findings only. Use when the user wants to ban a dangerous API or internal anti-pattern, encode a past bug as a rule, cut false positives from registry rulesets, add Semgrep or Opengrep to CI, or asks "write a semgrep rule for this". Try asking
- Write a Semgrep rule that flags any requests call with verify=False and add tests
- Our p/default Semgrep scan has 400 findings, mostly noise; tune it so CI can block
- We just fixed an IDOR in invoices.py; encode it as a rule so it never comes back
Use it when
- A past incident or review finding should become an automated check
- An internal API, library, or pattern needs to be banned outside one module
- Registry rulesets are too noisy to gate CI
- Adding Semgrep or Opengrep to pre-commit or a pipeline
Skip it when
- You need a one-time manual security review of a codebase - use owasp-review instead
- The concern is vulnerable dependencies rather than first-party code - use supply-chain-audit instead
How to check the result
semgrep --validate and semgrep --test pass, a full-repo scan shows only confirmed or baselined hits, and a scratch branch reintroducing the original bug makes CI fail.
The common mistake it prevents
Using pattern-not when the exclusion is about surrounding context; only pattern-not-inside removes matches by enclosing scope, so the false positives never go away.
A rule is done when it has a test file proving at least one true positive and one
near-miss negative, it runs clean (semgrep --test passes, --validate passes), it
produces zero unexplained findings on the current codebase, and its message tells the
developer what to do instead. A rule nobody trusts gets nosemgrep’d into silence.
Ground rules
- Start from a real bug or a real banned API in this repo. Rules written “in case” become noise. Collect two or three concrete code snippets first: the bad one, the fixed one, and a lookalike that must not match.
- Prefer structural patterns over
pattern-regex. Regex rules break on formatting and cannot see through aliases or arguments. - Every custom rule ships with a test file in the same commit.
- Gate CI on new findings only (baseline) unless the rule has zero existing hits.
- Keep blocking and advisory rules in separate directories so severity changes are a file move, not a debate.
Workflow
- [ ] 1. Collect positive, negative, and lookalike snippets from the repo
- [ ] 2. Prototype with semgrep -e on the real code
- [ ] 3. Write the rule YAML (search or taint mode)
- [ ] 4. Write the test file with ruleid/ok annotations
- [ ] 5. Run semgrep --test and --validate
- [ ] 6. Run on the whole repo, review every hit, tighten until hits are all real
- [ ] 7. Decide gating: blocking vs advisory, baseline vs full
- [ ] 8. Wire into CI and pre-commit; document the escape hatch
1-2. Prototype on real code
pipx install semgrep # or: brew install semgrep ; python3 -m pip install semgrep
semgrep --version
# Quick experiments: one pattern, one language, real code
semgrep -e 'requests.$METHOD(..., verify=False, ...)' --lang python src/
semgrep -e 'exec($CMD, ...)' --lang ts --json src/ | jq '.results | length'
Iterate the pattern until it matches the bad snippet and not the lookalike, then move it into a file.
3. Pick the rule mode
| Problem | Mode | Why |
|---|---|---|
Ban a call or argument shape (verify=False, yaml.load without SafeLoader, eval) | search: pattern + pattern-not | Local, syntactic, fast, near-zero false positives |
Call is fine except inside/outside some context (no db.query outside a repository module, open() without with) | search: pattern + pattern-inside / pattern-not-inside | Context is lexical |
| Untrusted data flows into a dangerous sink across assignments | mode: taint | Tracks flow through variables; sanitizers cut paths |
Name-based conventions (*_unsafe helpers, secrets in variable names) | search + metavariable-regex | Filters captured identifiers |
| Non-code text (Dockerfile lines, config keys) with no parser | pattern-regex or languages: [generic] | Last resort |
4. Search-mode rule anatomy
rules:
- id: no-tls-verify-disabled
languages: [python]
severity: HIGH # LOW | MEDIUM | HIGH | CRITICAL (INFO/WARNING/ERROR still map)
message: >-
TLS certificate verification is disabled in $METHOD(). Remove verify=False; for a
private CA pass verify="/path/to/ca.pem" instead.
metadata:
category: security
cwe: "CWE-295: Improper Certificate Validation"
confidence: HIGH
references: [https://internal.wiki/secure-http]
patterns:
- pattern-either:
- pattern: requests.$METHOD(..., verify=False, ...)
- pattern: httpx.$METHOD(..., verify=False, ...)
- pattern: httpx.Client(..., verify=False, ...)
paths:
exclude: ["tests/", "**/*_test.py"]
Operator cheat sheet:
| Operator | Use |
|---|---|
$X | Metavariable; same name must bind the same expression everywhere in one match |
... | Any arguments, statements, or expression sequence |
$...ARGS | Ellipsis captured into a metavariable |
<... $X ...> | Deep expression: $X appears anywhere inside |
pattern-either | OR of alternatives |
patterns | AND; pattern-not* and pattern-inside filter the positive patterns |
pattern-inside / pattern-not-inside | Require or forbid an enclosing context |
metavariable-regex | Filter a binding by regex (left-anchored) |
metavariable-pattern | Run a sub-pattern against a binding (optionally another language) |
metavariable-comparison | Numeric or string comparison on a binding ($N < 2048) |
focus-metavariable | Report only the bound sub-expression, not the whole match |
fix | Autofix template using bound metavariables; apply with --autofix |
For worked patterns in Python, JS/TS, Go, and Java (ownership checks, raw SQL, unsafe deserialization, internal API bans), read references/rule-cookbook.md when the target language or shape is not covered above.
4b. Taint-mode rule anatomy
rules:
- id: flask-request-to-subprocess
mode: taint
languages: [python]
severity: CRITICAL
message: Request data reaches a shell command. Pass an argument list without shell=True and validate against an allowlist.
pattern-sources:
- patterns:
- pattern-either:
- pattern: flask.request.$ATTR
- pattern: flask.request.$ATTR.get(...)
pattern-sanitizers:
- pattern: shlex.quote(...)
- pattern: int(...)
pattern-sinks:
- patterns:
- pattern-either:
- pattern: subprocess.$FN($CMD, ..., shell=True, ...)
- pattern: os.system($CMD)
- focus-metavariable: $CMD
Taint rules in the open-source engine are intraprocedural and single-file. Cross-function
and cross-file flow (and pattern-propagators) need the commercial Pro engine. Write the
sink with focus-metavariable on the dangerous argument, or tainted data in a harmless
position (a log message argument) will fire. Add --dataflow-traces when debugging why a
path did or did not match.
5. Tests
Put the test next to the rule with the same base name: rules/python/no-tls-verify.yaml
and rules/python/no-tls-verify.py.
import requests
# ruleid: no-tls-verify-disabled
requests.get(url, verify=False)
# ok: no-tls-verify-disabled
requests.get(url, verify="/etc/ssl/internal-ca.pem")
# ok: no-tls-verify-disabled
requests.get(url, timeout=5)
# todoruleid: no-tls-verify-disabled
session.verify = False # known gap: attribute assignment not covered yet
semgrep --validate --config rules/ # schema and pattern parse check
semgrep --test rules/ # rules and tests in one tree
semgrep --test --config rules/ tests/ # or separate trees with mirrored subdirs
ruleid:on the line above an expected finding;ok:above a line that must not match.todoruleid:/todook:document known gaps without failing the suite.- With
fix:, add<name>.fixed.py;--testapplies the fix and diffs it. - Minimum per rule: one
ruleid, one lookalikeok, oneokfor the sanctioned alternative named in the message.
6. Run on the repo and tune
semgrep scan --config rules/ --metrics=off --json -o /tmp/sg.json .
jq -r '.results[] | "\(.check_id)\t\(.path):\(.start.line)"' /tmp/sg.json | sort | uniq -c | sort -rn | head
semgrep scan --config rules/ --time --metrics=off . >/dev/null # find slow rules
Review every hit and classify it:
| Result | Action |
|---|---|
| True positive | Keep; file fix or baseline it |
| False positive, recurring shape | Add pattern-not / pattern-not-inside and an ok: test for that shape |
| False positive, one-off | # nosemgrep: <rule-id> with a reason on the same or preceding line |
| Test or generated code | paths.exclude or .semgrepignore |
Rule is slow (> 1 s per file in --time) | Anchor with a concrete function name first; avoid leading ... and broad $X($Y) |
Never tune by deleting test cases. Each fix to a false positive adds an ok: line.
7-8. Gating and CI
| Rule state | Gate |
|---|---|
| Zero existing hits, high confidence | Blocking on full scan |
| Existing hits, high confidence | Blocking on new findings only (--baseline-commit) plus a cleanup ticket |
| Medium confidence or new rule | Advisory: SARIF to code scanning, non-blocking |
Registry rulesets (p/default, p/owasp-top-ten) | Advisory first; promote individual rule IDs after a clean week |
GitHub Actions (open-source engine, no platform account):
name: semgrep
on: { pull_request: {}, push: { branches: [main] } }
jobs:
semgrep:
runs-on: ubuntu-latest
container: semgrep/semgrep:latest # pin a digest in production
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- name: Rule tests
run: semgrep --test rules/
- name: Blocking rules (new findings only on PRs)
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
semgrep scan --config rules/blocking --error --metrics=off \
--baseline-commit "origin/${{ github.base_ref }}"
else
semgrep scan --config rules/blocking --error --metrics=off
fi
- name: Advisory rules to SARIF
if: always()
run: semgrep scan --config rules/advisory --config p/default --metrics=off --sarif --output semgrep.sarif || true
--error makes findings exit non-zero. If the team uses the Semgrep platform, semgrep ci
with SEMGREP_APP_TOKEN from the CI secret store replaces the manual baseline logic; never
put the token in the workflow file. Opengrep (the community fork of Semgrep CE) reads the
same rule YAML; verify any engine-specific option against its docs before relying on it.
Pre-commit, for fast local feedback on blocking rules only:
# .pre-commit-config.yaml
- repo: https://github.com/semgrep/pre-commit
rev: v1.x.y # pin to the release you tested
hooks:
- id: semgrep
args: ["--config", "rules/blocking", "--error", "--metrics=off", "--skip-unknown-extensions"]
Gotchas
- Metavariable names unify:
$X == $Xonly matches identical expressions. Use$A == $Bwhen the sides may differ. pattern-notremoves matches that are exactly the excluded shape; to exclude by context usepattern-not-inside. Mixing these up is the top cause of “my exclusion does nothing”.- Import aliasing is resolved for fully qualified patterns (
requests.getmatchesfrom requests import get as g; g(...)) only when you write the qualified form. metavariable-regexis left-anchored;regex: secretwill not matchapi_secret. Useregex: (?i).*secret.*.- Taint sources match expressions, so
request.argsas source also taintsrequest.args.get("x"). Over-broad sources ($X.get(...)) taint everything. - YAML block scalars (
|) preserve indentation: multi-line patterns must be valid code after Semgrep strips the common indent. - Registry rules carry their own license terms; check before bundling them into a product.
nosemgrepwithout a rule ID suppresses every rule on that line. Require the ID in review.
Verification
semgrep --validate --config rules/andsemgrep --test rules/both pass.- Full-repo scan: every remaining hit is a confirmed true positive, baselined, or carries a
reasoned
nosemgrep: <id>. - Re-introduce the original bug in a scratch branch; the rule fires with a message that names the fix.
- CI run on a PR with a planted violation fails; a PR without one passes.
Output contract
When delivering rules, report:
## Semgrep rules added
| Rule ID | Mode | Severity | Gate | Existing hits | Tests (ruleid/ok) |
|---|---|---|---|---|---|
| no-tls-verify-disabled | search | HIGH | blocking | 0 | 2/3 |
| flask-request-to-subprocess | taint | CRITICAL | blocking (baseline) | 2 (TICKET-123) | 3/4 |
Known gaps: <todoruleid cases and why>
Suppressions added: <file:line, rule, reason> Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/rule-cookbook.md
# Semgrep rule cookbook
Worked shapes to adapt. Each rule needs its own `ruleid`/`ok` test file before it ships.
Replace module and function names with the ones the codebase actually uses. Every rule
below passes `semgrep --validate` and fires on a minimal fixture with the open-source engine
(Semgrep 1.18x); real codebases will still need `ok:` cases for their own lookalikes.
## Contents
1. Python: unsafe YAML, raw SQL string building, Django ORM without tenant filter
2. JavaScript / TypeScript: Express taint to SQL, `child_process`, JWT decode without verify
3. Go: `database/sql` string building, `InsecureSkipVerify`
4. Java: Spring `@RequestMapping` without auth annotation
5. Internal API bans and architecture rules
6. Secrets-shaped identifiers with metavariable-regex
7. Generic and config files
---
## 1. Python
### yaml.load without a safe loader
```yaml
- id: yaml-load-unsafe
languages: [python]
severity: HIGH
message: yaml.load() without SafeLoader can construct arbitrary objects. Use yaml.safe_load().
patterns:
- pattern: yaml.load(...)
- pattern-not: yaml.load(..., Loader=yaml.SafeLoader, ...)
- pattern-not: yaml.load(..., Loader=yaml.CSafeLoader, ...)
- id: yaml-load-single-arg-autofix
languages: [python]
severity: HIGH
message: yaml.load($DATA) without a Loader. Use yaml.safe_load($DATA).
pattern: yaml.load($DATA)
fix: yaml.safe_load($DATA)
```
The autofix variant matches only the single-argument form, where the rewrite is always
correct. Prove it with a `.fixed.py` test file before enabling `--autofix` anywhere.
### String-built SQL passed to a cursor
```yaml
- id: sql-string-building
languages: [python]
severity: HIGH
message: SQL built with string formatting. Use parameters, e.g. cur.execute("... WHERE id = %s", (user_id,)).
patterns:
- pattern-either:
- pattern: $CUR.execute(f"...", ...)
- pattern: $CUR.execute("..." % $X, ...)
- pattern: $CUR.execute("..." + $X, ...)
- pattern: $CUR.execute("...".format(...), ...)
- patterns:
- pattern-inside: |
$Q = f"..."
...
- pattern: $CUR.execute($Q, ...)
- metavariable-regex:
metavariable: $CUR
regex: (?i).*(cur|cursor|conn|connection|session|db).*
```
### Model lookup missing a tenant filter (Django)
Encodes "every query on tenant-owned models must filter by account":
```yaml
- id: tenant-model-unscoped-get
languages: [python]
severity: HIGH
message: $MODEL is tenant-owned; scope the lookup with account=request.user.account or use $MODEL.for_account(...).
patterns:
- pattern-either:
- pattern: $MODEL.objects.get(...)
- pattern: $MODEL.objects.filter(...)
- pattern-not: $MODEL.objects.get(..., account=$A, ...)
- pattern-not: $MODEL.objects.filter(..., account=$A, ...)
- metavariable-regex:
metavariable: $MODEL
regex: ^(Invoice|Project|Document|Member)$
paths:
exclude: ["**/migrations/**", "**/management/commands/**"]
```
Maintain the model list from the code (for example, every model with an `account` FK).
A test should include a chained `.filter(account=a).get(id=x)` as `ok`, and that case may
require an extra `pattern-not-inside`.
---
## 2. JavaScript / TypeScript
### Express request data into a raw query (taint)
```yaml
- id: express-taint-raw-sql
mode: taint
languages: [javascript, typescript]
severity: CRITICAL
message: Request input reaches a raw SQL string. Use placeholders ($1 / ?) with a values array.
pattern-sources:
- pattern-either:
- pattern: $REQ.query
- pattern: $REQ.body
- pattern: $REQ.params
pattern-sanitizers:
- pattern: Number($X)
- pattern: parseInt($X, ...)
pattern-sinks:
- patterns:
- pattern-either:
- pattern: $DB.query($SQL, ...)
- pattern: $DB.raw($SQL, ...)
- pattern: $PRISMA.$queryRawUnsafe($SQL, ...)
- focus-metavariable: $SQL
```
`$REQ.query` is broad; if it taints unrelated objects, constrain with
`pattern-inside: function $F($REQ, $RES, ...) { ... }` or the router callback shape.
### child_process.exec with a non-literal command
```yaml
- id: child-process-exec-dynamic
languages: [javascript, typescript]
severity: HIGH
message: exec() runs a shell. Use execFile()/spawn() with an argument array.
patterns:
- pattern-either:
- pattern: require("child_process").exec($CMD, ...)
- pattern: $CP.exec($CMD, ...)
- pattern-not: $CP.exec("...", ...)
- metavariable-regex:
metavariable: $CP
regex: ^(cp|child_process|childProcess)$
```
### JWT decoded but never verified
```yaml
- id: jwt-decode-without-verify
languages: [javascript, typescript]
severity: HIGH
message: jwt.decode() does not check the signature. Use jwt.verify() with an algorithm allowlist.
patterns:
- pattern: $JWT.decode($TOKEN, ...)
- pattern-not-inside: |
$JWT.verify($TOKEN, ...);
...
- metavariable-regex:
metavariable: $JWT
regex: ^(jwt|jsonwebtoken)$
```
---
## 3. Go
```yaml
- id: go-sql-sprintf
languages: [go]
severity: HIGH
message: SQL built with fmt.Sprintf. Use placeholders and pass args to Query/Exec.
patterns:
- pattern-either:
- pattern: $DB.$METHOD(fmt.Sprintf($FMT, ...), ...)
- patterns:
- pattern-inside: |
$Q := fmt.Sprintf($FMT, ...)
...
- pattern: $DB.$METHOD($Q, ...)
- metavariable-regex:
metavariable: $METHOD
regex: ^(Query|QueryRow|Exec|QueryContext|QueryRowContext|ExecContext)$
- id: go-tls-insecure-skip-verify
languages: [go]
severity: HIGH
message: InsecureSkipVerify disables certificate checks. Configure RootCAs instead.
pattern: "tls.Config{..., InsecureSkipVerify: true, ...}"
```
---
## 4. Java (Spring)
Handler methods that lack an authorization annotation:
```yaml
- id: spring-endpoint-missing-preauthorize
languages: [java]
severity: MEDIUM
message: Endpoint has no @PreAuthorize/@Secured. Add one or annotate @PublicEndpoint with a reason.
patterns:
- pattern-either:
- pattern: |
@$MAPPING(...)
$RET $METHOD(...) { ... }
- metavariable-regex:
metavariable: $MAPPING
regex: ^(GetMapping|PostMapping|PutMapping|PatchMapping|DeleteMapping|RequestMapping)$
- pattern-not: |
@PreAuthorize(...)
$RET $METHOD(...) { ... }
- pattern-not: |
@Secured(...)
$RET $METHOD(...) { ... }
- pattern-not: |
@PublicEndpoint(...)
$RET $METHOD(...) { ... }
```
Annotation order in source does not matter to Semgrep, but class-level annotations do:
add `pattern-not-inside` for `@PreAuthorize(...) class $C { ... }` if used.
---
## 5. Internal API bans and architecture rules
```yaml
- id: no-direct-stripe-outside-billing
languages: [typescript]
severity: MEDIUM
message: Call Stripe only through src/billing/stripeClient.ts so idempotency keys and logging stay consistent.
pattern-either:
- pattern: import $X from "stripe"
- pattern: import { $...X } from "stripe"
paths:
exclude: ["src/billing/**"]
- id: no-legacy-http-client
languages: [python]
severity: LOW
message: legacy_http is deprecated (ADR-014). Use app.http.client, which sets timeouts and retries.
pattern-either:
- pattern: import legacy_http
- pattern: from legacy_http import $X
```
Architecture rules are cheap to write and high value. Link the ADR in `metadata.references`.
---
## 6. Secret-shaped identifiers
```yaml
- id: hardcoded-credential-assignment-py
languages: [python]
severity: HIGH
message: Credential-like value assigned from a string literal. Load it from the environment or secret manager.
patterns:
- pattern: $NAME = "$VALUE"
- metavariable-regex:
metavariable: $NAME
regex: (?i).*(password|passwd|secret|api_?key|token|private_?key).*
- metavariable-regex:
metavariable: $VALUE
regex: ^.{12,}$
- id: hardcoded-credential-assignment-js
languages: [javascript, typescript]
severity: HIGH
message: Credential-like value assigned from a string literal. Read it from process.env or the secret manager.
patterns:
- pattern-either:
- pattern: const $NAME = "$VALUE"
- pattern: let $NAME = "$VALUE"
- metavariable-regex:
metavariable: $NAME
regex: (?i).*(password|passwd|secret|api_?key|token|private_?key).*
- metavariable-regex:
metavariable: $VALUE
regex: ^.{12,}$
```
This complements, not replaces, a real secret scanner such as gitleaks (see the
secrets-management skill).
---
## 7. Generic and config files
```yaml
- id: dockerfile-curl-pipe-shell
languages: [dockerfile]
severity: MEDIUM
message: Piping a downloaded script to a shell skips integrity checks. Download, verify a checksum, then run.
pattern-either:
- pattern: RUN ... curl ... | sh ...
- pattern: RUN ... curl ... | bash ...
- pattern: RUN ... wget ... | sh ...
- id: gha-pull-request-target-checkout-head
languages: [yaml]
severity: HIGH
message: pull_request_target with a checkout of the PR head runs untrusted code with secrets.
patterns:
- pattern-inside: |
on:
...
pull_request_target: ...
...
- pattern: "ref: ${{ github.event.pull_request.head.sha }}"
```
Structural matching for Dockerfile and YAML shapes is less predictable than for code.
Prove each of these with a test fixture first; if the structural pattern does not match the
real files, fall back to `pattern-regex` with a narrow regex and keep the fixture.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.