SAST & code scanning

Find bugs in your own source.

Intermediate12 min · lesson 8 of 17

A developer opens merge request !219 to add an order-search endpoint. To filter by user they wrote "SELECT * FROM orders WHERE user='" + params[:user] + "'" in app/models/order.rb, pasting whatever a visitor types straight into a database query. Two busy reviewers are one click from approving it. Then, before any human reads a line, the pipeline's semgrep-sast job flags line 42: "SQL Injection, CWE-89, High." The finding shows up in the merge request's security widget with the exact file and line. (CWE-89 is the catalogue number that the Common Weakness Enumeration, a public list of software bug types, gives to SQL injection.) That is code scanning earning its keep: a flaw caught seconds after the push, in the earliest stage of the pipeline, while it still costs one commit to fix.

What "static" actually means

A building inspector can read the blueprints at a desk and spot that the fire exit opens onto a brick wall. Nobody has to set foot on the site. SAST (Static Application Security Testing) works the same way. It reads your source code without running it. The analyzer parses each file into an abstract syntax tree, or AST, which is a structural map of the code rather than a wall of text, then matches security patterns against that map. The sharpest rules trace *taint*, the way a plumber drops dye into one pipe and watches which tap it comes out of. Untrusted data starts at a *source* (a request parameter, an environment variable) and the rule follows it into a dangerous *sink* (a SQL string, a shell command, a deserializer). Nothing executes, so there is no database to stand up, no server to boot, no compiled build to wait for. The checked-out repository is the entire input. That is why the scan sits in the earliest test stage, and why it catches the bug classes only your own source can reveal: injection, credentials hardcoded into logic, unsafe deserialization, path traversal, and weak-crypto calls.

Reading without running is also the ceiling. The analyzer sees syntax and dataflow, not behavior, so it often cannot tell that a value was scrubbed clean three function calls earlier. That blind spot is where *false positives* come from: findings that match a pattern but cannot actually be exploited. Switching the tool on takes one line of YAML. Living with the false positives is the part that takes real skill.

Switch it on: one include, no build

GitLab maintains the SAST template for you, so turning the scan on is an include, not a job you hand-write. The template defines a semgrep-sast job that works out which languages your repo contains and loads the matching Semgrep ruleset. In GitLab 17.x the old zoo of one analyzer per language was folded onto a single Semgrep engine, so that one job covers Ruby, Python, JavaScript, Go, Java and more. Tune it with the documented CI/CD (continuous integration and continuous delivery) variables instead of redefining the job. Set SAST_EXCLUDED_PATHS to skip test fixtures, or SAST_EXCLUDED_ANALYZERS to drop an engine you have no use for.

.gitlab-ci.yml
stages:
- test
include:
- template: Security/SAST.gitlab-ci.yml
# Tune the built-in analyzer without redefining the job
variables:
SAST_EXCLUDED_PATHS: "spec, test, tests, tmp"
SAST_EXCLUDED_ANALYZERS: "spotbugs"

Push the branch, open the merge request, and the job runs in the test stage. Watch what it never does: no bundle install, no compiler, no build of any kind. It reads the files exactly as they sit in the checkout. That is why it finishes in seconds, and why it still reports on a branch too broken to build:

Job log: semgrep-sast
$ /analyzer run
[INFO] [semgrep] GitLab semgrep analyzer v5.7.1
[INFO] [semgrep] Detected project languages: ruby
[INFO] [semgrep] Loading rules for ruby (247 rules)
[INFO] [semgrep] Running scan (source only, no build step)
[INFO] [semgrep] Scan complete: 1 file with findings, 1 finding (1 High)
[INFO] [semgrep] Writing report to gl-sast-report.json
Uploading artifacts...
gl-sast-report.json: found 1 matching files and directories
Job succeeded

Read that last line again. Job succeeded, with a High-severity SQL injection sitting in the report. The template gives the job allow_failure: true, so a finding *reports* and the pipeline stays green. Blocking a merge is a separate decision you make on purpose (see gating, below).

The gl-sast-report.json artifact

Every run writes gl-sast-report.json, declared in the template as artifacts: reports: sast:. It is the scanner's incident form, filled in the same way every time so a machine can read it, following GitLab's security-report schema. GitLab ingests the file, compares each finding against what already sits on the target branch, and renders only the *new* ones in the merge request widget. A legacy backlog of four hundred old findings therefore cannot bury the one line this change introduced. Here is the finding from the example above:

gl-sast-report.json (excerpt)
{
"version": "15.2.1",
"scan": {
"analyzer": { "id": "semgrep", "name": "Semgrep", "version": "1.86.0" },
"type": "sast",
"status": "success"
},
"vulnerabilities": [
{
"id": "9f2c1e7a5b3d4c6e8f10a2b4c6d8e0f2",
"name": "Improper neutralization of SQL element (SQL Injection)",
"severity": "High",
"location": {
"file": "app/models/order.rb",
"start_line": 42,
"end_line": 42
},
"identifiers": [
{ "type": "semgrep_id",
"name": "ruby.rails.security.sql-injection",
"value": "ruby.rails.security.sql-injection" },
{ "type": "cwe", "name": "CWE-89", "value": "89",
"url": "https://cwe.mitre.org/data/definitions/89.html" }
]
}
]
}

The location block is what paints the annotation onto the line in the diff. The identifiers block hands you the two handles triage runs on: the CWE number for classifying how bad the bug class is, and the semgrep_id for allow-listing one specific rule. You do not have to take the widget's word for any of it. Pull the raw report out of the job artifact through the API (the URL endpoints GitLab exposes for scripts):

