Tutorial: Finding Your First Bug
This tutorial walks through the full fuzzing workflow using a URL parser as the target. You will set up instrumentation, write a fuzz target, run the fuzzer, investigate a crash, and add it as a regression test.
The Target
Section titled “The Target”Suppose you have a URL parser module at src/url-parser.ts that parses URL strings into structured objects:
export interface ParsedUrl { protocol: string; hostname: string; port: number | undefined; pathname: string; query: Record<string, string>;}
export function parseUrl(input: string): ParsedUrl { // ... parsing logic}This is a classic fuzzing target: it takes untrusted string input and does complex parsing with many edge cases.
Step 1: Set Up the Project
Section titled “Step 1: Set Up the Project”If you have not already, install the packages and configure Vitest as described in the Quickstart.
Step 2: Write the Fuzz Target
Section titled “Step 2: Write the Fuzz Target”Create test/url-parser.fuzz.ts:
import { fuzz } from "@vitiate/core";import { parseUrl } from "../src/url-parser.js";
fuzz("parseUrl does not crash on arbitrary input", (data: Buffer) => { const input = data.toString("utf-8"); parseUrl(input);});This is the simplest form: pass every input to the parser and let any uncaught exception surface as a crash. We are not catching any errors here because any unhandled exception from parseUrl is a bug worth investigating.
Step 3: Run the Fuzzer
Section titled “Step 3: Run the Fuzzer”npx vitiate fuzzWatch the output. The edges counter shows how many unique code edges the fuzzer has reached. The corpus counter shows how many inputs have been kept because they found new coverage.
fuzz: elapsed: 1s, execs: 512 (2841/sec), corpus: 15 (15 new), edges: 89fuzz: elapsed: 4s, execs: 2048 (3120/sec), corpus: 34 (19 new), edges: 127fuzz: elapsed: 10s, execs: 8192 (3254/sec), corpus: 41 (7 new), edges: 143When a crash is found, the fuzzer prints the error, minimizes the crashing input to its smallest reproducing form, and writes it to disk.
Step 4: Examine the Crash
Section titled “Step 4: Examine the Crash”Look at the crash artifact (the path is printed in the crash output):
ls .vitiate/testdata/*parseUrl*/crashes/crash-*xxd .vitiate/testdata/*parseUrl*/crashes/crash-*The file contains the raw bytes that triggered the crash. The filename includes a SHA-256 hash for deduplication - if the fuzzer finds the same crash twice, it will not create a duplicate file.
Step 5: Fix the Bug
Section titled “Step 5: Fix the Bug”Examine the stack trace from the fuzzer output to understand the root cause. Fix the parser, then verify the fix by running the fuzzer again. The crash artifact remains on disk as a regression test.
Step 6: Run Regression Tests
Section titled “Step 6: Run Regression Tests”npx vitest runVitest runs your fuzz tests in regression mode, replaying every file in the seed corpus directory (including crash artifacts) and every cached corpus entry. If your fix is correct, the crash artifact no longer throws and the test passes.
If you revert the fix, the test fails immediately - the crash artifact is a permanent guard against regression.
Step 7: Add Seed Inputs (Optional)
Section titled “Step 7: Add Seed Inputs (Optional)”Seed inputs give the fuzzer a head start by providing representative examples to mutate from. Use vitiate init to create the seed directories, then add your seeds:
# Initialize test data directories (creates seed directories for all fuzz tests)npx vitiate init
# Find the created directorySEED_DIR=$(ls -d .vitiate/testdata/*parseUrl*/seeds)echo -n 'https://example.com' > "$SEED_DIR/seed-basic"echo -n 'http://user:pass@host.com:8080/path?key=value&foo=bar#section' > "$SEED_DIR/seed-full"echo -n 'ftp://[::1]:21/file' > "$SEED_DIR/seed-ipv6"Seeds do not need to trigger bugs - they just need to exercise different code paths so the fuzzer can mutate from diverse starting points. Run the fuzzer again and you should see it reach more edges faster.
Step 8: Add a Dictionary (Optional)
Section titled “Step 8: Add a Dictionary (Optional)”If the fuzzer is slow to find coverage, add domain-specific tokens. Place a dictionary file directly in the test’s data directory (it will be discovered automatically by convention):
"://""http""https""ftp""@"":""/""?""&""=""#""%20""[::1]""localhost"The fuzzer will use these tokens during mutation, making it much more likely to generate structurally valid URLs that exercise deep parsing paths. See Dictionaries and Seeds for the full dictionary syntax, hex escapes for binary tokens, and links to premade dictionaries for common formats.
Step 9: Tighten the Target (Optional)
Section titled “Step 9: Tighten the Target (Optional)”Once the parser handles arbitrary input without crashing, you can add assertions to check semantic correctness:
fuzz("parseUrl round-trips", (data: Buffer) => { const input = data.toString("utf-8"); let parsed; try { parsed = parseUrl(input); } catch { return; // parsing failures are acceptable }
// If parsing succeeds, the result should be consistent if (parsed.port !== undefined) { assert(parsed.port >= 0 && parsed.port <= 65535, `invalid port: ${parsed.port}`); } if (parsed.protocol) { assert(parsed.protocol.endsWith(":"), `protocol missing colon: ${parsed.protocol}`); }});This finds a different class of bugs: inputs that parse successfully but produce invalid results.
Summary
Section titled “Summary”The fuzzing workflow is:
- Write a fuzz target that exercises the code under test
- Run
npx vitiate fuzz(orVITIATE_FUZZ=1 npx vitest run) and let it find crashes - Fix the bugs; crash artifacts become regression tests automatically
- Optionally add seed inputs and a dictionary to improve coverage
- Tighten assertions over time as the code matures