Verified15 commandsAI-assisted

Semgrep

.md

Verified against Semgrep 1.175.0, flags verified via `semgrep scan --help` run locally; rule syntax and · official docs

What Semgrep is and where it fits#

Semgrep is a static application security testing (SAST) engine that matches source code against pattern-based rules — closer to "grep that understands syntax" than a traditional AST-walking analyzer, which is exactly why custom rules are approachable: a rule pattern looks almost like the vulnerable code itself. It runs entirely locally by default (nothing leaves the machine unless you explicitly pull rules from the Semgrep Registry or opt into the paid platform), which makes it a natural first SAST tool to adopt — no account, no server, no license negotiation before a team gets real findings.

In the shift-left pipeline, Semgrep sits at the code stage: it runs against source before a build even exists, ideally as a pre-commit hook and again as a required CI check on every PR. It complements, rather than replaces, a dependency scanner (Snyk/Trivy in this series) and a secrets scanner (Gitleaks/TruffleHog) — Semgrep finds vulnerabilities you wrote, not vulnerable dependencies you pulled in or secrets you leaked.

Installation#

pipx install semgrep              # recommended — isolated, no dependency conflicts with other Python tools
brew install semgrep               # macOS/Linux Homebrew, best-effort
uv tool install semgrep            # if you already use uv
docker pull semgrep/semgrep        # no local install at all

semgrep --version                  # confirm install
semgrep --disable-version-check --version    # skip the network call to check for a newer release

Upgrading follows the same path you installed with: pipx upgrade semgrep, brew upgrade semgrep, etc. Semgrep ships new releases roughly weekly — pin a version in CI (pipx install semgrep==1.175.0) rather than floating on latest, so a rule-engine change doesn't silently start failing (or silently stop catching) things on an unrelated commit.

Core concepts#

  • Pattern — a code snippet with metavariables ($X, $FUNC, ...) that matches structurally, not textually. $X == $X matches a == a, foo() == foo(), or 1+1 == 1+1 — anywhere the same expression is compared to itself, regardless of formatting or variable naming.
  • Rule — a YAML document combining one or more patterns with metadata (severity, message, languages) and, optionally, an fix: autofix template.
  • Ruleset — a curated bundle of rules published to the Semgrep Registry, referenced by a short id like p/security-audit or p/owasp-top-ten.
  • Taint tracking — a rule mode (pattern-sources / pattern-sinks / pattern-sanitizers) that traces untrusted data from where it enters the program to where it's used dangerously, catching injection bugs a purely syntactic pattern would miss because the tainted value passes through several functions first.

Running a scan#

semgrep --config auto .                        # auto-fetch rules tailored to this project (logs project URL to Semgrep)
semgrep --config=p/security-audit .             # a specific registry ruleset, no login required
semgrep --config=p/owasp-top-ten .              # maps findings to OWASP Top 10 categories — useful for compliance reporting
semgrep --config=p/ci .                         # curated CI-friendly ruleset (lower noise than security-audit)
semgrep --config=p/secrets .                    # community secrets-detection rules (see Snyk/Gitleaks pages for dedicated tools)
semgrep --config path/to/rules.yaml .           # local/custom rule file or directory
semgrep --config p/default --config p/security-audit --config p/owasp-top-ten .   # combine multiple rulesets in one run
semgrep -e '$X == $X' --lang python .           # one-off pattern search, no rule file needed

p/security-audit and p/owasp-top-ten differ in intent, not just size: security-audit is broader, moderate-confidence coverage meant to surface more candidates for manual review; owasp-top-ten is explicitly mapped to the ten OWASP categories, which is what a compliance-driven team cites when asked "how do you cover the OWASP Top 10." Most teams start with p/ci (deliberately tuned for low noise) and graduate to security-audit once the initial backlog of findings is triaged.

Rule syntax — writing a custom rule#

A minimal rule needs exactly one of pattern, patterns, pattern-either, or pattern-regex at the top level, plus id, message, severity, and languages:

# rules/hardcoded-secret.yaml
rules:
  - id: hardcoded-db-password
    message: >
      Database password appears to be hardcoded. Load it from an environment
      variable or a secrets manager instead.
    severity: ERROR
    languages: [python]
    pattern: connect(host=..., password="...")

Combining patterns with boolean logic (patterns, pattern-either, pattern-not) is what most real rules need — a single pattern is rarely precise enough on its own:

rules:
  - id: flask-debug-true
    message: Flask app running with debug=True — remote code execution risk in production.
    severity: ERROR
    languages: [python]
    patterns:
      - pattern: app.run(..., debug=True, ...)
      - pattern-not-inside: |
          if __name__ == "__main__":
              ...

