Skip to main content

Build a DevSecOps Pipeline with SAST and DAST

Build a GitHub Actions pipeline that runs Semgrep on your code and an OWASP ZAP scan on the running app, failing the build on real findings.

Blueprint Walkthrough

Architecture Overview & Problem Statement

This project builds a GitHub Actions pipeline that runs two kinds of security testing on a small Flask service: Semgrep reads the source code (SAST), and OWASP ZAP attacks the running container (DAST). Each finds problems the other cannot, and a failing result in either one stops the pipeline.

You will plant a SQL injection and an enabled debug mode for Semgrep to find, then add security headers to fix what ZAP reports. You finish with a pipeline that fails on both kinds of problem and a short write-up of what each test can and cannot see.

flowchart TD A[Push or pull request] --> B[Unit tests] B --> C[SAST: Semgrep on source] C --> D{Findings?} D -->|Yes| E[Fail: no image built] D -->|No| F[Build and start container] F --> G[DAST: ZAP baseline scan] G --> H{FAIL rules hit?} H -->|Yes| I[Fail with HTML report] H -->|No| J[Pass]

SAST and DAST find different things

SAST (static application security testing) reads code without running it. It is fast and points to the exact line, but it cannot see how the app behaves once deployed. DAST (dynamic application security testing) sends real HTTP requests to a running app and looks at the responses. It sees headers, cookies and runtime behaviour, but it cannot tell you which line of code is to blame.

Question SAST (Semgrep) DAST (ZAP)
Needs a running app No Yes
Points to a source line Yes No
Finds missing security headers No Yes
Finds string-built SQL in code Yes Only with an active scan

What you need first

  • A GitHub account and an empty repository
  • Git, Docker and Python 3.12 on Linux, macOS or WSL
  • Project "DevSecOps Pipeline with Security Gates" is a helpful warm-up but not required
Security

The starting code is deliberately vulnerable. Run it only on your own machine or in CI, never on a public server. Only point ZAP at systems you own or have written permission to test.

Milestone 1: Create the Vulnerable App

This milestone creates a small Flask service with two planted problems: a SQL query built from user input, and Flask debug mode switched on. You build the problem first so the scanners have something real to find.

Create the layout

Bash
mkdir sast-dast-demo && cd sast-dast-demo
git init -b main
mkdir -p app tests .semgrep .github/workflows
touch app/__init__.py tests/__init__.py
printf '.venv/\n__pycache__/\n*.pyc\nzap-out/\n' > .gitignore

Write the application

Python
# app/main.py
import sqlite3
from flask import Flask, jsonify, request
app = Flask(__name__)
def get_db():
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
conn.executemany("INSERT INTO users (name) VALUES (?)", [("alice",), ("bob",)])
return conn
@app.get("/")
def index():
return '<h1>Demo</h1><a href="/search?name=alice">Search for alice</a>'
@app.get("/health")
def health():
return jsonify(status="ok")
@app.get("/search")
def search():
name = request.args.get("name", "")
query = f"SELECT id, name FROM users WHERE name = '{name}'"
rows = get_db().execute(query).fetchall()
return jsonify(users=[{"id": r[0], "name": r[1]} for r in rows])
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080, debug=True)

The /search route puts the name value straight into the SQL text, so a visitor can change the meaning of the query. The debug=True line exposes Werkzeug's interactive debugger, which can run code on the server.

Add a test, dependencies and a Dockerfile

Python
# tests/test_app.py
import unittest
from app.main import app
class AppTest(unittest.TestCase):
def setUp(self):
self.client = app.test_client()
def test_health(self):
self.assertEqual(self.client.get("/health").get_json(), {"status": "ok"})
def test_search_finds_alice(self):
users = self.client.get("/search?name=alice").get_json()["users"]
self.assertEqual([u["name"] for u in users], ["alice"])
Bash
# requirements.txt
Flask==3.1.3
Dockerfile
# Dockerfile
FROM python:3.12-slim
WORKDIR /srv
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt \
&& useradd --system --no-create-home appuser
COPY app ./app
USER appuser
EXPOSE 8080
CMD ["python", "-m", "flask", "--app", "app.main", "run", "--host", "0.0.0.0", "--port", "8080"]

