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.

17 min 8 sections

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:

  1. 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.
  2. 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.
  3. 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.pyzap-full-scan.pyzap-api-scan.py
DiscoverySpider for 1 minute (configurable)Spider, optionally AJAX spider, no time limit by defaultImports OpenAPI, SOAP or GraphQL definitions
AttacksNone: passive onlyPassive and activePassive and active, tuned for APIs
Safe for shared environmentsYesNoNo
Typical duration1–3 minutes10 minutes to hoursMinutes, scales with endpoints
Where it fitsEvery deploy to stagingNightly or weekly against an isolated environmentNightly against API staging

Run a baseline against an app you're running locally:

bash
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.json
text
Total 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: 61

The 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:

bash
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.conf

Then keep only the lines you want to change. Columns are rule ID, action, and a free-text comment; they must be separated by tabs:

text
# 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.

yaml
# .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:

yaml
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:

bash
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.html

ZAP_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:

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.html

Use 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:

  1. Group by rule, not by URL. "CSP header missing ×9" is one fix in one place: middleware or the reverse proxy.
  2. Read the evidence. Each alert includes the request, the response and the matched evidence. Reproduce it with curl -i or in a browser.
  3. Decide: fix, accept with a reason, or mark as false positive in the rules file.
AlertTypical verdictFix
CSP Header Not Set [10038]RealSend Content-Security-Policy from app middleware or the proxy
Missing Anti-clickjacking Header [10020]RealContent-Security-Policy: frame-ancestors 'none' (or X-Frame-Options: DENY)
Server Leaks Version Information [10036]Real, low riskDisable the version banner in nginx (server_tokens off;)
Timestamp Disclosure [10096]Usually a false positiveNumbers in bundled JavaScript that happen to look like timestamps
SQL Injection [40018], High confidenceReal until proven otherwiseParameterised queries; add a SAST rule so it can't recur
Application Error Disclosure [90022]RealGeneric 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:

  1. Start OWASP Juice Shop on a Docker network and run a ZAP baseline against it, writing HTML and JSON reports.
  2. 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.
  3. From the JSON report, list each alert's name, risk and instance count.
Show solution
bash
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 only

Containers 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:

text
10038	FAIL	(Content Security Policy (CSP) Header Not Set)
10020	FAIL	(Missing Anti-clickjacking Header)
10096	IGNORE	(Timestamp Disclosure - Unix)
bash
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 FAIL

The 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.

bash
jq -r '.site[].alerts[] | "\(.riskdesc)\t\(.count)\t\(.name)"' zap/report.json | sort

Clean 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. 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. 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. 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. 4.Your nightly full scan finds no issues. What can you conclude?

Finished reading?

Mark it complete to fill in your roadmap.