zap-baseline

Configures and runs OWASP ZAP baseline scanning: zap-baseline.py Docker-packaged spider + passive scan suitable for CI gating; supports -t target_url + -r html_report + -c config_file rule customization (INFO/IGNORE/FAIL warnings) and Ajax spider via -j for JS-heavy SPAs; zap-full-scan.py active companion for staging. Covers authenticated scans end to end as a reference - ZAP Context, auth methods (form/JSON/script/browser), session management, verification strategy, OAuth/bearer injection, context XML export for -n - plus DAST cadence planning (PR-blocking passive baseline, nightly ZAP full + nuclei active layer, baseline-finding ratchet for legacy apps). Use when the user runs OWASP ZAP for pre-prod web app DAST, needs coverage of routes behind a login wall, or is designing a team's DAST rollout cadence.

/plugin install qa-security-scanning@testland-qa

Overview

Per zaproxy.org/docs/docker/baseline-scan/ (opens in new window):

"The ZAP Baseline scan is a script that is available in the ZAP Docker images. It runs the ZAP spider against the specified target for (by default) 1 minute and then waits for the passive scanning to complete before reporting the results."

The baseline scan is passive only - non-intrusive, safe for production. The companion zap-full-scan.py adds active scanning (injection probes, XSS payloads) and is NOT safe for production; reserve for staging.

When to use

  • The repo deploys a web app + needs pre-prod DAST gate.
  • A CI workflow needs a non-intrusive scan that's safe to point at a deployed staging environment.
  • The team uses OWASP ZAP (de facto OSS DAST) over commercial alternatives.
  • The app's interesting routes sit behind a login wall (references/auth.md).
  • The team is designing its DAST rollout cadence from scratch (references/cadence.md).

Step 1 - Install

ZAP baseline runs from the official Docker image - no host install needed. Per zap-base (opens in new window):

docker pull ghcr.io/zaproxy/zaproxy:stable

Image variants:

TagUse
:stableProduction-grade; pin in CI
:weeklyLatest features; for evaluation
:bareHeadless minimal; smallest

Step 2 - First baseline scan

Per zap-base (opens in new window) (verbatim):

docker run -v $(pwd):/zap/wrk/:rw -t ghcr.io/zaproxy/zaproxy:stable zap-baseline.py \
    -t https://www.example.com -r testreport.html

The -v $(pwd):/zap/wrk/:rw mount makes the report writable to the host directory. The report file lands at ./testreport.html after the scan.

Step 3 - Common flags

Per zap-base (opens in new window):

FlagUse
-t URLTarget URL (required)
-r FILEHTML report output
-w FILEMarkdown report
-x FILEXML report
-J FILEJSON report (for finding triage)
-c FILEConfig file: rule INFO/IGNORE/FAIL behavior
-P PORTSpecify ZAP listen port
-jUse Ajax spider in addition to traditional spider (JS-heavy apps)
-m MINSSpider duration in minutes (default 1)
-dShow debug messages
-IDon't return failure on warning (gate softly)
-n CONTEXT_FILEAuthenticated scan context file

Step 4 - Authenticated scans

For apps requiring login, export a ZAP context file from the GUI (Sites → right-click → Export Context). The context file encodes:

  • Auth method (form / script / HTTP / OAuth)
  • Login URL + creds (referenced env vars; never hardcode)
  • Session-management strategy
  • Logged-in / logged-out indicators

Building that context from scratch - choosing the auth method, wiring session management, calibrating verification indicators, CSRF and OAuth/bearer handling - is walked end to end in references/auth.md. Then:

docker run -v $(pwd):/zap/wrk/:rw \
  -e ZAP_AUTH_USERNAME=$USER \
  -e ZAP_AUTH_PASSWORD=$PASS \
  -t ghcr.io/zaproxy/zaproxy:stable \
  zap-baseline.py -t https://app.example.com -n /zap/wrk/context.xml -J report.json

Step 5 - Active scan companion (zap-full-scan.py)

For staging-only deeper analysis:

