Semgrep SAST: Writing Custom Security Rules for Your Codebase
Most SAST tools ship with hundreds of generic rules written by people who have never seen your codebase. They catch exec(user_input) — the obvious stuff. What they miss is the SQL query helper your team wrote in 2019 that constructs queries from dict keys, or the internal HTTP client wrapper that has its own SSL flag, or the YAML loader your framework uses before the framework's own sanitization layer runs.
Semgrep gives you the primitives to write rules that understand your codebase specifically. This guide covers the full workflow: rule anatomy, pattern operators, taint analysis, language-specific patterns, and CI/CD integration. By the end you will be able to ship a custom ruleset that catches the security bugs unique to your stack.
Why Semgrep Is Different from Traditional SAST
Traditional SAST tools — SonarQube, Checkmarx, Fortify — build a full abstract syntax tree of your codebase, construct control flow graphs, and run data flow analysis across the whole thing. That thoroughness comes at a cost: scans take minutes on a medium-sized repo, configuration is complex, and the false positive rate is high enough that teams start ignoring the output entirely.
Semgrep takes a different approach. Instead of full program analysis, it does syntactic pattern matching with semantic awareness. You write patterns that look like the code you want to find, and Semgrep finds it — respecting language syntax and variable scope, but without building a complete call graph. This makes it:
- Fast. Scanning a 100k-line Python repo typically takes under 30 seconds. Full scan on a monorepo runs in a few minutes, not 30.
- Easy to configure.
semgrep --config auto .will download and run the community ruleset for your detected languages with zero setup. - Easy to extend. Rules are YAML. If you can read code, you can write a rule in 10 minutes.
The Semgrep Registry contains over 3,000 community-maintained rules covering OWASP Top 10 patterns, framework-specific vulnerabilities, and security anti-patterns across 30+ languages. For many teams, running the registry is a good starting point.
But the registry does not know that your team uses a custom db.raw_query() wrapper instead of the ORM. It does not know that flask_utils.render(template, **user_data) is a dangerous call specific to your app. That is where custom rules come in — and they are Semgrep's real differentiator.
Semgrep Rule Anatomy
Every Semgrep rule is a YAML document. Here is the full structure with all common fields:
rules:
- id: my-rule-id # unique identifier, kebab-case
languages: [python] # python, javascript, java, go, ruby, etc.
severity: ERROR # INFO, WARNING, ERROR
message: |
$FUNC is called with user-controlled input. This may allow SQL injection.
Consider using parameterized queries instead.
metadata:
category: security
cwe: "CWE-89: SQL Injection"
owasp: "A03:2021 - Injection"
confidence: HIGH
patterns: # ALL patterns must match (AND logic)
- pattern: ...
- pattern-not: ...
- pattern-inside: ...
# OR use pattern-either for OR logic
pattern-either:
- pattern: ...
- pattern: ...
Pattern Operators
The six operators you will use most often:
pattern— the primary match expression. Uses metavariables ($X,$...ARGS) as wildcards.pattern-either— matches if ANY child pattern matches (OR logic). Use when the same vulnerability appears in multiple forms.pattern-not— excludes matches. Use to eliminate known-safe patterns from results.pattern-inside— the match must occur inside this surrounding context (e.g., inside a function, inside a try block).pattern-not-inside— the match must NOT occur inside this context.focus-metavariable— when you have a complex pattern, highlight a specific metavariable in the output rather than the whole matched expression.
Metavariables
Metavariables are Semgrep's wildcards. $X matches any single expression and binds it to X — you can then reference $X in other patterns within the same rule to require the same value appears. $...ARGS matches zero or more arguments in a function call (the ellipsis operator).
# $QUERY matches any single expression
# $...ARGS matches any number of arguments
pattern: cursor.execute($QUERY, $...ARGS)
# You can reuse a metavariable to enforce equality:
# This finds cases where the same variable is used in both positions
patterns:
- pattern: |
$X = request.args.get($KEY)
...
cursor.execute($X)
Writing Your First Security Rules
Rule 1: SQL String Concatenation in Python
This catches the classic SQL injection pattern — building a query string with + or % formatting rather than using parameterized queries:
rules:
- id: python-sql-string-concat
languages: [python]
severity: ERROR
message: |
SQL query constructed via string concatenation. Use parameterized queries
(cursor.execute(query, params)) to prevent SQL injection.
metadata:
cwe: "CWE-89: SQL Injection"
owasp: "A03:2021 - Injection"
pattern-either:
- pattern: $CURSOR.execute("..." + $X)
- pattern: $CURSOR.execute("..." % $X)
- pattern: $CURSOR.execute(f"...")
- pattern: |
$QUERY = "..." + $X
...
$CURSOR.execute($QUERY)
Rule 2: Hardcoded Password Strings in JavaScript
This catches common patterns where developers assign literal password strings to variables:
rules:
- id: js-hardcoded-password
languages: [javascript, typescript]
severity: ERROR
message: |
Hardcoded password detected in '$VAR'. Store credentials in environment
variables or a secrets manager, never in source code.
metadata:
cwe: "CWE-798: Use of Hard-coded Credentials"
owasp: "A07:2021 - Identification and Authentication Failures"
pattern-either:
- pattern: const password = "..."
- pattern: let password = "..."
- pattern: var password = "..."
- pattern: const $VAR = {password: "...", ...}
- pattern: const $VAR = {passwd: "...", ...}
- pattern: const $VAR = {pwd: "...", ...}
pattern-not:
- pattern: const password = ""
- pattern: const password = process.env.$ENV_VAR
Rule 3: Disabled SSL Verification in Python Requests
Developers often disable SSL verification to get past certificate errors in dev and forget to re-enable it before shipping:
rules:
- id: python-requests-ssl-disabled
languages: [python]
severity: ERROR
message: |
SSL certificate verification is disabled (verify=False). This makes the
connection vulnerable to man-in-the-middle attacks. Fix the certificate
issue rather than disabling verification.
metadata:
cwe: "CWE-295: Improper Certificate Validation"
owasp: "A02:2021 - Cryptographic Failures"
pattern-either:
- pattern: requests.get($URL, ..., verify=False, ...)
- pattern: requests.post($URL, ..., verify=False, ...)
- pattern: requests.put($URL, ..., verify=False, ...)
- pattern: requests.delete($URL, ..., verify=False, ...)
- pattern: requests.request($METHOD, $URL, ..., verify=False, ...)
- pattern: $SESSION.get($URL, ..., verify=False, ...)
- pattern: $SESSION.post($URL, ..., verify=False, ...)
Taint Analysis: Tracking Data from Source to Sink
Pattern matching finds locally obvious problems. Taint analysis finds the harder ones: user-controlled data flowing into a dangerous function across multiple lines, function calls, and variable assignments.
Semgrep's mode: taint lets you define sources (where untrusted data enters), sinks (dangerous functions), and sanitizers (functions that make data safe). Semgrep tracks whether tainted data reaches a sink without passing through a sanitizer.
rules:
- id: python-sqli-taint
mode: taint
languages: [python]
severity: ERROR
message: |
User-controlled data from '$SOURCE' flows into SQL query at '$SINK'
without sanitization. Use parameterized queries.
metadata:
cwe: "CWE-89: SQL Injection"
pattern-sources:
- patterns:
- pattern: request.args.get(...)
- patterns:
- pattern: request.form.get(...)
- patterns:
- pattern: request.json
- patterns:
- pattern: request.values.get(...)
pattern-sinks:
- patterns:
- pattern: $CURSOR.execute(...)
- focus-metavariable: $CURSOR
- patterns:
- pattern: db.raw_query(...)
- patterns:
- pattern: $ENGINE.execute(...)
pattern-sanitizers:
- patterns:
- pattern: sqlalchemy.text(...)
- patterns:
- pattern: $CURSOR.mogrify(...)
Tracking File Path from User Input to open()
This catches path traversal: user-controlled strings reaching file operations without validation:
rules:
- id: python-path-traversal-taint
mode: taint
languages: [python]
severity: ERROR
message: |
User-controlled path flows into file operation. Validate and normalize
the path to prevent directory traversal attacks.
metadata:
cwe: "CWE-22: Path Traversal"
owasp: "A01:2021 - Broken Access Control"
pattern-sources:
- patterns:
- pattern: request.args.get(...)
- patterns:
- pattern: request.form.get(...)
- patterns:
- pattern: flask.request.json[...]
pattern-sinks:
- patterns:
- pattern: open(...)
- focus-metavariable: $PATH
- patterns:
- pattern: os.path.join(...)
- focus-metavariable: $PATH
- patterns:
- pattern: pathlib.Path(...)
- focus-metavariable: $PATH
pattern-sanitizers:
- patterns:
- pattern: os.path.abspath(...)
- patterns:
- pattern: $PATH.resolve()
Taint analysis tip: Start with broad sources and narrow them down based on false positive rate. It is better to catch too much initially and refine than to miss real vulnerabilities because your source patterns are too specific.
Rules for Common Web Security Vulnerabilities
Command Injection: User Input in subprocess / os.system
rules:
- id: python-command-injection
languages: [python]
severity: ERROR
message: |
User input passed directly to shell command. Use subprocess with a list
of arguments (no shell=True) and avoid including user data in commands.
metadata:
cwe: "CWE-78: OS Command Injection"
owasp: "A03:2021 - Injection"
pattern-either:
- pattern: os.system($CMD + $X)
- pattern: os.system(f"... {$X} ...")
- pattern: subprocess.call($CMD + $X, shell=True)
- pattern: subprocess.run($CMD + $X, shell=True)
- pattern: subprocess.Popen($CMD + $X, shell=True)
- pattern: subprocess.check_output($CMD + $X, shell=True)
Hardcoded Secrets: API Keys, Tokens, Passwords
rules:
- id: hardcoded-api-key
languages: [python, javascript, typescript, java, go]
severity: ERROR
message: |
Hardcoded API key or token in '$VAR'. Use environment variables or a
secrets manager. Rotate this key immediately if already committed.
metadata:
cwe: "CWE-798: Use of Hard-coded Credentials"
pattern-either:
- pattern: $VAR = "sk-..."
- pattern: $VAR = "ghp_..."
- pattern: $VAR = "xoxb-..."
- pattern: $VAR = "AKIA..."
- pattern: api_key = "..."
- pattern: API_KEY = "..."
- pattern: secret_key = "..."
- pattern: SECRET_KEY = "..."
- pattern: access_token = "..."
- pattern: ACCESS_TOKEN = "..."
pattern-not:
- pattern: api_key = ""
- pattern: api_key = os.environ[...]
- pattern: api_key = os.getenv(...)
- pattern: api_key = process.env.$VAR
Insecure Cryptography: MD5, SHA1, DES
rules:
- id: python-insecure-hash
languages: [python]
severity: WARNING
message: |
'$FUNC' produces a cryptographically weak hash. Use SHA-256 or SHA-3
for security-sensitive operations. For passwords, use bcrypt, argon2,
or scrypt instead.
metadata:
cwe: "CWE-327: Use of a Broken or Risky Cryptographic Algorithm"
owasp: "A02:2021 - Cryptographic Failures"
pattern-either:
- pattern: hashlib.md5(...)
- pattern: hashlib.sha1(...)
- pattern: Crypto.Cipher.DES.new(...)
- pattern: Crypto.Cipher.DES3.new(...)
- pattern: Crypto.Cipher.ARC2.new(...)
- pattern: Crypto.Cipher.ARC4.new(...)
XML External Entities: Unsafe XML Parser Configuration
rules:
- id: python-xxe-vulnerable-parser
languages: [python]
severity: ERROR
message: |
XML parsed with external entity processing enabled. This allows XXE
attacks if the XML source is user-controlled. Use defusedxml instead
of the standard library xml module.
metadata:
cwe: "CWE-611: XML External Entity Reference"
owasp: "A05:2021 - Security Misconfiguration"
pattern-either:
- pattern: xml.etree.ElementTree.parse(...)
- pattern: xml.etree.ElementTree.fromstring(...)
- pattern: xml.dom.minidom.parseString(...)
- pattern: xml.dom.minidom.parse(...)
- pattern: lxml.etree.parse(...)
- pattern: lxml.etree.fromstring(...)
pattern-not:
- pattern: defusedxml.$MODULE.$FUNC(...)
Language-Specific Rules
Python: Flask and Django Specific Patterns
Generic rules miss framework-specific injection points. Flask's render_template_string is a classic example — it renders a Jinja2 template from a string, which becomes server-side template injection if user data ends up in the template string:
rules:
- id: flask-ssti-render-template-string
languages: [python]
severity: ERROR
message: |
render_template_string() called with user-controlled input. This allows
Server-Side Template Injection (SSTI). Use render_template() with a
static template file instead.
metadata:
cwe: "CWE-94: Code Injection"
owasp: "A03:2021 - Injection"
pattern-either:
- pattern: flask.render_template_string(request.$ATTR, ...)
- pattern: render_template_string(request.$ATTR, ...)
- pattern: |
$TMPL = request.$ATTR
...
render_template_string($TMPL, ...)
- pattern: |
$TMPL = "..." + request.$ATTR
...
render_template_string($TMPL, ...)
- id: django-raw-sql
languages: [python]
severity: ERROR
message: |
Django ORM raw() or extra() called with string concatenation. Use
parameterized queries: Model.objects.raw(query, [params]).
metadata:
cwe: "CWE-89: SQL Injection"
pattern-either:
- pattern: $MODEL.objects.raw("..." + $X)
- pattern: $MODEL.objects.raw("..." % $X)
- pattern: $MODEL.objects.raw(f"...")
- pattern: $QUERYSET.extra(where=["..." + $X])
- pattern: connection.execute("..." + $X)
JavaScript / Node.js: eval(), child_process, Prototype Pollution
rules:
- id: js-eval-user-input
languages: [javascript, typescript]
severity: ERROR
message: |
eval() called with non-literal argument. If this value is user-controlled,
it allows arbitrary code execution. Refactor to avoid eval entirely.
metadata:
cwe: "CWE-95: Improper Neutralization of Directives in Dynamically Evaluated Code"
pattern-either:
- pattern: eval($X)
- pattern: new Function($X)
- pattern: setTimeout($X, ...)
- pattern: setInterval($X, ...)
pattern-not:
- pattern: eval("...")
- id: node-command-injection
languages: [javascript, typescript]
severity: ERROR
message: |
User-controlled data passed to child_process execution. This allows
OS command injection. Use execFile() with argument arrays instead of exec().
metadata:
cwe: "CWE-78: OS Command Injection"
pattern-either:
- pattern: child_process.exec($CMD + $X, ...)
- pattern: child_process.exec(`... ${$X} ...`, ...)
- pattern: exec($CMD + $X, ...)
- pattern: execSync($CMD + $X, ...)
- id: js-prototype-pollution
languages: [javascript, typescript]
severity: WARNING
message: |
Object merge or deep copy without prototype check. If input is user-controlled,
this can lead to prototype pollution. Validate that merged keys are not
'__proto__', 'constructor', or 'prototype'.
metadata:
cwe: "CWE-1321: Improperly Controlled Modification of Object Prototype Attributes"
pattern-either:
- pattern: Object.assign($OBJ, $USER_DATA)
- pattern: _.merge($OBJ, $USER_DATA)
- pattern: $.extend(true, $OBJ, $USER_DATA)
Java: JDBC String Concatenation and XXE in DocumentBuilder
rules:
- id: java-jdbc-sqli
languages: [java]
severity: ERROR
message: |
JDBC Statement.execute() called with string concatenation. Use
PreparedStatement with parameterized queries to prevent SQL injection.
metadata:
cwe: "CWE-89: SQL Injection"
owasp: "A03:2021 - Injection"
pattern-either:
- pattern: $STMT.execute("..." + $X)
- pattern: $STMT.executeQuery("..." + $X)
- pattern: $STMT.executeUpdate("..." + $X)
- pattern: $CONN.createStatement().execute("..." + $X)
- id: java-xxe-documentbuilder
languages: [java]
severity: ERROR
message: |
DocumentBuilderFactory created without disabling external entity processing.
Set FEATURE_SECURE_PROCESSING and disable DOCTYPE declarations to prevent XXE.
metadata:
cwe: "CWE-611: XML External Entity Reference"
patterns:
- pattern: $DBF = DocumentBuilderFactory.newInstance()
- pattern-not: |
$DBF = DocumentBuilderFactory.newInstance()
...
$DBF.setFeature("http://xml.org/sax/features/external-general-entities", false)
- pattern-not: |
$DBF = DocumentBuilderFactory.newInstance()
...
$DBF.setFeature($FEATURE_SECURE, true)
Integrating Semgrep into CI/CD
A rule that runs only on developer laptops is better than nothing, but what you want is a gate in your pipeline that blocks merges when new security issues are introduced. Here is how to set that up.
GitHub Actions
name: Semgrep Security Scan
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
semgrep:
name: Semgrep SAST
runs-on: ubuntu-latest
container:
image: returntocorp/semgrep
steps:
- uses: actions/checkout@v4
- name: Run Semgrep (registry rules)
run: |
semgrep \
--config auto \
--config .semgrep/ \
--severity ERROR \
--error \
--json \
--output semgrep-results.json \
.
env:
SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}
- name: Upload results as artifact
uses: actions/upload-artifact@v4
if: always()
with:
name: semgrep-results
path: semgrep-results.json
- name: Upload SARIF to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: semgrep-results.json
The --error flag causes Semgrep to exit with a non-zero code when findings above the severity threshold are found, failing the build. --config .semgrep/ loads your custom rules from a local directory alongside the registry rules.
GitLab CI
semgrep-sast:
image: returntocorp/semgrep
stage: test
script:
- semgrep
--config auto
--config .semgrep/
--severity ERROR
--error
--gitlab-sast
--output gl-sast-report.json
.
artifacts:
reports:
sast: gl-sast-report.json
when: always
rules:
- if: $CI_MERGE_REQUEST_ID
- if: $CI_COMMIT_BRANCH == "main"
Pre-commit Hook
For fast feedback on every commit, add Semgrep as a pre-commit hook. This runs only on changed files, keeping it fast:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/returntocorp/semgrep
rev: v1.70.0
hooks:
- id: semgrep
args:
- --config
- auto
- --config
- .semgrep/
- --severity
- ERROR
- --error
Handling False Positives
When Semgrep flags something that is actually safe — for example, a SQL string concatenation that is provably not reachable with user input — annotate the line rather than suppressing the whole rule:
# Safe: this query is built from a whitelist of column names, not user input
column = get_allowed_column(user_request) # nosemgrep: python-sql-string-concat
query = f"SELECT {column} FROM users WHERE id = %s"
cursor.execute(query, (user_id,))
The # nosemgrep: rule-id comment suppresses only the named rule on that line. Avoid bare # nosemgrep — it suppresses all rules and makes audits difficult. Review nosemgrep annotations in code review the same way you would review a security exception.
Severity Thresholds and Rollout Strategy
Do not start by blocking merges on WARNING-level findings — your backlog will be enormous and developers will push back. A practical rollout:
- Week 1: Run in audit mode (
--json, no--error). Triage the output, fix the real issues, add# nosemgrepto known false positives. - Week 2: Block on ERROR only. Commit to fixing all new ERRORs within 24 hours of introduction.
- Week 4+: Add WARNING threshold once the ERROR backlog is clean. Enable Semgrep Cloud Platform for diff-aware scanning that only alerts on new issues introduced in the current PR.
Semgrep vs Other SAST Tools
| Tool | Analysis Depth | Scan Speed | Custom Rules | False Positive Rate | Best For |
|---|---|---|---|---|---|
| Semgrep | Pattern + taint | Very fast (seconds) | Simple YAML, minutes to write | Low–Medium | CI/CD gates, custom patterns, fast feedback |
| SonarQube | Full AST + dataflow | Slow (minutes) | Java plugins, complex | High | Code quality + security combined, large orgs with infra |
| CodeQL | Full program analysis | Slow (5–30 min) | CodeQL query language, steep learning curve | Low | Deep vulnerability research, high-assurance findings |
| Bandit | AST pattern matching | Fast | Python plugins | Medium–High | Python-only codebases, simple rule sets |
The practical answer for most teams is: Semgrep for the CI gate, CodeQL for periodic deep analysis. Semgrep's speed makes it viable as a pre-merge check. CodeQL's depth makes it worth running weekly or on releases to catch what Semgrep's pattern matching misses.
SonarQube is a reasonable choice if you are already using it for code quality metrics and want to consolidate tooling — but its scan time and false positive rate make it a poor choice as a hard merge gate. Bandit is fine for Python-only shops that want something extremely lightweight, but Semgrep covers the same ground with broader language support and better customizability.
SAST + DAST: The Full Picture
Semgrep is a powerful tool. It is also fundamentally limited by what it can see: static source code.
SAST cannot tell you whether a vulnerability is actually exploitable in your deployed environment. It cannot detect misconfigurations introduced by infrastructure (a missing HttpOnly flag set at the reverse proxy layer, not in code). It cannot find logic vulnerabilities that only manifest when the application is running with real data, real sessions, and real HTTP traffic. It cannot see what your third-party dependencies are doing at runtime.
That is exactly what DAST does. Dynamic Application Security Testing runs against your running application — sending real requests, fuzzing inputs, analyzing responses — the same way a manual penetration tester would. Where SAST finds a suspicious code pattern, DAST either confirms it is exploitable or rules it out.
The two approaches are complementary, not competing:
- SAST catches issues early, when they are cheapest to fix — in the PR before code is merged. It is fast, runs in CI, and gives developers direct line-number feedback.
- DAST confirms exploitability against the running system. It catches what SAST misses: runtime configuration issues, second-order injection, authentication and session vulnerabilities, API endpoint exposure, and vulnerabilities introduced by the deployment environment itself.
A mature AppSec program runs both. Write Semgrep rules to enforce your internal coding standards and catch the patterns your team is prone to. Run DAST against every staging deployment to confirm that what passes SAST is actually hardened in production. Neither tool alone gives you the full picture.
SAST finds bugs in code. DAST finds them in production.
Ironimo brings Kali Linux-powered dynamic security testing to your CI/CD pipeline — catching the vulnerabilities that static analysis alone can't confirm.
Start free scan