Check Performance Data - OWASP ZAP baseline scan
Every pull request with a review app (the deploy label) gets a "ZAP baseline scan" check (#618,
part of the OWASP Top 10 (2025) hardening #607). The job is in
.github/workflows/build-and-deploy.yml and runs beside the smoke tests, after "Deployment To
Review". The scan is the automation plan .github/zap/plan.yaml.
What it does
OWASP ZAP crawls the review app for up to three minutes and runs its passive rules over every response: headers, cookies, what is disclosed in the markup. It attacks nothing. The check looks at what a browser is actually sent, after the AKS ingress (review apps are not behind Front Door; scanning a Front Door hostname is #679), where the unit tests look at what the code sets.
What it does not do
-
It does not sign in. Only the anonymous pages are scanned: home, guidance and content pages,
search, cookies, privacy, accessibility, contact,
robots.txt. Everything under/dev/is out of scope, so the dev impersonation links in a review app's header are never followed. The signed-in pages need a DfE Sign-in session or the dev impersonation cookie, and an unattended crawler following every link there (sign out, withdraw, delete) would change the review app's data. An authenticated scan is a follow-up under #607. -
It submits no forms (
postForm: falseon the spider job). A review app creates real Zendesk tickets and the contact form is anonymous. GET forms, such as search, are still followed. - It does not run the active scan, and it does not scan the deployed environments. A manual scan of development, QA, preproduction or production is a follow-up under #607.
Reading a result
The job summary on the run page carries the counts and the report: each rule that fired, how many
URLs, and the URLs. The same report in HTML, Markdown and JSON, with the summary counts, is the
run's zap-baseline artefact (kept 14 days). A rule's documentation is at
https://www.zaproxy.org/docs/alerts/<rule id>/. The report lists the accepted alerts as well. The
counts line is what the gate reads. The 'New findings' section names what it failed on. The gate
also fails when the spider found fewer than 20 URLs, because an error or maintenance page would
scan clean.
The console (console.txt in the artefact) prints one line per rule that fired: IGNORE, INFO,
WARN-NEW or FAIL-NEW, the count and the custom message. It does not list passing rules or
URLs. That is the outputSummary job's Short format. Long reads each alert's HTTP message to
print its URLs. An alert whose message cannot be loaded makes the job fail before it writes the
summary (#784). The URLs are in the report instead.
When the run page says "ZAP baseline scan produced no report", ZAP stopped before its summary job
finished. The step summary shows ZAP's own failure lines; console.txt in the artefact has the rest.
The check is green when every rule that fired is accepted in the plan's rules. It is red when something new fired.
Triaging a new finding
In this order:
-
Fix it in code when the fix is small and clearly right: a header missing on one response
type, a cookie flag, a disclosed timestamp. Add the unit test first
(
SecurityHeadersMiddlewareTestsfor headers). -
Accept it with an
IGNOREentry under the plan'soutputSummaryjob when an existing control covers the risk, or the rule is informational. ThecustomMessagenames the control, or says "informational". The reviewer judges the reason; an IGNORE without one is not accepted. -
Leave it red (
WARN) when it needs more than the pull request. Raise a ticket under #607 and put its number in the message. If the team would rather the check were green meanwhile, use IGNORE with the ticket number instead.
Only rules a report has raised are listed, so a rule that a new ZAP release adds is noticed as a
red check and triaged the same way. The ZAP image is ghcr.io/zaproxy/zaproxy:stable by tag,
from a run: step: neither Dependabot nor the pin check (docs/dependabot.md#pins) covers it, a
digest there would be nobody's job to keep current, and a stale scanner is worse than a moving
one. A new release surfaces as a new finding to triage, never as a silent change to an accepted
one.
To collect a fresh baseline, set the job's ZAP_GATE to "false", push, read the report, and
set it back in the same pull request.
Why our own plan
zaproxy/action-baseline runs zap-baseline.py, which since ZAP 2.17 generates an automation
plan whose spider job carries postForm: true as a default that overrides any -config option.
A rehearsal with it submitted the contact form and, having followed the dev impersonation links,
admin forms. .github/zap/plan.yaml is that generated plan with postForm: false and the /dev/
exclusion.
What is accepted and why
| Rule | Decision | Reason |
|---|---|---|
| 2 | IGNORE | Private IP Disclosure: false positive, 10.02.68.86 is SVG path coordinates in assets/images/favicon.svg, not an address |
| 10015 | IGNORE | Re-examine Cache-control Directives: informational on anonymous pages (no-cache, no-store without private/must-revalidate; robots.txt sends none); the personal-data responses carry no-store (#615) |
| 10027 | IGNORE | Suspicious Comments: the matched block is the session diagnostic footer, which DiagnosticFooterMiddleware emits only outside Production (env.IsProduction() short-circuit, covered by DiagnosticFooterMiddlewareTests); the other hits are explanatory JS comments in _GtmHead and _ClarityHead. Follow-up #677: switch the footer off on review apps. Rule-wide: a new suspicious comment anywhere is also hidden until #677 removes this IGNORE |
| 10054 | IGNORE | Cookie with SameSite Attribute None: .AspNetCore.Correlation.* is the OpenID Connect handler's correlation cookie for the DfE Sign-in redirect; ASP.NET Core sets it SameSite=None by default so the cross-site return carries it (the Secure-cookie rule 10011 passed) |
| 10055 | IGNORE | CSP: style-src 'unsafe-inline' and the Google wildcard hosts are needed by Google Tag Manager and inline style attributes in our views; script-src is nonce-only, pinned by SecurityHeadersMiddlewareTests (#611). This IGNORE covers every 10055 variant, so those tests are the CSP guard |
| 10096 | IGNORE | Timestamp Disclosure: informational, the matches are numeric constants in minified GOV.UK Frontend and MOJ Frontend CSS, not timestamps |
| 10109 | IGNORE | Modern Web Application: informational; the match is the start page's placeholder href="#" for email sign-up (Views/Home/Index.cshtml), pending the notifications work; the service is server-rendered |
| 10112 | IGNORE | Session Management Response Identified: informational; the antiforgery and ASP.NET Core session cookies (the cookie rules 10010, 10011 and 10054 pass for both) |
Running it locally
Start the compose stack (docker compose up -d db azurite web), copy the plan into a writable
work folder with an empty zap-out folder beside it, then:
docker run --rm --user root -e ZAP_TARGET=http://host.docker.internal:8080 \
-v "<work folder>:/zap/wrk:rw" ghcr.io/zaproxy/zaproxy:stable \
zap.sh -cmd -autorun /zap/wrk/plan.yaml
--user root so ZAP can write zap-out on a Linux host. The reports and summary.json land in <work folder>/zap-out. Under Git Bash on Windows, prefix
the command with MSYS_NO_PATHCONV=1, or the shell turns /zap/wrk/plan.yaml into a Windows path.
The local target is plain http, so ZAP's HTTPS-only rules (HSTS 10035, cookie Secure 10011) do not run there and the cache-control rule 10015 does not fire on the Development web; only a review app tests them.