docker run -v $(pwd):/zap/wrk/:rw -t ghcr.io/zaproxy/zaproxy:stable \
  zap-full-scan.py -t https://staging.example.com -J report.json

zap-full-scan.py triggers active payloads (SQLi probes, XSS, SSRF). NEVER point at production - risk of data corruption + generates audit-log noise.

Step 6 - False-positive triage (MANDATORY)

Per zap-base (opens in new window), rule customization via -c config_file where each line is <rule_id>\t<INFO|WARN|IGNORE|FAIL>\t<URL_pattern>:

Example zap-config.tsv:

10049	IGNORE	*	# Cookie No HttpOnly: legacy session cookie; tracked in JIRA-1234
40012	WARN	https://app.example.com/admin/*	# XSS: legacy admin pages; not exposed to users
10063	FAIL	*	# Permissions Policy: must be set on all responses

Three suppression layers:

MechanismExampleWhen to use
Per-rule config TSV<rule_id>\tIGNORE\t*Rule disabled globally
Per-URL pattern<rule_id>\tWARN\thttps://app/admin/*Rule downgraded for known-old code path
Context exclusion<exclude_from_context> in context XMLWhole URL trees out of scope
-I flagzap-baseline.py -I ...Soft gate: warns but exits 0

Justification template (mandatory in config):

# Rule 10049 (Cookie No HttpOnly)
# Suppressed: 2026-05-15 by alice@example.com
# Reason: legacy session cookie; tracked in JIRA-1234; expires 2026-09-15
# Re-review-date: 2026-09-15
10049	IGNORE	*

Cadence: every quarter, audit the config TSV - every IGNORE entry should have an Re-review-date. Past-due entries are removed + re-evaluated.

Step 7 - Output formats for cross-tool triage

docker run -v $(pwd):/zap/wrk/:rw -t ghcr.io/zaproxy/zaproxy:stable \
  zap-baseline.py -t https://app.example.com -J zap-report.json

The JSON report feeds cross-tool aggregation. For SARIF output (GitHub Code Scanning), use a converter (zap-sarif container action).

Step 8 - CI integration

GitHub Actions:

jobs:
  zap-baseline:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: zaproxy/action-baseline@v0.13.0
        with:
          target: 'https://staging.example.com'
          rules_file_name: '.zap/rules.tsv'
          cmd_options: '-J zap-report.json'
      - uses: actions/upload-artifact@v4
        if: always()
        with: { name: zap-report, path: zap-report.json }

The official zaproxy/action-baseline action wraps the Docker invocation. Auto-creates a GitHub Issue on failure.

Designing the full team cadence around this job - PR-blocking passive baseline, nightly ZAP full + nuclei active layer, the baseline-finding ratchet for legacy apps, dedup, and coverage measurement - is in references/cadence.md.

Anti-patterns

Anti-patternWhy it failsFix
Run zap-full-scan.py against productionActive payloads pollute / damage dataStaging-only (Step 5)
Skip -J JSON outputCan't feed finding triage; report is HTML-onlyAlways pass -J (Step 7)
Ignore Ajax spider for SPAsMissing routes; coverage gapAdd -j flag (Step 3)
Hardcode credentials in context fileSecrets leak in repoReference env vars (Step 4)
Suppress without # Re-review-datePermanent debtRequired template (Step 6)

Limitations

  • Baseline is passive only; full-scan needed for active probes but is not safe in production.
  • Spider duration default 1 min may miss deep-routes apps; increase via -m (Step 3).
  • ZAP scans single-target only; for multi-app fleet, run per-app
    • aggregate via cross-tool triage.
  • Authentication setup via context file is fragile - apps with complex login flows often need custom ZAP scripts (references/auth.md).

References

  • zap-base (opens in new window) - official baseline scan documentation
  • zaproxy.org/docs/docker - full Docker docs
  • github.com/zaproxy/action-baseline - official GHA action
  • Authenticated-scan setup (context, auth methods, session management, verification): references/auth.md
  • Layered DAST cadence (PR baseline → nightly active, ratchet, dedup): references/cadence.md
  • nuclei-dast - sister DAST tool (template-driven scanning)
