Writing Custom Semgrep Rules for Static Analysis

Writing custom semgrep rules
Writing custom semgrep rules
By HOC Team  |  Updated: September 2026  |  Read time: ~18 min

Static Application Security Testing (SAST) is a cornerstone of modern DevSecOps, but out-of-the-box rulesets rarely cover organisation-specific business logic, proprietary frameworks, or unique architectural patterns. This is where a semgrep custom rules tutorial becomes invaluable. Semgrep (Semantic Grep) is a fast, open-source static analysis engine that finds bugs and detects vulnerabilities using lightweight, readable pattern-matching rules.

Unlike AST-based SAST tools that require complex compiler knowledge to write rules, Semgrep allows developers and security engineers to write rules that look like the code they are trying to find. This tutorial will guide you through the anatomy of a Semgrep rule, basic and advanced pattern matching, taint analysis, testing methodologies, and CI/CD integration.

📊 Why Custom Semgrep Rules Matter in 2026 73% of enterprises using SAST report that default rulesets generate excessive false positives or miss framework-specific vulnerabilities. Writing custom Semgrep rules allows teams to enforce internal security standards (e.g., "all database queries must use parameterised methods") with near-zero false positives and scan speeds up to 30x faster than legacy SAST tools.
1. Anatomy of a Semgrep Rule

Every Semgrep rule is written in YAML. A rule file can contain a single rule or a list of rules under the rules: key. Understanding the core components is the first step in any semgrep custom rules tutorial.

# basic-rule.yaml rules: - id: detect-hardcoded-aws-key languages: - python - javascript severity: WARNING message: "Hardcoded AWS Access Key detected. Use environment variables or a secrets manager." patterns: - pattern: | $KEY = "AKIA[0-9A-Z]{16}" metadata: category: security technology: - aws confidence: HIGH owasp: - A07:2021 - Identification and Authentication Failures
KeyRequiredDescription
idYesUnique, snake_case identifier for the rule (e.g., no-unsafe-yaml-load).
languagesYesList of languages the rule applies to (e.g., python, java, go, javascript).
severityYesImpact level: INFO, WARNING, ERROR, or INVENTORY.
messageYesHuman-readable explanation shown to the developer when the rule triggers. Include remediation steps.
patternsYesThe core matching logic. Can use pattern, pattern-either, pattern-not, etc.
metadataNoOptional tags for categorisation, OWASP mapping, CWE references, and confidence scoring.
2. Basic Pattern Matching

Semgrep’s superpower is that patterns look like code. If you want to find a specific function call, you write that function call in the pattern key.

# Rule to find Python's eval() function rules: - id: dangerous-eval-usage languages: [python] severity: ERROR message: "Use of eval() is dangerous and can lead to arbitrary code execution. Use ast.literal_eval() instead." pattern: eval(...)

The ellipsis (...) is a wildcard that matches zero or more arguments, statements, or function arguments. In the example above, eval(...) will match eval(user_input), eval("1+1"), or eval().

3. Advanced Pattern Matching

As your codebase grows, simple literal matching isn't enough. Semgrep provides powerful constructs for complex logic.

Metavariables ($X, $Y)

Metavariables match any expression and can be reused within the same rule to ensure consistency. A metavariable must start with an uppercase letter.

# Ensure the same variable is used in both the lock and unlock patterns: - pattern: | $LOCK.acquire() ... $LOCK.release()

Here, $LOCK ensures that the object calling .release() is the exact same object that called .acquire().

Deep Expression Matching (<... ...>)

Sometimes the code you want to find is nested deeply within other statements (e.g., inside an if block or a loop). The deep expression operator <... ...> tells Semgrep to ignore nesting levels.

# Find requests.get() even if it's nested inside try/except or loops patterns: - pattern-inside: | def $FUNC(...): ... - pattern: <... requests.get($URL, ...) ...>
Logical Operators: pattern-either and pattern-not

Use pattern-either to match multiple variations of a vulnerability, and pattern-not to exclude safe patterns (reducing false positives).

patterns: - pattern-either: - pattern: yaml.load($DATA) - pattern: yaml.load_all($DATA) - pattern-not: yaml.load($DATA, Loader=yaml.SafeLoader) - pattern-not: yaml.load($DATA, yaml.SafeLoader)
4. Data Flow and Taint Analysis

Pattern matching finds code that looks dangerous. Taint analysis finds code that is dangerous by tracking untrusted data (sources) as it flows through the application to a dangerous function (sinks), unless it is cleaned (sanitised).

To enable this, set mode: taint in your rule.

rules: - id: sql-injection-taint languages: [python] severity: ERROR message: "Potential SQL Injection. User-controlled data reaches a database query." mode: taint pattern-sources: - pattern: request.GET.get(...) - pattern: request.POST.get(...) pattern-sinks: - pattern: cursor.execute($QUERY, ...) pattern-sanitizers: - pattern: int(...) - pattern: django.utils.html.escape(...)

In this example, Semgrep will only flag the rule if data from request.GET or request.POST flows into cursor.execute() without first passing through int() or escape().

5. Real-World Example: Preventing Unsafe Deserialization

Let’s build a production-ready rule to prevent unsafe YAML deserialization in Python, a common vector for Remote Code Execution (RCE).