The container starts the app with the flask command, not app.run, so debug mode is off inside the image. The Semgrep rule still catches it in the source, which is the point of reading code statically.

Bash
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python3 -m unittest discover -s tests -t .
git add . && git commit -m "Add vulnerable demo app"
Common Mistake

Committing the .venv folder. Scanners then report hundreds of findings in packages you do not own. Keep .venv/ in .gitignore from the first commit.

Milestone 2: Catch Code Flaws with Semgrep (SAST)

This milestone writes two Semgrep rules and runs them. Writing a rule yourself shows what a SAST tool actually does: it matches code patterns, with no AI guessing involved.

How a Semgrep rule works

A rule is a YAML file with a code pattern. $DB and $X are metavariables that match any expression. ... matches anything in between. The rule below flags an f-string or a string concatenation passed to execute, which is how SQL injection usually appears in Python.

YAML
# .semgrep/rules.yml
rules:
- id: sql-built-from-string
languages: [python]
severity: ERROR
message: SQL query is built from a string. Use a parameterised query such as cursor.execute("... WHERE name = ?", (name,)).
patterns:
- pattern-either:
- pattern: |
$Q = f"...{$X}..."
...
$DB.execute($Q)
- pattern: |
$Q = f"...{$X}..."
...
$DB.execute($Q).$M(...)
- pattern: $DB.execute(f"...{$X}...")
- pattern: $DB.execute("..." + $X)
metadata:
cwe: "CWE-89"
- id: flask-debug-enabled
languages: [python]
severity: ERROR
message: Flask debug mode exposes an interactive debugger that allows remote code execution. Never enable it outside local development.
pattern: $APP.run(..., debug=True, ...)
metadata:
cwe: "CWE-489"

Run it

Bash
pip install semgrep==1.180.0
semgrep scan --config .semgrep/rules.yml --metrics=off --error app
echo "exit code: $?"

Expected result: two findings, sql-built-from-string on the query and flask-debug-enabled on the last line, and exit code 1. The --error flag is what turns a finding into a failing exit code.

Flag What it does
--config .semgrep/rules.yml Uses only your local rules, so results do not change when a public rule set updates
--metrics=off Stops Semgrep sending usage data
--error Exits with code 1 when any finding is reported
Tip

Public rule packs such as p/python find far more, and are worth adding later. Start with local rules so you understand every finding, then widen the net.

Common Mistake

Writing a rule and never testing that it matches. A rule that silently matches nothing gives false comfort. Always run it against code you know is vulnerable first, as you just did.

Milestone 3: Fix the Code and Add a Security Test

This milestone fixes both flaws and adds a test that proves the SQL injection is gone. A test that fails on the vulnerable code and passes on the fixed code is the strongest evidence that a fix works.

Fix the query and the debug flag

A parameterised query sends the SQL text and the user value to the database separately, so the value can never be read as SQL.

Python
# app/main.py (changed parts)
@app.get("/search")
def search():
name = request.args.get("name", "")
rows = get_db().execute(
"SELECT id, name FROM users WHERE name = ?", (name,)
).fetchall()
return jsonify(users=[{"id": r[0], "name": r[1]} for r in rows])
if __name__ == "__main__":
app.run(host="127.0.0.1", port=8080)

Add the injection test

The payload below turns the old query into "return every user". With the fix, no user has that odd name, so the result is empty.

Python
# add to tests/test_app.py inside AppTest
def test_sql_injection_returns_nothing(self):
payload = "x' OR '1'='1"
users = self.client.get("/search", query_string={"name": payload}).get_json()["users"]
self.assertEqual(users, [])
Bash
python3 -m unittest discover -s tests -t .
semgrep scan --config .semgrep/rules.yml --metrics=off --error app
echo "exit code: $?"

