Episode 11·

Stop Flying Blind: Monitor Webhooks and Portals with Passive ZAP Scans

Intro

This episode is for solo operators running client-facing automations who need continuous security hygiene without a dedicated security team. You'll get a production-safe, automated weekly sweep that catches common misconfigurations before they embarrass you in front of clients.

In This Episode

Jordan walks through the exact OWASP ZAP automation setup he runs—a passive baseline scan in Docker, scheduled with cron, that sweeps endpoint inventory every week and routes serious findings to Slack and ticket trackers. The episode covers Docker Compose configuration, config file tuning with OUTOFSCOPE patterns, exit code automation for alerts, and ticket creation in Linear or Jira. Jordan also addresses the honest limits of passive scanning, guidance on authenticated coverage with ZAP contexts, and why every solo operator needs an endpoint inventory. The whole stack is free, open source, and takes about thirty minutes to set up.

Key Takeaways

  • Set up a weekly passive ZAP baseline scan using Docker Compose with ghcr.io/zaproxy/zaproxy:stable that automatically checks your public endpoints for missing security headers and common misconfigurations
  • Create an endpoint inventory (CSV or Notion table) listing every webhook, portal, and form you manage—this becomes your scan target list and the document you reference when clients ask about exposed surfaces
  • Use ZAP's exit codes (0=clean, 1=failures, 2=warnings) to drive automated Slack alerts and ticket creation, routing only actionable findings to avoid alert fatigue

Timestamps

Companion Resource

  • ZAP – Download

    zaproxy.org

    • - Maintained Docker images for ZAP are published to both GHCR and Docker Hub with canonical tags: ghcr.io/zaproxy/zaproxy:{stable|weekly|nightly|bare} and docker.io/zaproxy/zap-{stable|weekly|nightly|bare}.
  • ZAP – Baseline Scan docs

    zaproxy.org

    • - zap-baseline.py runs a spider for 1 minute by default, then performs only passive scanning (no active attacks), and is designed to be safe for CI and even production sites.
  • ZAP – Authentication Concepts and Session Context Screens

    zaproxy.org

    • - ZAP contexts support include and exclude regexes for in‑scope URL control; authentication/session handling is associated with a context.
  • ZAP Blog – Docker Images in GHCR

    zaproxy.org

    • - The ZAP team unified image naming on GHCR and continues to publish to both registries; GHCR helps avoid Docker Hub pull‑rate limits.
  • ZAP – Docker User Guide

    zaproxy.org

    • - Stable images are updated on full releases and also regenerated monthly to pull latest base image and updated add‑ons; weekly images are typically updated every Monday.
  • Slack – Incoming Webhooks

    api.slack.com

    • - Slack Incoming Webhooks accept JSON payloads to post messages; full Block Kit layouts are supported.
  • Atlassian – Jira Cloud REST API (Issues)

    developer.atlassian.com

    • - Jira Cloud exposes POST /rest/api/3/issue to create issues programmatically.
  • Linear API

    linear.rest

    • - Linear’s public API is GraphQL over HTTPS at https://api.linear.app/graphql for programmatic issue creation.
  • ZAP – ZAP Baseline Scan docs (example commands)

    zaproxy.org

    • - Official example: run baseline and write reports to mounted working dir
    • - Demonstrates correct Docker invocation and report persistence path (/zap/wrk) for HTML/other reports.
  • ZAP – Docker User Guide (Packaged scans, mounting /zap/wrk)

    zaproxy.org

    • - Mounting current directory to /zap/wrk and packaged scans overview
    • - /zap/wrk is the canonical work directory in all images; packaged scans include Baseline/Full/API for CI usage.
  • Stack Overflow (Jenkins + ZAP in Docker)

    stackoverflow.com

    • - Community pipeline example using GHCR stable image to generate HTML report
    • - Concrete, named CI example for solo operators adopting CI; shows -v /zap/wrk and -r testreport.html.
  • ZAP – GitHub Actions (Baseline/Full/API)

    zaproxy.org

    • - Official GH Actions can raise GitHub issues from findings
    • - Shows a maintainers‑endorsed automation path and issue creation behavior that parallels Jira/Linear routing.

Jordan: Friday night. Eleven forty-seven PM. I'm brushing my teeth and my phone buzzes — Slack notification from a client's monitoring channel. "Webhook endpoint returning four-oh-three for all inbound events." I pull up the logs. Turns out I'd pushed a Cloudflare rule update that afternoon — tightened the WAF settings on their intake domain — and it silently broke the CORS headers on their order webhook. Every inbound order from their Shopify store had been bouncing for six hours.

Six hours. On a Friday night. During a flash sale they'd been promoting all week.

I fixed it in about four minutes. The actual remediation was trivial — one header rule. But the damage was already done. Forty-something orders that never made it into the fulfillment pipeline. Client found out before I did. That's the part that kept me up.

