CyberNugget

L54 · Shifting left: SAST, DAST, SCA, and IaC scanning

Module 8: Secure SDLC and Supply Chain · ~20 min · Lesson 54 of 71

🎯 What you'll learn

  • What SAST, DAST, SCA, IaC and secret scanners each look at, what they find, and what they miss
  • Where each one runs in a GitHub pipeline, from your laptop to production
  • How to triage a finding, and how to suppress a false positive without hiding the next real bug
  • What reachability adds to dependency alerts, and where it stops being trustworthy

🔥 Why this matters

A team turns on a new scanner for statements-api. The first pull request shows 212 findings, most of them about test code and an old MD5 cache key. By Wednesday the check is "not required", and nobody opens the report again. Three months later a penetration test finds a NoSQL injection (L31) that was finding #37. The scanner worked. The triage didn't.

1. Shifting left, and why left isn't enough

Picture your pipeline as a line from your laptop (left) to production (right). Shifting left means finding security problems earlier on that line, while the author still has the code open and a fix costs minutes instead of an incident.

 LAPTOP          PULL REQUEST        MAIN / BUILD        RUNNING
 IDE plugin,     every PR, on        built image,        staging and
 pre-commit      changed code        test deploy         production
 ──────────────────────────────────────────────────────────────▶
 secrets         SAST                SCA on the image    DAST on
 quick SAST      SCA (lockfiles)     (L49)               staging
                 IaC scanning        DAST in a test env  new-CVE
                 secret scanning                         alerts
 ◀── cheaper to fix, more context ──┼── closer to what really runs ─▶

Left isn't enough on its own. Some problems exist only at runtime (a header a gateway such as Kong strips), and a library clean on Monday gains a CVE on Tuesday. So scanning runs at both ends.

2. Five scanners, five views of your system

Each type reads a different artifact, so each has blind spots the others cover.

SAST (static application security testing) reads your source code without running it. Its core trick is taint analysis: follow data from a source an attacker controls to a dangerous sink, and report the path unless a sanitizer breaks it.

 SOURCE                   PROPAGATION            SINK
 @RequestBody Map body ─▶ new Document(body) ─▶ coll.find(q)
 (caller controls it)     (still tainted)        (operators run)
                                │
                 a SANITIZER breaks the path: bind to a
                 DTO with String fields, then build q

Semgrep matches patterns that look like code, is fast enough for every PR, and makes custom rules easy (its open-source edition is mostly single-file; cross-file analysis is in the paid engine). CodeQL compiles your code into a database and runs deep dataflow queries across files. It's free for public repositories; private ones need GitHub Code Security.

DAST (dynamic testing) attacks a running app over HTTP, with no source code. ZAP (open source) is the common choice. It sees what's really deployed, including headers and error pages, but only the paths it can reach and log in to.

SCA (software composition analysis) reads manifests, lockfiles or built images, and matches package versions against advisory databases (CVE, GitHub advisories, OSV). Examples: Dependabot, Snyk, OSV-Scanner, and Trivy or Grype on images (L49).

