Crashes
panics, exceptions, hangs, stack overflows
Empty results, failing queries, Unicode, a leap day, the year 2038. Every crash is shrunk to the smallest input that causes it.
appfuzz probe valuesPhase 1 Open source, early, works today
appfuzz runs your WebAssembly app thousands of times against a simulated backend and finds what a review misses: crashes on empty lists and Unicode, user input in your HTML unescaped, loops that send one database query per row. Pass or fail, in CI.
Install on Linux or macOS
$ curl -fsSL https://appfuzz.dev/install.sh | sh
Then run appfuzz review next to your .wasm.
Database queries per request, lower is better
0limit 1001,000
fail n_plus_1 calls to items#get grow as 1·n with n = length of the list items#all returns; cost breaks its limit at n = 100
The chatty test app, Rust build. appfuzz run with 10, 100 and 1,000 rows from the simulated items#all: 11, 101 and 1,001 host calls, and 11 ms, 101 ms and 1,004 ms of simulated database time.
Crashes on the first account with no items
let all = items::all();
let first = &all[0];
let short = &first.name[..8];fail crash when items#all returns empty list: index out of bounds: the len is 0 but the index is 0
fail crash when items#all returns 3-byte chars: end byte index 8 is not a char boundary; it is inside '日'
Two of the 26 test apps appfuzz must pass on every commit. The simulated backend tries empty lists, huge lists, errors and non-English text on every function the app imports.
The query goes straight into the page
let q = param(&req.query, "q").unwrap_or("");
html(format!("<p>Results for {q}</p>"))fail reflection handle puts the request field query.q into its HTML output unescaped
fail stored_reflection handle puts a string items#all returned into its HTML output unescaped
appfuzz plants marker strings in request fields and in backend data, then looks for them, unescaped, in the HTML the app returns.
Tested on components built from
Each one passes review because the code looks fine. appfuzz doesn't read code. It runs it, with the inputs and backend answers you didn't think of.
panics, exceptions, hangs, stack overflows
Empty results, failing queries, Unicode, a leap day, the year 2038. Every crash is shrunk to the smallest input that causes it.
appfuzz probe valuesXSS, leaked errors, ungranted imports
Request fields and stored data that reach your HTML unescaped. Internal error text on the page. Imports the platform never granted.
appfuzz reviewn+1, nested loops, retry storms
Queries that grow with a list or a request field, the same query twice, a loop that retries a failed write 50 times.
appfuzz amplify200 %, order bugs, swallowed errors
Usage above 100 %, a total that changes when rows come back in another order, an error page sent with status 200.
appfuzz probe pairsAlso for ops: CPU and memory that grow faster than the input, slow call chains, busy waiting, one slow dependency that blows the latency budget.
Five commands on chatty, a small app that lists items and then fetches each one again. Every output is real, cut short.
$ appfuzz inspect
{
"exports": [ "bench:web/handler#handle" ],
"imports": [ "bench:web/items@0.1.0", "wasi:cli", "wasi:io", … ],
"findings": [
{ "severity": "info", "kind": "host_call_in_loop",
"title": "handle calls bench:web/items#get inside a loop
(static; check with `scale`)" }
],
"next": [ "appfuzz run", "appfuzz fuzz --time 2m" ]
}$ appfuzz run
{
"run": "rd6d5fddc2a",
"outcome": "ok",
"result": { "status": 200, "body": "<ul><li>alpha grove</li></ul>" },
"totals": {
"host_calls": 2,
"cost": 2,
"fuel": 10477,
"virtual_ms": 2.01
},
"next": [ "appfuzz explain rd6d5fddc2a", "appfuzz probe pairs rd6d5fddc2a" ]
}$ appfuzz review --time 10s
{
"verdict": "fail",
"exit_code": 2,
"counts": { "fail": 2, "warn": 3, "info": 1, "confirmed": 5 },
"coverage": { "fuzz_runs": 180488, "unreached_call_sites": 0 },
"findings": [
{ "id": "f62ff690b", "severity": "fail", "kind": "n_plus_1",
"title": "calls to items#get grow as 1·n with n = length of
the list items#all returns; cost breaks its limit at n = 100" },
{ "id": "fad47d743", "severity": "fail", "kind": "over_budget",
"title": "handle uses 129 cost, the limit is 100" },
{ "id": "fe035e3e3", "severity": "warn", "kind": "redundant_calls",
"title": "items#get: 13 of 50 calls repeat earlier calls
with the same arguments" },
…
],
"next": [ "appfuzz explain rff6825faed" ]
}$ appfuzz explain rff6825faed
{
"status": 200,
"calls": { "bench:web/items#all": 1, "bench:web/items#get": 128 },
"vs_stored_runs": {
"runs": 1863,
"unusual": [
"host_calls is 64.5× the median",
"virtual_ms is 64.34× the median",
"fuel is 56.53× the median"
]
},
"findings": [
{ "severity": "fail", "kind": "over_budget",
"title": "handle uses 129 cost, the limit is 100" },
{ "severity": "warn", "kind": "chatty",
"title": "items#get is called for each element items#all
returned (128 calls for 128 elements)" }
]
}$ appfuzz confirm f62ff690b
{
"finding": { "kind": "n_plus_1", "run": "rff6825faed" },
"replays": 3,
"identical": 3,
"deterministic": true,
"confirmed": true,
"case": ".appfuzz/cases/f62ff690b.json",
"spec": {
"plan": {
"bench:web/items#all": { "return": [
{ "id": 83, "name": "tundra pine #1", "owner": "north delta" },
{ "id": 84, "name": "tundra pine #2", "owner": "north delta" },
…
] }
}
}
}appfuzz never reads your source. It runs the compiled component, so it doesn't care what you wrote it in. One n+1 loop, written three times, reviewed with the same limits.
for it in items::all() {
if let Ok(full) = items::get(it.id) {
out += &format!("<li>{}</li>", esc(&full.owner));
}
}65 KB component. cargo, wasm32-wasip2. About 11,800 runs a second on 24 threads.
for (const it of all()) {
try {
const full = get(it.id);
out += `<li>${esc(full.owner)}</li>`;
} catch {}
}12 MB component, the JS engine included. About 1,850 runs a second. Also found: a 10 KB name makes esc so slow the request never finishes.
for it in items.all():
try:
full = items.get(it.id)
out += f"<li>{escape(full.owner)}</li>"
except Err:
pass18 MB component, the interpreter included. About 1,750 runs a second. Also found: the runtime imports file system and network access the app never uses.
appfuzz review, the same in all three
fail n_plus_1 calls to items#get grow as 1·n with n = length of the list items#all returns; cost breaks its limit at n = 100
fail over_budget handle uses 129 cost, the limit is 100
warn chatty items#get is called for each element items#all returned
warn redundant_calls items#get: 13 of 50 calls repeat earlier calls with the same arguments
From the 26 test apps appfuzz runs on every commit. Each has one planted bug, and appfuzz must find it and nothing else. Findings word for word.
let it = items::get(id).unwrap();fail crash when items#get returns err: called `Result::unwrap()` on an `Err` value
Err(e) => format!(
"items.get({id}) failed in shard 7: {}",
esc(&e),
)warn handle shows internal error details in its output: the error text items#get returned
match store::save("visits", "1") {
Ok(id) => break id,
Err(_) if tries < 50 => tries += 1,
Err(_) => break 0,
}warn store#save is called 50 more times in a row after it failed
$ appfuzz check value_range .usage 0 100fail .usage is 200, outside 0–100. Counterexample saved, with the backend data that causes it.
Marketing builds a dashboard, Sales a calculator. An agent writes each in an afternoon, and nobody has time to read them. The same agent can run appfuzz on its own work.
check turns "this can't go over 100" into a search for a counterexample.A pass means the search found nothing. That's evidence, not proof.
create writes, or that a field is personal data.$ curl -fsSL https://appfuzz.dev/install.sh | sh
$ appfuzz --wasm my_app.wasm inspect
$ appfuzz --wasm my_app.wasm reviewOpen source under the MIT license. Written in Rust on wasmtime. Phase 1 of 3 is done.