name: 'Cairn Zig Fuzz (AFL++)' description: 'Build and run Zig AFL++ fuzz targets, reporting crashes and corpus to Cairn. Each target is built via `zig build fuzz -Dfuzz-target=`.' inputs: cairn_server: description: 'Cairn server URL' required: true repo: description: 'Repository name' required: true owner: description: 'Repository owner' required: true commit: description: 'Commit SHA' required: false default: '${{ github.sha }}' targets: description: | Line-delimited fuzz target names. Each line is passed as -Dfuzz-target=. Example: lexer parser varint_decode required: true fuzz_binary: description: 'Binary name in zig-out/bin/ (auto-detected if only one)' required: false default: '' seed_dir: description: | Directory containing per-target seed inputs. Each subdirectory should match a target name from the targets input. Files are merged with the downloaded corpus. Example layout: seeds/lexer/valid-token.txt seeds/parser/minimal-program.txt required: false default: '' duration: description: 'Fuzz duration per target in seconds' required: false default: '600' afl_args: description: 'Extra afl-fuzz arguments' required: false default: '' target: description: 'Target platform metadata' required: false default: '' cairn_version: description: 'Cairn CLI version' required: false default: 'latest' runs: using: 'composite' steps: - name: Setup AFL++ uses: https://git.ts.mattnite.net/mattnite/cairn/actions/setup-afl@main - name: Check prerequisites shell: sh run: | set -eu command -v zig >/dev/null 2>&1 || { echo "ERROR: zig not found in PATH. Install Zig before using this action (e.g. https://codeberg.org/mlugg/setup-zig@v2)."; exit 1; } command -v afl-cc >/dev/null 2>&1 || { echo "ERROR: afl-cc not found in PATH after setup-afl step."; exit 1; } echo "zig $(zig version), afl-cc found" - name: Setup Cairn CLI uses: https://git.ts.mattnite.net/mattnite/cairn/actions/setup-cairn@main with: version: ${{ inputs.cairn_version }} server_url: ${{ inputs.cairn_server }} - name: Build and fuzz with AFL++ shell: bash env: CAIRN_SERVER: ${{ inputs.cairn_server }} REPO: ${{ inputs.repo }} OWNER: ${{ inputs.owner }} COMMIT: ${{ inputs.commit }} TARGETS: ${{ inputs.targets }} FUZZ_BINARY: ${{ inputs.fuzz_binary }} SEED_DIR: ${{ inputs.seed_dir }} DURATION: ${{ inputs.duration }} EXTRA_AFL_ARGS: ${{ inputs.afl_args }} TARGET_PLATFORM: ${{ inputs.target }} run: | set -eu echo "Cairn CLI version: $(cairn version)" TOTAL_CRASHES=0 TARGET_NUM=0 ACTIVE_RUN_ID="" # Single EXIT trap that finishes whatever run is active. cleanup() { if [ -n "${ACTIVE_RUN_ID}" ]; then echo "Finishing run ${ACTIVE_RUN_ID} (cleanup)..." cairn run finish -server "${CAIRN_SERVER}" -id "${ACTIVE_RUN_ID}" || true ACTIVE_RUN_ID="" fi } trap cleanup EXIT # ── Iterate over each target name ── while IFS= read -r FUZZ_TARGET; do # Skip empty lines and comments FUZZ_TARGET=$(echo "${FUZZ_TARGET}" | sed 's/#.*//' | xargs) [ -z "${FUZZ_TARGET}" ] && continue TARGET_NUM=$((TARGET_NUM + 1)) BUILD_ARGS="fuzz -Dfuzz-target=${FUZZ_TARGET}" echo "" echo "==========================================" echo "Target ${TARGET_NUM}: ${FUZZ_TARGET} (zig build ${BUILD_ARGS})" echo "==========================================" # ── Ensure Cairn target ── CAIRN_TARGET_ID=$(cairn target ensure \ -server "${CAIRN_SERVER}" \ -repo "${REPO}" \ -owner "${OWNER}" \ -name "${FUZZ_TARGET}" \ -type fuzz) echo "Cairn target ID: ${CAIRN_TARGET_ID}" # ── Start a run ── RUN_ID=$(cairn run start \ -server "${CAIRN_SERVER}" \ -target-id "${CAIRN_TARGET_ID}" \ -commit "${COMMIT}") ACTIVE_RUN_ID="${RUN_ID}" echo "Run ID: ${RUN_ID}" # ── Download existing corpus ── SEEDS="afl-seeds-${TARGET_NUM}" rm -rf "${SEEDS}" mkdir -p "${SEEDS}" # Copy per-target seeds from repo if available. if [ -n "${SEED_DIR}" ] && [ -d "${SEED_DIR}/${FUZZ_TARGET}" ]; then REPO_SEED_COUNT=$(find "${SEED_DIR}/${FUZZ_TARGET}" -maxdepth 1 -type f | wc -l) if [ "${REPO_SEED_COUNT}" -gt 0 ]; then cp "${SEED_DIR}/${FUZZ_TARGET}"/* "${SEEDS}/" 2>/dev/null || true echo "Copied ${REPO_SEED_COUNT} repo seeds from ${SEED_DIR}/${FUZZ_TARGET}" fi fi DL_START=$(date +%s) cairn corpus download \ -server "${CAIRN_SERVER}" \ -target-id "${CAIRN_TARGET_ID}" \ -dir "${SEEDS}" || true DL_ELAPSED=$(( $(date +%s) - DL_START )) DL_COUNT=$(find "${SEEDS}" -maxdepth 1 -type f | wc -l) echo "Downloaded ${DL_COUNT} corpus entries (${DL_ELAPSED}s)" if [ "$(find "${SEEDS}" -maxdepth 1 -type f | wc -l)" -eq 0 ]; then printf 'A' > "${SEEDS}/seed-0" fi # ── Build ── rm -rf zig-out zig build ${BUILD_ARGS} # ── Locate fuzz binary ── if [ -n "${FUZZ_BINARY}" ]; then FUZZ_BIN="zig-out/bin/${FUZZ_BINARY}" else BIN_COUNT=$(find zig-out/bin -maxdepth 1 -type f -executable 2>/dev/null | wc -l) if [ "${BIN_COUNT}" -eq 0 ]; then echo "ERROR: No executable found in zig-out/bin/" exit 1 elif [ "${BIN_COUNT}" -gt 1 ]; then echo "ERROR: Multiple executables in zig-out/bin/, specify fuzz_binary input" find zig-out/bin -maxdepth 1 -type f -executable exit 1 fi FUZZ_BIN=$(find zig-out/bin -maxdepth 1 -type f -executable) fi if [ ! -x "${FUZZ_BIN}" ]; then echo "ERROR: Fuzz binary not found or not executable: ${FUZZ_BIN}" exit 1 fi echo "Fuzz binary: ${FUZZ_BIN}" # ── Minimize corpus ── SEED_COUNT=$(find "${SEEDS}" -maxdepth 1 -type f | wc -l) if [ "${SEED_COUNT}" -gt 1 ]; then echo "Minimizing corpus (${SEED_COUNT} inputs)..." CMIN_START=$(date +%s) MINIMIZED="afl-cmin-${TARGET_NUM}" rm -rf "${MINIMIZED}" mkdir -p "${MINIMIZED}" if afl-cmin -i "${SEEDS}" -o "${MINIMIZED}" -- "${FUZZ_BIN}" >/dev/null 2>&1; then CMIN_ELAPSED=$(( $(date +%s) - CMIN_START )) MINIMIZED_COUNT=$(find "${MINIMIZED}" -maxdepth 1 -type f | wc -l) echo "Corpus minimized: ${SEED_COUNT} -> ${MINIMIZED_COUNT} inputs (${CMIN_ELAPSED}s)" rm -rf "${SEEDS}" mv "${MINIMIZED}" "${SEEDS}" else echo "afl-cmin failed, using unminimized corpus" rm -rf "${MINIMIZED}" fi fi # ── Run AFL++ ── FINDINGS="findings-${TARGET_NUM}" rm -rf "${FINDINGS}" mkdir -p "${FINDINGS}" AFL_EXIT=0 { AFL_NO_UI=1 \ AFL_SKIP_CPUFREQ=1 \ AFL_I_DONT_CARE_ABOUT_MISSING_CRASHES=1 \ AFL_NO_CRASH_README=1 \ afl-fuzz \ -V "${DURATION}" \ -i "${SEEDS}" \ -o "${FINDINGS}" \ ${EXTRA_AFL_ARGS} \ -- "${FUZZ_BIN}" } >/dev/null 2>&1 || AFL_EXIT=$? if [ "${AFL_EXIT}" -eq 0 ]; then echo "AFL++ exited normally (completed run)" elif [ "${AFL_EXIT}" -eq 1 ]; then echo "AFL++ exited after reaching duration limit (${DURATION}s)" else echo "AFL++ exited with code ${AFL_EXIT}" fi # ── Upload crashes ── CRASH_DIR="${FINDINGS}/default/crashes" if [ -d "${CRASH_DIR}" ]; then for crash_file in "${CRASH_DIR}"/id:*; do [ -f "${crash_file}" ] || continue CRASH_NAME=$(basename "${crash_file}") SIG="" case "${CRASH_NAME}" in *,sig:*) SIG=$(echo "${CRASH_NAME}" | sed 's/.*,sig:\([0-9]*\).*/\1/') ;; esac # Replay the crash input to capture the stack trace. STACK_TRACE="" CRASH_MSG="AFL++ crash (${FUZZ_TARGET}): ${CRASH_NAME}" REPLAY_OUTPUT=$(timeout 10 "${FUZZ_BIN}" < "${crash_file}" 2>&1 || true) if [ -n "${REPLAY_OUTPUT}" ]; then STACK_TRACE="${REPLAY_OUTPUT}" # Extract a concise crash message from the first meaningful line. FIRST_LINE=$(echo "${REPLAY_OUTPUT}" | grep -m1 -iE 'panic|error|fault|abort|overflow|undefined|sanitizer|SUMMARY' || true) if [ -n "${FIRST_LINE}" ]; then CRASH_MSG="${FIRST_LINE}" fi fi echo "Uploading crash: ${CRASH_NAME}" set -- -server "${CAIRN_SERVER}" -repo "${REPO}" -owner "${OWNER}" \ -commit "${COMMIT}" -run-id "${RUN_ID}" -type fuzz -file "${crash_file}" \ -kind crash \ -crash-message "${CRASH_MSG}" if [ -n "${STACK_TRACE}" ]; then set -- "$@" -stack-trace "${STACK_TRACE}" fi if [ -n "${TARGET_PLATFORM}" ]; then set -- "$@" -target "${TARGET_PLATFORM}" fi if [ -n "${SIG}" ]; then set -- "$@" -signal "${SIG}" fi cairn upload "$@" TOTAL_CRASHES=$((TOTAL_CRASHES + 1)) done fi # ── Minimize and upload corpus ── QUEUE_DIR="${FINDINGS}/default/queue" if [ -d "${QUEUE_DIR}" ]; then QUEUE_COUNT=$(find "${QUEUE_DIR}" -maxdepth 1 -type f -name 'id:*' | wc -l) if [ "${QUEUE_COUNT}" -gt 0 ]; then UPLOAD_DIR="corpus-upload-${TARGET_NUM}" rm -rf "${UPLOAD_DIR}" mkdir -p "${UPLOAD_DIR}" CMIN_START=$(date +%s) if afl-cmin -i "${QUEUE_DIR}" -o "${UPLOAD_DIR}" -- "${FUZZ_BIN}" >/dev/null 2>&1; then CMIN_ELAPSED=$(( $(date +%s) - CMIN_START )) UPLOAD_COUNT=$(find "${UPLOAD_DIR}" -maxdepth 1 -type f | wc -l) echo "Corpus for upload minimized: ${QUEUE_COUNT} -> ${UPLOAD_COUNT} entries (${CMIN_ELAPSED}s)" else echo "afl-cmin failed, uploading full queue" rm -rf "${UPLOAD_DIR}" UPLOAD_DIR="${QUEUE_DIR}" UPLOAD_COUNT="${QUEUE_COUNT}" fi echo "Uploading corpus (${UPLOAD_COUNT} entries)..." cairn corpus upload \ -server "${CAIRN_SERVER}" \ -target-id "${CAIRN_TARGET_ID}" \ -run-id "${RUN_ID}" \ -dir "${UPLOAD_DIR}" rm -rf "corpus-upload-${TARGET_NUM}" fi fi # ── Finish run ── cairn run finish -server "${CAIRN_SERVER}" -id "${RUN_ID}" || true ACTIVE_RUN_ID="" done <