Every tool so far has read something: source, a lockfile, a Dockerfile, an image manifest. ZAP is the first that runs the application and attacks it.
That changes what it can find — no false positives about unreachable code, because it only reports what it actually triggered — and it introduces a limit none of the static tools have: ZAP can only test what it can find.
Verified against the ZAP stable container, attacking the Range app. Every number below is copied from a real run.
Step 1 — Stand the app up, locally
git clone https://github.com/jaybilgaye/aiopsone-range
cd aiopsone-range
docker build -f docker/Dockerfile -t range-app:vulnerable .
docker run -d --name range-dast -p 127.0.0.1:3100:3000 range-app:vulnerable
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3100/health # 200-p 127.0.0.1:3100:3000, not -p 3100:3000. The first publishes to your loopback only. The second publishes to every interface on the machine, which on a laptop on a café network means you have put a deliberately vulnerable application on the LAN. This is the single most important line in the lesson.
Step 2 — The baseline scan
docker run --rm --add-host=host.docker.internal:host-gateway \
ghcr.io/zaproxy/zaproxy:stable zap-baseline.py \
-t http://host.docker.internal:3100 -ITotal of 4 URLs
WARN-NEW: Missing Anti-clickjacking Header [10020] x 1
WARN-NEW: X-Content-Type-Options Header Missing [10021] x 2
WARN-NEW: User Controllable HTML Element Attribute (Potential XSS) [10031] x 1
WARN-NEW: Server Leaks Information via "X-Powered-By" [10037] x 4
WARN-NEW: Content Security Policy (CSP) Header Not Set [10038] x 2
WARN-NEW: Permissions Policy Header Not Set [10063] x 4
FAIL-NEW: 0 WARN-NEW: 9 PASS: 58Nine warnings, zero failures, on an application with a working SQL injection and a working reflected XSS.
That is not a bug. zap-baseline.py is passive: it spiders the site, reads the traffic that generates, and reports what it can infer from responses. It never sends an attack payload. Passive scanning finds missing headers and information disclosure — real findings, and none of them the reason you would be worried about this app.
-I means "do not exit non-zero on warnings", which is what makes baseline safe to run on every pull request.
Step 3 — The active scan
docker run --rm --add-host=host.docker.internal:host-gateway \
ghcr.io/zaproxy/zaproxy:stable zap-full-scan.py \
-t http://host.docker.internal:3100 -I -m 4Total of 7 URLs
WARN-NEW: SQL Injection [40018] x 1
http://host.docker.internal:3100/search?q=%27 (500 Internal Server Error)
WARN-NEW: Cross Site Scripting (DOM Based) [40026] x 3
WARN-NEW: Missing Anti-clickjacking Header [10020] x 3
…There it is. ZAP sent ?q=' — a single quote — got a 500 back, and inferred an injectable query. That 500 is the Range's verbose error handler leaking the SQL statement, which is why one probe was enough.
-m 4 bounds the scan to four minutes. Without a bound an active scan on a real application can run for hours.
Active means it sends attacks. Never point
zap-full-scan.pyat anything you do not own and have permission to test. Against production it can create records, trigger workflows, send email, and fill your logs with what looks exactly like an attack — because it is one.
Step 4 — What it still missed, and why that matters most
The Range has a reflected XSS at /greet?name=. Every XSS rule passed:
PASS: Cross Site Scripting (Reflected) [40012]
PASS: Cross Site Scripting (Persistent) [40014]The vulnerability is real — curl "http://127.0.0.1:3100/greet?name=<script>alert(1)</script>" returns it unescaped. ZAP did not find it because ZAP never visited /greet.
Look at what the spider actually discovered:
http://host.docker.internal:3100/
http://host.docker.internal:3100/robots.txt
http://host.docker.internal:3100/sitemap.xml
http://host.docker.internal:3100/search?q=ZAPFour real URLs. /greet is not linked from the homepage, so the spider never saw it, so no rule ever tested it. Same for /debug/config, the endpoint that returns the AWS credentials in JSON.
This is the defining limitation of DAST. A static analyser reads every file whether or not anything calls it. A dynamic scanner tests the attack surface it can discover — and modern applications hide most of theirs behind authentication, JavaScript routing, and API endpoints nothing links to.
The fix is to stop making it guess:
# feed it the endpoints directly
docker run --rm --add-host=host.docker.internal:host-gateway \
-v $(pwd):/zap/wrk/:rw \
ghcr.io/zaproxy/zaproxy:stable zap-api-scan.py \
-t /zap/wrk/openapi.yaml -f openapi -IAn OpenAPI spec, a Postman collection, or a plain URL list turns "whatever the spider stumbled onto" into "every endpoint we ship". If you take one thing from this lesson, it is that an unfed DAST scan reports on a fraction of your application and gives no indication of how small that fraction was.
Step 5 — Gate it
Baseline on every pull request, active on a schedule:
# .github/workflows/dast.yml
name: dast
on:
pull_request:
schedule:
- cron: '0 2 * * 0' # active scan, weekly
permissions:
contents: read
jobs:
zap:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: |
docker build -f docker/Dockerfile -t app:test .
docker run -d --name app -p 127.0.0.1:3100:3000 app:test
sleep 10
- name: ZAP baseline (fast, passive)
if: github.event_name == 'pull_request'
uses: zaproxy/action-baseline@v0.12.0
with:
target: http://127.0.0.1:3100
cmd_options: '-I'
- name: ZAP full (slow, active)
if: github.event_name == 'schedule'
uses: zaproxy/action-full-scan@v0.10.0
with:
target: http://127.0.0.1:3100
cmd_options: '-I -m 10'Baseline takes under a minute and belongs in the pull request. An active scan does not — it is slow, and it is an attack, and neither belongs in a gate someone is waiting on.
Step 6 — Clean up
docker rm -f range-dast
docker rmi range-app:vulnerable # optional; the image is 1.85 GBDo not leave a deliberately vulnerable container running. It binds to loopback, but a stopped container is a better guarantee than a correct flag.
What you learned
- Baseline is passive: 0 failures on an app with a working SQL injection. It finds missing headers, not vulnerabilities.
- Active found the SQL injection from a single
'producing a 500 — the verbose error handler did most of the work. - It missed the XSS entirely, because nothing links to
/greetand the spider only reached 4 real URLs. DAST tests the surface it can discover. - Feed it an OpenAPI spec or a URL list. An unfed scan covers a fraction of your app and never tells you how small that fraction was.
-p 127.0.0.1:3100:3000. Publishing to all interfaces puts a vulnerable app on whatever network you are sitting on.- Baseline on pull requests, active on a schedule. An active scan is an attack, not a check.