<!-- Canonical: https://lucioduran.com/blog/ai-code-review-ast-analysis-semgrep-custom-rules
Language: en
Content-SHA256: 9c381f134fc7bf6b507956c62d9d4fe8b66e2bc465f07cbaa5ba13eab83f6658
-->

Developer Tools

# Automated Code Review: Semgrep Custom Rules, AST Pattern Matching, and Building Org-Specific Security Scanners

2026-01-155 min read

Lucio Durán
Engineering Manager & AI Solutions Architect

Also available in: [Español](<https://lucioduran.com/es/blog/ai-code-review-ast-analysis-semgrep-custom-rules>)

## Why Semgrep, Not Regex

The fundamental problem with grep-based security scanning is that code is a tree, not a string. [Semgrep](<https://semgrep.dev/>) addresses this by operating on the Abstract Syntax Tree. Consider finding SQL injection in [Python](<https://www.python.org/>):

```python
# Grep catches this:
cursor.execute("SELECT * FROM users WHERE id = " + user_id)

# But misses this (same vulnerability, different formatting):
query = "SELECT * FROM users WHERE id = " + user_id
cursor.execute(
 query
)

# And false-positives on this (safe, parameterized):
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
```

Semgrep works on the AST (Abstract Syntax Tree), so it understands code structure, not just text:

```yaml
# semgrep rule: python-sql-injection.yaml
rules:
 - id: python-sql-injection
 patterns:
 - pattern: cursor.execute($QUERY, ...)
 - pattern-not: cursor.execute("...", (...))
 - metavariable-pattern:
 metavariable: $QUERY
 patterns:
 - pattern: |
 $X + ...
 - pattern: |
 f"..."
 - pattern: |
 "...".format(...)
 - pattern: |
 "..." % ...
 message: >
 SQL query built with string concatenation or formatting.
 Use parameterized queries instead.
 severity: ERROR
 languages: [python]
```

This matches any ` cursor.execute() ` where the query argument is built with concatenation, f-strings, ` .format() `, or ` % ` formatting, but explicitly excludes parameterized queries. It works regardless of whitespace, variable naming, or how many lines the code spans.

## Writing Your First Custom Rule

Semgrep rules are YAML files with a surprisingly powerful pattern language. The key building blocks:

```yaml
rules:
 - id: no-hardcoded-secrets
 patterns:
 # Match: any string assignment that looks like a secret
 - pattern: $KEY = "..."
 # Where the variable name suggests it's a secret
 - metavariable-regex:
 metavariable: $KEY
 regex: (?i)(password|secret|api_key|token|private_key)
 # But not when it's empty or a placeholder
 - pattern-not: $KEY = ""
 - pattern-not: $KEY = "CHANGEME"
 - pattern-not: $KEY = "placeholder"
 - pattern-not: $KEY = "xxx"
 message: |
 Hardcoded secret detected in variable '$KEY'.
 Use environment variables or a secrets manager instead.
 severity: WARNING
 languages: [python, javascript, typescript, java, go, ruby]
 metadata:
 category: security
 confidence: MEDIUM
 cwe: CWE-798
```

Test it:

```bash
# Create test file
cat > test_secrets.py << 'EOF'
# ruleid: no-hardcoded-secrets
api_key = "sk_live_4242424242424242"

# ruleid: no-hardcoded-secrets
DB_PASSWORD = "super_secret_123"

# ok: no-hardcoded-secrets
api_key = ""

# ok: no-hardcoded-secrets
password = os.environ.get("DB_PASSWORD")
EOF

# Run the rule
semgrep --config python-secrets.yaml test_secrets.py
```

## Taint Analysis: Following the Data

This is where Semgrep goes beyond pattern matching. Taint mode tracks data flow from sources to sinks:

```yaml
rules:
 - id: express-xss
 mode: taint
 message: >
 User input flows into HTML response without sanitization.
 This is a Cross-Site Scripting (XSS) vulnerability.
 severity: ERROR
 languages: [javascript, typescript]
 metadata:
 cwe: CWE-79
 owasp: A7:2017

 pattern-sources:
 - patterns:
 - pattern: req.query.$PARAM
 - patterns:
 - pattern: req.body.$PARAM
 - patterns:
 - pattern: req.params.$PARAM

 pattern-sinks:
 - patterns:
 - pattern: res.send($PAYLOAD)
 - patterns:
 - pattern: res.write($PAYLOAD)

 pattern-sanitizers:
 - patterns:
 - pattern: DOMPurify.sanitize(...)
 - patterns:
 - pattern: escape(...)
 - patterns:
 - pattern: encodeURIComponent(...)
```

This catches:

```javascript
// DETECTED: tainted data flows from source to sink
app.get('/search', (req, res) => {
 const query = req.query.q;
 res.send(`<h1>Results for: ${query}</h1>`); // XSS!
});

// DETECTED: even through intermediate variables
app.get('/profile', (req, res) => {
 const userId = req.params.id;
 const name = getUserName(userId);
 const html = `<div>${req.query.displayName}</div>`;
 res.send(html); // XSS via req.query.displayName
});

// NOT DETECTED: sanitizer breaks the taint chain
app.get('/safe', (req, res) => {
 const query = DOMPurify.sanitize(req.query.q);
 res.send(`<h1>Results for: ${query}</h1>`); // Safe
});
```

## Building an Org-Specific Rule Set

Beyond security rules, the rule set can expand to organization-specific patterns:

```yaml
# Rule: Enforce our internal API client usage
rules:
 - id: use-internal-http-client
 patterns:
 - pattern-either:
 - pattern: fetch(...)
 - pattern: axios.get(...)
 - pattern: axios.post(...)
 - pattern: new XMLHttpRequest()
 - pattern-not-inside: |
 // semgrep-ignore: use-internal-http-client
 ...
 paths:
 include:
 - "src/**"
 exclude:
 - "src/lib/http-client/**"
 - "**/*.test.*"
 message: |
 Use our internal HTTP client (`@myorg/http-client`) instead of
 raw fetch/axios. It handles auth, retries, circuit breaking,
 and distributed tracing automatically.
 severity: WARNING
 languages: [javascript, typescript]

 # Rule: Prevent direct database access outside repositories
 - id: no-direct-db-access
 patterns:
 - pattern-either:
 - pattern: prisma.$MODEL.$METHOD(...)
 - pattern: db.query(...)
 - pattern: knex($TABLE)
 - pattern-not-inside: |
 class $REPO {
 ...
 }
 paths:
 include:
 - "src/**"
 exclude:
 - "src/repositories/**"
 - "src/migrations/**"
 message: |
 Direct database access outside the repository layer.
 All database operations should go through repository classes.
 severity: WARNING
 languages: [javascript, typescript]
```

## CI Integration

The [GitHub](<https://github.com/>) Actions workflow:

```yaml
name: Semgrep
on: [pull_request]

jobs:
 semgrep:
 runs-on: ubuntu-latest
 container:
 image: semgrep/semgrep
 steps:
 - uses: actions/checkout@v4

 - name: Run Semgrep
 run: |
 semgrep scan \
 --config .semgrep/ \
 --config p/security-audit \
 --config p/owasp-top-ten \
 --error \
 --json \
 --output results.json \
 --sarif \
 --sarif-output results.sarif

 - name: Upload SARIF
 uses: github/codeql-action/upload-sarif@v3
 with:
 sarif_file: results.sarif
 if: always()

 - name: Comment on PR
 if: failure()
 uses: actions/github-script@v7
 with:
 script: |
 const fs = require('fs');
 const results = JSON.parse(fs.readFileSync('results.json'));
 const findings = results.results || [];

 if (findings.length === 0) return;

 let body = '## Semgrep Security Findings\n\n';
 body += `Found ${findings.length} issue(s):\n\n`;

 for (const f of findings.slice(0, 10)) {
 body += `- **${f.check_id}** (${f.extra.severity})\n`;
 body += ` ${f.path}:${f.start.line}\n`;
 body += ` ${f.extra.message}\n\n`;
 }

 github.rest.issues.createComment({
 issue_number: context.issue.number,
 owner: context.repo.owner,
 repo: context.repo.repo,
 body
 });
```

## Results After Six Months

The numbers tell the story:

```
Before Semgrep:
 - Manual security review time: ~45 min/PR
 - PR turnaround: 2.3 days average
 - Security findings per quarter: ~12 (found in review)
 - Missed findings (found in prod): ~4/quarter

After Semgrep (+ reduced manual review):
 - Automated scan time: ~90 seconds
 - Manual review (complex PRs only): ~15 min
 - PR turnaround: 4 hours average
 - Security findings per quarter: ~35 (caught in CI)
 - Missed findings (found in prod): 0 last quarter

Custom rules written: 47
 - Security patterns: 23
 - API usage enforcement: 12
 - Architecture guardrails: 8
 - Code quality: 4
```

The biggest win is not finding more bugs, it is freeing the security team to do actual threat modeling and architecture review instead of checking whether parameterized queries are used. The mechanical stuff is what machines are good at.

## Frequently asked questions

### What is Semgrep and how does it differ from ESLint or other linters?

Semgrep is a static analysis tool that matches code patterns using Abstract Syntax Tree (AST) analysis rather than text-based regex. Unlike ESLint which is JavaScript-specific, Semgrep supports 30+ languages with the same rule syntax. It also supports cross-function taint analysis for security vulnerability detection, which linters typically cannot do.

### What is taint analysis and when should I use it?

Taint analysis tracks how data flows from untrusted sources (user input, HTTP requests) through your code to dangerous sinks (SQL queries, shell commands, HTML output). Use it when you need to find injection vulnerabilities like SQL injection, XSS, or command injection that depend on data flow rather than individual code patterns.

### Can Semgrep replace human code review?

No. Semgrep excels at enforcing known patterns consistently, security anti-patterns, API usage rules, framework-specific best practices. But it cannot evaluate business logic correctness, architecture decisions, or code readability. Use it to automate the mechanical checks so human reviewers can focus on the hard stuff.

## Tips

1. **Tip:** Start with Semgrep's ` pattern-not ` before writing complex rules. Most security rules are really just 'find X unless Y is also present.' For example, ` fetch() ` without ` csrf_token ` catches 90% of CSRF issues with a two-pattern rule.
2. **Tip:** Semgrep's taint mode is where the real power is. Instead of pattern matching individual lines, you define ` sources ` (user input), ` sinks ` (dangerous functions), and ` sanitizers ` (escaping functions). Semgrep traces data flow between them across function boundaries. This catches injection bugs that grep-based tools completely miss.
3. **Tip:** Run ` semgrep --test ` against your custom rules with both positive and negative test cases. Teams that write rules matching too broadly flood PRs with false positives, killing developer trust in the tool within a week. Test rules like production code.

- semgrep
- ast
- code-review
- security
- static-analysis
- taint-analysis
- custom-rules
- ci-cd

[Back to Blog](<https://lucioduran.com/blog>)
