zaprun is a point-and-shoot ZAP driver: it builds an OWASP ZAP Automation Framework plan, runs ZAP via a digest-pinned container, and writes a stable set of artifacts so CI gates and humans can reason about results the same way.
This manual is the canonical reference for v0.3.1. It describes the self-contained zaprun crate and the CLI baked into ghcr.io/kerberosmansour/zaprun:v0.3.1.
Pick one of the three install methods, depending on your host:
# 1. From crates.io — builds the CLI from source. Cross-platform.
cargo install zaprun
# 2. From the prebuilt image — no Rust toolchain needed. Linux-amd64-native;
# on macOS arm64 the image runs via Rosetta / QEMU emulation.
docker pull ghcr.io/kerberosmansour/zaprun:v0.3.1
# 3. From source.
git clone https://github.com/kerberosmansour/zaprun
cd zaprun
cargo build --release -p zaprun
./target/release/zaprun --helpAt runtime zaprun drives Docker, so a working Docker daemon is required on the host regardless of how the CLI was installed.
This crate is the only publishable Cargo package in the workspace. Publish it explicitly:
cargo publish -p zaprunDo not publish the whole workspace; the legacy dast-spike crates are internal compatibility code and are marked non-publishable.
| Platform | CLI binary | Container image | Notes |
|---|---|---|---|
| Linux x86_64 | ✅ cargo install / prebuilt |
✅ native linux/amd64 |
First-class target. |
| Linux aarch64 | ✅ cargo install from source |
⚠ image is linux/amd64 only; pull works but runs via QEMU |
Multi-arch image build is a planned enhancement. |
| macOS Apple Silicon (aarch64) | ✅ cargo install from source |
⚠ Docker Desktop / OrbStack / Colima run the amd64 image via Rosetta; benign requested image's platform warning prints |
Works; slightly slower than native. |
| macOS Intel (x86_64) | ✅ cargo install from source |
✅ runs natively under Docker Desktop. | |
| Windows x86_64 | ✅ cargo install from source |
✅ runs under Docker Desktop's WSL2 backend | Volume-mount paths follow Docker Desktop's Windows-to-Linux translation. |
The Rust code is pure portable Rust (rustls-tls, no native-tls; getrandom for the CSPRNG; which for binary lookup). Cross-compilation should work out of the box for any target the Rust toolchain supports.
- Where the CLI lives
- Invocation patterns
- Artifact contract
- Exit codes
- Subcommands
- Image entrypoint dispatch
- Reaching the target from inside the scanner
- End-to-end examples
- Troubleshooting
Two distribution surfaces:
- Baked into the image at
/usr/local/bin/zaprun. Pull the digest-pinned image and invokezaprunas the first argument. This is the canonical way to run scans — no host Rust toolchain required. - Built from source by cloning the repo and running
cargo build --release -p zaprun. The resulting binary lives attarget/release/zaprun. Useful for development; for scans it's still better to use the image because the image bundles the matching ZAP runtime + add-ons + helper scripts.
The CLI's --image flag enforces digest pinning. Tag references (:v0.3.1, :edge) are NOT accepted; only <repo>@sha256:<64-hex> is. This is by design — every published digest carries a cosign signature and three attestations (SLSA Build Provenance, SPDX SBOM, CycloneDX SBOM) and tag-by-tag resolution loses the binding.
zaprun does not depend on dast-spike; the init, rederive, triage-sarif, schema, SARIF, and path-safety code used by the public CLI lives inside this crate.
docker run --rm \
-v "$PWD/output:/zap/wrk/output" \
ghcr.io/kerberosmansour/zaprun@sha256:<digest> \
zaprun scan http://host.docker.internal:4000 --active --profile spa-prThe first positional argument after the image ref selects the inner CLI: the entrypoint dispatches zaprun -> the baked binary; anything else falls through to the image's compatibility scan harness, which accepts --target / --output-dir / --policy for existing ZAP jobs (see image entrypoint dispatch for the literal-equality rule).
cargo build --release -p zaprun
./target/release/zaprun scan http://localhost:4000 --activeThe CLI itself talks to a Docker daemon to run ZAP; you still need a working Docker on the host.
Every successful run writes the following files under --output (default ./output/zaprun):
| File | Schema | Contents |
|---|---|---|
plan.yaml |
ZAP Automation Framework | The exact plan that was executed. |
run.json |
v1.0 | Run metadata: image digest, per-run 32-byte API-key envelope, target URL, exit code, timings. |
summary.json |
v1.0 | Normalised finding summary — severity counts + sample alerts per rule. |
coverage.json |
v1.0 | Coverage ledger — URLs discovered vs URLs scanned. |
capabilities.json |
v1.0 | doctor pre-flight result: backend, image digest, browser support. |
observations.json |
v1.0 | observe-mode replay record. Only written when observe runs. |
zap-report.json |
ZAP traditional JSON | Raw ZAP report, preserved verbatim alongside the normalised summary. |
zap-report.html |
ZAP HTML | Raw ZAP HTML report. |
zap.sarif |
SARIF 2.1.0 | For GitHub Code Scanning and other SARIF consumers. |
Not every subcommand writes every file — see the per-subcommand sections below. summary.json plus zap-report.json are the most consumed pair; plan.yaml is the deterministic-replay anchor.
zaprun exits with one of six well-defined codes — useful in CI gating logic.
| Code | Meaning |
|---|---|
0 |
scan completed and policy gate passed |
1 |
scan completed and policy gate failed |
2 |
tool or environment error (bad args, missing docker, unsafe path) |
3 |
target unavailable or scan could not start |
4 |
timeout or resource budget exceeded |
5 |
coverage contract failed |
The mapping is exhaustive: every Rust-side error category lands on exactly one exit code. This is asserted by crates/zaprun/tests/unit_exit_code_mapping_is_total.rs.
Pre-flight check (M1). Confirms the backend is reachable, the image digest is well-formed and pullable, and (optionally) the target URL responds.
Usage: zaprun doctor [OPTIONS]
Options:
--backend <BACKEND> Backend to probe. `docker` (default) or `local-zap` (stubbed) [default: docker]
--image <IMAGE> Optional image reference. When provided, must be `<repo>@sha256:<64-hex>`
--probe-target <PROBE_TARGET> Optional target URL to probe for reachability
--output <OUTPUT> Output directory for `capabilities.json` [default: ./output/zaprun]
-h, --help Print help
Examples
# Default: probe docker + the pinned image
zaprun doctor
# Probe a target URL for reachability before scanning
zaprun doctor --probe-target http://localhost:3001
# Validate a specific image digest is well-formed and pullable
zaprun doctor --image ghcr.io/kerberosmansour/zaprun@sha256:<digest>Writes: capabilities.json.
Build a ZAP Automation Framework plan for a target without running it (M2). Useful for inspecting the plan before kicking off a scan, or for emitting the plan into a target repo for archival.
Usage: zaprun plan [OPTIONS] <TARGET>
Arguments:
<TARGET> Target URL the plan should describe
Options:
--dry-run Materialise the plan but do not run the scanner (only mode in MVP1)
--output <OUTPUT> Output directory for plan.yaml and run.json [default: ./output/zaprun]
-h, --help Print help
Examples
# Materialise a plan for inspection (no scan)
zaprun plan https://example.test --dry-run
# Plan into a custom directory
zaprun plan http://localhost:3001 --dry-run --output output/zaprun-planWrites: plan.yaml, run.json.
Note: in v0.3.x, plan only supports --dry-run. Running the plan from plan directly is reserved for MVP2.
Run an active web URL scan (M3). The default profile is web-pr (traditional spider + active scan); spa-pr adds the Ajax spider + DOM-XSS active rules via Firefox-backed Selenium.
Usage: zaprun scan [OPTIONS] <URL>
Arguments:
<URL> Target URL to scan (http or https)
Options:
--active Run the active scanner (in addition to spidering and passive rules)
--passive Run only passive rules (mutually exclusive with --active in practice)
--profile <PROFILE> Scan profile: `web-pr` (default, traditional) or `spa-pr` (Ajax + DOM XSS) [default: web-pr]
--browser-id <BROWSER_ID> Selenium browser ID for browser-backed rules (e.g. DOM XSS) [default: firefox-headless]
--output <OUTPUT> Output directory for plan.yaml/run.json/summary.json/zap-report.* [default: ./output/zaprun]
--image <IMAGE> Optional image reference (`<repo>@sha256:<64-hex>`); defaults to the pinned digest
--scan-timeout <SCAN_TIMEOUT> Scan timeout (e.g. `8m`, `30m`) [default: 8m]
-h, --help Print help
Examples
# Default profile (web-pr): traditional spider + active scan
zaprun scan https://example.test --active
# SPA-aware profile (spa-pr): Ajax spider + DOM XSS rules
zaprun scan http://host.docker.internal:4000 --active --profile spa-pr
# Longer scan budget for a larger target
zaprun scan http://localhost:4000 --active --scan-timeout 30m
# Passive-only sweep (no active probes — useful in shared envs)
zaprun scan https://example.test --passive
# Pin a specific image digest (CI lane that wants reproducibility-by-digest)
zaprun scan http://localhost:4000 --active \
--image ghcr.io/kerberosmansour/zaprun@sha256:<digest>
# Direct output to a per-CI-job dir
zaprun scan http://localhost:4000 --active --output output/zaprun-pr-1234Writes: plan.yaml, run.json, summary.json, coverage.json, capabilities.json, zap-report.json, zap-report.html, zap.sarif.
--scan-timeout defaults to 8m. For active scans against larger or interactive targets, bump to 15m–30m. The scanner's internal timeout is independent of any CI job timeout — set both.
Run an OpenAPI active scan (M4). Loads the spec, rewrites the host so the scanner container can reach the host gateway, builds an Automation Framework plan with an inlined active-scan policy (no dependency on ~/.ZAP/policies/*.policy at scan time), and runs it.
Usage: zaprun api [OPTIONS] --target <TARGET> <SPEC>
Arguments:
<SPEC> Path to the OpenAPI spec (YAML or JSON)
Options:
--target <TARGET> Target base URL the spec describes (http or https)
--active Run the active scanner (passive-only is the default)
--output <OUTPUT> Output directory for plan.yaml/run.json/summary.json/zap-report.* [default: ./output/zaprun]
--image <IMAGE> Optional image reference (`<repo>@sha256:<64-hex>`); defaults to the pinned digest
--scan-timeout <SCAN_TIMEOUT> Scan timeout (e.g. `8m`, `30m`) [default: 8m]
-h, --help Print help
Examples
# Active scan a local API behind a generated OpenAPI doc
zaprun api ./openapi.yaml --target http://localhost:3001 --active
# Pin output dir for a smoke service run
zaprun api ./crates/secure_smoke_service/openapi.yaml \
--target http://localhost:3001 --active \
--output output/zaprun-smoke
# Passive-only sweep against a production-shaped spec
zaprun api ./openapi.yaml --target https://api.example.test
# Custom scan timeout for a deep API
zaprun api ./openapi.yaml --target http://localhost:3001 --active --scan-timeout 20mWrites: same set as scan, plus the inlined API-Minimal active policy is captured in plan.yaml so a reader can reproduce the run from the plan alone.
The inlined policy's SHA-256 is pinned as API_MINIMAL_POLICY_INLINE_HASH in crates/zaprun/src/scan_api.rs; drift is detected by crates/zaprun/tests/unit_api_inline_policy.rs. Changing the policy requires a deliberate, reviewed bump of the pinned hash.
Run an OWASP PTK Phase 1 scan. This lane uses ZAP's Client Spider to drive a real browser with PTK automation enabled. The scanner image must already contain the Client Side Integration and OWASP PTK add-ons; zaprun ptk never emits runtime Marketplace install jobs.
Usage: zaprun ptk [OPTIONS] <URL>
Arguments:
<URL> Target URL to scan (http or https)
Options:
--browser-id <BROWSER_ID> Selenium browser ID for the Client Spider [default: firefox-headless]
--browsers <BROWSERS> Number of browsers for Client Spider. Bounded to 1..=2 [default: 1]
--max-duration <MAX_DURATION> Client Spider duration budget (e.g. `3m`, `180s`) [default: 3m]
--output <OUTPUT> Output directory for plan.yaml/run.json/summary.json/zap-report.* [default: ./output/zaprun-ptk]
--image <IMAGE> Optional image reference (`<repo>@sha256:<64-hex>`); defaults to the pinned digest
--scan-timeout <SCAN_TIMEOUT> Total scan timeout (e.g. `8m`, `30m`) [default: 10m]
--dry-run Materialise plan.yaml and run.json but do not start Docker
-h, --help Print help
Examples
# Browser-backed PTK Phase 1 scan
zaprun ptk http://host.docker.internal:4000 --output output/zaprun-ptk
# Inspect the generated Automation Framework plan without running Docker
zaprun ptk http://localhost:4000 --dry-run --output output/zaprun-ptk-plan
# Keep browser load low but allow a deeper Client Spider crawl
zaprun ptk http://host.docker.internal:4000 --browsers 1 --max-duration 8mWrites: plan.yaml, run.json, summary.json, coverage.json, capabilities.json, zap-report.json, zap-report.html, zap.sarif on a real scan; --dry-run writes only plan.yaml and run.json.
Phase 1 limitations: PTK is configured separately from ZAP's active/passive scan policies, PTK findings may overlap with standard ZAP alerts, and unauthenticated browser crawling will miss logged-in flows unless the target itself exposes them without auth. The coverage ledger records the browser-backed crawl and keeps the seeded-journey/authentication gap explicit.
Send a candidate raw HTTP request and record replay evidence. Useful for incident-response and SAST-guided DAST flows: take a request that reaches a finding, replay it against a staging target, and capture whether the target responded before deciding how to tune scanner coverage.
Usage: zaprun observe [OPTIONS] --target <TARGET>
Options:
--request <REQUEST> Path to a candidate raw HTTP request to replay
--finding <FINDING> Path to a candidate finding JSON to replay
--target <TARGET> Target URL the request should be sent to
--output <OUTPUT> Output directory for observations.json [default: ./output/zaprun]
--allow-internal-target Opt out of the SSRF guard for RFC1918 + loopback targets.
Link-local / IMDS (169.254/16) is ALWAYS refused regardless of this flag
-h, --help Print help
Examples
# Replay a request file against an internal target
zaprun observe \
--request ./req.http \
--target http://localhost:3001 \
--allow-internal-target
# Replay a finding fixture and write observations.json
zaprun observe \
--finding ./finding.json \
--target https://example.testWrites: observations.json with request/response evidence such as request_sent, response_observed, request_path, http_status, and response_body_hash. ZAP alert correlation can be layered on top of this evidence path; the replay artifact is already useful for proving that a SARIF finding has an HTTP-reachable request.
SSRF guard — observe's --target is the only zaprun flag with a network-trust-boundary check. Link-local addresses (169.254.0.0/16, the IMDS range) are unconditionally refused. RFC1918 (10/8, 172.16/12, 192.168/16) and loopback (127/8) require the explicit --allow-internal-target opt-in. The rationale is in crates/zaprun/tests/unit_observe_ssrf_guard.rs. The scan subcommand uses scheme-only validation because loopback is the headline scan target in CI.
Bootstrap target-owned DAST configuration and a GitHub Actions workflow.
Usage: zaprun init [OPTIONS]
Options:
--target-dir <TARGET_DIR> Target repository to receive .zaprun/ config and .github/workflows/dast.yml [default: .]
--deployment-target <DEPLOYMENT_TARGET> Runtime base URL the workflow should scan
--image <IMAGE> Optional image reference (`<repo>@sha256:<64-hex>`); defaults to the pinned digest
-h, --help Print help
Writes .zaprun/policy-pr.yml, .zaprun/policy-nightly.yml, .zaprun/baseline.json, .zaprun/rules.tsv, .zaprun/manifest.json, and .github/workflows/dast.yml.
Recompute target-owned DAST configuration when the threat model or approved image digest drifts from .zaprun/manifest.json.
Usage: zaprun rederive [OPTIONS]
Options:
--target-dir <TARGET_DIR> Target repository containing .zaprun/manifest.json [default: .]
-h, --help Print help
When drift exists, rederive rewrites the generated files and opens one review PR with gh pr create from the target repository.
Classify SAST SARIF into endpoint x CWE guided DAST inputs.
Usage: zaprun triage-sarif [OPTIONS] --sarif <SARIF>
Options:
--target-dir <TARGET_DIR> Target repository containing route/OpenAPI context [default: .]
--sarif <SARIF> SARIF file to classify
--output <OUTPUT> Output directory for triage-report.json/guided-scan-map.json/filtered.sarif [default: ./output/zaprun-triage-sarif]
-h, --help Print help
Writes triage-report.json, guided-scan-map.json, and filtered.sarif. SARIF remains evidence, not authority: authenticated findings stay needs-human-input until logged-in reachability is configured.
Class-based calibration against expected plugin IDs (M5). Reads a calibration profile TOML that declares which ZAP plugin IDs are expected to fire on a target, and reports observed-vs-expected. Useful for regression-testing scanner coverage when you ship a new version of a vulnerable-app fixture.
Usage: zaprun calibrate [OPTIONS] <PROFILE>
Arguments:
<PROFILE> Calibration profile TOML describing expected plugin classes
Options:
--output <OUTPUT> Output directory for calibration evaluation results [default: ./output/zaprun]
-h, --help Print help
Examples
# Evaluate a calibration profile against NodeGoat
zaprun calibrate ./calibration/nodegoat.toml \
--output output/zaprun-calibrateWrites: calibration-results JSON in the output directory.
In v0.3.x the calibration evaluator reads the profile and produces a placeholder result; full scan-orchestration is tracked as a zaprun follow-up. The flag surface is stable.
Explain a previous run directory (placeholder in v0.3.x).
Usage: zaprun explain <RUN_DIR>
Arguments:
<RUN_DIR> Path to a previous zaprun output directory
Options:
-h, --help Print help
Reserved for MVP2. The CLI accepts the argument shape so future invocations don't need to change, but the implementation prints an MVP2-pending banner.
The hardened ZAP image's ENTRYPOINT is a shell script that does literal-string-equality dispatch on $1:
if [ "${1:-}" = "zaprun" ]; then
shift
exec /usr/local/bin/zaprun "$@"
fi
# Otherwise: fall through to the compatibility --target/--output-dir/--policy scan harness.The literal check (no regex, no case-fold, no eval) is a deliberate security property: shell metacharacters in $1 are NEVER expanded. An invocation like docker run … "zaprun; echo PWNED" does NOT match "zaprun" and falls through to the compatibility scan harness, which rejects with unknown argument: zaprun; echo PWNED. The argv-injection abuse case is exercised in .github/workflows/build-zap-image.yml's Smoke test final image step.
The scanner container runs with --add-host=host.docker.internal:host-gateway, so a target listening on the host's 127.0.0.1:<port> is reachable from inside the scanner as http://host.docker.internal:<port>.
For the api subcommand, zaprun rewrites http://127.0.0.1:<port> references in the OpenAPI spec's servers block to http://host.docker.internal:<port> automatically (see crates/zaprun/src/scan.rs::rewrite_openapi_host). For the scan subcommand, you pass the right URL directly; if the target is on the host, use http://host.docker.internal:<port>.
# 1. Start the target. NodeGoat exposes the app on port 4000.
docker run -d --name nodegoat -p 4000:4000 \
nirocr/nodegoat@sha256:1384d404f1eb89ba218a5988cccde902bb6b606e3ec70f60b185183f9639c392
# 2. Wait for the target to become healthy.
for i in $(seq 1 60); do
curl -fsS --max-time 2 http://127.0.0.1:4000/ >/dev/null && break
sleep 1
done
# 3. Active scan with the SPA-aware profile + a generous timeout.
mkdir -p output && chmod 0777 output
docker run --rm \
-v "$PWD/output:/zap/wrk/output" \
--add-host=host.docker.internal:host-gateway \
ghcr.io/kerberosmansour/zaprun:v0.3.1 \
zaprun scan http://host.docker.internal:4000 \
--active --profile spa-pr --scan-timeout 30m
# 4. Tear down.
docker rm -f nodegoatThe scan writes output/plan.yaml, output/zap-report.json, output/summary.json, output/zap.sarif, and the rest of the artifact contract.
# Target: a Rust service that emits its own openapi.yaml.
cargo build --release -p secure_smoke_service
./target/release/secure_smoke_service &
SVC_PID=$!
curl -fsS http://localhost:3001/openapi.yaml -o /tmp/openapi.yaml
docker run --rm \
-v "$PWD/output:/zap/wrk/output" \
-v /tmp/openapi.yaml:/spec/openapi.yaml:ro \
--add-host=host.docker.internal:host-gateway \
ghcr.io/kerberosmansour/zaprun:v0.3.1 \
zaprun api /spec/openapi.yaml \
--target http://host.docker.internal:3001 \
--active
kill "$SVC_PID"The api subcommand inlines its active-scan policy into plan.yaml so the result is reproducible from the plan alone.
# Suppose req.http is the request the reporter included, against a staging URL.
cat > req.http <<'REQ'
POST /api/checkout HTTP/1.1
Host: staging.example.test
Content-Type: application/json
{"coupon": "<script>alert(1)</script>"}
REQ
docker run --rm \
-v "$PWD/output:/zap/wrk/output" \
-v "$PWD/req.http:/in/req.http:ro" \
ghcr.io/kerberosmansour/zaprun:v0.3.1 \
zaprun observe \
--request /in/req.http \
--target https://staging.example.testoutput/observations.json carries replay evidence for the request and response. Use that evidence with zaprun triage-sarif and the DAST rule gate before promoting any scanner-rule change.
zaprun triage-sarif \
--target-dir /path/to/webapp \
--sarif ./sast.sarif \
--output output/zaprun-triage
cargo run --manifest-path xtasks/dast-verify/Cargo.toml -- gate \
--candidate ./candidate-rule.js \
--fixtures tests/synthetic-mocks \
--output output/dast-verify.json
cargo run --manifest-path xtasks/dast-verify/Cargo.toml -- gate \
--candidate ./target-owned-rule.js \
--fixtures tests/synthetic-mocks \
--output output/target-rule.json \
--target-owned \
--target-output /path/to/webapp/.zaprun/scriptsGeneric rule candidates must pass the gate before they belong in this repository. App-specific rules stay target-owned.
# Run the scan…
docker run --rm \
-v "$PWD/output:/zap/wrk/output" \
ghcr.io/kerberosmansour/zaprun:v0.3.1 \
zaprun scan http://host.docker.internal:4000 --active
SCAN_EXIT=$?
# …then key on the documented exit code in your gate.
case "$SCAN_EXIT" in
0) echo "PASS: no high-severity findings"; exit 0 ;;
1) echo "FAIL: policy gate failed (see output/summary.json)"; exit 1 ;;
3) echo "ERROR: target unavailable"; exit 1 ;;
4) echo "ERROR: scan timed out"; exit 1 ;;
*) echo "ERROR: tool/env error ($SCAN_EXIT)"; exit 1 ;;
esacThe exit codes are stable — see Exit codes.
| Symptom | Likely cause | Fix |
|---|---|---|
image digest must match ^sha256:[0-9a-f]{64}$ |
--image was given a tag, not a digest. |
Use the digest form: ghcr.io/kerberosmansour/zaprun@sha256:<hex>. Tags are intentionally rejected. |
target_scheme_unsupported: only http(s) targets are accepted |
--target is file://, ws://, etc. |
Use http:// or https://. |
unknown argument: zaprun |
The image's first arg didn't match the literal zaprun. Common when shell-quoting added a trailing newline or extra word. |
Pass zaprun as a clean single argv element. The dispatch is literal-string-equality by design. |
scan timed out after Ns |
Default --scan-timeout 8m was too short for the target. |
Pass --scan-timeout 30m (or longer). |
target unavailable (exit 3) |
Target's / returned non-2xx, or the host gateway resolution failed. |
Run zaprun doctor --probe-target <url> first; check --add-host=host.docker.internal:host-gateway is set on the docker run. |
Permission denied writing reports |
The container runs as UID 1000; the host mount is owned by a different UID. | mkdir -p output && chmod 0777 output before docker run -v "$PWD/output:/zap/wrk/output". |
WARNING: requested image's platform (linux/amd64) does not match the detected host platform (linux/arm64/v8) |
Running on an arm64 host (Apple Silicon). | Benign — the image runs fine via QEMU. Add --platform linux/amd64 to suppress the warning if it bothers you. |
For anything else, the run's plan.yaml + zap-report.json are the canonical evidence; summary.json is the normalised view. Open an issue at https://github.com/kerberosmansour/zaprun/issues with those attached.