shell — verify the report from CI
curl --silent --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.com/api/v4/projects/42/jobs/artifacts/feature%2Forder-search/download?job=semgrep-sast" \
--output artifacts.zip
unzip -p artifacts.zip gl-sast-report.json \
| jq '[.vulnerabilities[] | {sev: .severity, file: .location.file, line: .location.start_line}]'
Output
[
{
"sev": "High",
"file": "app/models/order.rb",
"line": 42
}
]

Triage: allow-list without going blind

A brand-new scanner is loud, like a smoke alarm that shrieks every time you make toast. A loud scanner teaches developers to scroll past it, and a scanner everyone ignores is worse than no scanner at all, because you get the paperwork of security without the security. So run it report-only at first. Let findings surface, work through them one by one, and silence the confirmed false positives with the smallest instrument that does the job. A finding in a test fixture that never reaches production? Put that path in SAST_EXCLUDED_PATHS. One rule that misfires all over your codebase? Switch off that single rule id in .gitlab/sast-ruleset.toml and leave the other 246 rules running. Never disable the whole analyzer to quiet one line. That trades every scrap of coverage you have for a single suppression.

.gitlab/sast-ruleset.toml
[semgrep]
[[semgrep.ruleset]]
disable = true
[semgrep.ruleset.identifier]
type = "semgrep_id"
value = "ruby.lang.correctness.useless-eqeq.eqeq-is-bad"
A green pipeline does not mean a gated merge request
The SAST template ships with allow_failure: true, so findings appear in the widget while the pipeline stays green and the merge button stays live. Assume that a switched-on scanner gates your merges and a Critical finding will walk straight through. Gating is its own control: a Merge Request Approval Policy (formerly the Scan Result Policy), configured under Secure > Policies, which requires an approval when a new finding at or above a severity you choose appears. Gate there, on the evidence in the report. Do not bolt allow_failure: false onto the job, because that fails the pipeline on every finding, including the false positives nobody has triaged yet.

In production: forks, scale, and gating on evidence

Open-source and cross-team repos add a trust problem. A merge request from a *fork* (a contributor's own copy of your project) runs its pipeline in that contributor's project by default, so their gl-sast-report.json is never pulled into your merge request's security widget. An outside contribution can sail past your SAST entirely unless you configure the project to run pipelines for fork merge requests. That setting carries a bill: it runs untrusted contributor code on your runners. Pair it with isolated, throwaway runners (covered in the runner-isolation lesson) rather than the privileged ones that hold your deploy credentials. As the repo grows, keep scan time bounded by listing vendored and generated directories in SAST_EXCLUDED_PATHS, and lean on the branch-versus-merge-request deduplication so reviewers only ever see what this change added.

Keep the scanner's blind spot in front of you. It reads the logic you wrote, which means it has nothing to say about a password pasted into a commit, a vulnerable library pinned in your lockfile, or a flaw that only appears once the app is serving traffic. The next stage closes the first of those gaps. Secret detection rakes through your commits and your git history for credentials, a hunt Semgrep's code patterns were never built for.

Diagram
New SAST finding on the merge request
gl-sast-report.json rendered in the security widget
Confirmed exploitable, High or Critical
Fix it in this merge request
Approval policy requires sign-off before merge
False positive
Allow-list precisely
Disable the semgrep_id in sast-ruleset.toml, or exclude the path
Real but accepted risk
Dismiss with a reason
Recorded on the vulnerability, audited and reversible
Low severity, not yet reviewed
Keep it report-only
Tracked in the widget; pipeline stays green
Quick check
01A SAST run flags a raw SQL string inside a test fixture that never ships to production. The rule itself is a good one, and it catches real injections elsewhere in the app. What do you do?
Incorrect — That drops SAST coverage across every language in the repo to quiet one file, the over-correction that leaves real injections unscanned.
Correct — This is the narrowest lever: the noisy location goes quiet while the rule keeps scanning production code, so people still trust what the scanner tells them.
Incorrect — That blocks a merge on a confirmed false positive, and that is the noise that gets scanners bypassed with [skip ci] inside a sprint.
Incorrect — The report is written fresh on every run, so the edit is gone by the next pipeline, and hand-editing evidence hides nothing.
02The semgrep-sast job never runs bundle install, never calls a compiler, never builds anything, and it still finds the SQL injection. How can SAST work without a build?
Correct — "Static" means it reads the source as structure, which is why it runs in seconds in the early test stage, even on a branch that would not compile.
Incorrect — No. SAST works on the checked-out repository alone; it neither needs nor fetches a built artifact.
Incorrect — No. Running the app to watch its behavior is dynamic testing. SAST reads without running, which is exactly where its blind spot comes from.
Incorrect — No. Reading lockfiles for vulnerable versions is dependency scanning (SCA), a different stage. SAST reads the source you wrote.
03Your semgrep-sast job logs 'Job succeeded' even though it reported a High-severity SQL injection, and the merge request is still mergeable. You want any new High or Critical finding to actually stop the merge. How do you set that up?
Incorrect — No. That fails the pipeline on every finding, untriaged false positives included, which teaches people to route around the scanner.
Correct — Gating is a separate control that acts on the evidence in the report, so you block the findings that matter without failing on untriaged noise.
Incorrect — No. The report regenerates on every run, so the edit disappears, and editing evidence changes nothing about the code.
Incorrect — No. The template runs with allow_failure: true, so findings report but never block, and a Critical can walk straight through.

Try this

Work through “In production: forks, scale, and gating on evidence” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: a green pipeline does not mean a gated merge request. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related