OWASP ZAP

I spent a few weeks setting up automated DAST scanning with ZAP for a Django application at work. The unauthenticated scanning was trivial. Getting ZAP to stay authenticated against a real application, inside a Docker container, on a cron, with results going to Slack took the rest of the time. Everything in this post is what I learned getting that working.

Start with the GUI

Even though the goal is full automation, start in the GUI. Configure and test everything interactively, then export the configuration for the CLI. When the Docker scan inevitably breaks, the GUI is how you debug it. Run the same config in both environments and compare.

ZAP GUI
Manually exploring the application through ZAP's integrated browser

Contexts

ZAP organizes everything around "contexts": URL scope, authentication method, session management, credentials, technology stack, scan settings. You configure a context in the GUI, export it as XML, and pass it to Docker later.

ZAP context configuration

Right-click the target URL in the Sites tree, Include in Context -> New Context. Three settings to configure upfront:

Technology: Disable checks for languages and servers your app doesn't use. If you're scanning a Django app on PostgreSQL, turn off SQL Server and ASP.NET checks. Less noise, faster scans.

Session Management: Tell ZAP how your app maintains sessions. For cookie-based sessions, select Cookie-based Session Management and ZAP will include the session cookie in every request automatically.

Authorization Detection: How ZAP recognizes an unauthorized response. This can be an HTTP status code (403) or a regex in the response body. ZAP uses this to detect when the session has dropped and trigger re-authentication.

Authentication with Mozilla Zest

ZAP has built-in form-based and JSON-based auth methods that work for simple login forms. For anything more complex (multi-step flows, CSRF tokens, JavaScript-rendered login pages, OAuth), you need a Zest script.

Zest is a JSON-based scripting language for recording and replaying HTTP sequences. The workflow:

  1. Create a new Zest authentication script from the Scripts tab
  2. Parameterize it for different users and URLs
  3. Start recording and manually log in through ZAP's integrated browser
  4. ZAP captures every request in the login flow as a Zest step
Creating a Zest script
Creating a new Zest authentication script from the Scripts tab
Zest script parameters
Parameterizing the script for reuse across users and environments
Recording login sequence
Recording the login sequence through ZAP's integrated browser

Test it with the Run button in the Script Console tab. ZAP checks assertions (response size, status codes) to verify login succeeded. If it fails, it's usually a CSRF token the script isn't capturing or a redirect it doesn't follow.

Once it works, add it to the context as the authentication method:

Zest auth in context
Setting the Zest script as the authentication method in the context

Two more settings in the context:

Auth parameters

Logged In/Out indicators: Regex patterns matching content that's only visible when authenticated (a "Logout" link) or unauthenticated (a "Login" button). ZAP uses these to detect dropped sessions and re-authenticate.

Forced User Mode: The lock icon in the toolbar. Enable it to force every request through the configured user. Without this, some requests go out unauthenticated.

Running Scans

With authentication configured, run scans in the GUI first to get a baseline before moving to Docker.

Spider: Right-click the context and run the Spider to crawl and discover endpoints. For JavaScript-heavy apps, run the Ajax Spider too, which uses a real browser to find dynamically rendered content.

Spider results
Spider results showing discovered endpoints

Active Scan: The actual vulnerability scan. Sends attack payloads to every discovered endpoint and parameter. For a typical Django app, expect about 20 minutes with default settings.

Active scan
Active scan requests and results

Note the number of URLs discovered and alerts generated. You'll compare against these numbers to verify the Docker scan is actually working.

Docker Automation

Export the context file (.context) and Zest script (.zst) from the GUI. These get mounted into the ZAP Docker container.

ZAP's Docker image has wrapper scripts for common scan types. zap-full-scan.py runs the spider, ajax spider, and active scan in sequence:

