SAST in CI with Semgrep and custom rules

Add fast static analysis to your pipeline and write project-specific rules that catch your own recurring bugs.

Jan 28, 2026·Updated ·5 min readIntermediate·By SecOpsLog · command-tested

A static analysis gate is switched off by the third week when it fails a merge request for a finding the author cannot see, cannot fix in their change, or does not believe. Semgrep survives that period better than most because its rules read like the code they match and a team can write one in ten lines, but the tool is not what decides whether the gate lasts. The rollout does: which command runs, what it is allowed to fail on, and how legacy findings are kept out of the way of new ones.

`semgrep scan` and `semgrep ci` are different products in one binary

semgrep scansemgrep ci
accountnone neededa Semgrep AppSec Platform token; rules and policy come from the organisation
ruleswhatever --config names: a registry pack (p/default), a local file, or autothe policies configured in the platform
scopethe paths given, every timediff-aware on merge requests, full scans on the default branch
exit code on findings0 unless --errornon-zero by default (blocking findings)
analysissingle-file rulesadds cross-file and cross-function analysis
use it fora self-hosted gate with pinned rules, and local runsthe managed service, with triage and policy in one place

The job: pinned rules, an honest exit code, a report the host can read

.gitlab-ci.yml
sast:
stage: test
image: semgrep/semgrep:1.177.0
script:
# 1. the report: every severity, never fails the job
- semgrep scan --config p/default --config rules/ --metrics off
--gitlab-sast-output gl-sast-report.json . || true
# 2. the gate: only ERROR-severity rules, exit 1 on any finding
- semgrep scan --config p/default --config rules/ --metrics off
--severity ERROR --error .
artifacts:
reports: { sast: gl-sast-report.json } # findings appear in the MR widget
when: always
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"

p/default is a registry pack fetched at run time, so the image tag pins the engine and the pack name does not pin the rules; a team that wants a byte-for-byte reproducible gate vendors the rules it relies on into rules/ and drops the pack. --config auto is the convenient option the docs are candid about: it logs in to the registry with the project URL to choose rules, which is a data flow to know about before enabling it on a private repository. --error is what turns findings into a failed job; without it scan exits 0 and the gate is decorative. With it, every finding fails the job regardless of the rule's severity, so the gate run is filtered with --severity ERROR and the report run is not. Exit code 2 means Semgrep itself failed, which a pipeline should treat differently from 1.

The rule that pays for the whole programme

rules/no-fstring-sql.yaml
rules:
- id: no-fstring-sql
languages: [python]
severity: WARNING # start here; flip to ERROR when the backlog is gone
message: >-
SQL built with an f-string: use a parameterised query (cursor.execute(sql, params)).
metadata:
category: security
cwe: "CWE-89"
pattern-either:
- pattern: $CURSOR.execute(f"...")
- pattern: $CURSOR.execute(f"..." % ...)
- pattern: |
$Q = f"..."
...
$CURSOR.execute($Q)

The generic packs know about subprocess with shell=True; they do not know that this codebase builds SQL in a helper called run_query, or that a particular internal client must never be constructed without a timeout. A rule for a mistake the team has actually made twice has a false-positive rate the team already understands, and it is the rule developers stop arguing with. $CURSOR and $Q are metavariables, ... matches any statements between, and the third pattern catches the query assembled a few lines earlier, the way the bug is usually written.

bash — observed: the rule against a file with three f-string queries and one parameterised callobserved
semgrep scan --config rules/ --metrics off app/; echo exit=$?
┌─────────────────┐
│ 3 Code Findings │
└─────────────────┘
app/db/reports.py
❯❱ rules.no-fstring-sql
❰❰ Blocking ❱❱
SQL built with an f-string: use a parameterised query (cursor.execute(sql, params)).
7┆ cur.execute(f"select * from orders where region = '{region}'")
⋮┆----------------------------------------
11┆ cur.execute(f"select * from orders where region = '%s'" % region)
⋮┆----------------------------------------
15┆ q = f"select * from orders where region = '{region}'"
16┆ q = q.strip()
17┆ cur.execute(q)
Ran 1 rule on 1 file: 3 findings.
exit=0
semgrep scan --config rules/ --metrics off --error app/ >/dev/null; echo exit=$?
exit=1
semgrep scan --config rules/ --metrics off --severity ERROR --error app/; echo exit=$?
exit=0
all three shapes match, the parameterised call does not. The rule is WARNING, so the gate run (--severity ERROR --error) selects nothing and passes; the same rule flipped to severity: ERROR made it exit 1. The id is reported with its directory as a prefix, and "Blocking" is printed whether or not --error is set

