Semgrep is an open source static analysis (SAST) scanner that looks for dangerous constructs in your code based on rules written in YAML, readable by any developer. This guide takes you from installation to a tested custom rule, then to a CI job that really blocks. It is for a developer or a DevOps engineer who has understood the SAST principle and wants a concrete tool. All outputs were captured with Semgrep 1.178.0, including an example rule that the official test caught out.
What you will learn
- Install Semgrep and run a first scan of your code.
- Choose the registry rules (packs by language and by framework).
- Write your own rules and test them before deploying them.
- Integrate Semgrep into a CI/CD pipeline without blocking teams.
- Master the false positives and exclusions, including their trap.
What is Semgrep?
Semgrep reads your source code without running it and looks for
patterns that match vulnerabilities, bugs or bad practices. Its name comes
from "semantic grep": like grep, it searches, but it understands the
structure of the code. It knows that a variable is a variable and that a
function call stays the same whatever the formatting.
# Construct that Semgrep can recognise: an SQL query built by concatenationquery = "SELECT * FROM users WHERE id = " + user_inputcursor.execute(query)The general principle of SAST, sources, sinks and flow tracking, is presented in the SAST lesson. This guide focuses on the tool.
Why Semgrep rather than another scanner?
The main asset of Semgrep is the readability of its rules: a rule fits in a few lines of YAML that you reread in code review, where other tools impose a dedicated query language. It is also fast, in the order of a second on a small repository. The number of false positives, for its part, depends mostly on the rule set chosen: a registry that is too broad produces duplicates and off-topic rules, as the first scan below shows.
| Aspect | Semgrep |
|---|---|
| Rules | Readable YAML files, testable with semgrep --test |
| Speed | In the order of a second on a small repository |
| Languages | 30+ |
| False positives | Depend on the rule set: choose the one for your framework |
| Licence | Open source engine, LGPL 2.1 |
| Output | Text, JSON, SARIF, JUnit |
What Semgrep can detect
The registry rules cover three families of problems. Vulnerabilities
first: SQL and command injections, XSS, path traversal (accessing a file
outside the intended directory). Cryptographic problems next: MD5 or SHA-1
for passwords, insecure random generators, disabled TLS verification. Bad
practices finally, such as eval() or overly broad permissions.
Installation
Semgrep installs with a single command. In CI, prefer the official image pinned by digest; on the workstation, any method will do as long as the version is pinned, otherwise two developers will not see the same results.
Semgrep 1.178.0 requires Python 3.10 or newer (requires_python field of
the package published on PyPI). pipx installs it in an isolated
environment, without touching the system Python:
pipx install semgrep==1.178.0
# Verificationsemgrep --version# 1.178.0Homebrew installs a precompiled version:
brew install semgrep
# Verificationsemgrep --versionThe official image is now called semgrep/semgrep; the old name
returntocorp/semgrep dates from before the vendor's rename. Pin the version
and the digest:
IMG="semgrep/semgrep:1.178.0@sha256:32e459968daabe7ab86968184a29109b9564aa00392401156f9788452b42786b"docker run --rm -v "$PWD:/src" -w /src "$IMG" \ semgrep scan --config p/python --metrics=off .-v "$PWD:/src" mounts the current directory into the container, and -w /src
makes it the working directory, which the final . designates.
With asdf (in French), pin a precise version
rather than latest, which changes without warning:
asdf plugin add semgrepasdf install semgrep 1.178.0asdf set semgrep 1.178.0 # writes the version to the project's .tool-versions
semgrep --version# 1.178.0First scan: discovering Semgrep
The first scan is mostly useful to understand the output and the noise
it contains. The test file of this guide contains five calls to
cursor.execute, four of them vulnerable (concatenation, %, .format(),
f-string) and one parameterised, plus an eval().
Automatic mode, and its condition
The --config auto mode picks the rules based on the languages detected.
It has a little-known condition: it requires sending usage metrics to the
vendor. Measured: with --metrics=off, Semgrep refuses to start and exits with
code 2 and the message Cannot create auto config when metrics are off. In
an environment where telemetry is forbidden, name an explicit pack instead.
semgrep scan --config auto .On the test file, auto mode loads 1,074 rules and reports 8 findings on
five lines. The eval() is reported, but the four vulnerable queries are
flagged by SQLAlchemy rules even though the code uses sqlite3, with two
rules for each formatted query. The scan is useful, but it shows why a targeted
pack is better than such a wide net.
Understanding a result
Every finding follows the same structure. Here is the real output of
the eval rule presented further down in this guide, run with a shortened
message (the measurement used the French version of the rule, hence the
French message below):
┌────────────────┐│ 1 Code Finding │└────────────────┘
app.py ❯❱ rules.dangerous-eval-usage ❰❰ Blocking ❱❱ eval() exécute du code arbitraire.
11┆ return eval(expr)The identifier rules.dangerous-eval-usage is the rule name, prefixed by
the folder that contains it: it is what you will quote in a nosemgrep, which
also accepts the short form, without the prefix (measured). The Blocking
label indicates that the finding would make a scan run with --error fail. The
11┆ line gives the exact location, and the message is the one the rule
author wrote.
Severity levels
Semgrep classifies its rules into three severities, set by the author of each rule. They are used to build a graduated blocking policy, detailed in the SAST lesson: you block on ERROR first, and triage the rest.
| Level | Meaning | Recommended action |
|---|---|---|
| ERROR | Probably exploitable problem | Fix before the merge |
| WARNING | Potential problem, to investigate | Analyse and fix if confirmed |
| INFO | Best practice not followed | Suggested improvement |
The --severity ERROR option limits the scan to one severity. Measured with the
p/python pack on the Flask application of the SAST lesson: 5 findings
without the filter, one of them a WARNING, and 4 with the filter, all
ERROR. It is the simplest way to start with blocking restricted to the most
serious findings, before widening it.
Scanning with specific rules
auto mode helps to get started, but a chosen pack makes the scan
predictable: you know which rules run, and the results do not change
depending on the languages detected.
What is a rule pack?
A pack is a set of registry rules grouped by theme or by language, and
maintained by the vendor or the community. Its identifier starts with
p/. All the packs listed below were loaded without errors with Semgrep
1.178.0.
# Security, all technologiessemgrep scan --config p/owasp-top-ten .semgrep scan --config p/security-audit .
# Secrets (passwords, API keys)semgrep scan --config p/secrets .
# By languagesemgrep scan --config p/python .semgrep scan --config p/javascript .semgrep scan --config p/golang .semgrep scan --config p/java .
# Pack designed by the vendor for CIsemgrep scan --config p/ci .Packs add up when you repeat --config. On the test file, the packs do not
react in the same way: p/security-audit reports one finding where
p/python reports none on that same code, whereas the latter found five on the
Flask application of the SAST lesson. A single pack therefore does not
cover everything, hence the value of combining two, then writing your own
rules.
semgrep scan --config p/security-audit --config p/secrets .Using a specific registry rule
The public registry can be browsed at semgrep.dev/explore,
by language, category or keyword. Each rule shows its source code there,
which lets you check what it looks for before enabling it. The r/ prefix
designates an individual rule, a local path designates your own files.
# A single registry rulesemgrep scan --config "r/python.lang.security.audit.dangerous-system-call" .
# Your rules, in a folder of the repositorysemgrep scan --config rules/ .Writing your own rules
Registry rules know neither your internal functions nor your conventions: a custom rule detects the forbidden call in your codebase, for example a deprecated data access function. It is the main strength of Semgrep, and also its main risk: a wrong rule gives false assurance. Hence the section on tests, which is not optional.
Anatomy of a rule
Here is a rule that detects eval() in Python. Measured on the test file, it
does report the return eval(expr) line:
rules: - id: dangerous-eval-usage patterns: - pattern: eval($X) message: | Use of eval() detected. The function executes arbitrary Python code: if the argument comes from user input, an attacker can execute any code on the server. Alternatives: ast.literal_eval() for simple structures, json.loads() for JSON. severity: WARNING languages: - python metadata: category: security cwe: "CWE-95: Improper Neutralization of Directives"Each field has a precise role, and five of them are mandatory. $X is
a metavariable: it captures any expression, which the rule can then
reuse or constrain.
| Field | Mandatory | Description |
|---|---|---|
id | Yes | Unique identifier of the rule |
patterns or pattern | Yes | What Semgrep must look for in the code |
message | Yes | Explanation displayed when the rule matches |
severity | Yes | INFO, WARNING or ERROR |
languages | Yes | List of the languages concerned |
metadata | No | Additional information (CWE, references) |
An SQL injection rule, and the defect the test found
This rule looks for the four ways of building an SQL query from a string.
An earlier version of this guide added two
pattern-not: cursor.execute($QUERY, ...) exclusions to set aside
parameterised queries. Measured with semgrep --test: that version
missed the f-string case, the very one its message quoted as an example.
The exclusions were useless, since none of the positive patterns matches a
two-argument call, and harmful.
rules: - id: sql-injection-python pattern-either: # Formatting with % - pattern: cursor.execute($QUERY % ...) # Formatting with .format() - pattern: cursor.execute($QUERY.format(...)) # f-string - pattern: cursor.execute(f"...") # Concatenation - pattern: cursor.execute($QUERY + ...) message: | SQL query built by string concatenation or formatting: an attacker can inject SQL into it. Use a parameterized query, cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,)). severity: ERROR languages: - python metadata: category: security cwe: "CWE-89: SQL Injection"Measured on the test file, this version reports the four vulnerable lines
and ignores the parameterised query. The operators combine: pattern-either
matches if any one of the patterns is present, pattern-not excludes a
case, patterns requires all the listed patterns to be true.
Testing a rule before using it
semgrep --test is the unit test for rules. Next to the rule, you place a
code file with the same name, annotated: # ruleid: <id> above each line
that must be reported, # ok: <id> above the one that must not be.
def lire(cursor, user_id): # ruleid: sql-injection-python cursor.execute("SELECT * FROM users WHERE id = " + user_id) # ruleid: sql-injection-python cursor.execute(f"SELECT * FROM users WHERE id = {user_id}") # ok: sql-injection-python cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))semgrep --test rules/On the old version of the rule, the test fails and names the missed line:
missed lines: [5], the f-string. On the corrected version, it prints 1/1: ✓ All tests passed and exits with code 0. Run this test in CI on the
rules/ folder: a rule modified without its test is a control that nobody
checks anymore. The online playground remains
handy for a first draft, but it does not replace a versioned test.
Integration into the CI/CD pipeline
Semgrep shows its value when it runs on every code change and can
block the merge. The old semgrep-action is deprecated by its vendor, who
recommends running Semgrep in its official image: that is what the examples
below do. The place of SAST in the chain and the blocking strategy are covered
in the SAST lesson.
GitHub Actions
This workflow scans every pull request and every push to main, then
publishes the SARIF report in the repository's Security tab.
permissions: {} removes all default permissions; the job only gets
contents: read to clone and security-events: write for the SARIF upload.
name: Semgrep
on: pull_request: push: branches: - main
permissions: {}
jobs: semgrep: runs-on: ubuntu-24.04 permissions: contents: read security-events: write container: image: semgrep/semgrep:1.178.0@sha256:32e459968daabe7ab86968184a29109b9564aa00392401156f9788452b42786b steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false
- name: Semgrep scan run: semgrep scan --config p/security-audit --error --metrics=off --sarif --output semgrep.sarif .
- name: Upload SARIF to the Security tab if: always() uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 with: sarif_file: semgrep.sarifThree options carry the control. --error makes the job fail as soon as a
finding exists: without it, the scan exits with code 0 whatever it finds.
--sarif --output writes the report, and if: always() uploads it even
when the scan failed, that is, precisely when it found something.
GitLab CI
On GitLab, the job runs in the official image pinned by digest, on merge requests and on the default branch. The JSON report is kept for one week as an artifact, even on failure, for analysis.
semgrep: stage: test image: semgrep/semgrep:1.178.0@sha256:32e459968daabe7ab86968184a29109b9564aa00392401156f9788452b42786b script: - semgrep scan --config p/security-audit --error --metrics=off --json --output semgrep.json . artifacts: paths: - semgrep.json when: always expire_in: 1 week rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCHPre-commit: scanning before each commit
The pre-commit hook gives feedback even before the commit. The official
hooks now live in the semgrep/pre-commit repository, versioned like the
tool. The hook only receives the modified files, which keeps it fast.
repos: - repo: https://github.com/semgrep/pre-commit rev: v1.178.0 hooks: - id: semgrep args: ["--config", "p/ci", "--error", "--skip-unknown-extensions"]pipx install pre-commitpre-commit installEvery git commit then runs the scan, and a finding blocks the commit.
The hook can still be bypassed with a git commit --no-verify: it serves the
developer's comfort, the CI remains the guarantee. On a large
repository, keep a light pack such as p/ci for the hook and reserve the
broad packs for CI.
Handling false positives
A false positive is a finding on code that is not actually exploitable. The three mechanisms below go from the most targeted to the broadest; the most targeted is almost always the right one, because a broad exclusion also disables detection of the real flaws that will land in the same place.
Ignoring a specific occurrence
The nosemgrep comment goes on the line reported by the finding, naming
the rule. Measured in the SAST lesson: with the exact identifier, the
finding disappears; with the identifier of another rule, it stays. A bare
nosemgrep silences every rule on the line, to be kept for cases justified
in review.
# The expression comes from an allowlist validated by parse_expression()result = eval(validated_expression) # nosemgrep: rules.dangerous-eval-usageAlways add a justification comment that says why the alert is ignored: that is what the person who inherits the code a year from now will reread.
Excluding files or folders
The .semgrepignore file, at the root of the repository, excludes paths
with the syntax of a .gitignore. Its trap is measured: without this file,
Semgrep applies a default exclusion list that already leaves out tests/;
as soon as the file exists, it replaces that list. An empty file makes
Semgrep scan everything, a file that only lists generated/ suddenly makes it
scan the tests. So explicitly carry over the exclusions you want to keep.
# Tests (the default list excluded them: we keep that choice)tests/*_test.pytest_*.py
# Generated codegenerated/*.pb.go
# Vendored dependenciesvendor/node_modules/third_party/Excluding on the command line
The --exclude option sets a path aside for the duration of one scan,
without touching the shared .semgrepignore. Measured: --exclude qa does
remove the qa/ folder from the results. It serves a one-off diagnosis; a
lasting exclusion belongs in the versioned file, where it is reread in
review.
semgrep scan --config p/security-audit --exclude "tests" --exclude "*.test.js" .Progressive rollout strategy
Turning Semgrep on in blocking mode overnight on an existing repository makes every merge request fail because of the accumulated debt, and the team ends up disabling the control. The rollout is therefore done in stages, each with a measurable exit criterion.
-
Observe: run a full scan without blocking, and count.
Terminal window semgrep scan --config p/security-audit --metrics=off --json --output rapport-initial.json .python3 -c "import json;print(len(json.load(open('rapport-initial.json'))['results']), 'constats')"Triage a sample: how many real flaws, how much noise? The result tells you whether you need to change packs before going further.
-
Block what is new: in CI, only block the findings introduced by the merge request, with
--baseline-commit. Measured in the SAST lesson: the debt is ignored, the new flaw makes the job fail.Terminal window semgrep scan --config p/security-audit --error --baseline-commit "$(git merge-base origin/main HEAD)" . -
Pay off the debt: handle the initial report in a dated backlog, with a scheduled full scan, otherwise the baseline becomes a blind spot.
-
Block everything: once the debt is paid off, remove the baseline. Every finding blocks, and every exception goes through a justified
nosemgrep.
Output formats
Semgrep writes its results in the format that serves whoever reads the report. Text serves the terminal, JSON serves scripts, SARIF serves security dashboards, JUnit serves CI tools that display failed tests.
# Text (default)semgrep scan --config p/security-audit .
# JSON: every detail, for automated processingsemgrep scan --config p/security-audit --json --output resultats.json .
# SARIF 2.1.0: GitHub Security tab, GitLab dashboardsemgrep scan --config p/security-audit --sarif --output resultats.sarif .
# JUnit: a finding becomes a failed testsemgrep scan --config p/security-audit --junit-xml --output resultats.xml .Measured: JUnit produces a testsuite named semgrep results where each
finding counts as a failure, and SARIF declares the tool as Semgrep OSS in version 2.1.0 of the format.
Semgrep AppSec Platform (paid offering)
This whole guide uses the open source engine, which covers most of a team's needs: complete scanner, public registry, custom rules, every output format and CI integration. The vendor also sells the Semgrep AppSec Platform, which mainly adds three things:
- cross-file analysis and more precise proprietary rules;
- complementary products, Supply Chain (dependency analysis) and Secrets (detection with validation);
- a centralised dashboard: triage, assignment, history, Jira and Slack integrations, single sign-on.
Pricing and the limits of the free tier change regularly: check the vendor's page when you decide. Moving to the paid version makes sense when you manage dozens of repositories, when an audit requires a history of findings, or when the volume of false positives from the public registry costs more than the licences.
Limits of Semgrep
Semgrep remains a static analysis tool, with the blind spots of the family. It does not see what only appears at runtime: that is the role of DAST. It does not look for the known vulnerabilities of your dependencies: that is the role of SCA. And the open source engine follows data within a single file: a flow that crosses several files escapes the taint rules of this edition.
Coverage also varies by language: Python, JavaScript, Go and Java have many rules, less common languages far fewer. Finally, custom rules are code: they are tested, reviewed and maintained as the codebase evolves, otherwise they stop detecting what they promise.
Key points
- Semgrep looks for patterns in the code based on readable YAML rules, without running it.
--config automode requires metrics: with--metrics=off, name a pack.- A targeted pack,
p/<language>orp/ci, makes the scan predictable; a net that is too wide produces duplicates. - A custom rule is tested with
semgrep --testandruleid:andok:annotations. - Without
--error, the scan exits with code 0 and blocks nothing. - A
.semgrepignorereplaces the default exclusion list: carry overtests/if you want to exclude it. - You roll out in stages: observe, block what is new with
--baseline-commit, pay off the debt, block everything. - Semgrep complements DAST and SCA, it does not replace them.
Knowledge check
Check that you have the essentials of this guide. The questions only cover what has just been explained here.
Knowledge check
Check what you have learned with this interactive quiz
How it works
- The timer starts when you click Start
- Multiple choice, true/false and short answer questions
- You can move between questions
- Detailed results are shown at the end
Starts the quiz and the timer
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: SCA: analysing dependency vulnerabilities
A new full quiz with random questions
Work again only on the questions you missed
Resources
- Semgrep documentation: complete official guide
- Rule registry: explore the public rules
- Playground: draft a rule online
- Semgrep source code: repository and issue tracker