Step 09 of 25
SAST & dependency scanning
Scan your code with Semgrep and CodeQL, find vulnerable dependencies, generate SBOMs and triage findings without drowning in noise.
Most vulnerabilities that reach production were sitting in a pull request first: a query built from user input, or a dependency with a known CVE that nobody noticed. Two kinds of automated check catch them before merge. SAST (static application security testing) reads your own source code for dangerous patterns. SCA (software composition analysis) compares your dependencies against databases of known vulnerabilities. This lesson shows you how to run both, write your own rule, produce an SBOM, and, the hard part, decide which findings actually matter.
What each scanner can and cannot see
Every scanner answers a narrow question. Knowing which question stops you from trusting a green check that never looked at the problem.
| Check | Looks at | Finds | Misses |
|---|---|---|---|
| SAST | Your source code, without running it | Injection sinks, weak crypto calls, unsafe deserialisation, missing auth decorators | Config and runtime behaviour, business-logic flaws |
| SCA | Lockfiles, manifests, binaries | Dependencies with published advisories (CVEs, GHSAs) | Vulnerabilities nobody has reported yet, malicious packages without an advisory |
| Secret scanning | Code and Git history | Committed keys and tokens | Secrets that live only in CI variables or logs |
| DAST | The running application | Missing headers, reflected input, misconfigured TLS | Code paths the scanner never reaches |
SAST and SCA run in seconds to minutes on every pull request, which makes them the cheapest checks you have. DAST, covered later in this stage, needs a deployed app.
Semgrep: pattern-based SAST
Semgrep parses code into a syntax tree and matches patterns that look like the code itself. You don't write regexes against text; you write a snippet of the target language with placeholders. That makes rules readable by any developer, which matters when a developer is the one deciding whether a finding is real.
Run the community rulesets locally first:
pip install semgrep # or: brew install semgrep
semgrep scan --config p/python --config p/secrets .┌─────────────────┐
│ 2 Code Findings │
└─────────────────┘
app/orders.py
❯❯❱ python.lang.security.audit.formatted-sql-query.formatted-sql-query
❰❰ Blocking ❱❱
Detected possible formatted SQL query. Use parameterized queries instead.
31┆ cur.execute(f"SELECT * FROM orders WHERE id = {order_id}")
app/report.py
❯❯❱ python.lang.security.audit.subprocess-shell-true.subprocess-shell-true
❰❰ Blocking ❱❱
Found 'subprocess' function 'run' with 'shell=True'. This is dangerous because this call will spawn
the command using a shell process. Doing so propagates current shell settings and variables, which
makes it much easier for a malicious actor to execute commands. Use 'shell=False' instead.
12┆ subprocess.run(f"pdftotext {path}", shell=True)Writing a custom rule
Registry rules are generic. Your codebase has its own sharp edges: an internal db.raw() helper, a deprecated auth decorator, a logging call that must never receive a token. Encode those once as rules and they're enforced on every pull request forever.
A rule is YAML. $X is a metavariable that matches any expression, and ... matches any sequence of arguments or statements:
# rules/sql-string-building.yaml
rules:
- id: sql-string-building
languages: [python]
severity: ERROR
message: >-
SQL is built by string formatting before reaching execute().
Pass values as parameters: cur.execute("... WHERE id = %s", (order_id,))
metadata:
cwe: "CWE-89: SQL Injection"
pattern-either:
- pattern: $CUR.execute(f"...", ...)
- pattern: $CUR.execute("..." % $ARGS, ...)
- pattern: $CUR.execute("..." + $X, ...)
- pattern: $CUR.execute("...".format(...), ...)Rules need tests just like code. Put a file with the same base name next to the rule and annotate the lines that must and must not match:
# rules/sql-string-building.py
def lookup(cur, order_id):
# ruleid: sql-string-building
cur.execute(f"SELECT * FROM orders WHERE id = {order_id}")
# ruleid: sql-string-building
cur.execute("SELECT * FROM orders WHERE id = " + order_id)
# ok: sql-string-building
cur.execute("SELECT * FROM orders WHERE id = %s", (order_id,))semgrep --test rules/The test run fails if a ruleid line isn't matched or an ok line is, so a rule change can never silently stop catching the thing it was written for.
Taint mode: following data, not shapes
The rule above flags every formatted query, even one built from a constant. Taint mode only fires when data flows from a source (user input) to a sink (the dangerous call) without passing a sanitiser:
rules:
- id: flask-request-into-sql
mode: taint
languages: [python]
severity: ERROR
message: Request data reaches a SQL query without parameterisation.
pattern-sources:
- pattern: flask.request.$ANYTHING
pattern-sanitizers:
- pattern: int(...)
pattern-sinks:
- patterns:
- pattern: $CUR.execute($QUERY, ...)
- focus-metavariable: $QUERYfocus-metavariable says only the query argument is a sink, so passing tainted data as a parameter (the second argument) is correctly treated as safe. Taint rules produce far fewer false positives, at the cost of being harder to write.
CodeQL: SAST as a database query
CodeQL, GitHub's analyser, works differently. It first extracts your code into a relational database of syntax, types, control flow and data flow, then runs queries written in the QL language against it. Its data-flow tracking crosses function and file boundaries more deeply than pattern matching, which is why it finds bugs like "user input reaches exec four calls later." The trade-off is speed: compiled languages need a build, and a large repository can take tens of minutes.
You rarely write QL yourself. You enable the maintained query suites and read the results in GitHub's code-scanning UI:
# .github/workflows/codeql.yml
name: codeql
on:
pull_request:
push:
branches: [main]
schedule:
- cron: "17 3 * * 1" # weekly, to pick up new queries
permissions:
contents: read
security-events: write # upload results to code scanning
jobs:
analyze:
runs-on: ubuntu-latest
strategy:
matrix:
language: [python, javascript-typescript]
steps:
- uses: actions/checkout@v7
- uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: none # interpreted languages need no build
queries: security-extended
- uses: github/codeql-action/analyze@v4A sensible split: Semgrep for fast, custom, organisation-specific rules on every PR; CodeQL for deep, slower data-flow analysis. Both emit SARIF, a standard JSON format for findings, so one dashboard can show both. On GitLab the same tools run as jobs and report into the merge request security widget; only the YAML wrapper differs.
Dependency scanning (SCA)
Your own code is often a few percent of what you ship; the rest is dependencies. SCA tools read your lockfile (the exact resolved versions) and look each package up in an advisory database. Scanning a manifest with ranges like ^4.17.0 is guesswork; scanning package-lock.json is exact.
osv-scanner queries the open OSV database and understands most ecosystems:
osv-scanner scan source -r .Scanning dir .
Scanned /src/package-lock.json file and found 412 packages
Scanned /src/requirements.txt file and found 38 packages
╭─────────────────────────────────────┬──────┬───────────┬──────────────┬─────────┬───────────────────╮
│ OSV URL │ CVSS │ ECOSYSTEM │ PACKAGE │ VERSION │ SOURCE │
├─────────────────────────────────────┼──────┼───────────┼──────────────┼─────────┼───────────────────┤
│ https://osv.dev/GHSA-35jh-r3h4-6jhm │ 7.2 │ npm │ lodash │ 4.17.20 │ package-lock.json │
│ https://osv.dev/GHSA-3xgq-45jj-v275 │ 7.5 │ npm │ cross-spawn │ 7.0.3 │ package-lock.json │
╰─────────────────────────────────────┴──────┴───────────┴──────────────┴─────────┴───────────────────╯Ecosystems also ship their own auditors. npm audit --omit=dev --audit-level=high fails only on high or critical issues in packages that ship to production. For Go, govulncheck goes further and checks whether your code calls the vulnerable function:
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...=== Symbol Results ===
Vulnerability #1: GO-2024-2687
HTTP/2 CONTINUATION flood in net/http
More info: https://pkg.go.dev/vuln/GO-2024-2687
Module: golang.org/x/net
Found in: golang.org/x/[email protected]
Fixed in: golang.org/x/[email protected]
Example traces found:
#1: cmd/server/main.go:42:23: server.main calls http2.ConfigureServer
Your code is affected by 1 vulnerability from 1 module.
This scan also found 0 vulnerabilities in packages you import and 3
vulnerabilities in modules you require, but your code doesn't appear to call
these vulnerabilities.That last paragraph is the point: four advisories exist in your module graph, but only one is reachable. That is the difference between a fix-today and a fix-in-the-next-routine-update.
SBOMs: an ingredient list for software
A software bill of materials lists every component in a build, with versions, licences and identifiers. You produce one per release so that when the next big vulnerability lands, the question "are we affected?" is a search, not an archaeology project.
Two standard formats exist, and most tools read both:
| CycloneDX | SPDX | |
|---|---|---|
| Steward | OWASP | Linux Foundation (ISO/IEC 5962) |
| Origin | Security use cases | Licence compliance |
| Extras | VEX, services, ML models | Rich licence expressions, file-level detail |
Syft generates either from a directory or a built image. Scanning the image is more truthful, because it includes OS packages from the base image that no lockfile mentions:
syft dir:. -o cyclonedx-json=sbom.cdx.json
syft registry.example.com/orders-api:1.4.2 -o spdx-json=sbom.spdx.json
grype sbom:./sbom.cdx.json --fail-on highEach component in the CycloneDX output carries a purl (package URL), the identifier vulnerability databases match on:
{
"type": "library",
"name": "lodash",
"version": "4.17.21",
"purl": "pkg:npm/[email protected]",
"licenses": [{ "license": { "id": "MIT" } }]
}Store SBOMs alongside each release artifact. Re-scanning last month's SBOM against today's advisory data tells you which deployed versions became vulnerable after they shipped.
Triage: deciding what matters
A scanner that reports 400 findings on day one gets switched off by day five. Triage is the skill that keeps scanning alive.
Severity is not priority. A CVSS score describes the worst case for the vulnerability in general. Your priority depends on your context:
- Reachability: is the vulnerable function called (govulncheck, taint-mode rules)? Is the package only a dev dependency?
- Exploitability: is it being exploited in the wild? CISA's Known Exploited Vulnerabilities (KEV) catalogue and EPSS scores (probability of exploitation in the next 30 days) answer this.
- Exposure: is the component internet-facing, or an internal batch job behind three other controls?
A critical CVE in an unreachable XML parser of a dev-only tool is lower priority than a medium-severity flaw on your login endpoint that appears in KEV.
False positives must be suppressed with a reason, in code, where reviewers see them:
# nosemgrep: sql-string-building -- table name comes from a fixed allowlist above
cur.execute(f"SELECT count(*) FROM {table}")For SCA, record the decision in the tool's ignore file with an expiry, or as a VEX statement ("not affected: vulnerable code not present"), so the exemption is re-examined later instead of living forever.
Baselines let you adopt a scanner on an old codebase without blocking everyone. Fail the pipeline only on findings introduced by this change, and burn down the existing backlog separately:
- name: Semgrep (new findings only)
run: >-
semgrep scan --config p/default --config rules/ --error
--baseline-commit "$(git merge-base "origin/${{ github.base_ref }}" HEAD)"Practice
In a small Python project:
- Write a Semgrep rule
subprocess-shell-truethat flagssubprocess.run,subprocess.callandsubprocess.Popenwhen called withshell=True, and a test file proving it matches the unsafe calls and not a safe list-of-arguments call. - Add a GitHub Actions job that runs your rule plus
p/pythonon pull requests and fails only on new findings. - Generate a CycloneDX SBOM of the project with syft and scan it with grype.
Show solution
rules/subprocess-shell-true.yaml:
rules:
- id: subprocess-shell-true
languages: [python]
severity: ERROR
message: >-
shell=True passes the command through /bin/sh, so any interpolated value can
inject commands. Pass a list instead: subprocess.run(["pdftotext", path])
metadata:
cwe: "CWE-78: OS Command Injection"
pattern-either:
- pattern: subprocess.run(..., shell=True, ...)
- pattern: subprocess.call(..., shell=True, ...)
- pattern: subprocess.Popen(..., shell=True, ...)rules/subprocess-shell-true.py:
import subprocess
def convert(path):
# ruleid: subprocess-shell-true
subprocess.run(f"pdftotext {path}", shell=True)
# ruleid: subprocess-shell-true
subprocess.Popen("ls " + path, shell=True)
# ok: subprocess-shell-true
subprocess.run(["pdftotext", path], check=True)Run semgrep --test rules/ and confirm both rule tests pass. Semgrep resolves imports, so from subprocess import run; run(cmd, shell=True) is caught too.
.github/workflows/sast.yml:
name: sast
on: pull_request
permissions:
contents: read
jobs:
semgrep:
runs-on: ubuntu-latest
container: semgrep/semgrep
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # the baseline commit must be present
- run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- run: >-
semgrep scan --config p/python --config rules/ --error
--baseline-commit "$(git merge-base "origin/${{ github.base_ref }}" HEAD)"SBOM:
syft dir:. -o cyclonedx-json=sbom.cdx.json
grype sbom:./sbom.cdx.jsonPinning an old package, for example requests==2.25.0, in requirements.txt should make grype report its published advisories; upgrading it and regenerating the SBOM clears them.
Key takeaways
- SAST reads your code, SCA reads your dependency graph; neither sees runtime behaviour or undiscovered flaws.
- Semgrep rules are code-shaped YAML; write custom rules for your own risky APIs and test them with
ruleid/okannotations. - Scan lockfiles and built images, not loose manifests; prefer reachability-aware tools like govulncheck where they exist.
- Generate a CycloneDX or SPDX SBOM per release with syft so "are we affected?" becomes a query.
- Prioritise by reachability, exploitation in the wild and exposure, not CVSS alone.
- Suppress false positives in code with a reason, and use baselines so only new findings block a merge.
Check yourself
Quiz
0/4 correct · 4 left
1.osv-scanner reports a critical CVE in a library that govulncheck says your code never calls. What is the best response?
2.Why does the taint-mode rule use focus-metavariable on $QUERY?
3.Why is scanning a container image with syft often more complete than scanning the repository?
4.You enable Semgrep on a five-year-old repository and it reports 600 existing findings. How do you roll it out without blocking every pull request?
Concepts in this lesson
One-page notes on each idea, for review or a quick lookup later.
Finished reading?
Mark it complete to fill in your roadmap.