WARNING first, baseline the rest, then flip

Forty-one findings on day one is the backlog, and the gate's job is to stop it growing while it is worked down. --baseline-commit compares against the target branch so a merge request fails only on findings it introduces. The CLI reference says the run aborts if the working tree has unstaged changes or the commit is missing; 1.177.0 did not abort on a modified tracked file. It created a git worktree from the baseline commit, scanned both sides, and reported the same finding, so the constraint is now softer than documented, and a CI checkout is clean anyway. New custom rules ship at WARNING, visible in the report run and excluded from the gate run, and move to ERROR in a reviewed change once a sprint of fixes has emptied their findings. Suppressions use # nosemgrep: rule-id with the rule named, so a diff review sees exactly what was waived; a bare nosemgrep waives every rule on the line.

bash — observed: --baseline-commit in a repository where the first commit already carried two findingsobserved
semgrep scan --config rules/ --metrics off --error app/ >/dev/null; echo exit=$?
exit=1
3 findings: the full scan fails on the backlog
semgrep scan --config rules/ --metrics off --error --baseline-commit HEAD~1 app/
Creating git worktree from 'HEAD~1' to scan baseline.
Will report findings introduced by these commits (may be incomplete for shallow checkouts):
* 3fe6112 adds the third finding
┌────────────────┐
│ 1 Code Finding │
└────────────────┘
app/db/reports.py
❯❱ rules.no-fstring-sql
❰❰ Blocking ❱❱
15┆ q = f"select * from orders where region = '{region}'"
16┆ q = q.strip()
17┆ cur.execute(q)
• Scan was limited to files changed since baseline commit.
exit=1
only the query the last commit assembled is reported; the two it inherited are not (the hash is from the recorded run; the fixture rebuilds the history each time). Repeated with an unstaged edit to reports.py: same output, exit 1, no abort

When the gate is wrong: recovery in order of preference

SituationDoDo not
one true false positive# nosemgrep: rule-id on the line, in the same merge request, with the rule nameda bare nosemgrep, which waives every rule on that line for good
a rule is noisy across the codebaseset its severity: WARNING again in a reviewed change: it stays in the report run and leaves the gate runallow_failure: true on the job; the gate is then green on every finding, forever
a registry pack update breaks the gatepin the pack by vendoring the rules the gate relies on into rules/ and drop p/default from the gate run (keep it in the report run)pin nothing and re-run until it passes
the baseline is wrong (a squash, a rebase, a shallow clone)compare against a commit that exists in the clone (GIT_DEPTH: 0 on the job) or run the full scan with --error on the default branch onlydelete --baseline-commit from the merge-request job: the backlog fails every merge request again
What was run for this article
Semgrep 1.177.0 (the semgrep/semgrep:1.177.0 image, metrics off, Docker Engine 28.5.2, linux/arm64) with the no-fstring-sql rule above against one Python file holding three f-string queries and one parameterised call, and against a two-commit git history built for the run. The terminal blocks marked observed are copied from that run; nine exit codes are asserted by the fixture script (with and without --error, the ERROR severity filter, the WARNING-to-ERROR flip, the full scan of the history, --baseline-commit on a clean and a dirty tree, nosemgrep and its finding count). The 41-finding estate, the p/default pack and the GitLab job are representative and were not executed; semgrep ci was not run. In the recovery table, the nosemgrep and severity rows are what the run showed; the pack pinning and baseline rows follow the documented model.
allow_failure: true is how a gate dies
The moment a scanner blocks a merge on a finding nobody can act on, someone adds allow_failure to the job, and the job runs red forever while everyone learns to ignore it. Fail only on what a merge request introduced, keep custom rules at WARNING until their backlog is zero, and give every suppression a rule id and a reviewer. A gate that is trusted at a small scope grows; one that is loud and wrong is removed.

SAST covers the code the team writes. The dependencies it pulls in are a separate scan, the secrets it might commit are gitleaks' job, and the image it ships in is Trivy's; each has its own baseline problem and its own reasons to be switched off, and the rollout discipline is the same for all four.

Related posts

Quick reference