Reference

Authenticated scans - full auth-context setup

View source (opens in a new tab)

Companion reference for zap-baseline. Unauthenticated DAST scans cover only the public attack surface - for most apps, 70-90% of routes sit behind a login wall. This build-an-X workflow walks the full authenticated session setup: ZAP Context creation, choosing the right Authentication Method, wiring Session Management, calibrating logged-in/out indicators, handling CSRF tokens, injecting OAuth/bearer headers, and exporting the context file that zap-baseline.py -n context.xml (and zap-full-scan.py) consume in CI.

How to use

  1. Create a ZAP Context that includes the app URLs and excludes /logout, so the scanner does not log itself out mid-scan (Step 1).
  2. Choose the Authentication Method that matches the app's login mechanism - Form, JSON, HTTP/NTLM, Script, or Browser-Based (Steps 2-6).
  3. Set the Session Management method (cookie, HTTP header, or script) to match how the app tracks sessions (Step 7).
  4. Calibrate the Verification Strategy with logged-in and logged-out indicators so ZAP detects session expiry and re-authenticates (Step 9).
  5. Add one or more Users, injecting credentials via -config or env vars rather than hardcoding them (Step 10).
  6. Confirm auth in the Authentication Tester, then export the Context to .zap/context.xml (Step 11).
  7. Run the scan in CI with -n context.xml, or replay the same session in Burp for manual testing (Steps 11-12).

Step 1 - Create a ZAP Context

Per zaproxy.org/docs/desktop/start/features/authentication/ (opens in new window), authentication in ZAP is always scoped to a Context - a named set of URLs. Create one before touching any auth setting:

  1. Open ZAP desktop. In the Sessions dialog: Session Properties > Contexts > Add.
  2. Set the Context Name (e.g., myapp-auth).
  3. Set the Include pattern to cover all app URLs: https://app.example.com/.*
  4. Add exclude patterns for logout URLs to avoid the spider logging itself out during a scan: https://app.example.com/logout.*

All auth settings attach to this Context. CLI scans reference it via -n context.xml (Step 11).

Step 2 - Choose the Authentication Method

Per zaproxy.org/docs/desktop/start/features/authmethods/ (opens in new window), ZAP supports five built-in methods. Choose by app login mechanism:

App login typeMethod to use
HTML form POST with username + password fieldsForm-Based
JSON POST {"username":"...","password":"..."}JSON-Based
HTTP Basic / Digest / NTLM challengeHTTP/NTLM
Custom flow (OTP, magic link, multi-step)Script-Based
Modern browser-rendered SSO / OAuth redirectBrowser-Based (auth-helper addon)

Step 3 - Configure Form-Based Authentication