IaC scanning reads Terraform, Kubernetes YAML, Helm charts and Dockerfiles for risky settings. Examples: Checkov, Trivy (trivy config; tfsec's checks now live in Trivy), and KICS.

Secret scanning looks for credentials in code and history: GitHub secret scanning with push protection, or gitleaks (L28's deep dive).

Type Finds Misses
SAST Injection paths, shell=True, weak crypto, unsafe deserialization (L36) Missing authorization (BOLA, L40), business logic, anything that depends on config or other services
DAST Missing headers (L37), verbose errors, injection that actually responds, cookie flags Code paths it can't reach or log in to; BOLA unless it has two users
SCA Known CVEs and licenses in dependencies Zero-days, your own bugs, malicious packages with no advisory yet (L55), shaded copies it can't identify
IaC Public buckets, 0.0.0.0/0 on port 22, privileged pods, no encryption Console drift, values injected at deploy time, whether "public" is intended
Secrets Keys and tokens matching known formats Custom formats, secrets that are already somewhere else

Notice that nobody's column says "broken access control", the top OWASP risk. That still needs tests (L59) and review (L58).

3. Wiring it into GitHub

Most tools can write SARIF, a standard JSON format for findings. Upload it to GitHub code scanning and findings appear as annotations on the pull request's changed lines. A trimmed workflow (pin actions to commit SHAs in real use, L57):

on: pull_request
permissions: {contents: read, security-events: write}
jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
        with: {fetch-depth: 0}
      - run: pipx run checkov -d infra --soft-fail
               -o sarif --output-file-path .
      - run: pipx run semgrep scan --config p/java
               --baseline-commit origin/main
               --sarif -o semgrep.sarif --error
      - if: always()
        uses: github/codeql-action/upload-sarif@v4
        with: {sarif_file: .}

--baseline-commit reports only findings that are new in this PR. That's the key to adoption: gate on new findings in changed code, and burn down the old backlog separately. Make the check required with a branch ruleset once its noise is low. Dependabot and secret scanning are switched on in repository settings rather than in the workflow.

4. Triage: true, false, and "true but fine"

Every finding lands in one of four boxes:

Really a problem Not a problem
Tool reports it True positive: fix it False positive: suppress it, carefully
Tool is silent False negative: the dangerous one True negative

Ask four questions: Can an attacker control the source? Is there a sanitizer the tool doesn't know about? Does this code ship and run? What's the impact if it's real?

Suppressions done right are narrow, explained and reviewable:

  • Inline, one rule, one place. # nosemgrep: <rule-id> or # checkov:skip=<ID>:<reason> on the exact line, never a whole directory or a rule switched off globally.
  • A reason and a ticket, so the next reader knows why.
  • An expiry where the tool supports one (.trivyignore accepts exp:2027-01-31).
  • Reviewed. Put suppression files under CODEOWNERS for your security champion.
  • Fix the rule instead when you'd otherwise suppress the same false positive 30 times: teach it your sanitizer (deep dive below).

In GitHub's UI, dismiss with the closest reason ("False positive", "Used in tests", "Won't fix") and a comment. If fixing is cheaper than arguing, just fix it.

5. Reachability: is the vulnerable code even called?

SCA works at package level: "you have lib-y 2.3, which has CVE-B". Reachability analysis checks whether a call path exists from your code to the vulnerable function.

 SCA: 3 CVEs in policy-service's image
  CVE-A  lib-x.parse()       ◀── PolicyController calls it
         REACHABLE: fix this sprint
  CVE-B  lib-y.renderPdf()   no path from your code
         NOT REACHABLE TODAY: normal upgrade cycle
  CVE-C  OS package (libssl) call graph can't see it
         UNKNOWN: treat as reachable if internet-facing

Go's govulncheck does this from the call graph, and several commercial SCA tools offer it for Java and JavaScript (coverage varies by vendor and language). Treat it as prioritization, not permission to ignore. Call graphs miss reflection, dependency injection and deserialization gadgets (L36), which are reached through data rather than direct calls. And "not reachable" is only true until the next commit. Combine it with EPSS and CISA KEV (L02's deep dive).

🧪 Worked example: triaging one pull request

A PR to statements-api (Spring Boot, MongoDB, Terraform) gets five findings (made-up names):

# Tool and finding Triage Action
1 Semgrep: request body reaches find() True positive: operator injection (L31) Fix: bind to a DTO
2 Semgrep: MD5 in etagFor() Not a security use, but SHA-256 costs nothing Fix it; no argument needed
3 Checkov CKV_AWS_20: public-read ACL on example-docs-public Intended: public product docs Suppress inline with reason
4 Dependabot: high CVE in a test-scoped library Doesn't ship in the jar Dismiss "not used", bump next cycle
5 Secret scanning: AWS access key in application-local.yml Real key, in git history Revoke and rotate first, then remove (L48)

The suppression for #3 is narrow and self-explaining (check IDs can change between Checkov versions):

resource "aws_s3_bucket_acl" "docs_public" {
  # checkov:skip=CKV_AWS_20:Public product docs, SEC-1388
  bucket = aws_s3_bucket.docs_public.id
  acl    = "public-read"
}

🛠 Try it (optional, ~5 min)

Run Semgrep on a tiny Flask file on your own laptop. Save as app.py in an empty folder:

import hashlib, subprocess
from flask import Flask, request
from pymongo import MongoClient

app = Flask(__name__)
db = MongoClient("mongodb://localhost:27017")["ledger"]

@app.post("/v1/statements/search")
def search():
    return {"n": db.statements.count_documents(request.json)}

@app.get("/v1/reports/<name>")
def report(name):
    out = subprocess.run(f"cat reports/{name}.csv",
                         shell=True, capture_output=True)
    return out.stdout

def etag(body: bytes) -> str:
    return hashlib.md5(body).hexdigest()

Then (pip install semgrep in a virtualenv also works; this downloads public rules only):

$ pipx run semgrep scan --config p/python --metrics=off app.py
...
 python.lang.security.audit.subprocess-shell-true...
   15┆ shell=True, capture_output=True)
 python.lang...insecure-hash-algorithm-md5
   19┆ return hashlib.md5(body).hexdigest()
