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:

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:

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:

  1. Week 1: Run in audit mode (--json, no --error). Triage the output, fix the real issues, add # nosemgrep to known false positives.
  2. Week 2: Block on ERROR only. Commit to fixing all new ERRORs within 24 hours of introduction.
  3. 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:

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
← Back to Blog