Run OWASP ZAP baseline, full, and API scan modes against staging, interpret reports, configure authenticated scans, and integrate ZAP into CI with false positive filtering.
Dynamic Application Security Testing tests your running application by sending crafted HTTP requests and analyzing responses. Unlike SAST which reads source code, DAST tests what actually happens at runtime — catching configuration issues, authentication bypasses, and injection vulnerabilities that SAST cannot see.
OWASP ZAP has three operational modes: baseline (passive), full scan (active), and API scan (OpenAPI-driven). Each suits a different CI pipeline stage.
Baseline Scan (passive, ~3 min): Spiders the target, does not send attack payloads. Finds: missing security headers, exposed debug pages. Safe for production.
Full Scan (active, 10-60 min): Sends actual attack payloads (XSS, SQLi). Finds: injection vulnerabilities. Run against staging ONLY.
API Scan (5-20 min): Uses OpenAPI spec to test all defined endpoints. More thorough than spidering.
1# Baseline scan — safe for staging/production
2docker run --rm \
3 -v $(pwd)/reports:/zap/wrk \
4 owasp/zap2docker-stable \
5 zap-baseline.py \
6 -t https://staging.api.example.com \
7 -J zap-baseline.json \
8 -r zap-baseline.html \
9 -I # don't fail on warnings only
10
11# API scan with OpenAPI spec
12docker run --rm \
13 -v $(pwd)/reports:/zap/wrk \
14 -v $(pwd)/openapi.json:/zap/openapi.json \
15 owasp/zap2docker-stable \
16 zap-api-scan.py \
17 -t /zap/openapi.json \
18 -f openapi \
19 -J zap-api.jsonUnauthenticated ZAP scans only test public endpoints. Pass a Bearer token for full coverage:
1# Get auth token first
2TOKEN=$(curl -sf -X POST https://staging.api.example.com/auth/login \
3 -H 'Content-Type: application/json' \
4 -d '{"email":"[email protected]","password":"ZapTestPass123!"}' \
5 | jq -r '.accessToken')
6
7# Baseline scan with auth header
8docker run --rm \
9 -v $(pwd)/reports:/zap/wrk \
10 owasp/zap2docker-stable \
11 zap-baseline.py \
12 -t https://staging.api.example.com \
13 -H "Authorization: Bearer $TOKEN" \
14 -J zap-auth.json \
15 -I
16
17# API scan with auth
18docker run --rm \
19 -v $(pwd)/reports:/zap/wrk \
20 owasp/zap2docker-stable \
21 zap-api-scan.py \
22 -t https://staging.api.example.com/api/openapi.json \
23 -f openapi \
24 -H "Authorization: Bearer $TOKEN" \
25 -J zap-api-auth.jsonZAP risk levels: 3=High, 2=Medium, 1=Low, 0=Informational.
1// scripts/check-zap-report.cjs
2const fs = require('fs');
3const report = JSON.parse(fs.readFileSync('reports/zap-baseline.json', 'utf8'));
4const alerts = report.site?.[0]?.alerts || [];
5
6const byRisk = {
7 high: alerts.filter(a => a.riskcode === '3'),
8 medium: alerts.filter(a => a.riskcode === '2'),
9};
10
11// Known false positives — reviewed and documented
12const FALSE_POSITIVES = new Set([
13 '10038', // CSP meta — we use HTTP header CSP, not meta tag
14 '10036', // Server leaks version — false positive on our nginx config
15]);
16
17const trueHighRisk = byRisk.high.filter(a => !FALSE_POSITIVES.has(a.pluginid));
18
19console.log('High risk:', byRisk.high.length, '( ' + trueHighRisk.length + ' after filtering)');
20console.log('Medium risk:', byRisk.medium.length);
21
22if (trueHighRisk.length > 0) {
23 trueHighRisk.forEach(a => {
24 console.error('[HIGH]', a.pluginid, a.alert);
25 console.error(' URL:', a.instances?.[0]?.uri || 'N/A');
26 });
27 process.exit(1);
28}
29console.log('PASS: No high-risk alerts after filtering');ZAP full scan activates hundreds of rules. Disable irrelevant ones for REST APIs.
Rules to disable for JSON-only REST APIs:
Rules to keep enabled:
1# Use a custom policy file with ZAP:
2docker run --rm \
3 -v $(pwd)/zap-policy.xml:/zap/policies/rest-api.policy \
4 -v $(pwd)/reports:/zap/wrk \
5 owasp/zap2docker-stable \
6 zap-full-scan.py \
7 -t https://staging.api.example.com \
8 -P rest-api.policy \
9 -J zap-full.json1# .github/workflows/dast.yml — ZAP baseline after staging deploy
2jobs:
3 zap-baseline:
4 needs: [staging-deploy]
5 runs-on: ubuntu-latest
6 steps:
7 - uses: actions/checkout@v4
8 - name: Get auth token
9 id: auth
10 run: |
11 TOKEN=$(curl -sf -X POST https://staging.api.example.com/auth/login \
12 -H 'Content-Type: application/json' \
13 -d '{"email":"[email protected]","password":"ZapTestPass123!"}' \
14 | jq -r '.accessToken' || echo '')
15 echo 'token='$TOKEN >> $GITHUB_OUTPUT
16 - name: ZAP Baseline Scan
17 uses: zaproxy/action-[email protected]
18 with:
19 target: https://staging.api.example.com
20 token: TOKEN_SECRET
21 cmd_options: -H 'Authorization: Bearer TOKEN_VALUE' -J zap.json -I
22 fail_action: false
23 - name: Check ZAP results
24 run: node scripts/check-zap-report.cjs
25 - uses: actions/upload-artifact@v4
26 if: always()
27 with: { name: zap-report, path: zap.json }End-to-end ZAP scan: authenticated baseline, result parsing with false positive filtering, and reporting.
1#!/usr/bin/env bash
2# scripts/run-zap-scan.sh
3# Usage: ./scripts/run-zap-scan.sh https://staging.api.example.com
4set -euo pipefail
5
6TARGET=${1:-http://localhost:3000}
7REPORT_DIR='reports/security/zap'
8CONTAINER='owasp/zap2docker-stable'
9
10mkdir -p "$REPORT_DIR"
11echo '=== OWASP ZAP DAST Scan ==='
12echo "Target: $TARGET"
13
14# Step 1: Get auth token
15echo 'Step 1: Authenticating...'
16TOKEN=$(curl -sf -X POST "$TARGET/auth/login" \
17 -H 'Content-Type: application/json' \
18 -d "{\"email\":\"$ZAP_TEST_EMAIL\",\"password\":\"$ZAP_TEST_PASS\"}" \
19 2>/dev/null | grep -o '"accessToken":"[^"]*"' | cut -d'"' -f4 || echo '')
20
21if [ -z "$TOKEN" ]; then
22 echo 'WARNING: Authentication failed — unauthenticated scan'
23else
24 echo ' Token acquired'
25fi
26
27# Step 2: Run baseline scan
28echo 'Step 2: ZAP baseline scan (~3 minutes)...'
29if [ -n "$TOKEN" ]; then
30 docker run --rm --network host \
31 -v "$(pwd)/$REPORT_DIR:/zap/wrk" \
32 "$CONTAINER" \
33 zap-baseline.py \
34 -t "$TARGET" \
35 -H "Authorization: Bearer $TOKEN" \
36 -J zap-baseline.json \
37 -r zap-baseline.html \
38 -I --auto 2>/dev/null || true
39else
40 docker run --rm --network host \
41 -v "$(pwd)/$REPORT_DIR:/zap/wrk" \
42 "$CONTAINER" \
43 zap-baseline.py \
44 -t "$TARGET" \
45 -J zap-baseline.json \
46 -r zap-baseline.html \
47 -I --auto 2>/dev/null || true
48fi
49
50# Step 3: Parse results
51echo 'Step 3: Analyzing results...'
52node -e "
53const fs = require('fs');
54const path = require('path');
55const reportPath = path.join(process.cwd(), 'reports/security/zap/zap-baseline.json');
56if (!fs.existsSync(reportPath)) { console.error('No ZAP report found'); process.exit(1); }
57
58const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
59const alerts = report.site?.[0]?.alerts || [];
60
61const high = alerts.filter(a => a.riskcode === '3');
62const medium = alerts.filter(a => a.riskcode === '2');
63
64// Known false positives reviewed and documented:
65const FALSE_POSITIVES = new Set(['10038', '10036']);
66const trueHigh = high.filter(a => !FALSE_POSITIVES.has(a.pluginid));
67
68console.log('High risk: ' + high.length + ' (' + trueHigh.length + ' after filter)');
69console.log('Medium risk: ' + medium.length);
70
71if (trueHigh.length > 0) {
72 console.error('HIGH RISK FINDINGS:');
73 trueHigh.forEach(a => {
74 const uri = a.instances?.[0]?.uri || 'N/A';
75 console.error(' [' + a.pluginid + '] ' + a.alert + ' - ' + uri);
76 console.error(' Solution: ' + (a.solution || '').substring(0, 100));
77 });
78 process.exit(1);
79}
80console.log('PASS: No high-risk alerts');
81"
82
83echo "Report: $REPORT_DIR/zap-baseline.html"This script implements the full ZAP workflow: authenticate first (for Bearer token coverage), run the Docker ZAP baseline scan, then parse the JSON report with false positive filtering. The false positive set (10038, 10036) should be populated after the first scan — review each high-risk finding, confirm it is a false positive, add its pluginid, and document the reason. The HTML report provides human-readable detail including solution recommendations.
The next lesson covers secrets detection and management — scanning git history with truffleHog, preventing commits with detect-secrets, and testing that secrets don't leak in API responses.
Your progress is saved in this browser, on this device only.