And here's what made it worse. The misconfiguration I introduced? Missing security headers on a public endpoint. That's not some exotic zero-day. That's the kind of thing a basic passive scan catches in under two minutes. A scan I could have been running automatically, every week, against every public endpoint I manage — and wasn't.

That was five months ago. Since then, every Monday at seven AM, a Docker container spins up on my server, runs OWASP ZAP's baseline scan against every webhook, portal, and form endpoint in my inventory, and writes a report. If anything comes back at warning level or above, I get a Slack message with the severity, the rule, and the URL. If it's a fail, a ticket gets created in Linear automatically.

The whole thing took me about thirty minutes to set up. And it would have saved me that Friday night.

Jordan: How many public endpoints are you exposing right now? Not the ones in your head. The actual count. Webhooks catching inbound data from client apps. Portals where clients log in. Form endpoints accepting submissions. If you can't name every one of them and tell me the last time something verified their security headers were intact — you're flying blind on the surfaces most likely to embarrass you.

I'm Jordan. This is Headcount Zero. And today I'm walking you through the exact OWASP ZAP automation setup I run — a passive baseline scan in Docker, scheduled with cron, that sweeps your endpoint inventory every week and routes anything serious to Slack and your ticket tracker. No security team. No paid tools. About thirty minutes of setup.

Jordan: So let me back up and explain why this matters specifically for us — solo operators running client-facing automations. Every webhook you expose, every client portal you deploy, every form endpoint that accepts data — those are your attack surface. And unlike a company with a security team running quarterly pen tests, nobody is checking yours. You deploy it, it works, you move on to the next client project. And those endpoints just... sit there. Exposed. Accumulating configuration drift every time you update a dependency or change a DNS rule.

The kinds of issues I'm talking about aren't sophisticated exploits. They're missing HTTP security headers — things like Strict-Transport-Security, Content-Security-Policy, X-Content-Type-Options. They're cookies without the secure flag. They're CORS misconfigurations. The boring stuff. The stuff that a procurement team's security review will flag in five minutes and that you won't notice until it costs you a deal or — like my Friday night — costs you a client's trust.

And the irony is, these are exactly the things a passive scan catches. No active attacks. No fuzzing. No injection testing. Just a crawler that looks at your responses and checks whether the basics are in place.

Jordan: That's where OWASP ZAP comes in. Specifically, a script called zap-baseline.py that ships inside ZAP's official Docker images. The ZAP team maintains these on both GitHub Container Registry and Docker Hub — the image you want is ghcr.io/zaproxy/zaproxy colon stable. There's also a weekly tag if you want fresher add-ons, but stable is the right starting point.

Here's what baseline actually does. It spiders your target URL for one minute by default — you can adjust that with the dash-m flag — and then it runs only passive scan rules against everything it finds. No active attacks. The ZAP docs are explicit about this: baseline is designed to be safe for CI pipelines and even production sites. It's not going to send malicious payloads to your endpoints. It's reading your responses and checking them against a rule set.

When it finishes, it writes reports. You get HTML with dash-r, XML with dash-x, JSON with dash-capital-J, and Markdown with dash-w. I generate all four because they cost nothing and the JSON is what I pipe to Slack later. All of these land in a directory called slash-zap-slash-wrk inside the container — which means you need to mount a local directory to that path or your reports disappear when the container exits.

That mount is the thing that trips people up first, by the way. You run the scan, it says it completed, and then you go looking for your report and it's gone. The fix is simple — dash-v, your local directory, colon, slash-zap-slash-wrk, colon-rw. Now your reports persist on the host.

Jordan: Okay — so the Docker Compose setup. I have a compose file with one service. Image is the GHCR stable tag. Working directory is slash-zap-slash-wrk. I mount a local dot-slash-zap folder to that path. The command runs zap-baseline.py with my target URL, a two-minute spider, and all four report formats. That's the whole service definition.

The config file is where it gets interesting. You create a file — I call mine zap-baseline.conf — and in it you set per-rule actions. FAIL, WARN, or IGNORE, followed by the rule ID. You won't know the rule IDs until you run your first scan and look at the report, which is fine — run it once with defaults, see what comes back, then tune. The important pattern is: only IGNORE a rule when you have a written reason. Default to WARN and create a ticket to follow up.

And then OUTOFSCOPE. This is critical. You add regex patterns for URLs you don't control — Google Analytics, CDN assets, third-party scripts. Without these, your report fills up with noise from domains you can't fix, and you stop reading the reports. That's the worst outcome — a scan that runs but nobody looks at.

Jordan: Scheduling is just cron. I run mine Monday at seven AM — zero seven, asterisk, asterisk, one. The command does a cd into my kit directory, runs docker compose run dash-dash-rm zap-baseline, and pipes output to a log file. I wrap it in flock to prevent overlapping runs if a scan takes longer than expected.

