SAST (Static Application Security Testing) reads the source code of an application without running it and spots the flaws that you wrote yourself: injections, unvalidated data, dangerous calls. This lesson is for a DevOps engineer or a developer who wants to understand what a SAST scanner really sees, what it does not see, and how to place it in a pipeline without drowning the team in alerts. The examples rely on Semgrep 1.178.0, run against a small, deliberately flawed Flask application.
What you will learn
- Understand how a scanner follows data from its source to its sink
- Distinguish what SAST, SCA and DAST see, and what escapes them
- Interpret a real SAST result and spot the noise it contains
- Place SAST in a pipeline with graduated blocking
- Handle a false positive without disabling the control for everyone
- Link this control to requirement SOCLE-APP-AST-1 (in French) of the SOCLE framework
What is SAST?
SAST is a static analysis: the tool reads the code like a very patient reviewer, without launching the application. It answers a precise question, "does my code contain dangerous constructs?", and that precision defines both its strength and its limits. It sees all the code, including a branch never executed by the tests, but it sees neither the running application nor the code of the libraries it imports.
How does a SAST scanner reason?
A modern scanner does not just look for words in the text. It builds a representation of the program, then follows the data path. The vocabulary is simple: a source is an entry point controlled from the outside (an HTTP parameter), a sink is a dangerous call (an SQL query, a system command), and taint tracking consists in checking whether data "tainted" by a source reaches a sink without being sanitised.
The diagram shows this reasoning on the SQL injection case: the data starts from an HTTP parameter, goes through a variable, and only produces a finding if it reaches the sink without having been sanitised along the way.
-
Parsing: the code becomes a syntax tree (AST), which makes it possible to recognise a concatenation or a function call whatever the formatting.
-
Pattern matching: each rule describes a dangerous construct, for example "a call to
subprocesswithshell=True". -
Flow tracking: for taint-type rules, the tool links the source to the sink through the intermediate variables.
-
Report: each finding gives the rule, the file, the line, a severity and often a hint for the fix.
What does SAST find, and what does it miss?
SAST excels at flaws that have a recognisable shape in the code: external data that ends up in a query, an obsolete hash function, uncontrolled deserialisation. On the other hand, it is structurally blind to anything that cannot be written as a local construct: a forgotten access control in the business logic, a flaw in a dependency, a misconfiguration of the production server.
| SAST sees well | SAST does not see |
|---|---|
| SQL, command, path injections, server-side XSS | A missing access control in the business logic |
| Weak cryptography (MD5, SHA-1, ECB mode) | Flaws in third-party dependencies |
| Deserialisation of untrusted data | The real configuration of the deployed server |
Dangerous system calls (shell=True, eval) | What depends on execution (session, timing) |
SAST, SCA, DAST: who sees what?
These three families of controls complement each other because they look at three different objects. SAST reads your code, SCA (Software Composition Analysis) inventories the libraries you import and checks them against published vulnerabilities, DAST attacks the running application. The Equifax breach in 2017, with 147 million people exposed, illustrates this well: it went through a known flaw in Apache Struts, a dependency that had not been updated. It is a textbook case for SCA, not for SAST, which does not read the Struts code.
| Control | What it reads | When | Example of a flaw found |
|---|---|---|---|
| SAST | your source code | from the commit | SQL injection in your route |
| SCA | your dependencies | from the commit | vulnerable Struts (Equifax) |
| DAST | the running application | after a test deployment | missing security header, exploitable flaw |
What does a real SAST result look like?
Nothing replaces a real scan to understand the job. The test application
fits in two Flask routes: /user builds an SQL query by concatenation,
/ping passes a parameter to a system command with shell=True. The scan uses
the p/python rule set from the Semgrep registry, downloaded at launch,
and the official image pinned by digest. The --metrics=off option stops the
sending of usage statistics to the vendor.
IMG="semgrep/semgrep:1.178.0@sha256:32e459968daabe7ab86968184a29109b9564aa00392401156f9788452b42786b"docker run --rm -v "$PWD:/src" -w /src "$IMG" \ semgrep scan --config p/python --metrics=off .The scan reports five findings for two real flaws:
| Line | Rule | Severity |
|---|---|---|
| 9 | sql-injection-db-cursor-execute (Django rule) | WARNING |
| 11 | tainted-sql-string (Flask rule, taint) | ERROR |
| 19 | subprocess-injection | ERROR |
| 19 | dangerous-subprocess-use | ERROR |
| 19 | subprocess-shell-true | ERROR |
This table already holds the two lessons of the job. First, several rules report the same flaw: three findings for a single line 19. Second, a rule written for Django fires on Flask code: it is right on substance, but its title is misleading. Triage therefore starts even before talking about false positives, and it justifies choosing a rule set suited to your framework rather than enabling the whole registry.
The costliest trap lies elsewhere: this scan exits with code 0, despite
five findings. Without the --error option, semgrep scan fails no
pipeline, and a "green" job can hide injections. With --error, the same scan
exits with code 1, and only then does the control become a blocking
control.
Choosing a SAST tool
The choice rests on three criteria: the languages of your repositories, the licence with regard to your usage, and how easy it is to write your own rules. In this course, Semgrep is the reference implementation, for its readable rules and its SARIF output, the standard format that GitHub and GitLab can display. The next lesson is entirely devoted to it.
| Tool | Languages | Licence and usage | Strength |
|---|---|---|---|
| Semgrep (CE) | 30+ | free engine, LGPL | readable rules, fast, SARIF |
| Bandit | Python | free, Apache 2.0 | simple, Python-specific |
| Bearer | JS, TS, Ruby, Python, Java, Go, PHP | source available, Elastic License 2.0 | flow of sensitive data |
| CodeQL | C/C++, C#, Go, Java, JS, Python, Ruby... | free only for open source code | powerful queries, built into GitHub |
| SonarQube | 25+ | LGPL community edition, paid advanced editions | quality and security in one dashboard |
The CodeQL row deserves a careful read. Its terms of use allow the tool on code published under an open source licence or for academic research, but explicitly exclude automated analysis in continuous integration of proprietary code without a commercial contract. On a private company repository, it therefore requires a GitHub Advanced Security licence.
The same static principle applies to targets other than application code: IaC scanners (Checkov, Trivy, KICS) read Terraform, Kubernetes or a Dockerfile, secret scanners (Gitleaks) read the code and the Git history. They have their own lessons in the course. Note in passing that tfsec has been merged into Trivy by its vendor, which now recommends migrating: a guide that still presents it as a tool for the future is out of date.
Where to place SAST in the chain?
SAST belongs in two places that do not play the same role. On the
workstation, a pre-commit hook gives the developer immediate feedback, but
it can still be bypassed with a --no-verify. In CI, the same scan cannot
be bypassed and can block the merge. The workstation serves comfort, the CI
serves the guarantee: requirement SOCLE-APP-AST-1 asks precisely for a SAST
run on every build, with blocking critical findings.
On the workstation: the pre-commit hook
Semgrep publishes its hooks in the semgrep/pre-commit repository,
versioned like the tool. The hook only scans the modified files of the
commit, which keeps it fast. The --skip-unknown-extensions option prevents it
from failing on a file type that no rule covers.
repos: - repo: https://github.com/semgrep/pre-commit rev: v1.178.0 hooks: - id: semgrep args: ["--config", "p/python", "--error", "--skip-unknown-extensions"]In CI: a job that really blocks
The old semgrep-action is deprecated by its vendor, who recommends running
Semgrep directly in its official image. Both examples below follow this
recommendation, with the image pinned by digest and the --error option
that turns findings into a job failure.
The job runs in the Semgrep container. permissions: {} removes all
default permissions, contents: read is enough to clone, and
persist-credentials: false prevents the token from staying in
.git/config.
name: SAST
on: pull_request:
permissions: {}
jobs: semgrep: runs-on: ubuntu-24.04 permissions: contents: read container: image: semgrep/semgrep:1.178.0@sha256:32e459968daabe7ab86968184a29109b9564aa00392401156f9788452b42786b steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Semgrep, fail on finding run: semgrep scan --config p/python --error --metrics=off .The job only runs on merge requests. Without --error, it would stay
green whatever the result, and allow_failure: false would change
nothing.
semgrep: stage: test image: semgrep/semgrep:1.178.0@sha256:32e459968daabe7ab86968184a29109b9564aa00392401156f9788452b42786b script: - semgrep scan --config p/python --error --metrics=off . rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" allow_failure: falseBlocking only new flaws
Enabling a blocking SAST on an old repository makes every merge request
fail because of the existing debt, and the team ends up disabling the
control. The --baseline-commit option solves this problem: Semgrep
compares against the given commit and only reports the findings introduced
since. Measured on the test repository, the full scan reports 5 findings;
with the main branch as the reference, it only reports 3, those of the new
/ping route, and it does exit with code 1 with --error.
# Only report what was introduced since the main branchsemgrep scan --config p/python --error --baseline-commit "$(git merge-base origin/main HEAD)" .The debt does not disappear for all that: it is handled in a dated backlog, with a scheduled full scan alongside, otherwise the baseline becomes a permanent blind spot.
Which blocking strategy?
Blocking on everything paralyses teams, blocking on nothing produces reports nobody reads. A graduated policy sets, by severity, what blocks the merge and what only opens a ticket. The reasonable starting point: block on the ERROR severities of taint-type rules, which follow real external data, and handle the rest in review.
| Semgrep severity | Action | Example |
|---|---|---|
| ERROR | block the merge | HTTP data reaching an SQL query |
| WARNING | ticket with a deadline | suspicious construct without a proven flow |
| INFO | information in review | best practice not followed |
Handling false positives
A false positive is an alert on code that is not actually exploitable: the data is validated elsewhere, or the code is a test example. Badly handled, false positives destroy trust in the tool: a team that ignores a hundred alerts ends up ignoring the hundred and first, the real one. Three mechanisms exist, from the most targeted to the broadest, and the most targeted is almost always the right one.
Ignoring a specific finding: nosemgrep
The nosemgrep comment goes on the line where the finding is reported, not
on the line that seems faulty to you. Measured on a variant of the test
application, where the query is first built in a query variable then passed
to cursor.execute(query): a # nosemgrep placed on the execute line
removed nothing, because the findings were reported on other lines. Placed
on the line that builds query, it removed that single finding, and the
other rule that reported the same flaw stayed active.
# The form that names the rule only silences that rule, on this linequery = "SELECT * FROM users WHERE id = " + user_id # nosemgrep: python.flask.security.injection.tainted-sql-string.tainted-sql-stringAlways name the rule and justify the exception in code review. Measured:
with the exact identifier, the finding disappears; with the identifier of
another rule, it stays, which protects you from an overly broad exception.
A bare nosemgrep, with no identifier, on the other hand silences every rule
on the line. For Bandit, the equivalent is # nosec B105, with the test
identifier.
Excluding paths: .semgrepignore
The .semgrepignore file, at the root of the repository, excludes whole
paths with the syntax of a .gitignore. A measured trap changes
everything: without this file, Semgrep applies a default exclusion list
that already leaves out tests/, and as soon as a .semgrepignore exists, it
replaces that list instead of adding to it. A file that only contains qa/
therefore suddenly makes Semgrep scan tests/, and an empty file makes it scan
everything. Always carry over the default exclusions you want to keep. It is
the right tool for generated code or datasets, and a bad one for tests that
contain real logic.
tests/vendor/*.min.jsChanging rules rather than piling up exceptions
When the same rule produces false positives everywhere, the problem is no longer the line but the rule set. Replace a broad registry with the one for your framework, or write a more precise custom rule: that is the subject of the Semgrep lesson. A global exclusion that grows with every sprint is a disabled control that does not say so.
Troubleshooting
Half of SAST incidents are not flaws but pipelines that lie. A green job proves nothing until you have checked that it can fail: push a deliberate flaw once on a test branch and watch the job go red.
| Symptom | Likely cause | Solution |
|---|---|---|
| Green job despite findings | semgrep scan without --error exits with code 0 | Add --error |
| Error while downloading the rules | No network access to the Semgrep registry | Open the access or version the rules in the repository |
nosemgrep has no effect | Comment on the wrong line | Put it on the line reported by the finding |
| Every MR fails on old code | Existing debt | --baseline-commit and a dated backlog |
| A Django rule on Flask code | Registry too broad | Choose the framework's rule set |
Key points
- SAST reads your code without running it and follows data from its source to its sink.
- It sees neither the dependencies (SCA) nor the running application (DAST): Equifax was an SCA case.
- A real scan produces duplicates: several rules for a single flaw.
- Without
--error,semgrep scanexits with code 0 and blocks nothing. --baseline-commitonly blocks new flaws; the debt is handled separately.nosemgrepgoes on the reported line, naming the rule.- CodeQL is only free for open source code.
- Evidence expected by SOCLE-APP-AST-1: a blocking SAST job and tickets on the critical findings.
Knowledge check
Check that you have the essentials of this lesson. 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: Semgrep: static code analysis for security
A new full quiz with random questions
Work again only on the questions you missed