OWASP ZAP in CI/CD: Automated Security Testing on Every Pull Request

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 scriptszap-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 failures

Full 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.json

API 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.json

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

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

Rule 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 entirely
  • WARN — report but don't fail the build
  • FAIL — 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 Include

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

  1. Run ZAP once and capture the current alert set as a baseline
  2. Export the report and mark existing alerts as WARN in the rules file
  3. Any new alerts (introduced by the current PR) trigger failures
  4. 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 -u

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

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

Read more

Start now free