# SAST & dependency scanning

> Scan your code with Semgrep and CodeQL, find vulnerable dependencies, generate SBOMs and triage findings without drowning in noise.

DevSecOps Engineer roadmap · Secure the pipeline · 19 min read · https://levelcode.org/roadmaps/devsecops/sast-and-sca

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:

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

> [!NOTE]
> The examples in this lesson reference actions by version tag for readability. The next lesson explains why production workflows should pin every third-party action to a full commit SHA.

## 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/net@v0.20.0
    Fixed in: golang.org/x/net@v0.23.0
    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:

```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/lodash@4.17.21",
  "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)"
```

> [!TIP]
> Agree on a written policy before turning on blocking: for example, block merges on new critical or high findings that are reachable, fix KEV-listed issues within 72 hours, and review everything else weekly. Without it, every red pipeline becomes a negotiation.

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

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

## Quiz

1. osv-scanner reports a critical CVE in a library that govulncheck says your code never calls. What is the best response?

   A. Ignore it permanently, since unreachable code can never be exploited
   B. Treat it as lower priority, record the reasoning, and upgrade in the next routine dependency update
   C. Block all deploys until it is fixed, because it is rated critical
   D. Delete the lockfile so the scanner stops reporting it

   **Answer: B.** Reachability lowers priority but doesn't make the risk zero: a later code change could start calling the function. Record the decision and upgrade on a normal schedule rather than blocking or ignoring forever.

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

   A. To make the rule run faster
   B. Because Semgrep requires every sink to name a metavariable
   C. So only the query argument counts as a sink, and tainted values passed safely as parameters are not flagged
   D. To make the rule match any function named execute

   **Answer: C.** Without focusing, any tainted data anywhere in the execute() call would fire, including the correct pattern of passing user input as a bound parameter.

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

   A. The image includes OS packages from the base image that no application lockfile lists
   B. Images are smaller, so the scan is more thorough
   C. Syft cannot read lockfiles
   D. Repository scans only work for Go

   **Answer: A.** A Debian or Alpine base layer brings in libraries like OpenSSL and zlib. They ship to production but appear in no package-lock.json or go.sum.

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?

   A. Disable all rules rated ERROR
   B. Run it only on the main branch after merge
   C. Mark all 600 findings with nosemgrep comments
   D. Use a baseline so pull requests fail only on findings they introduce, and burn down the backlog separately

   **Answer: D.** Diff-aware scanning with --baseline-commit stops new problems immediately while the existing backlog is triaged on its own schedule. Blanket suppressions hide real issues.
