Get listed

OWASP ZAP: a CI tripwire, not a pentest

A teal bug zapper lantern with a coral moth past it.

ZAP is a scanner you point at an origin you own. It is not a substitute for the unit test on IDOR.

A baseline spider will find obvious XSS and missing headers. It will not invent a second user and replay their session against someone else’s id unless you teach it that flow.

The usual mistake is a green GitHub Action and a production app that still concatenates SQL on one admin route. Use ZAP for the holes a spider can see. Keep the object-level tests in CI.

This page is how to run ZAP so the findings are yours, and the classes of bug you still have to write by hand.

ZAP 2.17.0 published on 15 December 2025. As of 24 October 2025, GitHub release. zaproxy/action-baseline v0.15.0 published wraps zap-baseline.py: a one-minute spider and a passive scan. That is a CI tripwire your team runs. It is not a pentest report.

Pair this page with the secure coding checklist for the control you still have to write, and with API security tests for the two-account replay a spider will not invent. Headers the baseline often flags sit on HTTP headers.

A green baseline is not a human review

A pentest is a scoped human review with two accounts, a threat model, and time to chase a business rule. A packaged ZAP job is a spider plus rules. Those are different products. the8472’s line is why this page exists: teams rename the job and stop writing the tests the spider cannot see.

As of 22 August 2026, ZAP Baseline Scan page. The script spiders the target for one minute by default, waits for passive scanning, then prints PASS, WARN, IGNORE, and FAIL. The page says it does not perform actual attacks and is meant for CI, even against production. That last clause is about the passive default. That clause is not permission to dump customer cookies into a shared runner log.

Exit codes from that page: 0 success, 1 at least one FAIL, 2 at least one WARN and no FAIL, 3 any other failure. Default alerts are WARN. A WARN-only run exits 2. Your pipeline must decide whether 2 fails the job. This page fails the job on FAIL rules you named, and treats leftover WARN as a review, not a ship gate, until you promote them.

What zap-baseline.py actually does

The image this page pins is ghcr.io/zaproxy/zaproxy:stable. The Docker about page says the stable tag updates on a full ZAP release and is regenerated monthly, usually the first Monday, for base-image and add-on bumps. 2.17.0 is the core on GitHub. The monthly rebuild can move add-ons under that tag. Pin a digest in production CI if you need a frozen add-on set.

docker pull ghcr.io/zaproxy/zaproxy:stable

docker run --rm -t \
 -v "$PWD:/zap/wrk:rw" \
 ghcr.io/zaproxy/zaproxy:stable \
 zap-baseline.py \
 -t "$STAGING_ORIGIN" \
 -c rules.tsv \
 -I \
 -m 1 \
 -J zap-baseline.json \
 -r zap-baseline.html

STAGING_ORIGIN is an origin you own, including the scheme. rules.tsv is the config file the -c flag reads. -I is the first-party switch that keeps a leftover WARN from failing the job. FAIL rules still exit 1. -m 1 is the one-minute spider. -J and -r write reports into the mounted /zap/wrk directory. The docs require that mount when you use file flags. Without the mount, the container cannot write zap-baseline.html.

-j adds the modern spider. The current help text says the Ajax spider is the default extra when -j is set, and --client-spider picks the Client spider instead. Use -j on an SPA that never emits links the traditional spider can follow. Do not turn that flag into an active scan. Baseline stays passive.

Run it on staging you own

Point the job at a staging host your pipeline started, or at a review app with synthetic users. Do not paste a customer session into the container. Do not scan a host you do not operate. Do not treat a public marketing site as a stand-in for the app behind login.

Authenticated baseline needs a context file and -U after ZAP 2.9.0, per the same usage list. That is how you give the spider a user that already exists in staging. That flag is not a reason to export production sessions. If you cannot build a staging user in CI, skip authenticated baseline and keep the two-account tests on the API page. A spider that never logs in will never see /tickets/:id.

Three jobs. Only the middle one is this page.
CHECKLIST you name the control
 Helmet, scoped WHERE, generic 500

TRIPWIRE zap-baseline.py on STAGING_ORIGIN
 rules.tsv FAIL on 10011, 10021, 90022
 exit 1 blocks the merge

NOT THIS a pentest SOW
 Bob GET /tickets/aliceTicketId
 a scanner that never swapped the
 cookie cannot see that miss

FAIL the rules you mean, IGNORE the rest

Generate a starter file with -g gen.conf once, then keep rules.tsv in git. The baseline page says only the rule ids matter. Names are comments. Change WARN to FAIL or IGNORE. I am pinning three FAILs this page cares about. Promote more when you have fixed the first three.

# rules.tsv tab-separated id, action, comment
10011	FAIL	Cookie Without Secure Flag
10021	FAIL	X-Content-Type-Options Header Missing
90022	FAIL	Application Error Disclosure
10010	FAIL	Cookie No HttpOnly Flag
10016	IGNORE	X-XSS-Protection is retired in modern browsers
10015	IGNORE	Cache-Control on public marketing pages
*	OUTOFSCOPE	.*\.js

Rule 90022 is application error disclosure. A 500 that still prints a stack is the Node error page. The tripwire is how you notice the helper that put the tree back. Rule 10011 and 10010 are cookie flags. Rule 10021 is X-Content-Type-Options. IGNORE 10016 if you have already dropped X-XSS-Protection on purpose. Do not IGNORE 90022 because the report is noisy. Fix the handler.

