Semgrep
.mdVerified 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 releaseUpgrading 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 == $Xmatchesa == a,foo() == foo(), or1+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-auditorp/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 neededp/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 fixturesTest 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-passwordIgnoring 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.tssemgrep --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 insteadCI-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 TTYReal-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-tensemgrep 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 rulesThis 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 autoas fully offline — it logs the project's remote URL to Semgrep's servers to select tailored rules; use a pinnedp/<ruleset>id instead if that's a concern. .semgrepignoresilently winning over.gitignore— if a path is scanned when you expected it to be ignored (or vice versa), check.semgrepignorefirst; it overrides.gitignoreon conflict.- 5-second per-rule-per-file timeout —
--timeoutdefaults 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.