Expected result: three tests pass, Semgrep reports no findings, and the exit code is 0.

To see the test earn its keep, temporarily revert the query to the f-string version and run the tests again. The injection test fails because the old query returns both users.

Bash
git add . && git commit -m "Fix SQL injection and debug mode, add injection test"
Security

Binding to 127.0.0.1 for local runs is safer than 0.0.0.0, which listens on every network interface. The container still binds to all interfaces because Docker needs that to publish the port.

Milestone 4: Scan the Running App with ZAP (DAST)

This milestone builds the container, starts it, and attacks it with the OWASP ZAP baseline scan. The baseline scan is passive: it crawls the app and inspects the responses without sending attack payloads, so it is safe to run in every pipeline.

Run the app

Bash
docker build -t sast-dast-demo:local .
docker run -d --rm --name demo -p 8080:8080 sast-dast-demo:local
curl -s http://127.0.0.1:8080/health

Tell ZAP which findings should fail the build

ZAP reports every rule as a warning by default. A rules file lets you promote the ones you care about to FAIL. The file must be tab-separated: rule ID, action, then a name.

Bash
mkdir -p zap-out && chmod 777 zap-out
printf '10020\tFAIL\t(Anti-clickjacking header)\n10021\tFAIL\t(X-Content-Type-Options header)\n10038\tFAIL\t(Content-Security-Policy header)\n' > zap-out/zap-rules.conf

Run the baseline scan

Bash
docker run --rm --network host -v "$PWD/zap-out:/zap/wrk:rw" \
ghcr.io/zaproxy/zaproxy:2.17.0 \
zap-baseline.py -t http://127.0.0.1:8080 -c zap-rules.conf -r zap-report.html
echo "ZAP exit code: $?"

Expected result: ZAP lists the three header rules as FAIL, writes zap-out/zap-report.html, and exits with code 1. ZAP exit codes are 0 for clean, 1 for at least one FAIL, 2 for warnings only, and 3 for a scan error.

Common Mistake

Expecting the baseline scan to find the SQL injection. It does not send attack payloads, so it cannot. That is exactly why you run SAST as well. Active scanning (zap-full-scan.py) can find injection, but it modifies data and must only target a test system you own.

Tip

--network host works on Linux and in GitHub Actions. On macOS or Windows Docker Desktop, use http://host.docker.internal:8080 as the target instead. If the pinned image tag is not found, check the ZAP project's container registry for a current tag, or use stable.

Fix what ZAP found

The three headers are one small hook in the app.

Python
# add to app/main.py
@app.after_request
def add_security_headers(response):
response.headers["Content-Security-Policy"] = "default-src 'none'; frame-ancestors 'none'"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
return response
Bash
docker stop demo
docker build -t sast-dast-demo:local .
docker run -d --rm --name demo -p 8080:8080 sast-dast-demo:local
docker run --rm --network host -v "$PWD/zap-out:/zap/wrk:rw" ghcr.io/zaproxy/zaproxy:2.17.0 \
zap-baseline.py -t http://127.0.0.1:8080 -c zap-rules.conf -r zap-report.html
echo "ZAP exit code: $?"
docker stop demo

Expected result: none of the three header rules is FAIL any more, and the exit code is 0 or 2 (warnings only).

Header What it prevents
Content-Security-Policy Browsers running scripts or loading content you did not intend
X-Content-Type-Options Browsers guessing a file type and running it as a script
X-Frame-Options Your pages being embedded in a hostile page for clickjacking

Milestone 5: Wire Both Scans into GitHub Actions

This milestone puts SAST and DAST into one workflow. SAST runs first because it is cheap and needs no container. DAST only starts when SAST passes, so you never spend time attacking code that already failed.

Create the workflow

