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.
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
SecurityThe 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
mkdir sast-dast-demo && cd sast-dast-demogit init -b mainmkdir -p app tests .semgrep .github/workflowstouch app/__init__.py tests/__init__.pyprintf '.venv/\n__pycache__/\n*.pyc\nzap-out/\n' > .gitignoreWrite the application
# app/main.pyimport 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
# tests/test_app.pyimport 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"])# requirements.txtFlask==3.1.3# DockerfileFROM python:3.12-slimWORKDIR /srvCOPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txt \ && useradd --system --no-create-home appuserCOPY app ./appUSER appuserEXPOSE 8080CMD ["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.
python3 -m venv .venv && source .venv/bin/activatepip install -r requirements.txtpython3 -m unittest discover -s tests -t .git add . && git commit -m "Add vulnerable demo app"Common MistakeCommitting the
.venvfolder. Scanners then report hundreds of findings in packages you do not own. Keep.venv/in.gitignorefrom 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.
# .semgrep/rules.ymlrules: - 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
pip install semgrep==1.180.0semgrep scan --config .semgrep/rules.yml --metrics=off --error appecho "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 |
TipPublic rule packs such as
p/pythonfind far more, and are worth adding later. Start with local rules so you understand every finding, then widen the net.
Common MistakeWriting 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.
# 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.
# 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, [])python3 -m unittest discover -s tests -t .semgrep scan --config .semgrep/rules.yml --metrics=off --error appecho "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.
git add . && git commit -m "Fix SQL injection and debug mode, add injection test"SecurityBinding to
127.0.0.1for local runs is safer than0.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
docker build -t sast-dast-demo:local .docker run -d --rm --name demo -p 8080:8080 sast-dast-demo:localcurl -s http://127.0.0.1:8080/healthTell 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.
mkdir -p zap-out && chmod 777 zap-outprintf '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.confRun the baseline scan
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.htmlecho "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 MistakeExpecting 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 hostworks on Linux and in GitHub Actions. On macOS or Windows Docker Desktop, usehttp://host.docker.internal:8080as the target instead. If the pinned image tag is not found, check the ZAP project's container registry for a current tag, or usestable.
Fix what ZAP found
The three headers are one small hook in the app.
# add to app/main.py@app.after_requestdef 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 responsedocker stop demodocker build -t sast-dast-demo:local .docker run -d --rm --name demo -p 8080:8080 sast-dast-demo:localdocker 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.htmlecho "ZAP exit code: $?"docker stop demoExpected 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
# .github/workflows/sast-dast.ymlname: 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: ignoreWhy 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.
git add . && git commit -m "Add SAST and DAST workflow"git remote add origin git@github.com:YOUR-USER/sast-dast-demo.gitgit push -u origin mainCommon MistakeStarting ZAP before the app is ready. The wait loop polls
/healthfor up to 30 seconds. Without it, ZAP scans a connection error and reports a clean result for an app it never reached.
SecurityThe 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
git switch -c test/sqlipython3 - << '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)EOFgit commit -am "test: reintroduce string-built SQL"git push -u origin test/sqliExpected 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
git switch main && git switch -c test/headerssed -i '/X-Frame-Options/d' app/main.pygit commit -am "test: remove X-Frame-Options"git push -u origin test/headersExpected result: SAST passes and DAST fails with the anti-clickjacking rule reported as FAIL. Open the zap-report artifact to read the details.
git switch maingit push origin --delete test/sqli test/headersgit branch -D test/sqli test/headersReflection 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.
TipRe-run both tests whenever you edit the workflow. A scan that quietly stopped failing is the most common way these pipelines go wrong.