A progress file can mark a known id as in progress with a ticket link. The baseline page shows that JSON. Use it when a FAIL is already owned and you need the job green for other rules. Do not use it as a junk drawer.

Rule What it catches What you do
10011 / 10010Cookie flagsSet Secure and HttpOnly on the session
10021Missing NOSNIFFHelmet or the headers page
90022Error body leakGeneric 500, no stack
Object authzNot a baseline ruleAPI replay tests

Full scan and API scan are different jobs

The Docker about page lists three packaged scripts. Baseline is the PR tripwire. Full scan spiders without the one-minute default, can add the Ajax spider, then runs an active scan. API scan takes OpenAPI or GraphQL and runs an active scan against those operations. Those two are scheduled jobs on staging, not a merge gate you run on every push.

# Weekly on staging you own. Not a PR gate.
docker run --rm -t \
 -v "$PWD:/zap/wrk:rw" \
 ghcr.io/zaproxy/zaproxy:stable \
 zap-full-scan.py \
 -t "$STAGING_ORIGIN" \
 -c rules.tsv \
 -J zap-full.json

# When you have an OpenAPI file in the repo. -t is the spec, not the app origin.
docker run --rm -t \
 -v "$PWD:/zap/wrk:rw" \
 ghcr.io/zaproxy/zaproxy:stable \
 zap-api-scan.py \
 -t /zap/wrk/openapi.json \
 -f openapi \
 -O staging.example.com \
 -c rules.tsv \
 -J zap-api.json

about page for those three names. I am not walking an active-scan payload. The job is: start staging, run the script you named, fail on the rules you marked FAIL, store the JSON as an artifact. If the OpenAPI file is stale, the API scan will miss the route you shipped yesterday. Commit the spec next to the handler.

zaproxy/action-baseline@v0.15.0 is the GitHub wrapper. Default docker_name is the stable image. Pass -I through cmd_options so a leftover WARN does not fail the step. FAIL rows in rules.tsv still exit 1. action readme for v0.15.0 and the 24 October 2025 release date. -O on the API scan overrides the hostname inside a spec that still names production.

#.github/workflows/zap-baseline.yml
name: zap-baseline
on:
 pull_request:
 branches: [main]
jobs:
 baseline:
 runs-on: ubuntu-latest
 steps:
 - uses: actions/checkout@v5
 - name: Start staging
 run: docker compose -f compose.staging.yml up -d --wait
 - uses: zaproxy/action-baseline@v0.15.0
 with:
 docker_name: "ghcr.io/zaproxy/zaproxy:stable"
 target: "http://127.0.0.1:3000"
 rules_file_name: "rules.tsv"
 cmd_options: "-I"

ZAP in Docker cannot always reach localhost on the host the way you think. The about page shows a docker0 IP pattern for that case. A compose network the ZAP container joins is cleaner. If the job reports 0 URLs, the app was not listening. That is a setup miss, not a secure app.

What a spider will never see

A baseline that never logs in will not hit an authenticated route. A baseline that logs in as one user will not replay Bob against Alice. Object-level authorization is API1:2023 and the API testing page. Mass assignment is a PATCH with role. A scanner that never swaps the session will score those green.

Business rules, IDOR, stored XSS in a field the spider never submitted, and a broken access check on an export URL are human or fixture work. The checklist is where you name the control. ZAP is where you notice a missing header, a cookie without Secure, or a 500 that still talks. Do not delete the fixture tests because the badge is green.

Do not paste attack strings into this job to "make it a pentest." That is how you turn CI into an unscoped fuzzer against a shared staging database. If you need an active pass, schedule zap-full-scan.py on a throwaway dataset. Keep production out of that schedule.

Prove the tripwire in your pipeline

You are proving your job failed when a rule you marked FAIL fired. You are not scanning a third-party host.

  1. Start your staging compose on a throwaway origin you own.
  2. Run the docker run line above with -c rules.tsv.
  3. Expect exit 0 when the three FAIL rules are clean, or exit 1 when you temporarily strip X-Content-Type-Options on that staging app.
  4. Restore the header. Expect exit 0 again.
  5. Confirm the workflow artifact contains zap-baseline.json.
rg -n "zap-baseline.py|action-baseline|90022|rules\\.tsv" \
 --glob '!node_modules'

If rules.tsv is missing, every alert is WARN and the job may exit 2 forever. If you omit -I and never IGNORE leftover WARN rows, the job fails on noise. If the target is production, stop and point it at staging. Identifiers stay STAGING_ORIGIN, rules.tsv, and zap-baseline.py.

Questions we keep getting

Does a green ZAP job replace a pentest?

No. It replaces a forgotten header and a leaked 500. It does not replace two accounts, a scoped WHERE, or a human reading a money-flow. Call it a tripwire in the ticket.

Should I run the full scan on every pull request?

No. Full scan is slower and active. Keep zap-baseline.py on the PR. Schedule the full scan on staging. The API scan waits for an OpenAPI file you actually commit.

The job found 0 URLs. Is the app safe?

No. The spider never reached the app. Fix compose, the wait, or the Docker network. Then read the report. Zero URLs is a missed target, not a pass.

Aphinya Dechalert

Aphinya Dechalert / About Author

Aphinya is a skilled technical writer with field experiences in software development, agile, and JavaScript full stack with AWS and Google cloud. She is a developer advocate and community builder, helping others navigate their journeys and careers as developers.