HOWTOIntermediate

Catching Vulnerable Code Early with Semgrep Static Analysis in CI

Install Semgrep, run community rulesets, write custom rules, and gate pull requests in GitHub Actions so insecure code never merges.

Dylan H.

Tutorials

November 2, 2026
6 min read
Catching Vulnerable Code Early with Semgrep Static Analysis in CI

Introduction

Static application security testing (SAST) finds bugs such as SQL injection, hardcoded credentials, unsafe deserialization, and weak crypto by reading source code rather than running it. Semgrep is a fast, open-source SAST engine that matches code by syntax-aware patterns, so rules look like the code they find. That makes custom rules easy to write, and scans of typical repositories finish in seconds.

This guide takes you from a local install to a pull-request gate: running community rules, tuning noise, writing a custom rule, and enforcing results in CI.

Prerequisites

  • Python 3.9+ with pip, or Docker
  • A repository containing code in a supported language (Python, JavaScript/TypeScript, Go, Java, and many more)
  • GitHub Actions enabled for the repo

Step 1: Install Semgrep

# Using pipx (recommended - isolated install)
pipx install semgrep
 
# Or run it without installing anything
docker run --rm -v "$PWD:/src" semgrep/semgrep semgrep --version

Confirm the version:

semgrep --version

Step 2: Run Your First Scan

Use the community-maintained auto configuration, which selects rules for the languages it detects. Note that --config auto sends rule-selection metadata to semgrep.dev; for fully offline scans use explicit rule files (see Step 4).

cd /path/to/your/repo
semgrep scan --config auto

For a more focused, security-only pass use curated registry packs:

semgrep scan \
  --config p/security-audit \
  --config p/owasp-top-ten \
  --config p/secrets

Each finding shows the rule ID, file, line, and matched code.

Step 3: Triage and Reduce Noise

Start by exporting results so you can review them outside the terminal:

semgrep scan --config p/security-audit --sarif -o semgrep.sarif
semgrep scan --config p/security-audit --json -o semgrep.json

Exclude generated or vendored code with a .semgrepignore file:

node_modules/
vendor/
dist/
*.min.js
tests/fixtures/

For a confirmed false positive, suppress a single line with an inline comment and a reason:

# nosemgrep: python.lang.security.audit.dangerous-subprocess-use
subprocess.run(["ls", trusted_dir])  # trusted_dir is a hardcoded constant

Prefer narrow, commented suppressions over disabling a whole rule.

Step 4: Write a Custom Rule

Custom rules enforce your own conventions. Suppose developers must never call an internal legacy_query() helper with string formatting. Create .semgrep/rules.yml:

rules:
  - id: no-fstring-in-legacy-query
    languages: [python]
    severity: ERROR
    message: >-
      legacy_query() must use parameterized arguments, not f-strings
      or string concatenation (SQL injection risk).
    pattern-either:
      - pattern: legacy_query(f"...")
      - pattern: legacy_query($A + $B)
      - pattern: legacy_query("..." % $X)
      - pattern: legacy_query("...".format(...))
    metadata:
      cwe: "CWE-89"
      category: security

Run only your rules (works fully offline):

semgrep scan --config .semgrep/rules.yml --error

Test rules with annotated sample code in .semgrep/rules.py, using # ruleid: and # ok: comments above lines that should and should not match:

# ruleid: no-fstring-in-legacy-query
legacy_query(f"SELECT * FROM users WHERE id = {uid}")
 
# ok: no-fstring-in-legacy-query
legacy_query("SELECT * FROM users WHERE id = %s", (uid,))
semgrep test .semgrep/

Step 5: Gate Pull Requests in GitHub Actions

Create .github/workflows/semgrep.yml:

name: Semgrep
 
on:
  pull_request:
  push:
    branches: [main]
 
permissions:
  contents: read
  security-events: write
 
jobs:
  semgrep:
    runs-on: ubuntu-latest
    container:
      image: semgrep/semgrep:latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
 
      - name: Run Semgrep
        run: |
          semgrep scan \
            --config p/security-audit \
            --config p/secrets \
            --config .semgrep/rules.yml \
            --severity ERROR \
            --error \
            --sarif -o semgrep.sarif
 
      - name: Upload SARIF
        if: always()
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: semgrep.sarif

Key flags: --error makes the job fail when findings exist, and --severity ERROR limits blocking to high-confidence issues so developers are not buried in warnings. Pin the container image to a specific version or digest for reproducible results, and make the check required in your branch protection rules.

To scan only changed code on pull requests and keep builds fast, add --baseline-commit "$(git merge-base origin/main HEAD)" to the command.

Step 6: Add a Pre-Commit Hook (Optional)

Catch issues before they are even committed:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/semgrep/pre-commit
    rev: "v1.100.0"
    hooks:
      - id: semgrep
        args: ["--config", ".semgrep/rules.yml", "--error"]
pre-commit install

Check the repository's releases page and pin rev to the current tag.

Verification

  1. Introduce a deliberate flaw on a throwaway branch:

    import hashlib
    digest = hashlib.md5(password.encode()).hexdigest()
    legacy_query(f"SELECT * FROM users WHERE name = '{name}'")
  2. Run semgrep scan --config p/security-audit --config .semgrep/rules.yml --error and confirm a non-zero exit code (echo $?) with findings for both lines.

  3. Open a pull request and confirm the Semgrep check fails and findings appear under the repository's Security > Code scanning tab.

  4. Fix the code, push, and confirm the check turns green.

  5. Run semgrep test .semgrep/ and confirm all rule tests pass.

Troubleshooting

SymptomLikely CauseFix
0 findings on code you know is vulnerableLanguage not detected or path ignoredRun with --verbose; check .semgrepignore and file extensions
Scan is slow on a large monorepoScanning vendored or generated codeAdd exclusions, use --baseline-commit, or raise --jobs
Too many false positivesBroad rulesets or low-confidence rulesUse --severity ERROR, trim packs, add narrow nosemgrep comments
Custom rule never matchesPattern too literal or wrong languageTest in the Semgrep Playground; use metavariables like $X and ...
CI fails with network errors fetching p/... packsRunner is offline or blockedVendor rules into the repo and use file paths instead
SARIF upload fails with permission errorMissing security-events: writeAdd the permission, and note private repos may need code scanning enabled
Exit code 7 or config errorsInvalid YAML in a rule fileRun semgrep scan --validate --config .semgrep/rules.yml

Summary

Semgrep gives you readable, fast static analysis without heavyweight tooling. You installed it, ran curated security rulesets, triaged noise with ignores and scoped suppressions, wrote and tested a custom rule, and enforced results on every pull request through GitHub Actions with SARIF reporting. Start with blocking only ERROR severity findings, review warnings periodically, and grow your custom rule library as you learn which mistakes recur in your codebase. Pair it with secrets scanning and dependency auditing for broader coverage across your pipeline.