Fuzzing an API surface
Use this skill when you want to systematically hammer a surface that decodes
input you do not control, and have every real failure become a named regression
test rather than a transient console reproduction. Evener is the proving ground;
the portable core (fuzz/promoter, fuzz/schemagen) carries unchanged to any
project, so the methodology below is project-agnostic and the evener targets are
worked examples.
Read docs/research/api-fuzzing-toolkit.md (§3 oracle soft spots, §5 the
promote discipline, §8 the portable split) and docs/design/fuzzing-toolkit-design.md
before extending the toolkit.
When to reach for this
A surface is fuzz-ready when it has:
- A single dispatch choke point — one function every input flows through
(
tool.Registry.ExecuteCall,Router.Dispatch,WebServer.Handler()). One harness then covers the whole surface by varying which operation it drives. - A machine-readable schema — per-operation JSON Schema, reflected param
types, OpenAPI, protobuf. Schema-driven generation reaches deep validator
paths raw bytes never hit (evener hands us tool
Definition.Parametersand theappwire.Methodscatalog for free). - A decode / parse seam — a byte-level frame/stream parser (
llm.ParseSSE,appwire.Message.UnmarshalJSON). These are the cheapest, highest-value targets: a Gotesting.Fbyte fuzzer auto-promotes its own crashers. - A metamorphic relationship — two ways to feed the same logical input that must agree (re-chunked reads, reordered/whitespace-padded frames, blocking vs timeout code paths). The relationship is the oracle.
Do NOT reach for this when the seam's only effect is to execute something dangerous (see Safety bounds), when no determinism is achievable offline (the real behavior needs a live LLM/network/subprocess), or when there is no oracle richer than "it returned" — a fuzzer with only a floor oracle on a trivial surface buys little.
Core rules (non-negotiable)
- Safety bound: never let a fuzzer EXECUTE a dangerous handler. Shell
(
bash -c), network fetch, file read/write under attacker-controlled paths, subprocess/agent spawn — a temp dir does NOT sandbox these. Fuzz the **decode- validate boundary** instead, or restrict execution to a vetted allowlist of
genuinely sandboxable operations. Evener's
FuzzToolArgsValidatedrives decode+Schema.Validateand never calls a tool handler;FuzzWebHandlerallowlists GET-only, non-mutating, non-networked routes and excludes spawn, shell, and provider-probe endpoints.
- validate boundary** instead, or restrict execution to a vetted allowlist of
genuinely sandboxable operations. Evener's
- Determinism is non-negotiable. Seed the generator; run offline (no real LLM, network, or subprocess); a flaky target — or a promoter that could promote a flake — is a defect, not a nuisance.
- "No panic" is the floor, never the only oracle. Invest in semantic oracles; the real bugs live there.
- A thin-but-real model beats a broad fake one. Model only what you can
drive deterministically against the real seam, and report the boundary you
could not reach. Never fake the unreachable part and assert on the mock — that
tests mocked behavior.
TestRouterSeqFuzzmodels exactly the transport initialize-gate it can reach offline and states in its doc comment that turn/job status lifecycle needs a live agent and is out of scope.
Procedure: pick a surface and seam (research §8, 7 steps)
- Pick a surface and its seam. Find the single choke point all input flows through. That is what the harness drives.
- Find or derive the schema. Point generation at whatever machine-readable
structure exists (
Definition.Parameters, reflectedappwire.Methods, OpenAPI). No schema → fall back to byte fuzzing. - Write the thinnest
testing.Ffirst and harvest the free auto-promotion: a single[]byteor typed-tuple target whose crashers Go saves totestdata/fuzz/and re-runs forever. This is ~60–200 LoC and finds the crash-class bugs before you build anything else. - Add oracles beyond "no panic" (next section). This is where valid-but-wrong bugs surface.
- For stateful surfaces, model the state machine and drive it with
property-based testing (
rapid). Keep the model declarative — evener'sliveOpTableis a transition table, the reusable artifact; therapidmachine just draws op sequences. - Wire the promoter with flake-guard before promote so non-
testing.Ffailures (rapidsequences, HTTP op-sequences) become permanent. - Gate it in CI: seed corpus + saved crashers under
-shorton every run (make fuzz/make test); the unbounded coverage-guided search runs nightly (make fuzz-nightly, 60s/target).
The oracle taxonomy
The portable tags are promoter.OracleTag: Panic, Invariant, ErrorShape,
Wedge, HTTP5xx, PathEscape. How to spot which fits, each tied to a real
evener target:
- Floor — never panic (
Panic). The seam crashes on some input. Always present; never sufficient alone. Live panic-hunt wherever validation is notrecover()-wrapped at runtime — e.g.Schema.ValidateinFuzzToolArgsValidate, andRouter.Dispatch(which propagates handler panics) inTestRouterSeqFuzz. - Metamorphic (
Invariant). Two equivalent inputs must produce the same output. Spot it when one logical input has more than one delivery shape: re-chunked reads or blocking-vs-timeout paths (FuzzParseSSE), one-byte-at-a-time SSE accumulation (FuzzOpenAIResponsesMetamorphic), decode→encode→decode fixed point (FuzzMessageDecode,FuzzMethodParams). - Divergence (
ErrorShape). A validator says clean but the handler disagrees, or vice versa. Spot it when validation and use are separate steps:TestToolArgsSchemaFuzzflags a schema-VALID generated value the real validator rejects — generator and schema disagree. - Invariant / monotonicity (
Invariant). A modeled property must hold across a sequence. Spot it on stateful surfaces with an ordering contract:TestRouterSeqFuzzasserts the connection's initialize-gate is monotonic (fresh → initialized, never reverts). - Never 5xx (
HTTP5xx). Bad input may legitimately 4xx, but a 500 from your own handler is a fault. Spot it on HTTP surfaces:FuzzWebHandler(it excludes the bare stdlibhttp.FileServer, whosefs.ErrInvalid→500 is documented net/http behavior, not handler logic). - Path escape (
PathEscape). Attacker-controlled path/query escapes its intended root. Spot it on any file/path resolver:FuzzWebHandlerplants a secret one level above the session cwd and fails if any response body contains it. - Never wedge (
Wedge). A call that should return makes no progress. Spot it on synchronous seams that could hang:TestRouterSeqFuzzruns each dispatch under a bounded context in a goroutine and reports a wedge on timeout. - Internal invariant (
Panic, under-tags evenerfuzz). A load-bearing assumption asserted inside production code withinvariant.Hold()so a logic bug trips at its origin, not at a distant surface. Zero-cost in a normal build; panics under-tags evenerfuzz(the fuzz build), so it surfaces via the Panic oracle. Spot it wherever a subsystem relies on a property a reader assumes — e.g. a folded job status never leaves a terminal state; an emitted item carries its turn id. Conditions must be side-effect-free; verify the invariant is TRUE and reached before trusting it. - Differential (
Invariant/ErrorShape). Two code paths that must compute the same thing, driven from one input and compared modulo an allow-list. The strongest class — it found both real decoder bugs this codebase has caught. Spot it wherever a value is produced two ways: per-provider streaming vs non-streaming decode (FuzzStreamVsNonStreamDifferential), one logical response across all providers (FuzzCrossProviderDifferential), a decoder vs a committed snapshot of its own output (appwire/golden_test.go), or independent readers of one format (FuzzTranscriptReadersAgree). A divergence outside the allow-list is a real bug — never widen the allow-list to silence it.
Generation technique — structure-aware inputs. Independent of which oracle you
pick: if the surface has a rich grammar, most raw-byte execs die at the first
parse. Drive the target from a deterministic byte-Source generator
(fuzz/schemagen) that emits valid-but-adversarial inputs (the Fuzz…Structured
targets), so coverage-guided search reaches deep logic. It typically lifts input
acceptance from ~0% to ~90%+.
The portable core
Two packages in the primeradiant.com/evener/fuzz module are the travelling
tooling. Nothing in them imports the project under test — fuzz/go.mod
declares no evener dependency, so the module will not build if that boundary is
violated. That structural guarantee is the portability test.
schemagen.FromJSONSchema(schema map[string]any) *rapid.Generator[any] walks
the JSON Schema subset a surface actually uses (type/properties/required/
enum/additionalProperties/items) and yields both schema-Valid and
schema-Adjacent (wrong-type, missing-required, out-of-enum, extra-when-closed)
values. Use schemagen.Generator(schema, mode) when an oracle needs known-valid
input.
promoter.Adapter is the four-hook seam a project implements; everything else
(flake-guard loop, bucket store, emitter, commit) is generic:
type Adapter interface {
Minimize(Failure) Failure // usually a passthrough
Signature(Failure) Signature // dedup key
Replay(context.Context, Failure) (failed bool, sameSignature bool)
Emit(Failure) (path string, err error)
}
A project implements it by carrying enough state to deterministically reproduce a
failure. toolArgsAdapter holds each tool's compiled validator; seqAdapter
holds the transition table + handler registrar. Replay re-runs the same oracle
function that found the failure (toolArgsFailure / seqOracleRun), so the live
property and the replay classify identically. Emit calls
promoter.WriteGoTest(dir, promoter.GoTest{...}) with an adapter-rendered
ReplayBody that decodes the minimized artifact and asserts the bug is fixed.
promoter.New(adapter, store, quarantiner, K) then runs Promote.
The flake-guard / dedup rubric (the load-bearing discipline)
Promoter.Promote is the whole point. In order:
- Dedup first.
store.Has(sig)against the committedBucketStore(a JSON file mappingSignature→ test path). A known bug returnsAlreadyKnownand is never re-promoted. - Flake-guard. Replay the minimized artifact K times (default 5). Promote
ONLY if it fails all K with the same signature. Any non-reproduction or
signature shift →
Quarantined: logged via theQuarantiner, never written as a test. This is the rule that keeps the gate trustworthy; budget it generously on timing-sensitive seams. - Emit-only by default. A surviving failure writes a
TestRegression_*file with a provenance header (// Code generated by fuzz/promoter ... DO NOT EDIT) and records its bucket. Promotion into the tree is the human/opt-in step — review the diff before committing.
Signatures must distinguish distinct bugs: Signature{Oracle, Key} where for
Panic the Key is the top-N normalized stack frames, and for semantic oracles
(Invariant/ErrorShape) the adapter folds the named invariant / Detail into
Key. Collapse them and distinct bugs silently dedup as already-known.
Hard-won lessons
- rapid owns its shrink loop.
pgregory.net/rapidreports failures throughtesting.Tand Goexits during shrinking; it does not hand you the minimized artifact as a return value. Capture the failing artifact into a closure variable insiderapid.Check, and promote it int.Cleanup(which runs during the Goexit unwind).Adapter.Minimizeis then a passthrough — rapid already minimized. BothTestToolArgsSchemaFuzzandTestRouterSeqFuzzuse thiscaptured *Failure+ cleanup pattern. - Liberal-in / conservative-out — the fix location matters.
FuzzMessageDecodefound a real codec asymmetry: the decoder accepted an id-less response/error frame the encoder then re-rendered unreadable, breaking the round-trip fixed point. The fix (commit6c7f4eac) was on the producer side —Response.MarshalJSON/ErrorResponse.MarshalJSONomit the empty id — keeping the decoder liberal. On a transport, tightening a decoder turns a tolerable frame into a connection-fatal error; tighten the producer instead. - Model only what you can drive for real. When the real status lifecycle needs a live LLM, model the transport/dispatch layer you can reach offline and report the boundary in the target's doc comment. Do not fabricate the unreachable part.
Per-language portability
The loop, the triage/promotion discipline, schema-driven generation, and the
oracle taxonomy generalize. Only the engine, corpus format, and emitter syntax
are per-ecosystem, all behind the same Adapter:
- Go:
testing.F(coverage-guided, free crasher auto-promotion) for byte/tuple targets;pgregory.net/rapidfor stateful/structured PBT. Emitter writes a*_test.go; corpus isf.Addseeds +testdata/fuzz/. - JS:
fast-check(property/model-based) for a renderer/protocol surface; its emitter writes atest-*.js. - Python:
hypothesis(+schemathesisfor HTTP); its emitter writes atest_*.py.
Cross-module wiring (Go specifics)
A local v0.0.0 workspace module is not go get-able. To consume fuzz/promoter
and fuzz/schemagen from another module in the same go.work:
- Add
require primeradiant.com/evener/fuzz v0.0.0(andpgregory.net/rapid) to the consumer'sgo.mod. - Add
replace primeradiant.com/evener/fuzz v0.0.0 => ./fuzzingo.work, thengo work sync. - Seed third-party checksums the workspace replace hides:
GOFLAGS=-mod=mod GOWORK=off go mod download pgregory.net/rapid.
Add fuzz to GO_MODULES so its tests are gated. make fuzz runs every
Fuzz* target's seed corpus + saved crashers per module as deterministic tests
(no -fuzz flag = no random search); make fuzz-nightly runs the bounded search.