Open source For WebAssembly components

No more code reviews. Fuzz instead.

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.

A search box

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

Four kinds of bug a quick read lets through.

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.

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 values

Security

XSS, 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 review

Database

n+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 amplify

Wrong answers

200 %, 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 pairs

Also for ops: CPU and memory that grow faster than the input, slow call chains, busy waiting, one slow dependency that blows the latency budget.

With labels in the manifest: SQL, shell and path injection, SSRF, open redirects, leaked secrets and personal data, writes on GET, the same write twice, other users' records on the page, scripts in a widget that must have none. The label list.

A minute with appfuzz.

Five commands on chatty, a small app that lists items and then fetches each one again. Every output is real, cut short.

~/bench/apps/chatty
$ 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" ]
}

Same bug, two languages, same finding.

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 twice, 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 13,250 runs a second on 24 threads.

appfuzz review, the same in both

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

13,250

fresh sandboxes a second. On one desktop CPU.

Fast enough for every push.

Every run is a whole request: a fresh sandbox, simulated host calls, every detector. Time is virtual, so a host that takes 200 ms costs nothing real. Thirty seconds of fuzzing is 400,000 requests to the n+1 app above.

The chatty test app, Rust. appfuzz fuzz --time 30s from a fresh start on an AMD Ryzen 9 3900X, 12 cores, 24 threads.

More bugs that pass a quick read.

From the 37 test apps appfuzz runs on every commit. Each has one planted bug, and appfuzz must find it and nothing else. Findings word for word.

Assumes the database answers

let it = items::get(id).unwrap();

fail crash when items#get returns err: called `Result::unwrap()` on an `Err` value

A helpful error page

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

Retries until it works

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

Usage can't go over 100 %. Can it?

$ appfuzz check value_range .usage 0 100

fail .usage is 200, outside 0–100. Counterexample saved, with the backend data that causes it.

Built for the agent that wrote the app.

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.

JSON everywhere
Every command prints one JSON document.
Always a next step
Each output lists the commands worth running next, ids filled in.
Suspicions become tests
check turns "this can't go over 100" into a search for a counterexample.
Same input, same result
Time and the backend are simulated. Every run replays exactly.

What the sandbox blocks, nobody has to review.

Reviewing an app written by marketing usually means reading every line for what it might do: write to ORDERS, flood a server, delete files. Run as WebAssembly in a shell, the app can call only the imports the shell grants. If it can't write ORDERS, nobody has to check that it doesn't. The review shrinks to what is granted, and appfuzz tests that.

An app written by marketing. As a native process it reaches orders, the network, the filesystem and processes. As WebAssembly in a shell it reaches only items.get; every other port is closed.NATIVE PROCESSreaches everythingpromo_bannernative · uid wwwordersread · writenetworkany hostfilesystemall of /dataprocessesno limititemsread · writeUPDATE orders10k req/srm -rf /datafork() ×∞SELECT itemsWASM IN A SHELLreaches items.getpromo_bannersame app, as .wasmordersnot grantednetworknot grantedfilesystemnot grantedprocessesnot granteditemsgranteditems.getWASM shellwasmtime · 64 MiB · metered

Not granted

orders.update, sockets, files, processes. The app can't call them, so there is nothing to review.

Granted

items.get. appfuzz fuzzes every call the app makes through it: n+1 queries, crashes, odd values.

Limited

CPU, memory, cost and latency have budgets in the manifest. appfuzz measures the app against them before it ships.

The app review in AppOS.

AppOS lets marketing ship to production the way apps ship to your phone. Engineering owns the shell. Each app runs in a WebAssembly sandbox and does only what its manifest grants. appfuzz is the automatic app review: it checks every app before it runs, pass or fail, with no human bottleneck.

The AppOS architecture slides

Manifest
Marketing and engineering agree on what an app may call. appfuzz reads the same grants and flags any import outside them.
App review
Before deploy, appfuzz fuzzes the app against simulated host functions: crashes, n+1 queries, injections, leaks.
Sandbox
In production every request has a budget for I/O, time, CPU and memory. appfuzz measures the app against the same budgets first.
Code review
Engineering reviews the manifest. appfuzz reviews the code.

What it won't do.

A pass means the search found nothing. That's evidence, not proof.

Catch malicious apps
Not yet. It finds careless code, not hidden backdoors.
Know your business rules
It can't tell that create writes or that a field is an email address. Labels in the manifest tell it: SQL, secrets, personal data, writes, who owns a record.
Test sequences of requests
Each run is one request. Double submits are out of reach for now.
See inside interpreters
TypeScript and Python apps are measured just as well from outside, but the search is less guided and runs are slower.

Try it on your app.

your app
$ curl -fsSL https://appfuzz.dev/install.sh | sh
$ appfuzz --wasm my_app.wasm inspect
$ appfuzz --wasm my_app.wasm review

Open source under the MIT license. Written in Rust on wasmtime.