
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.
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 / 10010 | Cookie flags | Set Secure and HttpOnly on the session |
10021 | Missing NOSNIFF | Helmet or the headers page |
90022 | Error body leak | Generic 500, no stack |
| Object authz | Not a baseline rule | API 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.
- Start your staging compose on a throwaway origin you own.
- Run the
docker runline above with-c rules.tsv. - Expect exit 0 when the three FAIL rules are clean, or exit 1 when you temporarily strip
X-Content-Type-Optionson that staging app. - Restore the header. Expect exit 0 again.
- 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.



