The fuzzer is the cheap part: Claude Code for security
Introduction⌗
Fun fact: in security testing, the fuzzer is the cheap part. Knowing the system is the expensive part.
Over the last few weeks I have been pointing open source security tools at services I work on. The tools are good at sending thousands of strange requests. They are bad at the work around it: starting the service, giving it realistic data, knowing which customer owns which record, and deciding whether an alert is real. That work used to be mine, and it took most of the time.
So I handed it to Claude Code. In this post we will look at the setup I ended up with: two Claude Code skills, a small router that picks between them, and the rules that keep an AI agent with a fuzzer from doing something stupid.
Side Note: This post is about the setup, not about any service it tested. I don’t name targets, endpoints or findings. More on that at the end.
Two kinds of targets, two skills⌗
The services I care about come in two shapes.
- APIs with a spec. They serve an OpenAPI or GraphQL schema. A fuzzer can read the schema and generate requests for every operation.
- Server-rendered web apps. HTML pages and forms. No spec. A scanner has to crawl the pages to find out what exists.
One tool doesn’t fit both, so there are two skills:
api-security-fuzzdrives Schemathesis from the schema, then adds manual probes.web-security-scanruns OWASP ZAP in Docker against the pages and forms, then adds web probes.
A third skill, security-navigator, only routes. It asks one question about the repo: does it serve a spec, render HTML, both, or neither? Both means two runs, one per skill. Neither (a worker, a CLI, a queue consumer) means no scan fits, and the navigator says so instead of forcing one.
What a skill looks like⌗
Both skills have the same layout. Here is the API one:
1api-security-fuzz/
2├── SKILL.md # the steps Claude follows, and the rules it keeps
3├── CONCEPT.md # how it works and why, for the next reader
4├── scripts/
5│ ├── 000-preflight.sh
6│ ├── 003-start-server.sh
7│ ├── 010-run-schemathesis.sh
8│ ├── 020-triage.py
9│ └── ...
10└── targets/
11 └── <repo>/ # one profile per service
12 ├── profile.md
13 ├── 000-seed-extra.sql
14 ├── 010-seed-db-edge.sql
15 ├── 020-probes.sh
16 ├── schemathesis.toml
17 └── findings-<date>.json
The scripts are numbered in the order a run uses them. The targets/ folder is the skill’s memory: what Claude learned about each service on its first run, the probes it wrote, and every finding so far. The second run of a service starts from there, not from zero.
Only I start a scan. One line in the front matter of SKILL.md does that:
1disable-model-invocation: true
The outbound check runs in its own sub-agent, and the sub-agent can only read:
1---
2name: outbound-recon
3description: Read-only recon for the api-security-fuzz skill. Lists every call a service
4 makes outside its own process and database, and says whether a local run reaches a
5 real external system. Never edits, never starts anything, never makes a network call.
6tools: Read, Grep, Glob
7---
It can’t edit a file, run a command or open a connection. The worst it can do is give a wrong answer.
How a run works⌗
flowchart LR U([Me]) -- "/api-security-fuzz" --> S["Claude Code session"] S -- "brief" --> R["outbound-recon agent
read-only"] R -- "outbound-call table" --> S S -- "fuzz, probe" --> T["Service on localhost
+ stubs for every external call"] T -- "server.log" --> S S -- "report" --> P[("HTML report
findings JSON")]
Let us walk through an API run. The web scan follows the same shape with ZAP in place of Schemathesis.
- Preflight. A script checks the tools, and installs Schemathesis if it is missing.
- Read the repo’s own docs. Claude reads the README and the agent instructions to learn how to start the service. No start command, no run.
- Map outbound calls. A read-only sub-agent,
outbound-recon, lists every call the service makes outside its own process and database. Any call that would reach a real external system stops the run until it is stubbed or its operations are excluded. - Seed. Claude asks the user for existing seed data. It reads the guards in the code and seeds every state they check, plus the states the API never writes. All this core data goes straight into the database.
- Start the service on a free local port, and find its spec.
- Pass 1: random values. Most requests stop at 404, because random ids miss every record.
- Pass 2: pinned values. Claude restores the seed. Through the API it creates only the records the service itself owns, so each one is consistent across the database and the identity provider. Every path parameter is pinned to a seeded id, so requests get past the lookup and into the handler logic.
- Probes. A script per service covers what a fuzzer cannot know: who owns which id.
- Triage. Every server error is joined to its line in
server.logby trace id, and grouped by cause. - Re-check and report. Each lead is replayed on fresh state. Only then does it go into the HTML report.
The fuzzer only sends requests. Claude decides how to start the service, what to stub, what to seed, which probes to write and which cause each error has. One example from triage: the Schemathesis text output keeps one example per failure type per operation, so two different root causes on one operation looked like one. Claude switched triage to the NDJSON report, joined to server.log by trace id, and the two causes separated.
The rules that make it trustworthy⌗
An agent that sends thousands of hostile requests needs rules more than it needs cleverness. These are the ones the skills keep on every run.
- Nothing leaves the laptop. No fuzzed request reaches a real external system. Each outbound target is stubbed or excluded, and the skill never accepts a real side effect, even if I say yes. The web skill goes one step further: a stub that itself calls something real counts as real.
- A finding needs a cause. Nothing is reported until it is traced to a log line or a code symbol. A ZAP alert on its own is a lead, never a finding.
- The model does not start it. Both skills set
disable-model-invocation: true. Only I start a scan, with/api-security-fuzzor/web-security-scan. - Source changes are my call. In the API skill, if coverage needs a hidden route unhidden, Claude asks, names the change in the report, and reverts it at clean-up.
- A fixed finding stays on the list. Its probe re-runs on every scan. The re-check is the regression test.
The second rule matters most. In my runs, most of ZAP’s alert types turned out to be false positives or not relevant to the app. Most of the real input defects raised no alert at all. They showed up as server errors in the log. Reading the log, not the alert list, is what found them.
It works the other way too. ZAP flagged a missing anti-CSRF token on every form. Claude noticed the app guards its forms another way, and wrote a probe instead of trusting either side: post forms from another site, expect a 403, and check that nothing was written. The probes passed, so the whole alert class was dismissed on evidence, not by assumption.
The same habit shows up on new alerts. In one run ZAP raised an alert type it had never raised before: a form that accepts GET as well as POST. Claude didn’t dismiss it on a guess. It read the route table, sent the GET with the form’s fields itself and got back the same page as a plain GET, and checked the log for any write. There was none. Then it added a probe, so the dismissal holds only while that probe passes.
Where the findings go⌗
Both skills can also write their findings into a local DefectDojo, an open source vulnerability tracker.
- One fingerprint per finding. It is built from the repo, the CWE (the public catalogue of weakness types), the operation and the cause, and it never changes between runs. DefectDojo matches runs on it, so a reworded finding does not come back as a new one. It stays stable because each repo’s profile keeps the findings table, and the next run reuses it.
- Absence closes a finding. A run lists only what is still open. A finding a later run no longer lists is closed. The catch: a run that skipped an operation also closes findings it never re-tested.
- The report and DefectDojo agree. Both come from one traced findings file. A raw ZAP alert never reaches DefectDojo on its own.
- The team shares dumps, not the database. The live database stays on each laptop. Postgres files in a synced folder can be corrupted mid-write, so the team shares a dump file instead.
Things that surprised me⌗
- The fuzzer wrecks its own test data. Schemathesis has a stateful phase that follows links between operations and writes to seeded records. My first probe run was invalid because of it. The seed is now restored before probing.
- One finding was withdrawn. It turned out to be designed behaviour.
- Never seed during a pass. Rows written while a pass runs mix two seed states into its results, and a server error can no longer be tied to the rows that caused it.
- A dead stub hides routes. If a stub answers nothing, every route that calls it returns 500 and the scanner reports nothing useful about them. The web skill now checks one page per stub before the scan starts.
- Pin the scanner version. Rule ids and alert counts change between ZAP releases. Two runs compare only on the same image.
- Run one active-scan thread. Each server error then sits next to its own line in
server.log. ZAP keeps no record of its active-scan requests, so the log is the only place to join them.
What I kept out of this post⌗
Security write-ups have two audiences, and one of them is not on your side. So this post has no target names, no endpoints, no findings, and nothing about how the skills get test credentials. The skills keep that detail in a per-service profile folder that is not published.
What is left is the method. Schemathesis and ZAP are public and well documented. The value here is how an AI agent fits around them, and the guardrails that keep it local.
Conclusion⌗
Schemathesis and ZAP send the requests. They don’t know how the service is built or what its data means. Claude Code does that part: it reads the docs, maps the outbound calls, seeds the data, writes the probes and traces every error to its cause. What I still own is the decision to run, and anything that changes the source.
If you try something similar, write down the rules before you pick the tools. Decide what may never leave the laptop, and what counts as a finding, before the first request goes out.