DAST with OWASP ZAP: Automated Security Testing for Web Applications
Dynamic Application Security Testing (DAST) does what static analysis can't: it attacks your running application the same way a real attacker would. Where SAST reads code, DAST sends HTTP requests, follows redirects, submits forms, and probes for weaknesses at the network layer. No source code required — which means it also catches vulnerabilities that exist in third-party libraries, misconfigured servers, and runtime behavior that no static tool would ever see.
OWASP ZAP (Zed Attack Proxy) is the most widely used open-source DAST tool in the world. It runs as a proxy between browser and server, intercepting and modifying traffic. It ships with active and passive scanning, an API, a daemon mode for CI/CD, and a growing library of scan rules. This guide covers running ZAP effectively in automated pipelines.
Active Scan vs Passive Scan
Understanding this distinction prevents both over-reliance and misconfiguration.
Passive scanning observes traffic without modifying requests. ZAP logs every request/response passing through the proxy and analyzes them for issues it can detect without sending additional traffic: missing security headers, cookies without the HttpOnly flag, information leakage in responses, insecure form submissions. Passive scanning is safe to run against production — it generates zero attack traffic.
Active scanning fires attack payloads at your application. It attempts SQL injection, XSS, path traversal, command injection, and dozens of other attack categories by actually sending malicious inputs and analyzing the responses. Active scanning should only run against non-production environments (or against production-equivalent staging with explicit authorization).
In practice, your CI pipeline will use both: passive scanning during integration tests (cheap, safe), active scanning in a dedicated security stage against a staging environment.
Installing and Running ZAP
Docker (recommended for CI):
docker pull ghcr.io/zaproxy/zaproxy:stable
# Quick scan against a target
docker run --rm ghcr.io/zaproxy/zaproxy:stable \
zap-baseline.py \
-t https://staging.your-app.com \
-r zap-report.htmlLocal installation:
# macOS
brew install owasp-zap
# Or download directly
curl -L https://github.com/zaproxy/zaproxy/releases/download/v2.15.0/ZAP_2.15.0_unix.sh \
-o zap-install.sh && bash zap-install.shThe Three ZAP Scan Modes
ZAP ships three scan scripts for CI use:
zap-baseline.py — Passive scan only. Spiders the site, runs passive rules, generates a report. Fast (minutes), safe for production, good for catching obvious misconfigurations and missing headers.
docker run --rm ghcr.io/zaproxy/zaproxy:stable \
zap-baseline.py \
-t https://staging.your-app.com \
-J baseline-results.json \
-r baseline-report.html \
-I # Don't fail on warnings, only on errorszap-full-scan.py — Active scan. Spiders, then attacks. Takes significantly longer (30 min to several hours depending on app complexity). Never run against production.
docker run --rm ghcr.io/zaproxy/zaproxy:stable \
zap-full-scan.py \
-t https://staging.your-app.com \
-J full-scan-results.json \
-r full-scan-report.html \
-z "-config scanner.strength=HIGH"zap-api-scan.py — Designed for API endpoints. Accepts an OpenAPI/Swagger spec and systematically tests every endpoint.
docker run --rm -v $(pwd):/zap/wrk/:rw \
ghcr.io/zaproxy/zaproxy:stable \
zap-api-scan.py \
-t /zap/wrk/openapi.yaml \
-f openapi \
-J api-scan-results.json \
-r api-scan-report.htmlAuthenticated Scanning
The most common failure mode in DAST deployments is scanning only the unauthenticated surface. Login pages and public endpoints represent a fraction of most applications' attack surface. Authenticated scanning requires ZAP to maintain a session.
Script-Based Authentication
Create a ZAP authentication script (JavaScript or Python via the ZAP API):
# Start ZAP in daemon mode
docker run -u zap -p 8090:8090 -d \
ghcr.io/zaproxy/zaproxy:stable \
zap.sh -daemon -host 0.0.0.0 -port 8090 \
-config api.disablekey=true \
-config api.addrs.addr.name=.* \
-config api.addrs.addr.enabled=true
# Wait for startup
sleep 15
# Configure authentication via ZAP API
curl "http://localhost:8090/JSON/authentication/action/setAuthenticationMethod/" \
-d "contextId=1&authMethodName=formBasedAuthentication&authMethodConfigParams=loginUrl%3Dhttps%3A%2F%2Fstaging.your-app.com%2Fapi%2Fauth%2Flogin%26loginRequestData%3Dusername%3D%7B%25username%25%7D%26password%3D%7B%25password%25%7D"
# Create user
curl "http://localhost:8090/JSON/users/action/newUser/" \
-d "contextId=1&name=test-user"
# Set credentials
curl "http://localhost:8090/JSON/users/action/setAuthenticationCredentials/" \
-d "contextId=1&userId=0&authCredentialsConfigParams=username%3Dtestuser%40example.com%26password%3DTestPass123!"
# Enable user
curl "http://localhost:8090/JSON/users/action/setUserEnabled/" \
-d "contextId=1&userId=0&enabled=true"Token-Based Authentication (JWT/Bearer)
For APIs using Bearer tokens:
#!/usr/bin/env python3
# zap-auth.py
import requests
import json
ZAP_BASE = "http://localhost:8090"
TARGET = "https://staging.your-app.com"
def get_auth_token():
"""Get a fresh auth token for scanning."""
resp = requests.post(
f"{TARGET}/api/auth/login",
json={"email": "scanner@example.com", "password": "ScannerPass123!"},
timeout=10
)
return resp.json()["token"]
def configure_zap_with_token(token):
"""Inject Bearer token into ZAP as a replacement header."""
# Add header to all requests in scope
requests.get(
f"{ZAP_BASE}/JSON/script/action/runStandaloneScript/",
params={
"scriptName": "add-auth-header",
"scriptType": "standalone",
"scriptEngine": "ECMAScript",
}
)
token = get_auth_token()
# Use ZAP's replacer to inject the Authorization header
requests.get(
f"{ZAP_BASE}/JSON/replacer/action/addRule/",
params={
"description": "Auth Bearer Token",
"enabled": "true",
"matchType": "REQ_HEADER",
"matchString": "Authorization",
"matchRegex": "false",
"replacement": f"Bearer {token}",
"initiators": "",
}
)
print("ZAP configured with auth token")API Scanning with OpenAPI Specs
ZAP's API scanner is most effective when it can read your OpenAPI spec. It uses the spec to:
- Discover all endpoints automatically (no spidering required)
- Generate appropriate payloads for each parameter type
- Test authentication on every endpoint
# .github/workflows/dast.yml
name: DAST API Scan
on:
schedule:
- cron: '0 2 * * *' # Nightly
workflow_dispatch:
jobs:
dast-api:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Start application
run: |
docker compose up -d
sleep 30
curl --retry 10 --retry-delay 3 http://localhost:3000/health
- name: Run ZAP API Scan
run: |
docker run --rm \
--network host \
-v $(pwd):/zap/wrk/:rw \
ghcr.io/zaproxy/zaproxy:stable \
zap-api-scan.py \
-t /zap/wrk/openapi.yaml \
-f openapi \
-T 60 \
-J /zap/wrk/zap-results.json \
-r /zap/wrk/zap-report.html \
-z "-config api.disablekey=true" \
2>&1 | tee zap-output.txt
# Capture exit code — ZAP exits non-zero when issues found
ZAP_EXIT=${PIPESTATUS[0]}
echo "ZAP exit code: $ZAP_EXIT"
exit $ZAP_EXIT
- name: Upload ZAP Report
uses: actions/upload-artifact@v4
if: always()
with:
name: zap-report
path: |
zap-report.html
zap-results.jsonConfiguring Scan Rules and Policies
ZAP's active scanner runs 200+ rules by default. For CI pipelines, you want to tune this to reduce scan time and focus on high-signal rules.
Create a scan policy XML file:
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<configuration>
<policy>
<name>CI-Security-Policy</name>
<scanner>
<strength>MEDIUM</strength>
<threshold>MEDIUM</threshold>
</scanner>
<!-- Enable only critical rule categories -->
<rules>
<!-- SQL Injection -->
<rule><id>40018</id><enabled>true</enabled><strength>HIGH</strength></rule>
<!-- XSS Reflected -->
<rule><id>40012</id><enabled>true</enabled><strength>HIGH</strength></rule>
<!-- XSS Persistent -->
<rule><id>40014</id><enabled>true</enabled><strength>HIGH</strength></rule>
<!-- Path Traversal -->
<rule><id>6</id><enabled>true</enabled><strength>HIGH</strength></rule>
<!-- Remote File Inclusion -->
<rule><id>7</id><enabled>true</enabled><strength>HIGH</strength></rule>
<!-- Command Injection -->
<rule><id>90020</id><enabled>true</enabled><strength>HIGH</strength></rule>
<!-- Disable noisy/slow rules for CI -->
<!-- Buffer Overflow (slow, rarely applicable to web) -->
<rule><id>30001</id><enabled>false</enabled></rule>
<!-- Format String (slow) -->
<rule><id>30002</id><enabled>false</enabled></rule>
</rules>
</policy>
</configuration>Use the policy:
docker run --rm -v $(pwd):/zap/wrk/:rw \
ghcr.io/zaproxy/zaproxy:stable \
zap-full-scan.py \
-t https://staging.your-app.com \
-z "-config scanner.attackStrength=MEDIUM" \
-P /zap/wrk/ci-policy.xml \
-J /zap/wrk/results.jsonInterpreting ZAP Results
ZAP produces findings with four risk levels:
| Risk | Color | Action |
|---|---|---|
| High | Red | Block the build immediately |
| Medium | Orange | Block unless waived with justification |
| Low | Yellow | Track and schedule remediation |
| Informational | Blue | Log and review periodically |
The JSON output structure:
{
"site": [{
"@name": "https://staging.your-app.com",
"alerts": [{
"pluginid": "40018",
"alertRef": "40018-1",
"alert": "SQL Injection",
"name": "SQL Injection",
"riskcode": "3",
"confidence": "2",
"riskdesc": "High (Medium)",
"desc": "SQL injection may be possible.",
"instances": [{
"uri": "https://staging.your-app.com/api/users/search",
"method": "GET",
"param": "q",
"attack": "1 AND 1=1 --",
"evidence": "error in your SQL syntax"
}],
"solution": "Do not trust client side input...",
"reference": "https://cheatsheetseries.owasp.org/...",
"cweid": "89",
"wascid": "19"
}]
}]
}Parse this in CI to generate structured failure reasons:
#!/bin/bash
# parse-zap-results.sh
RESULTS_FILE="$1"
HIGH_RISK=$(jq '[.site[].alerts[] | select(.riskcode == "3")] | length' "$RESULTS_FILE")
MEDIUM_RISK=$(jq '[.site[].alerts[] | select(.riskcode == "2")] | length' "$RESULTS_FILE")
echo "ZAP Scan Results:"
echo " High Risk: $HIGH_RISK"
echo " Medium Risk: $MEDIUM_RISK"
if [ "$HIGH_RISK" -gt 0 ]; then
echo "FAIL: $HIGH_RISK high-risk vulnerabilities found"
jq -r '.site[].alerts[] | select(.riskcode == "3") | " - [\(.name)] \(.instances[0].uri) (\(.instances[0].param))"' "$RESULTS_FILE"
exit 1
fi
echo "PASS: No high-risk vulnerabilities found"Managing False Positives with Context Files
ZAP false positives are managed through context files — XML configurations that define scope, exclusions, and alert suppressions:
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<configuration>
<context>
<name>My App</name>
<desc/>
<inscope>true</inscope>
<incregexes>https://staging\.your-app\.com.*</incregexes>
<excregexes>https://staging\.your-app\.com/static/.*</excregexes>
<excregexes>https://staging\.your-app\.com/api/health</excregexes>
<alertfilters>
<!-- Suppress "X-Content-Type-Options" on image endpoints — intentional -->
<filter>
<ruleid>10021</ruleid>
<url>https://staging\.your-app\.com/api/images/.*</url>
<urlregex>true</urlregex>
<newlevel>-1</newlevel>
<enabled>true</enabled>
</filter>
</alertfilters>
</context>
</configuration>Integrating ZAP into Your CI/CD Pipeline
For teams running nightly security builds, a complete GitHub Actions workflow:
name: Nightly DAST
on:
schedule:
- cron: '0 3 * * 1-5' # Weeknights at 3am
jobs:
dast-baseline:
name: Passive Scan (Baseline)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: ZAP Baseline Scan
uses: zaproxy/action-baseline@v0.12.0
with:
target: 'https://staging.your-app.com'
rules_file_name: '.zap/rules.tsv'
issue_title: 'ZAP Baseline Scan Report'
fail_action: false # Alert, don't block on baseline
create_issue: true
dast-full:
name: Active Scan (Full)
runs-on: ubuntu-latest
needs: dast-baseline
steps:
- uses: actions/checkout@v4
- name: ZAP Full Scan
uses: zaproxy/action-full-scan@v0.10.0
with:
target: 'https://staging.your-app.com'
rules_file_name: '.zap/rules.tsv'
cmd_options: '-T 60'
fail_action: true # Block on high/medium findingsRunning DAST alongside functional test suites gives you something valuable: security coverage over the same code paths your users exercise. HelpMeTest's pipeline monitoring can track whether your security scan jobs are running on schedule and surface regressions — such as new endpoints appearing in your API that haven't yet been covered by your security scan configuration.
What ZAP Won't Catch
DAST has blind spots. Be explicit with your team about what it covers and what it doesn't:
- Business logic flaws — ZAP doesn't know that users shouldn't be able to access other users' data. It sends requests but doesn't understand your authorization model.
- Client-side-only vulnerabilities — ZAP uses a headless browser for some tests, but complex JavaScript SPAs may not be fully exercised.
- Race conditions — Concurrent request vulnerabilities require specialized tooling.
- Vulnerabilities in rarely-hit code paths — If ZAP's spider doesn't discover an endpoint, it won't test it.
The DAST + SAST combination addresses these gaps partially. SAST catches the authorization logic gaps that DAST misses; DAST catches the runtime misconfigurations that SAST misses. Neither replaces manual penetration testing for high-risk applications — but together they eliminate the obvious vulnerabilities that manual testers shouldn't have to spend time finding.
ZAP's real value in a DevSecOps pipeline is continuous, automated coverage. Running it nightly against staging means you find the SQL injection you introduced on Tuesday before your pentest firm finds it on Friday.