Verified9 commandsAI-assisted

OWASP ZAP

.md

Verified against ZAP 2.17.0 (`zaproxy/zap-stable` Docker image), automation-script `--help` output run · official docs

What it is and where it fits#

ZAP is the most widely used open-source DAST (Dynamic Application Security Testing) tool — instead of reading source code like Semgrep/SonarQube (SAST), it attacks a running application the way a real attacker would: spidering it to find pages, then passively (and optionally actively) probing for vulnerabilities like reflected XSS, SQL injection, and missing security headers. This is the stage-gate difference that matters: SAST/SCA/IaC scanning all run against source or artifacts before deployment; DAST is the one class of scanning that requires the app to actually be up and reachable, which is why it typically runs against a staging environment late in a pipeline, not on every commit.

How a ZAP scan actually works#

Diagram

Baseline scan stops after the passive step — it never sends attack payloads, which is why it's safe to point at a production-adjacent target. Full scan continues into the active step, which genuinely attacks the target and must only ever run against a target you're authorized to attack.

Installation#

docker pull zaproxy/zap-stable        # official, CI-oriented — this is genuinely the recommended path, not a fallback
docker pull zaproxy/zap-weekly        # latest add-ons, less stable

docker run --rm zaproxy/zap-stable zap.sh -version    # inside the container, confirms the packaged ZAP version

A desktop-GUI installer also exists (ZAP_<version>_Linux.sh etc. on the GitHub releases page) for interactive use, but every CI/automation workflow — and this cheat sheet — uses the Docker image and its bundled scripts. There is no separate standalone zap CLI binary for headless use; the automation surface is the set of Python scripts (zap-baseline.py, zap-full-scan.py, zap-api-scan.py) shipped inside the image, or a hand-written Automation Framework YAML plan run via zap.sh -cmd -autorun plan.yaml.

Baseline scan — passive only, safe against production#

docker run --rm -t zaproxy/zap-stable zap-baseline.py \
  -t https://staging.example.com \
  -m 5                                 # spider for 5 minutes before the passive scan (default: 1)

Baseline never sends attack payloads — it spiders the app and passively analyzes traffic already captured. Safe to point at a production URL from a pure ZAP-behavior standpoint; still get authorization first and respect the target's terms of use.

Sample output shape (captured from a real zap-baseline.py run structure — the exact WARN/FAIL counts and rule IDs will differ by target and ZAP version):

WARN-NEW: X-Content-Type-Options Header Missing [10021] x 4 https://staging.example.com/ https://staging.example.com/login WARN-NEW: Content Security Policy (CSP) Header Not Set [10038] x 6 FAIL-NEW: 0 FAIL-EXIST: 0 WARN-NEW: 2 WARN-EXIST: 0 INFO: 0 IGNORE: 0 PASS: 42

Full scan — active, will send attack payloads#

docker run --rm -t zaproxy/zap-stable zap-full-scan.py \
  -t https://staging.example.com \
  -a                                   # include alpha-quality active + passive rules too

Never point zap-full-scan.py at a production target — it actively attacks the app (SQLi, XSS payloads, etc.) and can trigger real side effects (form submissions, account creation, even data mutation on a poorly built app). Staging/test environments only, with explicit authorization.

API scan — for OpenAPI/SOAP/GraphQL-defined services#

docker run --rm -t zaproxy/zap-stable zap-api-scan.py \
  -t https://staging.example.com/openapi.json -f openapi

docker run --rm -t zaproxy/zap-stable zap-api-scan.py \
  -t https://staging.example.com/graphql -f graphql \
  --schema https://staging.example.com/schema.graphqls

zap-api-scan.py imports the API definition first (so it knows every endpoint/parameter, not just what a spider happens to discover by following links) and then attacks each operation — the right tool when the target is a pure API rather than a browsable web app.

Common flags (all three scripts)#

