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

DevSecOps Engineer roadmap · Secure the pipeline · 17 min read · https://levelcode.org/roadmaps/devsecops/dast

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.

> [!WARNING]
> DAST sends real attack payloads. Only scan systems you own or have explicit written permission to test, and scope every scan to those hosts. Scanning someone else's site, even "just a baseline," can violate computer misuse laws and their terms of service. Point active scans at staging or a local environment, never production.

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

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

> [!TIP]
> Seed the spider. Discovery is DAST's weak point. Feeding ZAP your OpenAPI file, or running your end-to-end test suite through ZAP as a proxy, covers far more routes than crawling alone.

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

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

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.

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

## Quiz

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?

   A. It runs the AJAX spider by default
   B. It finds more SQL injection bugs
   C. It only spiders and passively inspects responses, so it is fast and sends no attack payloads
   D. It is the only script that produces JSON reports

   **Answer: C.** The baseline never attacks. Full scans actively inject payloads, take much longer, and can corrupt data or lock accounts that other people rely on.

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?

   A. Set rule 10038 to FAIL in a rules file passed with -c
   B. Add the -a flag
   C. Run the scan twice
   D. Remove the -J flag

   **Answer: A.** The rules file maps rule IDs to IGNORE, INFO, WARN or FAIL. A FAIL-level alert makes the script exit with code 1 even with -I, which only suppresses warnings.

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?

   A. A rules file
   B. A logged-in or logged-out indicator so ZAP can detect the lost session and re-authenticate
   C. The -m flag
   D. The Server header

   **Answer: B.** Without an indicator, ZAP can't tell that responses have turned into login redirects, so it keeps scanning the login page while believing it's authenticated.

4. Your nightly full scan finds no issues. What can you conclude?

   A. The application has no exploitable vulnerabilities
   B. SAST is no longer needed
   C. Authorisation is correctly enforced on every endpoint
   D. The scanner found nothing it knows how to detect on the pages it reached

   **Answer: D.** DAST coverage is limited by discovery and by its rule set. Business-logic and authorisation flaws in particular need purpose-written tests or manual review.
