Skip to main content
DevSecOps (AppSec in the pipeline) · lesson 5 of 7
DevSecOps (AppSec in the pipeline) intermediate Lesson 5 of 7

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

devsecops dast owasp-zap security-headers docker

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

  1. Make missing CSP a hard failure: add rule 10038 as FAIL to the rules file, remove the Content-Security-Policy line from HEADERS, rebuild and scan. What exit code do you get?

    Solution

    Add a line with 10038, a tab, FAIL, a tab and (CSP is required) to rules.tsv. The scan prints FAIL-NEW: Content Security Policy (CSP) Header Not Set [10038], the summary shows FAIL-NEW: 1, and the exit code is 1. Removing CSP also brings back the clickjacking warning (10020), because frame-ancestors lived inside it.

  2. Generate a starter rules file listing every rule the baseline ran, instead of writing it by hand.

    Solution

    Add -g gen.conf with the folder mounted at /zap/wrk. ZAP writes gen.conf with one line per rule, each set to WARN with 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.

  3. Save a report you can attach to a pull request.

    Solution

    Add -r report.html (or -J report.json for 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.

Frequently Asked Questions

What is DAST?
Dynamic application security testing probes a running application over HTTP, the way a browser or an attacker would, without reading its source. It finds problems that only exist at runtime, such as missing security headers, cookies without the Secure flag or verbose error pages. It complements SAST, which reads the code but can't see how the server is configured.
Is a ZAP baseline scan safe to run?
The baseline scan spiders the target for a short time and then only reads the responses; it doesn't send attack payloads. It is still traffic you are generating against a server, so run it only against applications you own or are authorised to test. This lesson scans a container it builds itself, on a private Docker network.
What do the ZAP baseline exit codes mean?
zap-baseline.py exits 0 when there are no warnings or failures, 1 when at least one rule is at FAIL level, 2 when there are warnings but no failures, and 3 for any other error. A CI step that fails on any non-zero code therefore fails on warnings too. The -I flag makes warnings exit 0, but a rules file is the more precise tool.
How do I ignore a ZAP baseline warning I have reviewed?
Write a tab-separated rules file with the rule ID, the level IGNORE, WARN or FAIL, and a note, then pass it with -c. The scan reports that rule as IGNORE and leaves it out of the exit code. Because the file is in the repository, each exception is visible and reviewed.
Which security headers does a ZAP baseline scan check?
Among others: Content-Security-Policy, X-Content-Type-Options, anti-clickjacking protection through X-Frame-Options or CSP frame-ancestors, Permissions-Policy, the Cross-Origin-Embedder, Opener and Resource policies, cache headers, and a Server header that reveals software versions. Each one appears in the report with its rule ID, such as 10038 for a missing CSP.