-r report.html      # write full HTML report — the one to attach to a build artifact or share with a team
-J report.json       # write full JSON report — the one to parse programmatically
-x report.xml         # write full XML report
-l WARN              # minimum level to show: PASS, IGNORE, INFO, WARN, FAIL
-c rules.conf        # config file to override individual rule severities (INFO/IGNORE/FAIL per rule ID)
-I                    # do not fail the run on WARN-level findings — only FAIL-level
-s                    # short output — hide PASSes and example URLs
-z "-config aaa=bbb"  # pass raw ZAP command-line options straight through

CI gating with a rules config#

docker run --rm -t zaproxy/zap-stable zap-baseline.py \
  -t https://staging.example.com \
  -c /zap/wrk/zap-rules.conf -I
# zap-rules.conf — one rule ID per line, controls whether it's INFO / IGNORE / FAIL 10021 IGNORE (missing X-Content-Type-Options — accepted risk for this app) 40012 FAIL (reflected XSS — always block the build)

-c is the mechanism for tuning noisy rules to IGNORE and specific ones you actually care about to FAIL — without it, baseline's default behavior returns exit code 1 (soft "WARN") for most findings rather than a hard CI failure, and -I further suppresses even that unless you've explicitly escalated a rule to FAIL in the config.

The Automation Framework — for authenticated scans#

Most real applications require login before there's anything interesting to scan. The recommended path is: configure and test authentication in the ZAP desktop GUI against a context, then export that context into an Automation Framework YAML plan and run it headlessly:

# zap-plan.yaml
env:
  contexts:
    - name: my-app
      urls: ["https://staging.example.com"]
      authentication:
        method: script
        parameters:
          script: /zap/scripts/auth.js
          scriptEngine: "Oracle Nashorn"
      users:
        - name: test-user
          credentials:
            username: tester@example.com
            password: ${TEST_USER_PASSWORD}
jobs:
  - type: spider
    parameters:
      context: my-app
      user: test-user
  - type: activeScan
    parameters:
      context: my-app
  - type: report
    parameters:
      template: traditional-html
      reportFile: zap-report.html
docker run --rm -v $(pwd):/zap/wrk -t zaproxy/zap-stable zap.sh -cmd -autorun /zap/wrk/zap-plan.yaml

Jobs run in the order they appear in the plan. This is the only reliable way to scan anything behind a login form — zap-baseline.py -U user exists as a shortcut but requires the same underlying context/authentication setup to already be defined in a context file.

Real-world scenario: staging-gated GitHub Actions pipeline#

# .github/workflows/dast.yml
name: DAST (ZAP baseline)
on:
  deployment_status:
jobs:
  zap-scan:
    if: github.event.deployment_status.state == 'success'
    runs-on: ubuntu-latest
    steps:
      - uses: zaproxy/action-baseline@v0.14.0
        with:
          target: ${{ github.event.deployment_status.target_url }}
          rules_file_name: '.zap/rules.conf'
          cmd_options: '-I'

Running on deployment_status (rather than pull_request) ties the scan to an actual live staging deployment finishing — DAST is meaningless without a running target, so it belongs after deploy, not before.

Common pitfalls#

  • Running full scan against production — the single most damaging mistake possible with this tool.
  • Skipping authentication setup and concluding "ZAP found nothing" — an unauthenticated scan of an app that requires login only ever sees the login page; almost the entire real attack surface goes unscanned.
  • Not tuning -c rules.conf — baseline's defaults are noisy (a lot of WARN-level header-hygiene findings); teams that skip tuning tend to either ignore ZAP entirely or disable it, rather than actually triaging once.
  • Treating baseline as a substitute for full/active scanning — baseline is a fast smoke test, not a thorough assessment; it deliberately never fires attack payloads.

Exit codes#

0 no FAIL-level findings (or -I suppressing WARN-as-FAIL) · non-zero when FAIL-level findings exist, per the -l/rules-config severity mapping described above.