Ran 151 rules on 1 file: 2 findings.

That's real output from Semgrep 1.184 (trimmed). Look at what's missing: the worst bug, the request.json filter on line 10, isn't reported. That's a false negative, and the deep dive fixes it.

🧠 Key takeaways

  • SAST reads code, DAST attacks the running app, SCA checks dependencies, IaC scanning checks infrastructure files; each misses what the others see.
  • None of them reliably finds broken access control; that still needs tests and review.
  • Gate pull requests on new findings only, and burn down the backlog separately.
  • Suppress narrowly: one rule, one line, a reason, a ticket, an expiry, a reviewer.
  • Reachability helps prioritize; it doesn't prove a CVE is safe to ignore.

📖 Words to know

Term Meaning
SAST Static analysis: scanning source code without running it
DAST Dynamic testing: attacking a running application from outside
SCA Software composition analysis: matching dependencies against known vulnerabilities
Taint analysis Tracing untrusted data from a source to a dangerous sink
False negative A real problem the tool didn't report
SARIF A standard JSON format for static analysis results
Reachability Whether your code can actually call a dependency's vulnerable function

⏭ Next time

L55: the dependencies themselves as an attack. Typosquatting, dependency confusion, lockfiles, SBOMs and SLSA, or how a package you never meant to install ends up in your build.

Deep dive 1

Writing your own Semgrep rule: turning one bug into a guardrail

The Try it showed Semgrep's public Python rules missing a NoSQL injection. Public rules are generic; your bugs are specific to your frameworks and helpers. A custom rule takes about 20 lines, and from then on every pull request is checked for that bug class.

Anatomy of a taint rule