Now — exit codes. This is what makes the automation work. Baseline returns zero for no failures, one if any rule hit FAIL, two if there are warnings but no failures, and three for other errors like network issues. Your downstream logic keys off these codes. I route Slack on anything non-zero. I create tickets only on exit code one — meaning at least one FAIL-level finding.

Jordan: The Slack piece is a curl POST to an incoming webhook. I parse the JSON report with jq to pull the highest severity, the finding counts, and the top alert title. Then I build a Block Kit payload — header with the service name and severity, a section with the target URL and finding counts, and a context line with the exit code. Color-coded — red for FAIL, yellow for WARN, green for clean.

For tickets, I use Linear's GraphQL API — one mutation, issueCreate, with the team ID, a title that includes the severity and rule name, and a description linking to the HTML report. If you're on Jira, it's a REST POST to slash-rest-slash-api-slash-three-slash-issue with the same fields. Either way, it's one curl call. No middleware. No Zapier. Just the scan output piped to an API.

The whole alert pipeline — Slack plus ticket creation — is maybe twenty lines of bash. It's not elegant. It doesn't need to be.

Jordan: Now — a thing I want to be honest about. Baseline scans are passive. They will catch header misconfigurations, missing security policies, cookie issues, information disclosure in responses. They will not catch SQL injection. They will not catch broken access controls. They will not find authorization flaws. Those require active scanning — which means ZAP's full scan or their Automation Framework — and you should not run active scans against production.

So yes, a passive baseline sweep is not a penetration test. It's not even close. If someone tells you "I run ZAP weekly so my security is handled" — that person is wrong. What baseline gives you is a regression safety net. It catches the things that change between deployments — the header that disappeared, the cookie flag that got dropped, the new endpoint that shipped without CSP. It's hygiene, not armor.

The way I think about it: baseline is the smoke detector. You still need to not leave the stove on. But the smoke detector means you find out at two AM instead of six AM when the kitchen is gone.

For deeper coverage without going active, you can add a ZAP context file. This lets you scan authenticated areas — pass dash-n with your context file and dash-U with a low-privilege test user. The scan stays passive, but the spider can reach pages behind login. You set include and exclude regex patterns in the context to control scope. And if your app is a single-page app, you can add dash-j to enable the Ajax spider — but only do that on staging first. On production, keep it off until you've verified it doesn't hammer your server.

My recommendation: start with unauthenticated passive baseline on production. Run it for a few weeks. Tune your config file — tighten the OUTOFSCOPE patterns, set your FAIL and WARN thresholds. Once that's clean and boring, add the context for authenticated coverage on staging. And schedule a quarterly full scan on staging or a dedicated test environment for the active stuff. That's a security posture that's honest about what it covers and what it doesn't.

Jordan: One more piece — and this is the part people skip. You need an endpoint inventory. A simple CSV or Notion table. URL, HTTP method, auth scope, rate limit, owner, and notes. Every webhook, every portal, every form. When you add a new endpoint for a client, it goes in the inventory. When you decommission one, it comes out.

The inventory isn't just for ZAP. It's the document you consult when a client asks "what endpoints are exposed on my behalf?" It's the thing that would have told me, five months ago, that I had a webhook endpoint I hadn't scanned since I deployed it.

If you want to skip the setup friction, I put together the Baseline Sweep Starter Kit — it's on the Resources page. The Compose file, the config template, the endpoint inventory CSV, the Slack payload, and the Jira and Linear ticket payloads. All copy-paste with brackets where you fill in your values. First run should take about ten minutes instead of building it from scratch.

Jordan: So here's where we land. Five months ago I was brushing my teeth when a client's Friday night flash sale went sideways because of a missing header I could have caught with a two-minute scan. Now a Docker container catches that for me every Monday before I've finished my coffee. The scan is passive — it won't break anything. The config file means I only see what matters. The exit codes drive the alerts. And the whole stack is free, open source, and runs on any machine that has Docker.

If you take one thing from this episode, it's this: don't start with the alert routing. Don't start with the ticket automation. Start with the inventory. Open a spreadsheet or a Notion table right now and list every public endpoint you manage. URL, method, auth scope. That's it. Once you have the list, the scan is a single Docker command away. Everything else — the config tuning, the Slack alerts, the Linear tickets — you layer on over the next couple of weeks as you learn what your reports actually look like.

Inventory first. Scan second. Alerts third. That's the order.

I'm Jordan. This is Headcount Zero. Go build something.

OWASP ZAPDocker automationendpoint securitywebhook monitoringpassive scanningsecurity headersSlack alertsticket automationcron schedulingsolo operator securityCORS configurationLinear APIJira APIendpoint inventoryconfiguration drift