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.

~/.claude/skills/semgrep-rules/SKILL.md

--- name: semgrep-rules description: Writes, tests, and tunes custom Semgrep rules for a specific codebase - structural patterns with metavariables and ellipsis, pattern-inside… license: MIT ---

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

ProblemModeWhy
Ban a call or argument shape (verify=False, yaml.load without SafeLoader, eval)search: pattern + pattern-notLocal, 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-insideContext is lexical
Untrusted data flows into a dangerous sink across assignmentsmode: taintTracks flow through variables; sanitizers cut paths
Name-based conventions (*_unsafe helpers, secrets in variable names)search + metavariable-regexFilters captured identifiers
Non-code text (Dockerfile lines, config keys) with no parserpattern-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:

OperatorUse
$XMetavariable; same name must bind the same expression everywhere in one match
...Any arguments, statements, or expression sequence
$...ARGSEllipsis captured into a metavariable
<... $X ...>Deep expression: $X appears anywhere inside
pattern-eitherOR of alternatives
patternsAND; pattern-not* and pattern-inside filter the positive patterns
pattern-inside / pattern-not-insideRequire or forbid an enclosing context
metavariable-regexFilter a binding by regex (left-anchored)
metavariable-patternRun a sub-pattern against a binding (optionally another language)
metavariable-comparisonNumeric or string comparison on a binding ($N < 2048)
focus-metavariableReport only the bound sub-expression, not the whole match
fixAutofix 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; --test applies the fix and diffs it.
  • Minimum per rule: one ruleid, one lookalike ok, one ok for 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:

ResultAction
True positiveKeep; file fix or baseline it
False positive, recurring shapeAdd 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 codepaths.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 stateGate
Zero existing hits, high confidenceBlocking on full scan
Existing hits, high confidenceBlocking on new findings only (--baseline-commit) plus a cleanup ticket
Medium confidence or new ruleAdvisory: 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 == $X only matches identical expressions. Use $A == $B when the sides may differ.
  • pattern-not removes matches that are exactly the excluded shape; to exclude by context use pattern-not-inside. Mixing these up is the top cause of “my exclusion does nothing”.
  • Import aliasing is resolved for fully qualified patterns (requests.get matches from requests import get as g; g(...)) only when you write the qualified form.
  • metavariable-regex is left-anchored; regex: secret will not match api_secret. Use regex: (?i).*secret.*.
  • Taint sources match expressions, so request.args as source also taints request.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.
  • nosemgrep without a rule ID suppresses every rule on that line. Require the ID in review.

Verification

  1. semgrep --validate --config rules/ and semgrep --test rules/ both pass.
  2. Full-repo scan: every remaining hit is a confirmed true positive, baselined, or carries a reasoned nosemgrep: <id>.
  3. Re-introduce the original bug in a scratch branch; the rule fires with a message that names the fix.
  4. 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 313 lines · 9.5 KB
# 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.

esc