DevSecOps (AppSec in the pipeline) · lesson 5 of 7
Beginner
3Intermediate
3Also part of DevSecOps (AppSec in the pipeline)
10- + Bash Security Best Practices from Bash
- + Secrets: Real Log Masking, and the Base64 Trick That Defeats It from GitHub Actions
- + Docker Security from Docker
- + Multi-Stage Builds from Docker
- + sensitive = true: What It Hides, and What It Doesn't from Terraform
- + Variable Precedence: host_vars Beats group_vars, and extra-vars Beats Everything from Ansible
- + Scanning Terraform for Misconfiguration with Checkov from Cloud Security
- + Policy as Code: Testing Terraform Plans with OPA and Conftest from Cloud Security
- + MCP Authorization: OAuth 2.1, CIMD and a Real Identity Provider from MCP (Model Context Protocol)
- + Running MCP in Production: Scaling, Caching and Operations from MCP (Model Context Protocol)
Also part of: Ethical Hacking (methodology, lab-only)
DAST with an OWASP ZAP Baseline Scan Against Your Own App
Run a passive OWASP ZAP baseline scan against a tiny Python app in its own container, read each warning, add the missing headers, and get to exit 0.
By SkillByExample · Published
Static analysis can’t tell you what headers your server actually sends. A DAST scan asks the
running application. This lesson runs an OWASP ZAP baseline scan, which is passive: it crawls
for a short time and inspects the responses, without sending attack payloads. The target is a
small Python server that the lesson builds and runs on a private Docker network, and nothing
else. Tested with ZAP 2.17.0 (ghcr.io/zaproxy/zaproxy:2.17.0), Python 3.12.15
(python:3.12-slim), curl 8.17.0 (curlimages/curl:8.17.0) and Docker Engine 29.8.2.
The app under test
A standard-library HTTP server that returns the same page for every path.
# app.py
from http.server import BaseHTTPRequestHandler, HTTPServer
PAGE = b"<!doctype html><html><head><title>Shop</title></head><body><h1>Shop</h1></body></html>"
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(PAGE)
HTTPServer(("0.0.0.0", 8000), Handler).serve_forever()
# Dockerfile
FROM python:3.12-slim
COPY app.py /app.py
CMD ["python", "-u", "/app.py"]
Build it and start it on its own network, with no published port. Only containers on zapnet can
reach it. The -q and -d commands print only IDs, so their output is left out here.
$ docker build -q -t shop-app .
$ docker network create zapnet
$ docker run -d --name shop --network zapnet shop-app
$ docker run --rm --network zapnet curlimages/curl:8.17.0 -s -D - -o /dev/null http://shop:8000
HTTP/1.0 200 OK
Server: BaseHTTP/0.6 Python/3.12.15
Date: Wed, 14 Oct 2026 12:57:04 GMT
Content-Type: text/html; charset=utf-8
These headers are what the scan judges: a content type, a date, and a Server header that names
the exact Python version. Nothing else.
Run the baseline scan
ZAP joins the same network and targets http://shop:8000. Its full report lists every passing rule
too, so grep keeps the warnings and the summary. In bash, ${PIPESTATUS[0]} is the exit code of
the first command in the pipe, which is ZAP’s.
$ docker run --rm --network zapnet ghcr.io/zaproxy/zaproxy:2.17.0 zap-baseline.py -t http://shop:8000 | grep -E "^(WARN|FAIL)"
WARN-NEW: Missing Anti-clickjacking Header [10020] x 3
WARN-NEW: X-Content-Type-Options Header Missing [10021] x 3
WARN-NEW: Server Leaks Version Information via "Server" HTTP Response Header Field [10036] x 3
WARN-NEW: Content Security Policy (CSP) Header Not Set [10038] x 3
WARN-NEW: Storable and Cacheable Content [10049] x 3
WARN-NEW: Permissions Policy Header Not Set [10063] x 3
WARN-NEW: Cross-Origin-Embedder-Policy Header Missing or Invalid [90004] x 9
FAIL-NEW: 0 FAIL-INPROG: 0 WARN-NEW: 7 WARN-INPROG: 0 INFO: 0 IGNORE: 0 PASS: 60
$ echo ${PIPESTATUS[0]}
2
Seven warnings, each with a rule ID in brackets. x 3 means three URLs: ZAP requested /,
/robots.txt and /sitemap.xml, and this server answers all three with the same page. Every
warning here is a missing or leaky header, which is typical of a first baseline scan. Exit code
2 means “warnings, no failures”; a CI step that fails on any non-zero code fails here.
Add the headers
Each warning maps to one header. CSP with frame-ancestors 'none' also covers clickjacking, so a
separate X-Frame-Options isn’t needed. Setting server_version and sys_version stops the
version leak.
# app.py
from http.server import BaseHTTPRequestHandler, HTTPServer
PAGE = b"<!doctype html><html><head><title>Shop</title></head><body><h1>Shop</h1></body></html>"
HEADERS = {
"Content-Type": "text/html; charset=utf-8",
"Content-Security-Policy": "default-src 'self'; frame-ancestors 'none'; form-action 'self'",
"X-Content-Type-Options": "nosniff",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
"Cache-Control": "no-store",
"Cross-Origin-Embedder-Policy": "require-corp",
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Resource-Policy": "same-origin",
}
class Handler(BaseHTTPRequestHandler):
server_version, sys_version = "shop", ""
def do_GET(self):
self.send_response(200)
for name, value in HEADERS.items():
self.send_header(name, value)
self.end_headers()
self.wfile.write(PAGE)
HTTPServer(("0.0.0.0", 8000), Handler).serve_forever()
Rebuild and restart the container, then check the headers.
$ docker build -q -t shop-app . && docker rm -f shop && docker run -d --name shop --network zapnet shop-app
$ docker run --rm --network zapnet curlimages/curl:8.17.0 -s -D - -o /dev/null http://shop:8000
HTTP/1.0 200 OK
Server: shop
Date: Wed, 14 Oct 2026 12:57:01 GMT
Content-Type: text/html; charset=utf-8
Content-Security-Policy: default-src 'self'; frame-ancestors 'none'; form-action 'self'
X-Content-Type-Options: nosniff
Permissions-Policy: camera=(), microphone=(), geolocation=()
Cache-Control: no-store
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin
The Server header now says only shop; the trailing space is where the empty sys_version
would go. Scan again.
$ docker run --rm --network zapnet ghcr.io/zaproxy/zaproxy:2.17.0 zap-baseline.py -t http://shop:8000 | grep -E "^(WARN|FAIL|IGNORE)"
WARN-NEW: Non-Storable Content [10049] x 3
FAIL-NEW: 0 FAIL-INPROG: 0 WARN-NEW: 1 WARN-INPROG: 0 INFO: 0 IGNORE: 0 PASS: 66
$ echo ${PIPESTATUS[0]}
2
Six warnings gone, and PASS went from 60 to 66. The one left is rule 10049 again, with the opposite
title: before, the content was “Storable and Cacheable”; now it’s “Non-Storable”, because
Cache-Control: no-store is exactly what was added. Rule 10049 reports caching behaviour either
way. It’s information, not a flaw, and it still makes the scan exit 2.
Record the decision in a rules file
A rules file sets the level of each rule: IGNORE, WARN or FAIL. It’s tab-separated: rule ID,
level, and a note.
10049 IGNORE (Non-Storable Content: no-store is deliberate)
Save it as rules.tsv. ZAP reads files from /zap/wrk, so mount the current folder there.
$ docker run --rm --network zapnet -v "$PWD":/zap/wrk ghcr.io/zaproxy/zaproxy:2.17.0 zap-baseline.py -t http://shop:8000 -c rules.tsv | grep -E "^(WARN|FAIL|IGNORE)"
IGNORE: Non-Storable Content [10049] x 2
FAIL-NEW: 0 FAIL-INPROG: 0 WARN-NEW: 0 WARN-INPROG: 0 INFO: 0 IGNORE: 1 PASS: 66
$ echo ${PIPESTATUS[0]}
0
Exit 0. The rule still runs and is still reported, now as IGNORE, so the decision shows up in
every scan instead of disappearing. The count is x 2 on this run because the spider reached
fewer URLs; that number can vary between runs, the verdict doesn’t. Compare this with -I, which
would also exit 0, but by ignoring every warning, including a new one someone adds next month.
When you’re done, remove the lab with docker rm -f shop && docker network rm zapnet.
Practice
-
Make missing CSP a hard failure: add rule 10038 as
FAILto the rules file, remove theContent-Security-Policyline fromHEADERS, rebuild and scan. What exit code do you get?Solution
Add a line with
10038, a tab,FAIL, a tab and(CSP is required)torules.tsv. The scan printsFAIL-NEW: Content Security Policy (CSP) Header Not Set [10038], the summary showsFAIL-NEW: 1, and the exit code is 1. Removing CSP also brings back the clickjacking warning (10020), becauseframe-ancestorslived inside it. -
Generate a starter rules file listing every rule the baseline ran, instead of writing it by hand.
Solution
Add
-g gen.confwith the folder mounted at/zap/wrk. ZAP writesgen.confwith one line per rule, each set toWARNwith its name. Edit the levels you care about and pass the file with-c. It’s also a quick way to see the full rule list of your ZAP version. -
Save a report you can attach to a pull request.
Solution
Add
-r report.html(or-J report.jsonfor machines) with the folder mounted at/zap/wrk. ZAP writes the file into your current folder, with each alert’s description, evidence and the URLs it was found on.
Where this fits
You now have a scanner for each layer: pytm for the design, Bandit and Semgrep for the code, Gitleaks for secrets, pip-audit and Grype for dependencies, and ZAP for the running app. The next lesson puts the code-side ones into a single CI security gate.