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 (
.trivyignoreacceptsexp: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.
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/rulesin 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:andok:annotations, and runsemgrep --testin CI. - Every bug found by a human is a candidate rule.
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.