A Semgrep rule is YAML. In mode: taint you describe three things, in code-shaped patterns:

 pattern-sources      where untrusted data enters
   flask.request.json, request.get_json(), request.args
 pattern-sanitizers   what makes it safe
   str(...)  (a string can't carry $ne or $where)
 pattern-sinks        where it must not arrive
   the FIRST argument of find / find_one / count_documents

Semgrep then tracks data through assignments, dictionaries and function returns within the file.

Worked example: a rule and its tests

Save as rules/mongo-request-filter.yaml:

rules:
  - id: mongo-request-filter
    mode: taint
    languages: [python]
    severity: ERROR
    message: >-
      Request data reaches a MongoDB filter. A caller can send
      operators such as {"$ne": null} (L31). Build the filter
      from validated, typed fields instead.
    pattern-sources:
      - pattern-either:
          - pattern: flask.request.json
          - pattern: flask.request.get_json(...)
          - pattern: flask.request.args
    pattern-sanitizers:
      - pattern: str(...)
    pattern-sinks:
      - patterns:
          - pattern: $COLL.$METHOD($FILTER, ...)
          - metavariable-regex:
              metavariable: $METHOD
              regex: ^(find|find_one|count_documents|update_many)$
          - focus-metavariable: $FILTER

Rules are code, so they get tests. Next to it, rules/mongo-request-filter.py marks which lines must match (ruleid:) and which must not (ok:):

from flask import request

def bad_direct(db):
    # ruleid: mongo-request-filter
    return db.statements.count_documents(request.json)

def bad_via_variable(db):
    body = request.get_json()
    q = {"accountId": body["accountId"]}
    # ruleid: mongo-request-filter
    return db.statements.find(q)

def ok_cast(db):
    acct = str(request.json["accountId"])
    # ok: mongo-request-filter
    return db.statements.find({"accountId": acct})

Run the tests, then the rule against the Try it's app.py (real output, Semgrep 1.184, trimmed):

$ semgrep --test rules/
1/1: ✓ All tests passed

$ semgrep scan --metrics=off \
    --config rules/mongo-request-filter.yaml app.py
 rules.mongo-request-filter
   Request data reaches a MongoDB filter. ...
   10┆ return {"n": db.statements.count_documents(request.json)}

The bad_via_variable case matters: body["accountId"] could itself be {"$ne": null}, and the rule follows it through q. The str() sanitizer is what teaches the rule your team's safe pattern. For Java you'd write sources like @RequestBody $T $X and sinks like $COLL.find($FILTER), with your DTO mapping as the sanitizer.

Making rules part of the team

  • Keep rules in a shared repo and run them with --config path/to/rules in every service's CI.
  • Write a rule after every real bug found in review or a pentest: it turns one fix into a guardrail.
  • Start new rules as warnings, check their noise on a few repos, then make them blocking.
  • Semgrep's license terms for its own registry rules changed in late 2024 (and the Opengrep fork followed); your own rules are yours, but check terms before redistributing theirs.

🧠 Extra takeaways

  • Public rules are generic; the bugs you keep making need your own rules.
  • A taint rule is sources, sanitizers and sinks; the sanitizer encodes your safe pattern.
  • Test rules with ruleid: and ok: annotations, and run semgrep --test in CI.
  • Every bug found by a human is a candidate rule.
Deep dive 2

DAST on your own API: a ZAP API scan on localhost

SAST guesses what code will do; DAST asks the running service. For an API, ZAP's API scan reads your OpenAPI spec, calls every operation, and attacks the parameters. Run it only against an app on your own machine or a test environment you own: an active scan sends real attack payloads.

What the three packaged scans do

ZAP ships Docker images with three ready-made scripts:

Script What it does Safe on shared envs?
zap-baseline.py Crawls briefly, passive checks only (headers, cookies, errors) Usually, with permission
zap-api-scan.py Imports OpenAPI, SOAP or GraphQL, then active attacks Your own test env only
zap-full-scan.py Crawls and runs the full active scan Your own test env only

Exit codes make them CI-friendly: 0 for no problems, 1 if any rule set to FAIL fired, 2 for warnings only, 3 for an error running the scan.

Worked example: scanning a Spring Boot API on your laptop

Start your service locally on port 8080 with springdoc (it serves the spec at /v3/api-docs), and a local MongoDB (L53's Try it). Then, with Docker Desktop (on Linux, also pass --add-host=host.docker.internal:host-gateway to docker run):

mkdir -p ~/tmp/zap-l54 && cd ~/tmp/zap-l54
docker run --rm -v "$PWD":/zap/wrk:rw \
  -e ZAP_AUTH_HEADER_VALUE="Bearer $DEV_TOKEN" \
  ghcr.io/zaproxy/zaproxy:stable zap-api-scan.py \
  -t http://host.docker.internal:8080/v3/api-docs \
  -f openapi -r report.html -J report.json

ZAP_AUTH_HEADER_VALUE adds an Authorization header to every request, so ZAP gets past your auth filter (use a local dev token, never a real one). The summary looks like this (illustrative):

WARN-NEW: X-Content-Type-Options Header Missing [10021] x 6
WARN-NEW: Application Error Disclosure [90022] x 2
WARN-NEW: Unexpected Content-Type was returned [100001] x 1
FAIL-NEW: 0  FAIL-INPROG: 0  WARN-NEW: 3  WARN-INPROG: 0
INFO: 0  IGNORE: 0  PASS: 117

How to read it: 10021 means a header a gateway such as Kong may add in front of you in real life (L37), so check there before "fixing" it twice. 90022 means a stack trace in a 500 response: a real finding, since it leaks class names and versions. Generate a rules file with -g zap.conf, set rules to IGNORE, WARN or FAIL, and pass it back with -c zap.conf, which is ZAP's version of a reviewed suppression list.

What it won't find

ZAP called GET /v1/accounts/{id} with the IDs it was given, as one user. It can't know that user should be refused account ACC-5522, so BOLA (L40) stays invisible unless you script two users (L34's deep dive does this by hand). It also can't reach endpoints missing from the spec, and a spec that's out of date silently shrinks the scan. DAST complements SAST; it doesn't replace it.

🧠 Extra takeaways

  • Baseline scans are passive; API and full scans actively attack, so only use them on systems you own.
  • Feed ZAP your OpenAPI spec and a dev auth header, or it only scans the login page.
  • A rules config file (-c) is DAST's reviewed suppression list.
  • DAST sees deployed behavior (headers, errors) but not business rules such as BOLA.

The quiz for this lesson and the Tests section need JavaScript.