docker run -v $(pwd):/zap/wrk/:rw -t owasp/zap2docker-stable zap-full-scan.py \
-t http://targeturl \
-j -d \
-n demo.context \
-U admin \
-z "-config script.scripts(0).name=login.zst \
-config script.scripts(0).engine='Mozilla Zest' \
-config script.scripts(0).type=authentication \
-config script.scripts(0).enabled=true \
-config script.scripts(0).file=/zap/wrk/auth.zst" \
-J zap_results.json
Flag Purpose
-v $(pwd):/zap/wrk/:rw Mounts current directory into the container for context/script reading and report writing
-t http://targeturl Target URL
-j Run the Ajax Spider in addition to the traditional spider
-d Debug output (you want this)
-n demo.context Load the exported context file
-U admin User to authenticate as (must exist in the context)
-z "..." Additional ZAP CLI options, in this case loading the Zest auth script
-J zap_results.json Write results as JSON

To load multiple scripts, increment the index: script.scripts(0) for the first, script.scripts(1) for the second.

Slack Reporting

ZAP can output HTML, Markdown, and JSON reports. For a scheduled scan, a Slack notification with a summary is more useful than a report file nobody opens.

I wrote a small script that parses the JSON output and sends a color-coded summary to a Slack webhook:

import json
from slack_sdk.webhook import WebhookClient

f = open('zap_results.json')
data = json.load(f)
findings = data['site'][0]['alerts']
out = ""

def formatter(finding):
code = int(finding['riskcode'])
name = finding['name']
count = finding['count']
indicators = {3: ":red_circle:", 2: ":large_orange_circle:", 1: ":large_green_circle:"}
icon = indicators.get(code, ":large_blue_circle:")
return icon + " " + name + " - Count: " + count + "\n"

for finding in findings:
out += formatter(finding)

block = [
{"type": "header", "text": {"type": "plain_text", "text": ":zap: ZAP Scan Results :zap:"}},
{"type": "section", "text": {"type": "mrkdwn", "text": out}}
]

url = "https://hooks.slack.com/{your_webhook}"
webhook = WebhookClient(url)
response = webhook.send(text="fallback", blocks=block)

Red for high, orange for medium, green for low, blue for informational. The value over time isn't any single scan. It's watching the delta between scans. A new red finding means something regressed. A finding disappearing means a fix shipped.

Slack report
Scan results posted to Slack

Troubleshooting

The most frustrating thing about containerized ZAP scans is that they fail silently. The scan completes, produces a report, but the report is missing findings because auth broke halfway through or the spider only found half the endpoints. Always run with -d and compare against your GUI baseline.

Authentication dropping mid-scan: The most common issue I hit. The active scan takes 20+ minutes, and if the session expires faster than ZAP re-authenticates, you're scanning unauthenticated pages for most of the run. Check your web server logs for 401/403 responses during the scan. A wall of them partway through means the session expired and the Logged In/Out indicators aren't triggering re-auth correctly. Tighten the regex patterns.

Low endpoint count: Compare the number of URLs the Docker spider discovers against the GUI baseline. If Docker finds significantly fewer, the spider is probably hitting unauthenticated pages and can't follow links behind the session. This cascades into fewer findings in the active scan.

Zest script failing in the container: Worked in the GUI, fails in Docker. Common causes: the target URL is different (localhost vs. container networking), the context references a user that isn't configured, or the .zst file path in the -z config doesn't match the mount point. Check if the first few requests in the scan have your user's session cookie in the debug output.

Limitations

ZAP is a solid general-purpose DAST scanner, but there are things it's just not good at:

Single-page applications: The traditional spider follows HTML links. SPAs render content with JavaScript, and the spider can't parse that. The Ajax Spider uses a real browser which helps, but it's slower and still misses routes that require specific user interactions. If your app is a React or Angular SPA, expect gaps in coverage.

Scan noise: The default active scan generates a lot of informational and low-severity findings (missing headers, cookie flags, server version disclosure). Real observations, but they drown out the findings that matter. Tune the scan policy and filter the report output.

Complex authorization: ZAP handles authentication (proving who you are) much better than authorization (what you're allowed to do). It won't find IDOR vulnerabilities or broken access controls unless you write custom scripts for your specific authorization logic.

Automated DAST won't replace manual testing or code review, but running on a schedule it catches regressions before they ship. That's the value. An early warning system, not a comprehensive assessment.