OWASP ZAP in CI/CD: Automated Security Testing on Every Pull Request
Most security tools are designed to be run manually by security engineers during a dedicated audit phase. OWASP ZAP (Zed Attack Proxy) is different — it's designed to be automated. The ZAP team maintains official Docker images with CI-oriented scan modes, GitHub Actions integrations, and a configuration system for tuning alerts to your specific application. The result is a practical path to automated security scanning on every pull request.
This guide covers ZAP's Docker-based CI workflow from first principles: the difference between scan modes, constructing a GitHub Actions workflow, filtering noisy alerts, setting meaningful failure thresholds, and avoiding the common trap of security theater (scans that always pass, proving nothing).
Why ZAP for CI, Not Burp or SQLMap
ZAP is specifically designed for automated, unauthenticated and authenticated scanning with configurable rules, output formats that CI systems can consume, and scan profiles that balance thoroughness against time. Burp Suite Pro supports headless operation but requires a paid license and more configuration overhead. SQLMap tests one vulnerability class. ZAP covers the broad OWASP Top 10 surface in a single automated run.
ZAP's CI integration path:
- Official Docker images — no installation, no version management
- Packaged scan scripts —
zap-baseline.py,zap-full-scan.py,zap-api-scan.py - GitHub Actions — official action maintained by the ZAP team
- SARIF output — integrates with GitHub's code scanning dashboard
ZAP Scan Modes
ZAP offers three packaged scan modes, each serving a different purpose.
Baseline Scan
The baseline scan is the right starting point for CI integration. It:
- Spiders the application (crawls links, forms, and resources)
- Runs passive analysis only (observing traffic, not attacking)
- Completes quickly (typically 1-5 minutes for a moderate-sized application)
- Produces a focused set of alerts with low false positive rate
The baseline scan won't find SQLi or XSS through active probing — but it will find missing security headers, insecure cookies, information disclosure, mixed content issues, and configuration problems. These are real vulnerabilities that are easy to automate and easy to fix.
docker run --rm \
-v $(pwd):/zap/wrk:rw \
ghcr.io/zaproxy/zaproxy:stable \
zap-baseline.py \
-t http://your-application.example.com \
-r zap-report.html \
-J zap-report.json \
-I # Don't fail on warnings, only on failuresFull Scan
The full scan adds active attack probes to the baseline's passive analysis. It:
- Runs all passive checks
- Actively probes for injection, XSS, SSRF, and dozens of other vulnerabilities
- Takes significantly longer (10-60+ minutes depending on application size)
- Produces more alerts, including more false positives
The full scan is appropriate for nightly runs or pre-release gates, not every pull request. The time cost and noise level make it unsuitable for fast PR feedback.
docker run --rm \
-v $(pwd):/zap/wrk:rw \
ghcr.io/zaproxy/zaproxy:stable \
zap-full-scan.py \
-t http://your-application.example.com \
-r zap-full-report.html \
-J zap-full-report.jsonAPI Scan
If your application exposes an OpenAPI, Swagger, or GraphQL schema, the API scan is the most targeted option. It reads the schema definition and tests every endpoint and parameter defined there — more accurate than crawling because it knows exactly what the API surface looks like.
docker run --rm \
-v $(pwd):/zap/wrk:rw \
ghcr.io/zaproxy/zaproxy:stable \
zap-api-scan.py \
-t http://your-application.example.com/openapi.json \
-f openapi \
-r zap-api-report.html \
-J zap-api-report.jsonGitHub Actions Workflow
The official ZAP GitHub Action simplifies the setup considerably. Here's a complete workflow for baseline scanning on every pull request:
name: Security Scan
on:
pull_request:
branches: [main, develop]
schedule:
- cron: '0 2 * * 1' # Weekly full scan on Monday at 2am
jobs:
baseline-scan:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
security-events: write
steps:
- uses: actions/checkout@v4
- name: Start application
run: |
docker-compose -f docker-compose.test.yml up -d
timeout 120 bash -c 'until curl -sf http://localhost:8080/health; do sleep 3; done'
- name: ZAP Baseline Scan
uses: zaproxy/action-baseline@v0.12.0
with:
target: 'http://localhost:8080'
rules_file_name: '.zap/rules.tsv'
cmd_options: '-I'
allow_issue_writing: true
fail_action: true
- name: Upload ZAP Report
uses: actions/upload-artifact@v4
if: always()
with:
name: zap-security-report
path: |
report_html.html
report_json.json
- name: Upload SARIF to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: report_sarif.sarif
full-scan:
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
permissions:
issues: write
security-events: write
steps:
- uses: actions/checkout@v4
- name: Start application (staging)
run: echo "Point at staging environment for full scan"
- name: ZAP Full Scan
uses: zaproxy/action-full-scan@v0.10.0
with:
target: 'https://staging.yourapp.example.com'
rules_file_name: '.zap/rules.tsv'
allow_issue_writing: trueNetworking: Reaching localhost from Docker
The ZAP Docker container runs in its own network namespace. To reach an application running on the host, use the host's Docker gateway IP, not localhost:
- name: Get Docker gateway IP
id: gateway
run: echo "ip=$(docker network inspect bridge --format='{{range .IPAM.Config}}{{.Gateway}}{{end}}')" >> $GITHUB_OUTPUT
- name: ZAP Baseline Scan
uses: zaproxy/action-baseline@v0.12.0
with:
target: 'http://${{ steps.gateway.outputs.ip }}:8080'Alternatively, use host.docker.internal on Docker Desktop environments, or configure docker-compose with network_mode: host.
Alert Filtering with Rules Files
Out of the box, ZAP will flag issues that may not apply to your application or that you've explicitly accepted as low-risk. The rules file lets you configure per-alert behavior.
Create .zap/rules.tsv in your repository:
# Rule ID Threshold Notes
10020 IGNORE X-Frame-Options header — we use CSP frame-ancestors instead
10038 IGNORE Content Security Policy header — we have CSP but ZAP doesn't parse it correctly
10021 WARN X-Content-Type-Options — warning only, not a build failure
10098 IGNORE Cross-Domain Misconfiguration — false positive on our CDN setup
90011 IGNORE Charset Mismatch — not applicable to JSON APIsRule IDs correspond to ZAP's plugin IDs. Find them in ZAP's alert details (shown in the JSON report as pluginid).
Threshold values:
IGNORE— suppress the alert entirelyWARN— report but don't fail the buildFAIL— fail the build on any instance (overrides default behavior)INFO— report as informational
Acceptable vs Unacceptable Alerts
The goal is not zero alerts — it's meaningful alerts that reflect real security issues in your application. Document which alerts you're ignoring and why:
# Accepted risk: We use HSTS preload list — direct HTTP access is impossible in practice
10035 IGNORE Strict-Transport-Security header — handled at infrastructure level
# Accepted risk: Our CDN sets these headers; application doesn't need to duplicate them
10036 IGNORE Server Leaks Version Information — suppressed by CDN in production
# These MUST fail the build — never suppress
40012 FAIL XSS reflected
40014 FAIL SQL Injection
40016 FAIL SQL Injection MySQL
90019 FAIL Server Side IncludeFail Thresholds
The ZAP GitHub Action supports fail_action: true, which fails the build if any FAIL-level alerts are found. But the default behavior — where new alerts fail the build — can create friction if you're introducing ZAP to an existing application with existing issues.
Strategy for adopting ZAP on an existing codebase:
- Run ZAP once and capture the current alert set as a baseline
- Export the report and mark existing alerts as WARN in the rules file
- Any new alerts (introduced by the current PR) trigger failures
- Address existing alerts incrementally, removing WARN overrides as you fix issues
# Step 1: Generate initial report and extract alert IDs
docker run --rm \
ghcr.io/zaproxy/zaproxy:stable \
zap-baseline.py \
-t http://your-staging-app.com \
-J /tmp/initial-scan.json
# Step 2: Extract plugin IDs from report
cat /tmp/initial-scan.json | jq -r '.site[].alerts[].pluginid' | sort -uAdd each existing plugin ID to your rules file as WARN. Now you have a ratchet: the current state never fails the build, but any new vulnerability class does.
Authentication Configuration
The baseline and full scans run unauthenticated by default. Authenticated scanning reaches more of your application but requires configuration.
For applications with form-based authentication:
# .zap/auth-config.yaml
env:
contexts:
- name: "Default Context"
urls:
- "http://localhost:8080"
authentication:
method: "form"
parameters:
loginUrl: "http://localhost:8080/auth/login"
loginRequestData: "email=testuser@example.com&password=TestPassword123!"
verification:
method: "response"
loggedInRegex: "\"role\":\"user\""
loggedOutRegex: "\"error\":\"Unauthorized\""
users:
- name: "Test User"
credentials:
username: "testuser@example.com"
password: "TestPassword123!"For JWT-based APIs, inject the token directly:
docker run --rm \
-v $(pwd):/zap/wrk:rw \
-e ZAP_AUTH_HEADER="Authorization" \
-e ZAP_AUTH_HEADER_VALUE="Bearer ${TEST_JWT_TOKEN}" \
ghcr.io/zaproxy/zaproxy:stable \
zap-api-scan.py \
-t http://localhost:8080/openapi.json \
-f openapi \
-r zap-report.htmlReading the ZAP Report
ZAP's JSON report is the most useful output for automation. Each alert includes:
{
"pluginid": "10020",
"alertRef": "10020-1",
"alert": "Anti-clickjacking Header",
"name": "Anti-clickjacking Header",
"riskcode": "2",
"confidence": "2",
"riskdesc": "Medium (Medium)",
"desc": "The response does not include either Content-Security-Policy...",
"instances": [
{
"uri": "http://localhost:8080/",
"method": "GET",
"evidence": ""
}
],
"count": "1",
"solution": "Ensure either the Content-Security-Policy and X-Frame-Options HTTP headers are set...",
"reference": "...",
"cweid": "1021",
"wascid": "15",
"sourceid": "3"
}Risk codes: 0=Informational, 1=Low, 2=Medium, 3=High. Confidence: 1=Low, 2=Medium, 3=High, 4=Confirmed.
Parse the report in your CI pipeline to generate a summary:
import json
import sys
with open('zap-report.json') as f:
report = json.load(f)
alerts_by_risk = {0: [], 1: [], 2: [], 3: []}
for site in report.get('site', []):
for alert in site.get('alerts', []):
risk = int(alert['riskcode'])
alerts_by_risk[risk].append(alert)
print(f"High: {len(alerts_by_risk[3])}")
print(f"Medium: {len(alerts_by_risk[2])}")
print(f"Low: {len(alerts_by_risk[1])}")
print(f"Informational: {len(alerts_by_risk[0])}")
if alerts_by_risk[3]:
print("\nHigh risk alerts (build failure):")
for alert in alerts_by_risk[3]:
print(f" - {alert['alert']} ({alert['count']} instances)")
sys.exit(1)Combining ZAP with Application-Level Security Tests
ZAP covers the automated dynamic scanning layer — it's excellent at finding configuration issues, missing headers, and common vulnerability patterns. It won't cover your application's specific business logic or access control rules.
The complete security testing picture:
- ZAP baseline on every PR (fast, catches configuration drift)
- Application-level security tests (pytest/Jest tests for access control, injection, auth) via HelpMeTest or direct CI
- ZAP full scan nightly or pre-release (thorough active scanning)
- Dependency scanning on every PR (npm audit, safety, Dependabot)
Each layer catches different things. ZAP catches "did we forget a security header in the new endpoint." Application tests catch "does this endpoint enforce ownership of resources." Dependency scanning catches "did we introduce a known-CVE library."
Avoiding Security Theater
The most common failure mode for ZAP in CI is security theater — the scan always passes because everything is suppressed, or the scan is never set up to actually fail the build, or the scan runs against a stub application that doesn't exercise real code paths.
Avoid it by:
1. Verifying the scan actually hit your application: Check the ZAP report's sitemap — does it show the endpoints you expect? If the spider only found the landing page, your scan is not covering your application.
2. Testing that new vulnerabilities are detected: Intentionally introduce a missing security header on a test branch. Verify the ZAP scan flags it. Remove the header. Verify the build passes. This confirms the integration is working end-to-end.
3. Reviewing the IGNORE list regularly: Treat suppressed alerts as technical debt. Review them quarterly. Some will remain valid suppressions; others will be vulnerabilities you've now fixed and can remove from the suppression list.
4. Using the SARIF integration: GitHub's Security tab shows ZAP findings alongside code scanning results. It makes security findings visible to the whole team, not buried in CI logs.
ZAP in CI won't replace a manual penetration test, but it will catch a large class of vulnerabilities automatically, on every change, without requiring security expertise from the developers triggering the scan. That's a significant, achievable improvement over the quarterly-audit model that leaves vulnerabilities in production for months.