Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

Semgrep: static code analysis for security

By updated

Improve this page

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 concatenation
query = "SELECT * FROM users WHERE id = " + user_input
cursor.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.

AspectSemgrep
RulesReadable YAML files, testable with semgrep --test
SpeedIn the order of a second on a small repository
Languages30+
False positivesDepend on the rule set: choose the one for your framework
LicenceOpen source engine, LGPL 2.1
OutputText, 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:

Terminal window
pipx install semgrep==1.178.0
# Verification
semgrep --version
# 1.178.0

First 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.

Terminal window
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.

LevelMeaningRecommended action
ERRORProbably exploitable problemFix before the merge
WARNINGPotential problem, to investigateAnalyse and fix if confirmed
INFOBest practice not followedSuggested 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.

Terminal window
# Security, all technologies
semgrep scan --config p/owasp-top-ten .
semgrep scan --config p/security-audit .
# Secrets (passwords, API keys)
semgrep scan --config p/secrets .
# By language
semgrep 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 CI
semgrep 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.

Terminal window
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.

Terminal window
# A single registry rule
semgrep scan --config "r/python.lang.security.audit.dangerous-system-call" .
# Your rules, in a folder of the repository
semgrep 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/eval.yaml
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.

FieldMandatoryDescription
idYesUnique identifier of the rule
patterns or patternYesWhat Semgrep must look for in the code
messageYesExplanation displayed when the rule matches
severityYesINFO, WARNING or ERROR
languagesYesList of the languages concerned
metadataNoAdditional 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/sql-injection.yaml
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.

rules/sql-injection.py
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,))
Terminal window
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.

.github/workflows/semgrep.yml
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.sarif

Three 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.

.gitlab-ci.yml
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_BRANCH

Pre-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.

.pre-commit-config.yaml
repos:
- repo: https://github.com/semgrep/pre-commit
rev: v1.178.0
hooks:
- id: semgrep
args: ["--config", "p/ci", "--error", "--skip-unknown-extensions"]
Terminal window
pipx install pre-commit
pre-commit install

Every 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-usage

Always 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.

.semgrepignore
# Tests (the default list excluded them: we keep that choice)
tests/
*_test.py
test_*.py
# Generated code
generated/
*.pb.go
# Vendored dependencies
vendor/
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.

Terminal window
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.

  1. 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.

  2. 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)" .
  3. Pay off the debt: handle the initial report in a dated backlog, with a scheduled full scan, otherwise the baseline becomes a blind spot.

  4. 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.

Terminal window
# Text (default)
semgrep scan --config p/security-audit .
# JSON: every detail, for automated processing
semgrep scan --config p/security-audit --json --output resultats.json .
# SARIF 2.1.0: GitHub Security tab, GitLab dashboard
semgrep scan --config p/security-audit --sarif --output resultats.sarif .
# JUnit: a finding becomes a failed test
semgrep 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

  1. Semgrep looks for patterns in the code based on readable YAML rules, without running it.
  2. --config auto mode requires metrics: with --metrics=off, name a pack.
  3. A targeted pack, p/<language> or p/ci, makes the scan predictable; a net that is too wide produces duplicates.
  4. A custom rule is tested with semgrep --test and ruleid: and ok: annotations.
  5. Without --error, the scan exits with code 0 and blocks nothing.
  6. A .semgrepignore replaces the default exclusion list: carry over tests/ if you want to exclude it.
  7. You roll out in stages: observe, block what is new with --baseline-commit, pay off the debt, block everything.
  8. 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

6 questions
6 min
70% to pass

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

Resources

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn