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.
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 scan | semgrep ci | |
|---|---|---|
| account | none needed | a Semgrep AppSec Platform token; rules and policy come from the organisation |
| rules | whatever --config names: a registry pack (p/default), a local file, or auto | the policies configured in the platform |
| scope | the paths given, every time | diff-aware on merge requests, full scans on the default branch |
| exit code on findings | 0 unless --error | non-zero by default (blocking findings) |
| analysis | single-file rules | adds cross-file and cross-function analysis |
| use it for | a self-hosted gate with pinned rules, and local runs | the managed service, with triage and policy in one place |
The job: pinned rules, an honest exit code, a report the host can read
sast:stage: testimage: semgrep/semgrep:1.177.0script:# 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 widgetwhen: alwaysrules:- 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:- id: no-fstring-sqllanguages: [python]severity: WARNING # start here; flip to ERROR when the backlog is gonemessage: >-SQL built with an f-string: use a parameterised query (cursor.execute(sql, params)).metadata:category: securitycwe: "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.
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=0semgrep scan --config rules/ --metrics off --error app/ >/dev/null; echo exit=$?exit=1semgrep scan --config rules/ --metrics off --severity ERROR --error app/; echo exit=$?exit=0all 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 setWARNING 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.
semgrep scan --config rules/ --metrics off --error app/ >/dev/null; echo exit=$?exit=13 findings: the full scan fails on the backlogsemgrep 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=1only 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 abortWhen the gate is wrong: recovery in order of preference
| Situation | Do | Do not |
|---|---|---|
| one true false positive | # nosemgrep: rule-id on the line, in the same merge request, with the rule named | a bare nosemgrep, which waives every rule on that line for good |
| a rule is noisy across the codebase | set its severity: WARNING again in a reviewed change: it stays in the report run and leaves the gate run | allow_failure: true on the job; the gate is then green on every finding, forever |
| a registry pack update breaks the gate | pin 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 only | delete --baseline-commit from the merge-request job: the backlog fails every merge request again |
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.