Why Semgrep, Not Regex

The fundamental problem with grep-based security scanning is that code is a tree, not a string. Semgrep addresses this by operating on the Abstract Syntax Tree. Consider finding SQL injection in 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:

# 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:

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:

# 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:

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:

// 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:

# 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 Actions workflow:

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.