Per zap-methods (opens in new window), Form-Based auth requires:

  • Login URL: the POST endpoint (e.g., https://app.example.com/login)
  • Login Request POST Data: encodes credentials as URL params: username={⁠%username%}&password={⁠%password%} ZAP replaces {⁠%username%} / {⁠%password%} with User credentials at scan time. Never hardcode credentials in this field.
  • Username field and Password field names (match the HTML name attributes).

Per zap-methods (opens in new window), Form-Based auth supports re-authentication - ZAP detects session expiry and re-logs in automatically mid-scan.

CSRF token handling for form login: if the login form contains an anti-CSRF token field, configure its name in Tools > Options > Anti CSRF Tokens (per zap-auth (opens in new window)). ZAP fetches the login page, extracts the token, and replays it with the POST automatically.

Step 4 - Configure JSON-Based Authentication

Per zap-methods (opens in new window), JSON-Based auth is for apps whose login endpoint accepts a JSON body rather than form-encoded params:

  • Login URL: the POST endpoint
  • Login Request POST Data: {"username":"{⁠%username%}","password":"{⁠%password%}"}

ZAP sends Content-Type: application/json automatically. Supports re-authentication. Use this for REST API login endpoints returning a session cookie or JWT response body.

Step 5 - Configure Script-Based Authentication

Per zap-methods (opens in new window), Script-Based auth handles flows that Form-Based and JSON-Based cannot: OTP-augmented logins, multi-step forms, OAuth authorization-code flows with PKCE, or apps that rotate CSRF seeds on every page load. It requires the Script Console add-on and an Authentication script that builds the login request via helper.prepareMessage().

See script-based-auth.md (opens in new window) for the Script Console setup, the Groovy authenticate() skeleton, and the OAuth authorization-code exchange pattern.

Step 6 - Configure Browser-Based Authentication (auth-helper addon)

Per zaproxy.org/docs/desktop/addons/authentication-helper/ (opens in new window), the Authentication Helper add-on provides Browser-Based Authentication for apps that use JS-rendered login pages, SSO redirects, or WebAuthn flows that headless HTTP clients cannot replay:

authentication:
  method: "browser"
  parameters:
    loginPageUrl: "https://app.example.com/login"
  verification:
    method: "autodetect"
sessionManagement:
  method: "autodetect"

ZAP launches Firefox, navigates to loginPageUrl, fills the username and password fields, and captures the resulting session token. The autodetect verification asks ZAP to find a suitable verification URL automatically.

Step 7 - Configure Session Management

Per zaproxy.org/docs/desktop/start/features/sessionmanagement/ (opens in new window), ZAP supports three session management methods. Set in Session Properties > Context > Session Management:

App session typeMethod
Session ID in a cookie (JSESSIONID, session, etc.)Cookie-Based Session Management
Authorization header (Basic, JWT Bearer)HTTP Authentication Session Management
Custom header or token rotationScript-Based Session Management

Per zap-session (opens in new window), Cookie-Based "session is being tracked through cookies" and tokens are imported from the HTTP Sessions Extension.

Per zap-session (opens in new window), Script-Based "is called whenever session management actions are performed" and requires the Scripts Console add-on.

Step 8 - Inject OAuth/Bearer Tokens via Environment Variables

Per zap-auth (opens in new window), ZAP exposes environment variables (ZAP_AUTH_HEADER_VALUE, ZAP_AUTH_HEADER, ZAP_AUTH_HEADER_SITE) for header-based injection of pre-obtained bearer tokens - OAuth client-credentials, API keys, CI-issued JWTs. Set them in the CI environment before the scan. For a full authorization-code exchange, use Script-Based auth (Step 5) instead and let ZAP manage token refresh.

See oauth-bearer-injection.md (opens in new window) for the variable table and the CI Docker example.

Step 9 - Set Authentication Verification Strategy

Per zap-verify (opens in new window), ZAP uses an Authentication Verification Strategy to know whether a request runs as an authenticated user, driven by a Logged-In Indicator and a Logged-Out Indicator regex. Four strategies exist (Check Every Response, Check Every Request, Check Every Request or Response, Poll the Specified URL); calibrate the indicators by flagging logged-in and logged-out responses in the History tab.

See verification-strategy.md (opens in new window) for the indicator examples, the strategy-selection table, and the calibration steps.

Step 10 - Add Users

Per zaproxy.org/docs/desktop/start/features/users/ (opens in new window), users are configured per-context at Session Properties > Context > Users > Add. Each user stores credentials that map to the Authentication Method's {⁠%username%} / {⁠%password%} placeholders.

Per zap-users (opens in new window): "Authentication Methods define the process; Users store the specific credentials needed for each user account." One context can hold multiple users (admin, read-only, unauthenticated) to test privilege separation in a single scan.

Never store plaintext credentials in the exported context XML committed to version control. Reference environment variables in CI (Step 8 pattern) or use ZAP's -config CLI flag to inject credentials at scan time:

zap-full-scan.py -t https://app.example.com \
  -n /zap/wrk/context.xml \
  -config context.users\(0\).name=scanner \
  -config context.users\(0\).credentials.username=$ZAP_USER \
  -config context.users\(0\).credentials.password=$ZAP_PASS

Step 11 - Export the Context XML

Once auth is confirmed working via the Authentication Tester (per zap-helper (opens in new window), under Tools > Authentication Tester or Ctrl+T), export the Context:

File > Export Context > save as context.xml

Commit context.xml to the repo at .zap/context.xml. The file encodes auth method, session management strategy, verification strategy, and include/exclude URL patterns. It does NOT contain user credentials when users are configured with the -config override pattern above.

Use in CI:

docker run --rm \
  -e ZAP_AUTH_USERNAME=$ZAP_USER \
  -e ZAP_AUTH_PASSWORD=$ZAP_PASS \
  -v $(pwd):/zap/wrk/:rw \
  ghcr.io/zaproxy/zaproxy:stable \
  zap-baseline.py -t https://app.example.com -n /zap/wrk/.zap/context.xml -J report.json

Per ../SKILL.md (opens in new window), the -n CONTEXT_FILE flag loads this file and activates authentication for the scan.

Step 12 - Replay with Burp Suite

For apps already configured in ZAP, mirror the session in Burp for manual testing by capturing a valid authenticated request via ZAP proxy, then:

  1. Export the HAR: right-click the authenticated request in ZAP History, Save as HAR.
  2. In Burp, import via Proxy > HTTP history > Import HAR.
  3. Set up a Macro (Project > Session handling rules > Macros) that replays the login POST and extracts the session token using a regex matching the cookie or JSON access_token field.
  4. Add a Session handling rule (Settings > Sessions > Session handling rules > Add) with scope covering the entire app and the macro set as the rule action.

This keeps Burp and ZAP scanning the same authenticated surface without re-configuring login from scratch in each tool.

Worked example

A team needs authenticated DAST coverage of a form-login SPA at app.example.com whose login page carries an anti-CSRF token.

  1. They create a Context myapp-auth including https://app.example.com/.* and excluding https://app.example.com/logout.* (Step 1).
  2. Login is an HTML form POST, so they pick Form-Based auth with POST data username={⁠%username%}&password={⁠%password%} and register the CSRF field name under Tools > Options > Anti CSRF Tokens (Steps 2-3).
  3. Sessions ride a JSESSIONID cookie, so they set Cookie-Based Session Management (Step 7).
  4. They flag a logged-in response containing href="/logout" and a logged-out Please log in page as indicators, using Check Every Response (Step 9, verification-strategy.md (opens in new window)).
  5. They add a scanner user and export the Context to .zap/context.xml, with credentials supplied at scan time via -config (Steps 10-11).
  6. CI runs zap-baseline.py -t https://app.example.com -n /zap/wrk/.zap/context.xml -J report.json.

Result: the baseline scan authenticates, re-logs in when the session expires, and reports vulnerabilities across the routes behind the login wall instead of only the public pages.

Anti-patterns

Anti-patternWhy it failsFix
Skip context creation, use -u user:pass flagNo re-auth; spider logs out mid-scanContext + auth method (Steps 1-3)
Hardcode credentials in context.xmlSecrets leak in version control-config injection or env vars (Step 10)
No logged-out indicatorZAP reports false coverage on expired sessionsCalibrate both indicators (Step 9)
Form-based auth on a JSON-API loginZAP sends form-encoded body; app rejects itJSON-based auth (Step 4)
Exclude /login from context scopeAuth POST never proxied; ZAP can't authenticateInclude login URL; exclude only /logout (Step 1)
Browser-based auth without auth-helper addonmethod: browser is not a built-in; scan failsInstall Authentication Helper from Marketplace (Step 6)
Set verification strategy but no indicatorsStrategy is inactive; ZAP never detects re-auth needSupply at least one logged-in regex (Step 9)

Limitations

  • ZAP auth context cannot be built or tested without the ZAP desktop or automation framework; no pure-CLI context creation exists.
  • MFA (TOTP, SMS OTP) requires Script-Based auth with a TOTP library or a pre-generated token injected via env var; ZAP has no native MFA support.
  • Browser-Based auth requires a local browser and is not available in headless Docker without a virtual display or the auth-helper's browser-in-Docker mode.
  • Per zap-methods (opens in new window), Manual Authentication "does not support re-authentication in case the webapp logs a user out"; avoid for active scans longer than the session TTL.
  • Context XML export includes URL patterns but not user credentials when using the -config flag injection pattern; anyone needing credentials must supply them separately.

Sources

Reference

DAST scan cadence - layered rollout planning

View source (opens in a new tab)

Companion reference for zap-baseline. Consult when a team adopts DAST from scratch, restructures scan cadence, or is drowning in findings with no triage discipline. Layers ZAP passive baseline and active scanning (ZAP full scan + nuclei templates) across PR / nightly windows, with a baseline ratchet so pre-existing findings do not block PRs.

Step 1 - Layer the scans by intrusiveness

LayerScan typeCadenceTargetRisk
1Passive baseline (ZAP baseline)Per-PR (blocking)StagingSafe - passive only
2Active scan (ZAP full scan + nuclei templates)NightlyStagingActive probes - pollute staging data

The PR-blocking layer is intentionally narrow - only fail on findings that didn't exist before. That requires the baseline ratchet (Step 2). Nuclei (nuclei-dast skill) complements the nightly ZAP full scan with template-driven checks; its JSONL output feeds the same aggregation layer.

Step 2 - Baseline-finding ratchet

The first scan against a legacy app surfaces 100s of pre-existing findings; if they all block PRs, the team disables DAST. The ratchet pattern:

  1. Run the scan once against current main → save as baseline-findings.json
  2. Per-PR: run the scan, diff against baseline, fail only on NEW findings
  3. Periodically (weekly): re-baseline, requiring waiver entries for any persisted findings
# pr-gate.py
import json

def diff_findings(current, baseline):
    baseline_keys = {(f['file'], f['rule_id']) for f in baseline}
    new = [f for f in current if (f['file'], f['rule_id']) not in baseline_keys]
    return new

with open('current.json') as f:
    current = json.load(f)
with open('baseline.json') as f:
    baseline = json.load(f)

new_findings = diff_findings(current, baseline)
if any(f['severity'] in ['critical', 'high'] for f in new_findings):
    print(f"FAIL: {len(new_findings)} new finding(s) on PR; not in baseline")
    exit(1)

ZAP baseline natively supports per-rule gating via the -c config.tsv rule file; mirror the pattern for the cross-tool aggregation layer.

Step 3 - Alert deduplication across runs

Consecutive PR-runs catch the same vulnerability multiple times; each PR comment shows duplicate noise. Dedupe by (rule_id, url, parameter) tuple:

def dedupe_findings(findings):
    seen = set()
    deduped = []
    for f in findings:
        key = (f['rule_id'], f['url'], f.get('parameter', ''))
        if key not in seen:
            seen.add(key)
            deduped.append(f)
    return deduped

Cross-tool dedup is handled at the aggregation layer (security-finding-triager); this dedup is per-tool per-run.

Step 4 - CI cadence

Two workflows implement the layering:

Workflow fileTriggerJob
.github/workflows/dast.ymlpull_requestZAP baseline + dast-pr-gate.py (Step 2)
.github/workflows/dast-nightly.ymlcron: 0 2 * * *ZAP full scan + nuclei template scan
# .github/workflows/dast.yml - PR-blocking baseline
on:
  pull_request:
    branches: [main]
jobs:
  zap-baseline-pr:
    name: DAST baseline (PR-blocking)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: zaproxy/action-baseline@v0.13.0
        with:
          target: ${{ secrets.STAGING_URL }}
          rules_file_name: '.zap/rules.tsv'
      - run: python ci/dast-pr-gate.py current.json .zap/baseline-findings.json
# .github/workflows/dast-nightly.yml - nightly active scan
on:
  schedule:
    - cron: '0 2 * * *'   # 2 AM daily
  workflow_dispatch:
jobs:
  zap-full-scan:
    name: DAST active full scan
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: zaproxy/action-full-scan@v0.13.0
        with:
          target: ${{ secrets.STAGING_URL }}
      - uses: actions/upload-artifact@v4
        with: { name: zap-full-report, path: report_html.html }

  nuclei:
    name: DAST template scan (nuclei)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - run: |
          nuclei -u ${{ secrets.STAGING_URL }} -jsonl -o nuclei.jsonl
      - uses: actions/upload-artifact@v4
        with: { name: nuclei-report, path: nuclei.jsonl }

Nuclei flag details are in the nuclei-dast skill.

Step 5 - Per-finding triage workflow

When a new finding appears in a PR-blocking scan, the team has 4 options:

  1. Fix - code change resolves the finding
  2. Suppress with justification - add to .zap/rules.tsv with # Reason: ... Re-review-date: ...
  3. Add to baseline - explicit acceptance; finding tracked in baseline file with reviewer attribution
  4. Escalate - beyond PR scope; create a tracker ticket + waive per-PR with explicit ticket reference

Each option requires reviewer + reason + Re-review-date in commit message or PR comment. No silent suppression.

Step 6 - Coverage measurement

Post-scan, measure coverage to detect blind spots:

# How many endpoints did the scan cover?
jq '.spider_results.urls | length' report.json
# How many endpoints did the OpenAPI spec define?
jq '.paths | length' openapi.yaml
# Coverage ratio

If coverage < 80% of API surface, the spider missed routes; investigate auth flows (auth.md (opens in new window)), JS-heavy SPAs, route-discovery gaps.

Step 7 - Aggregate cross-tool findings

Once both tools run, aggregate each tool's output:

zap-baseline.py -t $URL -J zap.json
nuclei -u $URL -jsonl -o nuclei.jsonl

# Aggregate both + emit unified verdict

The aggregation layer (the security-finding-triager agent + multi-tool-finding-triage) handles cross-tool dedup, severity normalization, and waiver enforcement.

Anti-patterns specific to DAST cadence

Anti-patternWhy it failsFix
Run full active scans on every PRScan time blows out CI; staging data corruptedBaseline-only on PR; full nightly (Step 4)
Skip baseline ratchetLegacy findings block every PRBaseline + diff (Step 2)
Ignore coverage measurementMissing endpoints unscanned silentlyStep 6 weekly check
One scan per app, never re-baselineBaseline grows stale; misses regressions in old codeQuarterly re-baseline + waiver review
Run ZAP + nuclei without dedupSame finding shows twiceAggregate via triager (Step 7)

End-to-end cadence checklist

  1. ZAP baseline runs PR-blocking against staging (Step 4)
  2. ZAP full scan + nuclei run nightly against staging (Step 4)
  3. Baseline ratchet active for legacy apps (Step 2)
  4. Per-tool dedup applied (Step 3)
  5. Cross-tool dedup via triager (Step 7)
  6. Coverage measured weekly (Step 6)
  7. Suppression entries have Re-review-date + reviewer (Step 5)

Limitations

  • Active scans are inherently destructive on staging data; pair with a staging-data refresh cadence.
  • DAST coverage is bounded by spider + spec discovery; SPA-heavy apps need careful auth + route-discovery setup (auth.md (opens in new window)).
  • The cadence assumes overnight windows; high-velocity shops may need shorter cycles + smaller scan scopes.

Sources

  • OWASP WSTG - owasp.org/www-project-web-security-testing-guide
  • OWASP DSOMM (DevSecOps Maturity Model) for cadence guidance
  • nuclei-dast - nuclei flags + CI integration
Reference

ZAP OAuth / Bearer token injection

View source (opens in a new tab)

Per zap-auth (opens in new window), ZAP exposes three environment variables for header-based authentication injection - useful for pre-obtained bearer tokens (OAuth client-credentials flow, API keys, CI-issued JWTs):

VariablePurpose
ZAP_AUTH_HEADER_VALUEThe token value (Bearer eyJ...)
ZAP_AUTH_HEADERHeader name (defaults to Authorization if unset)
ZAP_AUTH_HEADER_SITERestrict injection to this domain only

Set these in the CI environment before running the scan:

export ZAP_AUTH_HEADER_VALUE="Bearer $(./scripts/get-ci-token.sh)"
export ZAP_AUTH_HEADER_SITE="app.example.com"

docker run --rm \
  -e ZAP_AUTH_HEADER_VALUE \
  -e ZAP_AUTH_HEADER_SITE \
  -v $(pwd):/zap/wrk/:rw \
  ghcr.io/zaproxy/zaproxy:stable \
  zap-full-scan.py -t https://app.example.com -n /zap/wrk/context.xml -J report.json

For OAuth flows requiring a full authorization-code exchange, use Script-Based auth (references/script-based-auth.md (opens in new window)) to run the exchange inside ZAP and let ZAP manage token refresh during the scan. Environment-variable injection is the right path for client-credentials and static-API-key auth.

Reference

ZAP Script-Based Authentication

View source (opens in a new tab)

Per zap-methods (opens in new window), Script-Based auth handles flows that Form-Based and JSON-Based cannot: OTP-augmented logins, multi-step forms, OAuth authorization-code flows with PKCE, or apps that rotate CSRF seeds on every page load.

Prerequisites:

  1. Install the Script Console add-on from the ZAP Marketplace.
  2. In Tools > Scripts, create a new Authentication script (type: Authentication). ZAP ships example scripts at scripts/authentication/ inside the ZAP installation directory.
  3. The script receives helper, paramsValues, and credentials; it must call helper.prepareMessage() to build a login request and return the response.

Minimal skeleton (Groovy):

def authenticate(helper, paramsValues, credentials) {
    def loginUrl = paramsValues.get("Login URL")
    def msg = helper.prepareMessage()
    msg.setRequestHeader("POST " + loginUrl + " HTTP/1.1\r\n" +
        "Host: app.example.com\r\n" +
        "Content-Type: application/json\r\n")
    def body = '{"user":"' + credentials.getParam("Username") + '",' +
               '"pass":"' + credentials.getParam("Password") + '"}'
    msg.setRequestBody(body)
    helper.sendAndReceive(msg)
    return msg
}

Select the script in Session Properties > Context > Authentication > Script-Based Authentication, then set any script parameters.

For OAuth authorization-code flows: the script fetches the /authorize redirect, extracts the code, POSTs to /token, and stores the resulting access_token in a ZAP environment variable for header injection (see references/oauth-bearer-injection.md (opens in new window)).

Reference

ZAP Authentication Verification Strategy

View source (opens in a new tab)

Per zap-verify (opens in new window), ZAP uses an Authentication Verification Strategy to know whether a request is executing as an authenticated user. Configure in Session Properties > Context > Authentication > Verification:

Logged-In Indicator: a regex present in responses when the user is authenticated. Examples:

  • \QWelcome, \E (welcome banner with the username)
  • \Qhref="/logout"\E (logout link in nav)
  • \Q"role":"user"\E (JSON response field)

Logged-Out Indicator: a regex present in responses when the session has expired. Examples:

  • \QPlease log in\E
  • \Qlocation: /login\E (redirect header)
  • HTTP/1\.1 401

Per zap-verify (opens in new window), four strategies are available:

StrategyUse when
Check Every ResponseTraditional HTML apps (indicator in page body)
Check Every RequestClient-side sessions (JWT in Authorization header)
Check Every Request or ResponseMixed; SPA + API combo
Poll the Specified URLDedicated /api/me or /session/check endpoint

Calibration steps:

  1. Browse the app manually through ZAP proxy while logged in.
  2. Right-click a response in the History tab that contains the logged-in text. Choose Flag as Context > <context-name> Logged in indicator. ZAP extracts the regex automatically.
  3. Browse to a page after logging out. Right-click that response. Choose Flag as Context > <context-name> Logged out indicator.
  4. Confirm both indicators in Session Properties > Context > Authentication.