Taint tracking for injection-class bugs (the pattern a pure syntactic match can't express, because the dangerous value passes through intermediate variables/functions):

rules:
  - id: sql-injection-via-request-param
    message: Untrusted request data flows into a raw SQL query.
    severity: ERROR
    languages: [python]
    mode: taint
    pattern-sources:
      - pattern: request.args.get(...)
    pattern-sinks:
      - pattern: cursor.execute(...)
    pattern-sanitizers:
      - pattern: escape_sql(...)

Autofix — a fix: template gets applied with --autofix:

rules:
  - id: use-secrets-compare-digest
    message: Use secrets.compare_digest for constant-time comparison of secrets.
    severity: WARNING
    languages: [python]
    pattern: $A == $B
    fix: secrets.compare_digest($A, $B)

Validating and testing custom rules#

semgrep --validate --config myrule.yaml         # lint a rule file before using it — catches YAML/schema errors early
semgrep --test --config myrule.yaml             # run the rule's test fixtures

Test fixtures live alongside the rule as <rule-id>.<lang> files using inline annotations:

# hardcoded-db-password.py
connect(host="db", password="hunter2")  # ruleid: hardcoded-db-password
connect(host="db", password=os.environ["DB_PASSWORD"])  # ok: hardcoded-db-password

Ignoring files and folders#

Semgrep excludes untracked files already covered by .gitignore, plus a small built-in default-ignore list (binaries, .git/, node_modules/, etc.). A .semgrepignore file at the repo root adds project-specific exclusions using the same glob syntax as .gitignore — and takes precedence over .gitignore when the two disagree (something .gitignore includes but .semgrepignore excludes will not be scanned):

# .semgrepignore
tests/fixtures/
vendor/
*.generated.ts
semgrep --config=p/security-audit . --exclude '*.min.js' --exclude tests/    # CLI-level exclusion, same run
semgrep --config=p/security-audit . --include 'src/**/*.py'                   # scan only a subset instead

CI-oriented flags and gating#

semgrep --config=p/security-audit --error .    # exit 1 on any finding — for pipeline gating
semgrep ci                                      # CI-aware mode: diff-aware, reads SEMGREP_APP_TOKEN if set
semgrep --baseline-commit=main .                # only report findings not present in this commit — adopt on a legacy codebase without a wall of pre-existing noise
semgrep --config=p/security-audit --severity ERROR .     # ERROR, WARNING, or INFO

--error is what turns Semgrep from "prints findings, exits 0" into "fails the build" — without it, a scan with real findings still exits 0 and a CI step silently passes.

Output formats#

semgrep --config=p/security-audit --json -o results.json .
semgrep --config=p/security-audit --sarif -o results.sarif .   # for GitHub code scanning upload
semgrep --config=p/security-audit --gitlab-sast .               # GitLab SAST report format
semgrep --config=p/security-audit --junit-xml -o results.xml .
semgrep --config=p/security-audit --text .                      # human-readable, default when writing to a TTY

Real-world scenario: gating a PR on new findings only#

A team adopting Semgrep on a five-year-old codebase can't realistically fix every existing finding before merging their first PR. Baseline against the target branch instead of the whole tree:

git fetch origin main
semgrep --config=p/security-audit --baseline-commit=origin/main --error .

This reports (and fails the build on) only findings introduced by the current branch — pre-existing issues on main are silently excluded from the gate, so the team can fix the backlog on its own timeline while still blocking new problems immediately.

Real-world scenario: GitHub Actions CI gate#

# .github/workflows/semgrep.yml
name: Semgrep
on: [pull_request]
jobs:
  semgrep:
    runs-on: ubuntu-latest
    container:
      image: semgrep/semgrep
    steps:
      - uses: actions/checkout@v4
      - run: semgrep ci --config=p/security-audit --config=p/owasp-top-ten

semgrep ci (rather than semgrep scan) is purpose-built for this: it's diff-aware against the PR's base branch automatically, and if SEMGREP_APP_TOKEN is set it also posts results back to the Semgrep platform for trend tracking across the repo.

Secrets scanning (Semgrep Secrets, needs Pro/registry access)#

semgrep --config=p/secrets .              # community secrets rules, no login required
semgrep --secrets .                       # full Secrets product with live validation — requires a paid plan
semgrep --historical-secrets .            # scan git history, not just the working tree, for secrets rules

This overlaps with Gitleaks/TruffleHog (see their dedicated cheat sheets) — the community p/secrets ruleset is a reasonable free baseline, but a dedicated secrets scanner with live verification is generally more precise for that specific job.

Common pitfalls#

  • Forgetting --error — the most common "why didn't this fail CI" report. Semgrep's default exit code ignores findings entirely.
  • Treating --config auto as fully offline — it logs the project's remote URL to Semgrep's servers to select tailored rules; use a pinned p/<ruleset> id instead if that's a concern.
  • .semgrepignore silently winning over .gitignore — if a path is scanned when you expected it to be ignored (or vice versa), check .semgrepignore first; it overrides .gitignore on conflict.
  • 5-second per-rule-per-file timeout--timeout defaults to 5.0s; a very large generated file can silently get skipped rather than scanned. Raise it (--timeout 30) rather than assuming a clean scan of such a file actually happened.

Exit codes#

0 OK (clean, or findings exist but --error not set) · 1 findings found (with --error) · 2 fatal error · 3 invalid target code · 4 invalid pattern · 5 unparseable YAML rule file · 7 missing configuration · 8 invalid language · 13 invalid API key.

When to reach for something else#

Semgrep covers first-party source code (SAST). For dependency vulnerabilities, reach for Snyk or Trivy's filesystem scan; for container images, Trivy or Grype; for IaC misconfigurations, Checkov or tfsec (or trivy config); for leaked credentials, Gitleaks or TruffleHog. All are documented as their own cheat sheets in this Security & Compliance section.