PlatformTesting & Adversarial Analysis
Fuzz Infrastructure
Twenty-five libFuzzer harnesses exercise selected parser, decoder, and verifier entry points. ClusterFuzzLite runs configured targets on pull requests and nightly.
Standalone workspace by design
fuzz/Cargo.toml carries an empty [workspace] stanza so libFuzzer's nightly-only runtime requirements do not leak into the main stable workspace. The main workspace denies unwrap_used and expect_used; the fuzz workspace inherits the same lints so harnesses cannot mask panics behind a hidden .unwrap().What Fuzzing Does
libFuzzer is a coverage-guided in-process fuzzer. It starts from a seed corpus, executes the harness, observes which code edges the input touched, and mutates the input toward inputs that cover new edges. When an input causes a panic, an assertion violation, or an ASAN/UBSAN report, libFuzzer captures it as a crash and shrinks toward a minimal reproducer.
Chio targets trust-boundary entry points: places where externally controlled bytes first enter Chio code. The contract for each target is that no input causes a panic, an unwrap failure, or undefined behavior; structurally invalid input must result in a typed Err return.
Fuzz Targets
Twenty-five [[bin]] entries in fuzz/Cargo.toml, each backed by a fuzz_target! in fuzz/fuzz_targets/<name>.rs. The owning crate per target is declared in fuzz/owners.toml as [targets.<name>] with crate and path fields. Owner resolution is declared for twenty of the twenty-five targets today; the four fuzz_-named policy, SQL, Merkle, and tool-action targets plus wasm_guard_escape have no [targets.<name>] block yet, so scripts/promote_fuzz_seed.sh cannot resolve their owner until the block is added.
| Target | Source | Owning crate | Input boundary |
|---|---|---|---|
canonical_json | fuzz_targets/canonical_json.rs | chio-core-types | RFC 8785 canonical-JSON round-trip; structure-aware mutator self-driver |
capability_receipt | fuzz_targets/capability_receipt.rs | chio-core-types | CapabilityToken and ChioReceipt JSON deserialization plus verify_signature |
manifest_roundtrip | fuzz_targets/manifest_roundtrip.rs | chio-manifest | Tool-manifest decode plus canonicalization |
attest_verify | fuzz_targets/attest_verify.rs | chio-attest-verify | Sigstore bundle parser plus cert chain verify |
jwt_vc_verify | fuzz_targets/jwt_vc_verify.rs | chio-credentials | JWT VC verifier; constant-time compare assertions |
oid4vp_presentation | fuzz_targets/oid4vp_presentation.rs | chio-credentials | OID4VP holder response decode |
did_resolve | fuzz_targets/did_resolve.rs | chio-did | chio-did parser plus resolver |
anchor_bundle_verify | fuzz_targets/anchor_bundle_verify.rs | chio-anchor | Anchor proof bundle plus checkpoint records |
mcp_envelope_decode | fuzz_targets/mcp_envelope_decode.rs | chio-mcp-edge | MCP NDJSON decode plus edge dispatch into the evaluator |
a2a_envelope_decode | fuzz_targets/a2a_envelope_decode.rs | chio-a2a-adapter | A2A SSE parse plus per-event fan-out |
acp_envelope_decode | fuzz_targets/acp_envelope_decode.rs | chio-acp-edge | ACP NDJSON plus handle_jsonrpc dispatch |
wasm_preinstantiate_validate | fuzz_targets/wasm_preinstantiate_validate.rs | chio-wasm-guards | ComponentBackend, WasmtimeBackend, format detection |
wit_host_call_boundary | fuzz_targets/wit_host_call_boundary.rs | chio-wasm-guards | GuardRequest and GuestDenyResponse serde deserialization |
chio_yaml_parse | fuzz_targets/chio_yaml_parse.rs | chio-config | chio-config YAML loader |
openapi_ingest | fuzz_targets/openapi_ingest.rs | chio-openapi-mcp-bridge | OpenApiMcpBridge::from_spec ingest |
receipt_log_replay | fuzz_targets/receipt_log_replay.rs | chio-kernel-core | Receipt log replay decode plus chain-invariant state machine |
fuzz_policy_parse_compile | fuzz_targets/policy_parse_compile.rs | chio-policy | HushSpec parser, validator, compiler, and YAML round-trip |
fuzz_sql_parser | fuzz_targets/sql_parser.rs | chio-data-guards | SQL parser and SQL guard fail-closed analysis across dialects |
fuzz_merkle_checkpoint | fuzz_targets/merkle_checkpoint.rs | chio-kernel | Merkle tree inclusion proofs and signed checkpoint validation |
fuzz_tool_action | fuzz_targets/tool_action.rs | chio-guards | Tool action classification and guard verdicts for egress, shell, SQL, memory, MCP |
eval_receipt_bundle | fuzz_targets/eval_receipt_bundle.rs | chio-eval-receipt | verify_bundle eval-receipt bundle JSON verification |
federation_trust_establishment | fuzz_targets/federation_trust_establishment.rs | chio-federation | Kernel trust-establishment handshake decode plus envelope signature verify and peer accept |
revocation_oracle_merkle | fuzz_targets/revocation_oracle_merkle.rs | chio-revocation-oracle | Revocation-oracle insert plus inclusion and non-inclusion Merkle proof verification |
underwriting_policy_input | fuzz_targets/underwriting_policy_input.rs | chio-underwriting | Underwriting policy and decision input decode, validation, and premium pricing |
wasm_guard_escape | fuzz_targets/wasm_guard_escape.rs | chio-wasm-guards | Component-model wasm escape-class fuzzing: undeclared imports, oversized memory, fuel exhaustion, table abuse, deep recursion, host reentry, malformed component, signed-but-malicious modules |
Each target is wired through target-map.toml with the source-path globs that, when changed, must trigger that target on the PR. The list is in lockstep with .clusterfuzzlite/build.sh and infra/oss-fuzz/build.sh.
Harness Shape
The smallest harnesses delegate to a fuzz_ entrypoint exported by the owning crate. From fuzz_targets/jwt_vc_verify.rs:
#![no_main]
use chio_credentials::fuzz::fuzz_jwt_vc_verify;
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
fuzz_jwt_vc_verify(data);
});Targets that need a structure-aware mutator declare one with fuzz_mutator!. The canonical-JSON mutator at fuzz/mutators/canonical_json.rs is the worked example; it is consumed by six targets: canonical_json, capability_receipt, manifest_roundtrip, mcp_envelope_decode, federation_trust_establishment, and underwriting_policy_input:
#![no_main]
use chio_core_types::{CapabilityToken, ChioReceipt};
use chio_fuzz::canonical_json::canonical_json_mutate;
use libfuzzer_sys::{fuzz_mutator, fuzz_target};
fuzz_target!(|data: &[u8]| {
if let Ok(token) = serde_json::from_slice::<CapabilityToken>(data) {
let _ = token.verify_signature();
}
if let Ok(receipt) = serde_json::from_slice::<ChioReceipt>(data) {
let _ = receipt.verify_signature();
}
});
fuzz_mutator!(|data: &mut [u8], size: usize, max_size: usize, seed: u32| {
canonical_json_mutate(data, size, max_size, seed)
});Targets that drive the full state machine (HushSpec policy parse-compile, tool-action classification) embed seed corpora with include_bytes! so the first iteration always exercises a known-good shape.
Corpus
Seed corpora live under fuzz/corpus/<target>/. The cargo-fuzz convention is corpus/<target>/ for unprefixed targets and corpus/fuzz_<target>/ for the main-branch fuzz_* binaries. A typical corpus directory holds:
- A handful of hand-curated seed inputs covering the major structural cases (one valid case, one boundary case, one intentionally malformed case).
- Promoted seeds: every crash that triaged through
scripts/promote_fuzz_seed.shbecomes a permanent corpus entry plus a regression test in the owning crate.
Adding a new seed:
# Drop the input bytes
cp my-seed.bin fuzz/corpus/<target>/
# Run the target with the seed in the corpus
cd fuzz
cargo +nightly fuzz run <target>
# If the seed survives the run, commit it:
git add fuzz/corpus/<target>/my-seed.binCorpus snapshot
Seed counts per committed corpus directory under fuzz/corpus/. Each directory name matches its [[bin]] name: the four targets whose binary name carries the fuzz_ prefix (fuzz_merkle_checkpoint, fuzz_policy_parse_compile, fuzz_sql_parser, fuzz_tool_action) take a fuzz_-prefixed corpus directory; the rest use the plain target name. Four targets ( eval_receipt_bundle, federation_trust_establishment, revocation_oracle_merkle, and underwriting_policy_input ) have no committed corpus yet and rely on the libFuzzer mutator to grow coverage from generated input.
| Target | Seeds | Target | Seeds |
|---|---|---|---|
a2a_envelope_decode | 8 | acp_envelope_decode | 7 |
anchor_bundle_verify | 6 | attest_verify | 1 |
canonical_json | 5 | capability_receipt | 14 |
chio_yaml_parse | 7 | did_resolve | 6 |
fuzz_merkle_checkpoint | 1 | fuzz_policy_parse_compile | 6 |
fuzz_sql_parser | 10 | fuzz_tool_action | 12 |
jwt_vc_verify | 5 | manifest_roundtrip | 7 |
mcp_envelope_decode | 7 | oid4vp_presentation | 4 |
openapi_ingest | 9 | receipt_log_replay | 7 |
wasm_guard_escape | 8 | wasm_preinstantiate_validate | 7 |
wit_host_call_boundary | 7 |
Targets with a single seed (e.g. attest_verify, fuzz_merkle_checkpoint) rely on the libFuzzer mutator to grow coverage from a single valid seed. Targets with double-digit seed counts (e.g. capability_receipt, fuzz_tool_action, fuzz_sql_parser) carry promoted regression seeds from prior crash triages.
Promoted regression seeds also land as a tests/ file in the owning crate (per owners.toml) so the seed stays exercised even when the fuzz lane is offline.
ClusterFuzzLite Integration
ClusterFuzzLite (CFLite) is a continuous fuzzing layer for repos that do not run on Google's OSS-Fuzz infrastructure. Two GitHub Actions workflows wire it in:
.github/workflows/cflite_pr.yml· PR-time changed-target sampling. Each PR computes which source-path globs changed, intersects them withtarget-map.tomltriggers, and runs the matching subset for a short budget..github/workflows/cflite_batch.yml· nightly rotation that runs every target for a longer budget. Crashes feed intofuzz/artifacts/<target>/.
The build entrypoint is .clusterfuzzlite/build.sh; it mirrors infra/oss-fuzz/build.sh target-for-target. Storage is a sibling private repo (the Chio fuzz lane does not use GCS); the storage-repo input is passed via FUZZ_CORPUS_PAT in the workflow files.
The PR check, verbatim
The required check on every PR is cflite_pr / changed-target-sampling. It computes the diff against the merge base, intersects the changed source paths with the trigger globs in fuzz/target-map.toml, and runs only the matching targets at 60 seconds each. The PR label fuzz: full promotes the run to a 120-second-per-target full sweep.
name: cflite_pr
on:
pull_request:
types:
- opened
- synchronize
- reopened
- labeled
paths:
- "crates/**"
- "Cargo.toml"
- "Cargo.lock"
- ".cargo/**"
- "fuzz/**"
- "spec/schemas/**"
- "tests/bindings/vectors/**"
- ".github/workflows/cflite_pr.yml"
- ".clusterfuzzlite/**"
- "scripts/check-fuzz-budget.sh"
env:
CHIO_FUZZ_DURATION_SECONDS: "60"
CHIO_FUZZ_DURATION_SECONDS_FULL: "120"
jobs:
budget-check:
name: fuzz-budget
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- name: Verify 30-day fuzz budget
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
scripts/check-fuzz-budget.sh "${{ github.repository }}"
changed-target-sampling:
name: changed-target-sampling
needs: budget-check
runs-on: ubuntu-latest
timeout-minutes: 30The changed-target detection step reads target-map.toml and glob-matches each entry against the diff:
- name: Compute changed targets
id: targets
if: steps.mode.outputs.full == 'false'
run: |
set -euo pipefail
base_sha="${{ github.event.pull_request.base.sha }}"
head_sha="${{ github.event.pull_request.head.sha }}"
git diff --name-only "${base_sha}" "${head_sha}" > changed.txt
mapfile -t targets < <(yq -p=toml -r '.targets | keys | .[]' \
fuzz/target-map.toml)
fired=()
for tgt in "${targets[@]}"; do
mapfile -t triggers < <(yq -p=toml -r \
".targets.${tgt}.triggers[]" fuzz/target-map.toml)
for trig in "${triggers[@]}"; do
regex="$(printf '%s' "${trig}" | sed -e 's|\.|\\.|g' \
-e 's|\*\*|__DSTAR__|g' -e 's|\*|[^/]*|g' \
-e 's|__DSTAR__|.*|g')"
if grep -qE "^${regex}$" changed.txt; then
fired+=("${tgt}")
break
fi
done
done
# Inventory fallback: builder, workflow, and target-map edits
# can break changed-target sampling without matching a source
# trigger. Run the full inventory for those control-plane edits.The actual fuzz invocation is the upstream google/clusterfuzzlite action, pinned by commit SHA, fed the comma-separated target list via the CHIO_CFLITE_TARGETS environment variable that .clusterfuzzlite/build.sh reads:
- name: Build ClusterFuzzLite changed-target fuzzers
if: steps.mode.outputs.full == 'false' && steps.targets.outputs.count != '0'
uses: google/clusterfuzzlite/actions/build_fuzzers@82652fb49e77bc29c35da1167bb286e93c6bcc05
env:
CHIO_CFLITE_TARGETS: ${{ steps.targets.outputs.targets }}
with:
language: rust
sanitizer: address
github-token: ${{ secrets.GITHUB_TOKEN }}
keep-unaffected-fuzz-targets: true
- name: Run ClusterFuzzLite changed-target sampling without corpus storage
if: steps.mode.outputs.full == 'false' && steps.targets.outputs.count != '0'
uses: google/clusterfuzzlite/actions/run_fuzzers@82652fb49e77bc29c35da1167bb286e93c6bcc05
env:
CHIO_CFLITE_TARGETS: ${{ steps.targets.outputs.targets }}
with:
language: rust
fuzz-seconds: ${{ steps.mode.outputs.fuzz_seconds }}
mode: code-change
sanitizer: address
github-token: ${{ secrets.GITHUB_TOKEN }}
report-unreproducible-crashes: falseCrashes are uploaded as the cflite-pr-crashes-<PR> crash archive at ./out/artifacts, with a 14-day retention window. The companion cflite_batch.yml runs the full inventory nightly with a longer per-target budget; the project config that both workflows share lives at .clusterfuzzlite/project.yaml.
language: rust
primary_contact: "security@chio.world"
auto_ccs:
- "security@chio.world"
sanitizers:
- address
- undefined
architectures:
- x86_64
fuzzing_engines:
- libfuzzer
report_to_oss_fuzz: falseRunning Locally
Setup once:
rustup toolchain install nightly
cargo install cargo-fuzz --lockedRun a single target. The cargo-fuzz wrapper builds the binary with libFuzzer instrumentation, links the seed corpus from fuzz/corpus/<target>/, and forwards the rest of the args to libFuzzer:
cd fuzz
cargo +nightly fuzz run attest_verifySample stdout from a clean run (libFuzzer's standard coverage-guided trailer):
INFO: Running with entropic power schedule (0xFF, 100).
INFO: Seed: 3829461072
INFO: 12 files found in fuzz/corpus/attest_verify
INFO: -max_len is not provided; libFuzzer will not generate inputs larger than 4096 bytes
INFO: seed corpus: files: 12 min: 184b max: 2718b total: 9420b rss: 41Mb
#13 INITED cov: 1842 ft: 4127 corp: 12/9420b exec/s: 0 rss: 89Mb
#256 pulse cov: 1842 ft: 4127 corp: 12/9420b lim: 184 exec/s: 128 rss: 92Mb
#1024 pulse cov: 1844 ft: 4131 corp: 13/9711b lim: 612 exec/s: 102 rss: 105Mb
...Each entry pairs the cumulative iteration count, the coverage edge count (cov), the feature count (ft), and the corpus size. A run that finds no crash inside its time budget exits 0 and prints Done {N} runs in {S} second(s).
Build only (the gate that mirrors CI's build_fuzzers action):
cargo +nightly fuzz build attest_verifyRun with a maximum total time budget (useful for local soak runs):
cargo +nightly fuzz run attest_verify -- -max_total_time=300CI pins a dated nightly so fuzz crashes reproduce across machines; consult .github/workflows/cflite_pr.yml and .github/workflows/nightly.yml for the toolchain currently in force.
Crashes, Minimization, Regression
When libFuzzer finds a crashing input it dumps the bytes to fuzz/artifacts/<target>/crash-... and prints a stack trace. Triage path:
- Reproduce:
cargo +nightly fuzz run <target> fuzz/artifacts/<target>/crash-... - Minimize:
cargo +nightly fuzz tmin <target> fuzz/artifacts/<target>/crash-...shrinks the input to the smallest reproducer. - Classify: parser bug, validator bug, or genuine logic flaw in the code path under test.
- Fix the bug.
- Promote the minimized seed via
scripts/promote_fuzz_seed.sh: the seed is added to the corpus and a regression test is generated in the owning crate (path resolved throughfuzz/owners.toml).
The regression test pins the bug forever; even if the fuzz lane is offline, the seed still runs as a normal cargo test.
Crash report walkthrough
A libFuzzer crash report has three parts: the deduplicated finding banner, the sanitizer stack, and the input-bytes snapshot. A typical OOB-read finding from canonical_json looks like:
==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000c30
READ of size 1 at 0x602000000c30 thread T0
#0 0x55c... in chio_core_types::canonical::canonicalize crates/core/chio-core-types/src/canonical.rs:184
#1 0x55c... in chio_fuzz::canonical_json::run fuzz/src/lib.rs:42
#2 0x55c... in rust_fuzzer_test_input fuzz/fuzz_targets/canonical_json.rs:8
SUMMARY: AddressSanitizer: heap-buffer-overflow crates/core/chio-core-types/src/canonical.rs:184 in canonicalize
==12345==ABORTING
MS: 5 EraseBytes-ChangeBit-CopyPart-CMP-CrossOver-; base unit: 7c6f...
0x7b,0x22,0x6b,0x22,0x3a,0x22,0x5c,0x75,0x64,0x38,0x30,0x30,0x22,0x7d,0x00,
{"k":"\ud800"}\x00
artifact_prefix='./'; Test unit written to ./crash-9d2c1f...
Base64: eyJrIjoiXHVkODAwIn0AThe triage path:
# 1. Reproduce. cargo-fuzz consumes the crash-file path positionally;
# libFuzzer runs it as a single-input replay rather than a fuzz loop.
cd fuzz
cargo +nightly fuzz run canonical_json \
fuzz/artifacts/canonical_json/crash-9d2c1f...
# 2. Minimize. tmin shrinks the input toward a smaller reproducer that
# still triggers the same crash signature.
cargo +nightly fuzz tmin canonical_json \
fuzz/artifacts/canonical_json/crash-9d2c1f...
# 3. Promote. The minimized seed lands as a permanent corpus entry
# plus a regression test in the owning crate (chio-core-types here,
# resolved through fuzz/owners.toml).
scripts/promote_fuzz_seed.sh canonical_json \
fuzz/artifacts/canonical_json/min-9d2c1f...After promotion, the seed lives at fuzz/corpus/canonical_json/min-9d2c1f... and a regression test is generated in the owning crate (resolved through fuzz/owners.toml via the [targets.canonical_json] block, which names crate = "chio-core-types" and the path the regression file writes to). The MS: prefix in the crash banner records the libFuzzer mutation chain that produced the input; the trailing UTF-8 decode of the bytes is the human-readable form of the input.
Coverage Tracking
cargo-fuzz can dump an HTML coverage report after a run:
cd fuzz
cargo +nightly fuzz coverage <target>
cargo +nightly fuzz coverage <target> --htmlThe output lands in target/<target-triple>/coverage/<target>/ with per-line and per-region edge counts. Regions that the corpus does not reach are candidates for new seeds or for tightening the fuzz_target body to drive more state.
Corpus smoke test
fuzz/tests/smoke.rs is a regular cargo test that runs every fuzz target on its corpus seeds without nightly. It establishes that the targets build and run on the checked-in corpus; it does not explore new inputs.Scope and Limits
- Trust-boundary only. Each target wraps the entry point where externally controlled bytes first hit Chio code. Internal helpers are not fuzzed directly; if they fail, the failure surfaces through one of the wrappers above.
- Fail-closed contract. Targets accept
Err(_)as an expected outcome; the harness rejects a panic, an assert failure, an unwrap, or a sanitizer report. - Sanitizers in CI. ClusterFuzzLite runs with
addressandundefinedsanitizers. Memory safety bugs and undefined behavior are surfaced as crashes. - Coverage, not exhaustion. Fuzzing can find bugs reachable by mutating from the corpus within the run budget. It does not enumerate all inputs. Kani explores selected functions within configured bounds.
Next
- Kani Harnesses · the bounded model-checking complement to coverage-guided fuzzing.
- Differential Tests · the property-based lane for the same parser surfaces.
- Formal Assurance Overview · how fuzz, Kani, and proofs sit on the assurance pyramid.