YAML
# .github/workflows/sast-dast.yml
name: SAST and DAST
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
sast:
name: "SAST: tests and Semgrep"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Run unit tests
run: |
pip install -r requirements.txt
python -m unittest discover -s tests -t .
- name: Run Semgrep
run: |
pip install semgrep==1.180.0
semgrep scan --config .semgrep/rules.yml --metrics=off --error app
dast:
name: "DAST: ZAP baseline"
runs-on: ubuntu-24.04
needs: [sast]
steps:
- uses: actions/checkout@v4
- name: Build and start the app
run: |
docker build -t sast-dast-demo:ci .
docker run -d --name demo -p 8080:8080 sast-dast-demo:ci
for i in $(seq 1 30); do
curl -fs http://127.0.0.1:8080/health && break
sleep 1
done
- name: Prepare ZAP rules
run: |
mkdir -p zap-out && chmod 777 zap-out
printf '10020\tFAIL\t(Anti-clickjacking header)\n10021\tFAIL\t(X-Content-Type-Options header)\n10038\tFAIL\t(Content-Security-Policy header)\n' > zap-out/zap-rules.conf
- name: Run ZAP baseline scan
run: |
set +e
docker run --rm --network host -v "$PWD/zap-out:/zap/wrk:rw" \
ghcr.io/zaproxy/zaproxy:2.17.0 \
zap-baseline.py -t http://127.0.0.1:8080 -c zap-rules.conf -r zap-report.html
code=$?
set -e
echo "ZAP exit code: ${code}"
if [ "${code}" -eq 1 ] || [ "${code}" -ge 3 ]; then exit 1; fi
- uses: actions/upload-artifact@v4
if: always()
with:
name: zap-report
path: zap-out/zap-report.html
if-no-files-found: ignore

Why the ZAP step handles exit codes itself

ZAP exits with 2 when it only has warnings. Most CI systems treat any non-zero exit as failure, which would block you on informational notes. The step above fails only on exit code 1 (a FAIL rule) or 3 or higher (a scan error), and lets 0 and 2 pass.

Bash
git add . && git commit -m "Add SAST and DAST workflow"
git remote add origin git@github.com:YOUR-USER/sast-dast-demo.git
git push -u origin main
Common Mistake

Starting ZAP before the app is ready. The wait loop polls /health for up to 30 seconds. Without it, ZAP scans a connection error and reports a clean result for an app it never reached.

Security

The workflow only has contents: read. A scanner step never needs write access to your repository, so do not grant it.

Validation & Testing

Prove that both scans block real problems, then confirm you can explain what each one does.

Test 1: SQL injection is blocked by SAST

Bash
git switch -c test/sqli
python3 - << 'EOF'
p = "app/main.py"
s = open(p).read()
s = s.replace('"SELECT id, name FROM users WHERE name = ?", (name,)', 'f"SELECT id, name FROM users WHERE name = \'{name}\'"')
open(p, "w").write(s)
EOF
git commit -am "test: reintroduce string-built SQL"
git push -u origin test/sqli

Expected result: the SAST job fails (the injection test and Semgrep both fail) and the DAST job is skipped.

Test 2: a missing header is blocked by DAST

Bash
git switch main && git switch -c test/headers
sed -i '/X-Frame-Options/d' app/main.py
git commit -am "test: remove X-Frame-Options"
git push -u origin test/headers

Expected result: SAST passes and DAST fails with the anti-clickjacking rule reported as FAIL. Open the zap-report artifact to read the details.

Bash
git switch main
git push origin --delete test/sqli test/headers
git branch -D test/sqli test/headers

Reflection questions

Put your answers in the README.

  • Why does SAST run before DAST, and what does that save?
  • What can ZAP find that Semgrep cannot, and the reverse?
  • Why does the baseline scan not find the SQL injection, and when would you run an active scan?
  • Why does the ZAP step handle exit codes itself?

Portfolio deliverable

Your README should have the pipeline diagram, a Semgrep finding with the line number, a ZAP report excerpt, and a comparison table of what SAST and DAST each catch. Link the failed run from Test 1 so a reviewer can see a gate blocking.

Tip

Re-run both tests whenever you edit the workflow. A scan that quietly stopped failing is the most common way these pipelines go wrong.

Found a mistake, an outdated step or a broken link?