# rules/unsafe-yaml-load.yaml rules: - id: python-lang.security.unsafe-yaml-load languages: [python] severity: ERROR message: "Detected unsafe YAML deserialization. `yaml.load()` without a safe Loader can execute arbitrary Python code. Use `yaml.safe_load()` instead." patterns: - pattern-either: - pattern: yaml.load(...) - pattern: yaml.load_all(...) - pattern-not: yaml.load(..., Loader=yaml.SafeLoader) - pattern-not: yaml.load(..., yaml.SafeLoader) - pattern-not: yaml.load(..., Loader=yaml.CSafeLoader) metadata: cwe: "CWE-502: Deserialization of Untrusted Data" owasp: "A08:2021 - Software and Data Integrity Failures" references: - https://github.com/yaml/pyyaml/wiki/PyYAML-yaml.load(input)-Deprecation category: security confidence: HIGH
6. Testing Custom Rules

A critical step in any semgrep custom rules tutorial is validation. Writing a rule is only half the battle; proving it works without generating false positives is the other half. Semgrep provides a built-in testing framework.

Create a directory structure with your rule and a target file:

# Directory structure tests/ unsafe-yaml-load.yaml # Your rule file test_unsafe_yaml.py # The code to test against

In your test file (test_unsafe_yaml.py), use special comments to tell Semgrep what should and shouldn't trigger:

# test_unsafe_yaml.py # ruleid: python-lang.security.unsafe-yaml-load data = yaml.load(user_input) # ruleid: python-lang.security.unsafe-yaml-load data = yaml.load_all(file_stream) # ok: python-lang.security.unsafe-yaml-load safe_data = yaml.safe_load(user_input) # ok: python-lang.security.unsafe-yaml-load safe_data = yaml.load(user_input, Loader=yaml.SafeLoader)

Run the test command:

semgrep --test tests/ # Output: 2/2 tests passed (0 errors, 0 false positives)
7. CI/CD Integration

Custom rules should be enforced automatically in your CI/CD pipeline. Below is a GitHub Actions workflow that runs your custom rules alongside the official Semgrep Registry rules.

# .github/workflows/semgrep.yml name: Semgrep Security Scan on: pull_request: branches: [main, develop] push: branches: [main] jobs: semgrep: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Run Semgrep uses: returntocorp/semgrep-action@v1 with: # Combine official rules with your custom rules directory config: > p/security-audit p/secrets ./semgrep-custom-rules/ # Fail the PR if ERROR or WARNING severity issues are found auditOn: push
8. Best Practices for Rule Authors
💡
Rules for Writing Rules
  • Start specific, then generalise: Write a rule that catches one specific instance of a bug in your codebase first. Then, expand it using metavariables and pattern-either to catch similar patterns.
  • Aggressively use pattern-not: The difference between a useful rule and a noisy, ignored rule is the pattern-not clause. Actively hunt for false positives in your codebase and add exceptions for safe patterns.
  • Write actionable messages: The message field should not just say "Bad code". It must explain why it is bad and how to fix it, ideally with a code snippet of the safe alternative.
  • Namespace your IDs: Use a consistent naming convention like company-name.language.category.rule-name (e.g., hoc.python.security.unsafe-yaml-load) to avoid collisions with the public Semgrep Registry.
  • Test against real code: Always run semgrep --test against a diverse set of real-world examples from your own repositories before deploying to CI/CD.
Frequently Asked Questions
Is Semgrep better than traditional SAST tools like SonarQube or Checkmarx?

"Better" depends on the use case. Traditional SAST tools excel at deep, cross-file data flow analysis out of the box but are notoriously slow and generate high false-positive rates. Semgrep is significantly faster (often 10-30x), easier to write custom rules for, and integrates seamlessly into developer workflows (like pre-commit hooks). Many mature organisations use Semgrep for fast, custom, framework-specific checks in CI, while reserving heavier SAST tools for nightly, comprehensive scans.

Can Semgrep track data across multiple files?

Yes, but with caveats. Basic pattern matching is single-file. However, Semgrep's taint analysis (mode: taint) can track data flow across function boundaries within a single file. For true cross-file, interprocedural data flow analysis, Semgrep Pro (the paid tier) offers advanced reachability analysis that tracks sources to sinks across multiple files and repositories.

How do I share my custom Semgrep rules with my team?

The simplest method is to store your .yaml rule files in a dedicated directory within your organisation's monorepo or a shared internal Git repository. You can then reference this directory in your CI/CD pipeline (e.g., semgrep --config ./internal-rules/). Alternatively, you can publish a private Semgrep App account and share rulesets via the Semgrep Registry.

What languages does Semgrep support for custom rules?

Semgrep supports over 30 languages, including Python, JavaScript, TypeScript, Java, Go, Ruby, C++, C#, PHP, Rust, and Kubernetes YAML/Helm. The pattern matching syntax adapts to the AST of each specific language, meaning a metavariable like $X will correctly match a Java object, a Python dictionary, or a Go struct depending on the languages key defined in the rule.

About the author Written by the HOC Team at Hackers Online Club -- a cybersecurity community trusted by DevSecOps engineers, cloud security architects, and application security practitioners since 2010. 15+ years of practical security guides, secure coding tutorials, and enterprise hardening resources. Learn more about HOC

Join Our Club

Enter your Email address to receive notifications | Join over Million Followers

Previous Article
macOS Clickfix

Microsoft Exposes macOS ClickFix Cloaked Gates - Research

Related Posts