name: AFL++ Fuzzing on: schedule: - cron: "0 2 * * *" # nightly at 02:00 UTC workflow_dispatch: # manual trigger env: RISC0_DEV_MODE: "1" jobs: # ──────────────────────────────────────────────────────────────────────────── # afl-smoke — 120-second campaign for 7 priority targets # ──────────────────────────────────────────────────────────────────────────── afl-smoke: name: "AFL++ smoke — ${{ matrix.target }}" runs-on: ubuntu-latest container: image: aflplusplus/aflplusplus:v4.40c strategy: fail-fast: false matrix: target: - fuzz_transaction_decoding - fuzz_encoding_roundtrip - fuzz_state_serialization - fuzz_stateless_verification - fuzz_state_transition - fuzz_apply_state_diff_split_path - fuzz_sequencer_vs_replayer steps: - name: Checkout repository uses: actions/checkout@v4 - name: Install Rust (stable) uses: dtolnay/rust-toolchain@stable - name: Cache fuzz/target uses: actions/cache@v4 with: path: fuzz/target key: afl-fuzz-target-${{ matrix.target }}-${{ hashFiles('fuzz/Cargo.lock') }} restore-keys: | afl-fuzz-target-${{ matrix.target }}- - name: Install cargo-afl run: cargo install cargo-afl --locked - name: Build fuzz target run: | cd fuzz && cargo afl build \ --no-default-features \ --features fuzzer-afl \ --release \ --bin ${{ matrix.target }} - name: Create corpus directory run: mkdir -p fuzz/corpus/${{ matrix.target }} - name: Run AFL++ for 120 seconds run: | mkdir -p afl-output/${{ matrix.target }} timeout 120 \ afl-fuzz \ -i fuzz/corpus/${{ matrix.target }} \ -o afl-output/${{ matrix.target }} \ -- fuzz/target/release/${{ matrix.target }} \ || true # timeout exit code 124 is expected - name: Sync queue entries to shared corpus run: | TARGET="${{ matrix.target }}" DEST="fuzz/corpus/${TARGET}" mkdir -p "$DEST" count=0 for instance_dir in afl-output/${TARGET}/*/; do QUEUE="${instance_dir}queue" [ -d "$QUEUE" ] || continue for f in "$QUEUE"/id:*; do [ -f "$f" ] || continue HASH=$(sha1sum "$f" | cut -d' ' -f1) DEST_FILE="${DEST}/${HASH}" if [ ! -f "$DEST_FILE" ]; then cp "$f" "$DEST_FILE" count=$((count + 1)) fi done done echo "Synced ${count} new input(s) to ${DEST}" - name: Open corpus PR (if new inputs found) uses: peter-evans/create-pull-request@v6 with: commit-message: "chore: add AFL++ corpus entries for ${{ matrix.target }}" title: "AFL++ corpus update — ${{ matrix.target }}" body: | Automated corpus update from the nightly AFL++ smoke run. Target: `${{ matrix.target }}` branch: "afl-corpus/${{ matrix.target }}" add-paths: "fuzz/corpus/${{ matrix.target }}/" delete-branch: true - name: Upload crashes and hangs artifact if: always() uses: actions/upload-artifact@v4 with: name: afl-findings-${{ matrix.target }} path: | afl-output/${{ matrix.target }}/*/crashes/ afl-output/${{ matrix.target }}/*/hangs/ if-no-files-found: ignore # ──────────────────────────────────────────────────────────────────────────── # afl-coverage — LLVM coverage report for 3 key targets # ──────────────────────────────────────────────────────────────────────────── afl-coverage: name: "AFL++ coverage — ${{ matrix.target }}" runs-on: ubuntu-latest needs: afl-smoke strategy: fail-fast: false matrix: target: - fuzz_state_transition - fuzz_sequencer_vs_replayer - fuzz_apply_state_diff_split_path steps: - name: Checkout repository uses: actions/checkout@v4 - name: Install Rust nightly + llvm-tools-preview uses: dtolnay/rust-toolchain@nightly with: components: llvm-tools-preview - name: Install cargo-afl run: cargo install cargo-afl --locked - name: Download smoke findings for ${{ matrix.target }} uses: actions/download-artifact@v4 with: name: afl-findings-${{ matrix.target }} path: afl-output/${{ matrix.target }} continue-on-error: true # no crashes/hangs is fine - name: Build with LLVM instrumented coverage env: RUSTFLAGS: "-C instrument-coverage" RISC0_DEV_MODE: "1" run: | cargo build \ --manifest-path fuzz/Cargo.toml \ --no-default-features \ --features fuzzer-afl \ --release \ --bin ${{ matrix.target }} - name: Run corpus + queue entries through instrumented binary run: | TARGET="${{ matrix.target }}" BINARY="fuzz/target/release/${TARGET}" PROFRAW_DIR="coverage/afl/${TARGET}/profraw" mkdir -p "$PROFRAW_DIR" idx=0 # Shared corpus for f in fuzz/corpus/${TARGET}/id:* fuzz/corpus/${TARGET}/*; do [ -f "$f" ] || continue LLVM_PROFILE_FILE="${PROFRAW_DIR}/${idx}.profraw" "$BINARY" < "$f" 2>/dev/null || true idx=$((idx + 1)) done # AFL++ queue entries (if available from the smoke job) for instance_dir in afl-output/${TARGET}/*/; do QUEUE="${instance_dir}queue" [ -d "$QUEUE" ] || continue for f in "$QUEUE"/id:*; do [ -f "$f" ] || continue LLVM_PROFILE_FILE="${PROFRAW_DIR}/${idx}.profraw" "$BINARY" < "$f" 2>/dev/null || true idx=$((idx + 1)) done done echo "Ran ${idx} inputs through ${TARGET}" - name: Merge raw profiles run: | TARGET="${{ matrix.target }}" PROFRAW_DIR="coverage/afl/${TARGET}/profraw" PROFDATA="coverage/afl/${TARGET}/merged.profdata" shopt -s nullglob files=("${PROFRAW_DIR}"/*.profraw) if [ ${#files[@]} -eq 0 ]; then echo "No .profraw files found — skipping merge." exit 0 fi llvm-profdata merge -sparse "${files[@]}" -o "$PROFDATA" - name: Generate HTML coverage report run: | TARGET="${{ matrix.target }}" BINARY="fuzz/target/release/${TARGET}" PROFDATA="coverage/afl/${TARGET}/merged.profdata" HTML_DIR="coverage/afl/${TARGET}/html" if [ ! -f "$PROFDATA" ]; then echo "No profdata — skipping HTML report." exit 0 fi mkdir -p "$HTML_DIR" llvm-cov show \ "$BINARY" \ --instr-profile="$PROFDATA" \ --format=html \ --output-dir="$HTML_DIR" \ --ignore-filename-regex='\.cargo|rustc' echo "Coverage report: ${HTML_DIR}/index.html" - name: Upload coverage report artifact uses: actions/upload-artifact@v4 with: name: afl-coverage-${{ matrix.target }} path: coverage/afl/${{ matrix.target }}/html/ if-no-files-found: ignore