Step 11 of 25
Dynamic testing (DAST)
Scan a running application with OWASP ZAP, run baseline scans in CI against staging, handle authentication and triage the alerts it raises.
Static analysis reads code; it can't tell you what your application actually sends over the wire. Is the session cookie missing Secure? Does the reverse proxy strip your Content-Security-Policy? Does an error page leak a stack trace? DAST (dynamic application security testing) answers these by talking to the running application over HTTP, the way a browser or an attacker would. This lesson uses OWASP ZAP, the most widely used open-source DAST tool, to scan locally, in CI against staging, and behind a login, and then shows you how to read what it reports.
How ZAP works
ZAP is an intercepting proxy with a scanner built in. A scan has three phases:
- Discovery. The traditional spider follows links and forms in HTML. The AJAX spider drives a headless browser, which finds routes in single-page apps that only exist after JavaScript runs. For APIs, ZAP can import an OpenAPI definition instead of crawling.
- Passive scanning. Every response ZAP sees is inspected for problems without sending anything extra: missing security headers, cookies without flags, version banners, error messages, mixed content. Passive scanning is safe and fast.
- Active scanning. ZAP replays each request with attack payloads in every parameter, header and cookie: SQL injection probes, XSS strings, path traversal sequences, and many more. This finds real injection bugs, but it creates junk data, can trigger account lockouts, and takes much longer.
Each finding is an alert with a numeric rule ID, a risk (High, Medium, Low, Informational) and a confidence (High, Medium, Low, plus Confirmed and False Positive once a human has reviewed it). Risk says how bad it would be; confidence says how sure ZAP is that it's real.
Baseline scan vs full scan
ZAP's Docker image ships packaged scan scripts, so you don't need to drive the UI.
zap-baseline.py | zap-full-scan.py | zap-api-scan.py | |
|---|---|---|---|
| Discovery | Spider for 1 minute (configurable) | Spider, optionally AJAX spider, no time limit by default | Imports OpenAPI, SOAP or GraphQL definitions |
| Attacks | None: passive only | Passive and active | Passive and active, tuned for APIs |
| Safe for shared environments | Yes | No | No |
| Typical duration | 1–3 minutes | 10 minutes to hours | Minutes, scales with endpoints |
| Where it fits | Every deploy to staging | Nightly or weekly against an isolated environment | Nightly against API staging |
Run a baseline against an app you're running locally:
mkdir -p zap && chmod a+w zap
docker run --rm --network host -v "$PWD/zap:/zap/wrk:rw" \
ghcr.io/zaproxy/zaproxy:stable \
zap-baseline.py -t http://localhost:3000 -r report.html -J report.jsonTotal of 47 URLs
PASS: Vulnerable JS Library (Powered by Retire.js) [10003]
PASS: Cookie No HttpOnly Flag [10010]
PASS: Cross-Domain JavaScript Source File Inclusion [10017]
...
WARN-NEW: Content Security Policy (CSP) Header Not Set [10038] x 9
http://localhost:3000/ (200 OK)
http://localhost:3000/robots.txt (200 OK)
WARN-NEW: Missing Anti-clickjacking Header [10020] x 3
http://localhost:3000/ (200 OK)
WARN-NEW: Server Leaks Version Information via "Server" HTTP Response Header Field [10036] x 12
http://localhost:3000/ (200 OK)
WARN-NEW: X-Content-Type-Options Header Missing [10021] x 11
http://localhost:3000/main.js (200 OK)
WARN-NEW: Timestamp Disclosure - Unix [10096] x 2
http://localhost:3000/main.js (200 OK)
FAIL-NEW: 0 FAIL-INPROG: 0 WARN-NEW: 5 WARN-INPROG: 0 INFO: 0 IGNORE: 0 PASS: 61The exit code is designed for pipelines: 0 means no warnings or failures, 1 means at least one FAIL, 2 means warnings but no failures, and 3 means the scan itself broke. By default every alert is a WARN, so a baseline never fails a build until you tell it which alerts matter.
The useful flags: -r/-J/-w write HTML, JSON and Markdown reports; -m 5 spiders for five minutes; -j adds the AJAX spider; -a includes alpha-quality passive rules; -c rules.tsv loads a rules file; -I stops warnings affecting the exit code.
Tuning with a rules file
A rules file turns ZAP's opinions into your policy. Generate a starting point with -g, which writes one line per passive rule:
docker run --rm --network host -v "$PWD/zap:/zap/wrk:rw" \
ghcr.io/zaproxy/zaproxy:stable \
zap-baseline.py -t http://localhost:3000 -g gen.confThen keep only the lines you want to change. Columns are rule ID, action, and a free-text comment; they must be separated by tabs:
# zap/rules.tsv: rule-id <TAB> action <TAB> comment
10038 FAIL (Content Security Policy (CSP) Header Not Set)
10020 FAIL (Missing Anti-clickjacking Header)
10021 FAIL (X-Content-Type-Options Header Missing)
10035 FAIL (Strict-Transport-Security Header Not Set)
10010 FAIL (Cookie No HttpOnly Flag)
10011 FAIL (Cookie Without Secure Flag)
10096 IGNORE (Timestamp Disclosure: build hashes in bundles, reviewed 2026-10-01)Valid actions are IGNORE, INFO, WARN and FAIL, and you can also mark URL patterns OUTOFSCOPE. Commit the file next to the code, review changes to it like code, and write why in the comment when you ignore something.
Running ZAP in CI against a staging deploy
DAST needs a running target, so it belongs after the deploy-to-staging job, not alongside unit tests. The pipeline shape is: build, deploy to staging, wait until healthy, scan, publish the report, fail on FAIL rules.
# .github/workflows/deploy.yml (excerpt)
jobs:
deploy-staging:
# ... deploys and exposes the URL as an output ...
dast-baseline:
needs: deploy-staging
runs-on: ubuntu-latest
permissions:
contents: read
env:
TARGET: https://staging.orders.example.com
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Wait for staging to be healthy
run: |
for i in $(seq 1 30); do
curl -fsS "$TARGET/healthz" && exit 0
sleep 5
done
echo "staging never became healthy" >&2; exit 1
- name: ZAP baseline
run: |
mkdir -p zap-out && chmod a+w zap-out
cp zap/rules.tsv zap-out/
docker run --rm -v "$PWD/zap-out:/zap/wrk:rw" \
ghcr.io/zaproxy/zaproxy:stable \
zap-baseline.py -t "$TARGET" -c rules.tsv \
-r report.html -J report.json -I
- name: Summarise high-risk alerts
if: always()
run: |
fails=$(jq '[.site[].alerts[] | select(.riskcode=="3")] | length' zap-out/report.json)
echo "High-risk alerts: $fails"
- if: always()
uses: actions/upload-artifact@v7
with:
name: zap-baseline
path: zap-out/The chmod matters: ZAP runs as an unprivileged user inside the container, and without write permission on the mounted directory it can't save reports. Here -I keeps warnings from failing the job, so only rules you set to FAIL in rules.tsv break the build (exit code 1). The jq step shows how to read the JSON report, which is useful if you'd rather gate on risk level. ZAP also publishes zaproxy/action-baseline, which wraps the same script and can open a GitHub issue with the results. On GitLab the job is identical in substance: a job in a later stage using the ZAP image, with the report kept as an artifact.
Schedule the full scan separately, nightly, against an environment you can reset:
on:
schedule:
- cron: "30 2 * * *"Full scans write data, so give them a dedicated environment (or one rebuilt from fixtures each night), and tell whoever watches staging alerts so a burst of SQL injection payloads at 02:30 doesn't trigger an incident.
Authenticated scanning
Unauthenticated scans see the login page and little else. Most of your attack surface (account settings, orders, admin pages) sits behind a session. Authenticated scanning means teaching ZAP four things:
- How to log in: form-based (POST username and password), JSON-based, browser-based (ZAP drives a real browser through the login flow, handling redirects and JavaScript), or no login at all because you supply a token.
- How the session is maintained: cookie, or a header such as
Authorization: Bearer …. - How to tell it's still logged in: a logged-in indicator (a regex that matches only authenticated responses, such as a "Sign out" link) or a logged-out indicator (a redirect to
/login). Without this, ZAP happily scans the login page for an hour after its session expires and reports nothing. - What not to touch: exclude logout, password change, account deletion and anything that sends emails or payments.
The simplest robust approach for APIs is to obtain a token yourself and have ZAP add it to every request through environment variables:
docker run --rm -v "$PWD/zap-out:/zap/wrk:rw" \
-e ZAP_AUTH_HEADER=Authorization \
-e ZAP_AUTH_HEADER_VALUE="Bearer $STAGING_SCAN_TOKEN" \
-e ZAP_AUTH_HEADER_SITE=staging.orders.example.com \
ghcr.io/zaproxy/zaproxy:stable \
zap-api-scan.py -t https://staging.orders.example.com/openapi.json -f openapi -r api.htmlZAP_AUTH_HEADER_SITE restricts the header to your host, so the token isn't sent to third-party domains the app links to. For complex flows, ZAP's Automation Framework describes contexts, users, authentication and jobs in one YAML plan run with zap.sh -cmd -autorun plan.yaml:
env:
contexts:
- name: orders-staging
urls: ["https://staging.orders.example.com"]
includePaths: ["https://staging.orders.example.com/.*"]
excludePaths:
- "https://staging.orders.example.com/logout.*"
- "https://staging.orders.example.com/account/delete.*"
parameters:
failOnError: true
jobs:
- type: spider
parameters:
maxDuration: 5
- type: passiveScan-wait
- type: activeScan
- type: report
parameters:
template: traditional-html
reportFile: zap-report.htmlUse a dedicated test account with realistic but synthetic data, the least privilege that still covers the routes you need, and a short-lived token stored as a CI secret. Scan as a normal user and, separately, as an admin; comparing the two finds authorisation bugs.
Interpreting alerts
Treat each alert as a hypothesis to confirm, not a verdict. A workable process:
- Group by rule, not by URL. "CSP header missing ×9" is one fix in one place: middleware or the reverse proxy.
- Read the evidence. Each alert includes the request, the response and the matched evidence. Reproduce it with
curl -ior in a browser. - Decide: fix, accept with a reason, or mark as false positive in the rules file.
| Alert | Typical verdict | Fix |
|---|---|---|
| CSP Header Not Set [10038] | Real | Send Content-Security-Policy from app middleware or the proxy |
| Missing Anti-clickjacking Header [10020] | Real | Content-Security-Policy: frame-ancestors 'none' (or X-Frame-Options: DENY) |
| Server Leaks Version Information [10036] | Real, low risk | Disable the version banner in nginx (server_tokens off;) |
| Timestamp Disclosure [10096] | Usually a false positive | Numbers in bundled JavaScript that happen to look like timestamps |
| SQL Injection [40018], High confidence | Real until proven otherwise | Parameterised queries; add a SAST rule so it can't recur |
| Application Error Disclosure [90022] | Real | Generic error pages; log the detail server-side |
A passing DAST scan doesn't mean the application is secure; it means the scanner found nothing it knows how to look for on the pages it reached. Business-logic flaws such as "user A can view user B's order by changing the ID" need tests you write yourself or manual testing.
Practice
Scan an intentionally vulnerable application that you run yourself, so you have permission by construction:
- Start OWASP Juice Shop on a Docker network and run a ZAP baseline against it, writing HTML and JSON reports.
- Generate a rules file, set the CSP and anti-clickjacking rules to
FAIL, ignore Timestamp Disclosure, and re-run so the scan exits with code 1. - From the JSON report, list each alert's name, risk and instance count.
Show solution
docker network create zapnet
docker run -d --rm --name juice --network zapnet bkimminich/juice-shop
mkdir -p zap && chmod a+w zap
docker run --rm --network zapnet -v "$PWD/zap:/zap/wrk:rw" \
ghcr.io/zaproxy/zaproxy:stable \
zap-baseline.py -t http://juice:3000 -g gen.conf -r report.html -J report.json
echo "exit=$?" # 2: warnings onlyContainers on the same user-defined network resolve each other by name, so ZAP reaches the app at http://juice:3000 without exposing it on your host.
Create zap/rules.tsv with tab-separated columns:
10038 FAIL (Content Security Policy (CSP) Header Not Set)
10020 FAIL (Missing Anti-clickjacking Header)
10096 IGNORE (Timestamp Disclosure - Unix)docker run --rm --network zapnet -v "$PWD/zap:/zap/wrk:rw" \
ghcr.io/zaproxy/zaproxy:stable \
zap-baseline.py -t http://juice:3000 -c rules.tsv -J report.json
echo "exit=$?" # 1: at least one FAILThe summary line now shows FAIL-NEW: 1 and IGNORE: 1. Only the CSP rule fails: Juice Shop already sends X-Frame-Options: SAMEORIGIN, so the anti-clickjacking rule passes.
jq -r '.site[].alerts[] | "\(.riskdesc)\t\(.count)\t\(.name)"' zap/report.json | sortClean up with docker stop juice && docker network rm zapnet. Juice Shop contains many vulnerabilities the baseline won't find, because it never attacks; that gap is exactly the difference between baseline and full scans.
Key takeaways
- DAST tests the running app over HTTP and finds configuration and response-level issues SAST can't see. Only scan what you own or are authorised to test.
- Baseline scans are passive and safe for every staging deploy; full and API scans attack and belong on a schedule against a resettable environment.
- A committed, tab-separated rules file decides which alerts fail the build, with reasons for every ignore.
- Run DAST after the staging deploy, wait for health, keep the report as an artifact, and fail only on alerts you've promoted to FAIL.
- Authenticated scans need a login method, session handling, a logged-in indicator and exclusions for destructive routes, using a dedicated test account.
- Triage by rule, reproduce the evidence, and remember a clean scan only covers what the scanner reached.
Check yourself
Quiz
0/4 correct · 4 left
1.Why is zap-baseline.py, rather than zap-full-scan.py, the right choice to run on every deploy to a shared staging environment?
2.A baseline scan reports several WARN-NEW alerts but your pipeline stays green when using -I. How do you make the CSP alert break the build?
3.During an authenticated scan, ZAP's session expires after 15 minutes but the scan runs for an hour and reports few issues. What was most likely missing?
4.Your nightly full scan finds no issues. What can you conclude?
Finished reading?
Mark it complete to fill in your roadmap.