Semgrep — custom rule development
TL;DR: Semgrep is grep-for-code with structural awareness — patterns match AST shapes, not raw text. Rules are YAML, fast to write, fast to run (seconds on million-line repos). Best for codebase-specific anti-patterns where CodeQL would be overkill: forbidden function calls, missing decorators, dangerous coding idioms. Pairs with codeql-custom-query-development for deep taint analysis.
What it is
Semgrep open-source CLI + Semgrep AppSec Platform (commercial backend, optional). Three rule registries:
semgrep --config auto— community rules, language auto-detectsemgrep --config p/security-audit— curated security packsemgrep --config ./my-rules/— local custom rules
Rule language: YAML with pattern, patterns, pattern-either, metavariable-pattern, taint modes.
Preconditions / where it applies
- Source code; works on partial / non-compiling code (unlike CodeQL)
- ~30 supported languages; depth varies (JS/Python/Java/Go/C# mature, Rust/Swift improving)
- For CI: works in GitHub Actions, GitLab, Jenkins, pre-commit hooks
Tradecraft
Run defaults:
1
2
3
4
pip install semgrep
semgrep --config auto src/
# Or specific pack
semgrep --config p/owasp-top-ten src/
Anatomy of a custom rule:
1
2
3
4
5
6
7
8
9
10
11
12
13
rules:
- id: jwt-no-expiry-check
message: JWT verification without expiry check
severity: ERROR
languages: [javascript, typescript]
metadata:
cwe: 'CWE-613: Insufficient Session Expiration'
owasp: 'A07:2021'
pattern-either:
- pattern: jwt.verify($TOKEN, $KEY)
- pattern: jsonwebtoken.verify($TOKEN, $KEY)
pattern-not: jwt.verify($TOKEN, $KEY, { ..., maxAge: ..., ... })
pattern-not: jwt.verify($TOKEN, $KEY, { ..., expiresIn: ..., ... })
$TOKEN / $KEY are metavariables — they match any expression. $...ARGS matches variable-length argument lists.
Common pattern primitives:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# Match function call
pattern: dangerous_func($X)
# Match with type info (Java/Go/TS)
pattern: |
String $X = request.getParameter("...");
$SINK($X);
# Boolean combinator
patterns:
- pattern: db.query($Q)
- pattern-not: db.query("SELECT * FROM users WHERE id = ?", $X)
# Metavariable regex
pattern: |
process.env.$VAR
metavariable-regex:
metavariable: $VAR
regex: '(?i)(secret|key|token|pass)'
# Ellipsis matches any expression
pattern: |
if (req.user.role === 'admin') {
...
db.execute(req.body.query);
...
}
Taint mode — first-class source-to-sink tracking:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
rules:
- id: custom-ssti
mode: taint
pattern-sources:
- pattern: request.args.get(...)
- pattern: request.form.get(...)
pattern-sinks:
- pattern: render_template_string($X)
pattern-sanitizers:
- pattern: escape($X)
- pattern: markupsafe.escape($X)
message: SSTI via render_template_string with user input
severity: ERROR
languages: [python]
Not as deep as CodeQL but covers most direct flows in seconds.
Match Java annotations:
1
2
3
4
5
6
7
8
9
10
11
12
pattern: |
@RequestMapping(...)
public $RET $METHOD(...) {
...
}
pattern-not-inside: |
@PreAuthorize(...)
public $RET $METHOD(...) {
...
}
languages: [java]
message: Endpoint missing @PreAuthorize
Codebase-specific examples worth writing for any team:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# Forbid raw SQL through internal ORM
rules:
- id: no-raw-sql-orm
pattern-either:
- pattern: MyORM.raw(...)
- pattern: $X.rawQuery(...)
message: Use parameterised builder, not raw()
# Detect logging of PII fields
- id: log-pii-leak
patterns:
- pattern-either:
- pattern: logger.$LEVEL($X)
- pattern: console.$LEVEL($X)
- metavariable-pattern:
metavariable: $X
patterns:
- pattern-either:
- pattern: $OBJ.ssn
- pattern: $OBJ.creditCard
- pattern: $OBJ.email
# Detect new commits adding TODO security comments
- id: todo-security
pattern-regex: 'TODO.*(security|auth|crypto|fix.*later)'
# Forbid go's exec.Command with user input
- id: go-exec-tainted
mode: taint
pattern-sources:
- pattern: r.URL.Query().Get(...)
pattern-sinks:
- pattern: exec.Command(...)
languages: [go]
Run only changed files in CI (fast PR scan):
1
2
3
semgrep --config ./rules \
--baseline-ref origin/main \
--error
--baseline-ref diffs against base branch; only new findings fail the build. Critical for adopting on legacy codebases without bankruptcy.
Autofix:
1
2
3
4
5
6
rules:
- id: md5-replace-sha256
pattern: hashlib.md5($X)
fix: hashlib.sha256($X)
languages: [python]
message: Use SHA-256 instead of MD5
semgrep --autofix rewrites code in place. Use carefully; reviewer must still validate.
Test rules:
Drop test file my-rule.test.py:
1
2
3
4
5
# ruleid: jwt-no-expiry-check
jwt.verify(token, key);
# ok: jwt-no-expiry-check
jwt.verify(token, key, { maxAge: 3600 });
semgrep --config my-rule.yaml --test runs.
Rule registry contribution: Send rules to semgrep/semgrep-rules repo for community benefit. Acceptance criteria: high precision, real-world relevance, tests included.
CI integration
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# .github/workflows/semgrep.yml
jobs:
semgrep:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: returntocorp/semgrep-action@v1
with:
config: >-
p/security-audit
p/owasp-top-ten
./.semgrep/
generateSarif: '1'
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: semgrep.sarif
Findings surface in GitHub Security tab alongside CodeQL.
When to pick Semgrep vs CodeQL
| Need | Semgrep | CodeQL |
|---|---|---|
| Find a specific forbidden API call | ✅ minutes | overkill |
| Codebase-wide pattern enforcement | ✅ | ✅ but slow to author |
| Cross-procedural taint with library awareness | partial | ✅ |
| Compiled-language extraction | partial | ✅ |
| CI on every PR | ✅ seconds | minutes |
| Per-team customisation by AppSec engineer (not specialist) | ✅ low barrier | high barrier |
| Path-explanation for triage | basic | rich |
Many teams run BOTH: Semgrep on every PR (fast guardrails), CodeQL on nightly (deep audit).
OPSEC pitfalls
--config autodownloads rules at runtime; pin a version (p/security-audit:v1.x) for reproducible CI- Pattern-not blocks have subtle semantics —
pattern-not-insidevspattern-notdiffer - Metavariable scope:
$Xmatches differently acrosspatterns:vspattern-either: - Performance: 50,000+ rules across a monorepo can take 30 min — split rule packs by language and run in parallel
- False positives degrade trust — disable noisy rules per-project rather than letting team ignore all findings
References
- Semgrep documentation
- Semgrep Registry — public rules
- Semgrep playground — write + test interactively
- Semgrep Rule Ideas — practitioner blog
- returntocorp/semgrep-rules — community rules repo
See also: codeql-custom-query-development, source-sink-flow-analysis, whitebox-to-exploit-methodology, sast-dast-ci-integration, sast-dast-iast-vendor-selection, appsec-champions-program, secrets-in-code-detection-patterns, ghost-commit-smuggling, dangerous-java-sinks, dangerous-nodejs-sinks, python-dangerous-sinks