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.

19 min 8 sections

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.

CheckLooks atFindsMisses
SASTYour source code, without running itInjection sinks, weak crypto calls, unsafe deserialisation, missing auth decoratorsConfig and runtime behaviour, business-logic flaws
SCALockfiles, manifests, binariesDependencies with published advisories (CVEs, GHSAs)Vulnerabilities nobody has reported yet, malicious packages without an advisory
Secret scanningCode and Git historyCommitted keys and tokensSecrets that live only in CI variables or logs
DASTThe running applicationMissing headers, reflected input, misconfigured TLSCode 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:

bash
pip install semgrep          # or: brew install semgrep
semgrep scan --config p/python --config p/secrets .
text
┌─────────────────┐
│ 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:

yaml
# 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:

python
# 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,))
bash
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:

yaml
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: $QUERY

focus-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:

yaml
# .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@v4

A 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:

bash
osv-scanner scan source -r .
text
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:

bash
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...
text
=== 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:

CycloneDXSPDX
StewardOWASPLinux Foundation (ISO/IEC 5962)
OriginSecurity use casesLicence compliance
ExtrasVEX, services, ML modelsRich 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:

bash
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 high

Each component in the CycloneDX output carries a purl (package URL), the identifier vulnerability databases match on:

json
{
  "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:

python
# 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:

yaml
- 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:

  1. Write a Semgrep rule subprocess-shell-true that flags subprocess.run, subprocess.call and subprocess.Popen when called with shell=True, and a test file proving it matches the unsafe calls and not a safe list-of-arguments call.
  2. Add a GitHub Actions job that runs your rule plus p/python on pull requests and fails only on new findings.
  3. Generate a CycloneDX SBOM of the project with syft and scan it with grype.
Show solution

rules/subprocess-shell-true.yaml:

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:

python
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:

yaml
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:

bash
syft dir:. -o cyclonedx-json=sbom.cdx.json
grype sbom:./sbom.cdx.json

Pinning 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/ok annotations.
  • 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. 1.osv-scanner reports a critical CVE in a library that govulncheck says your code never calls. What is the best response?

  2. 2.Why does the taint-mode rule use focus-metavariable on $QUERY?

  3. 3.Why is scanning a container image with syft often more complete than scanning the repository?

  4. 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.