name: 'Cairn Zig Fuzz (AFL++)' description: 'Install AFL++, then build and run AFL++ fuzz targets, reporting crashes to Cairn. Each line in zig_build_args is a separate 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 }}' zig_build_args: description: | Line-delimited zig build argument sets. Each line builds and fuzzes one target. Example: fuzz -Dfuzz-target=lexer fuzz -Dfuzz-target=parser fuzz -Dfuzz-target=varint_decode required: true fuzz_binary: description: 'Binary name in zig-out/bin/ (auto-detected if only one)' required: false default: '' corpus_dir: description: 'Seed corpus directory (minimal seed created if empty)' 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; } if ! command -v jq >/dev/null 2>&1; then if command -v apt-get >/dev/null 2>&1; then apt-get update -qq apt-get install -y -qq jq >/dev/null 2>&1 else echo "ERROR: jq not found and apt-get unavailable" exit 1 fi fi 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 }} ZIG_BUILD_ARGS: ${{ inputs.zig_build_args }} FUZZ_BINARY: ${{ inputs.fuzz_binary }} CORPUS_DIR: ${{ inputs.corpus_dir }} DURATION: ${{ inputs.duration }} EXTRA_AFL_ARGS: ${{ inputs.afl_args }} TARGET: ${{ inputs.target }} run: | set -eu # ── Start Cairn campaign ── SHORT_SHA=$(printf '%.8s' "${COMMIT}") if [ -n "${TARGET}" ]; then CAMPAIGN_NAME="fuzz-${TARGET}" else CAMPAIGN_NAME="fuzz-${SHORT_SHA}" fi CAMPAIGN_OUTPUT=$(cairn campaign start \ -server "${CAIRN_SERVER}" \ -repo "${REPO}" \ -owner "${OWNER}" \ -name "${CAMPAIGN_NAME}" \ -type fuzzing) CAMPAIGN_ID="${CAMPAIGN_OUTPUT#Campaign started: }" echo "Campaign ${CAMPAIGN_ID} started" cleanup() { cairn campaign finish -server "${CAIRN_SERVER}" -id "${CAMPAIGN_ID}" || true } trap cleanup EXIT TOTAL_CRASHES=0 TARGET_NUM=0 # ── Iterate over each line of zig_build_args ── while IFS= read -r BUILD_ARGS; do # Skip empty lines and comments BUILD_ARGS=$(echo "${BUILD_ARGS}" | sed 's/#.*//' | xargs) [ -z "${BUILD_ARGS}" ] && continue TARGET_NUM=$((TARGET_NUM + 1)) echo "" echo "==========================================" echo "Target ${TARGET_NUM}: zig build ${BUILD_ARGS}" echo "==========================================" # Special-case Zig fuzz targets: if build args contain -Dfuzz-target=, # use that as the effective Cairn target for metadata/track keying. LINE_FUZZ_TARGET=$(printf '%s' "${BUILD_ARGS}" | sed -n 's/.*-Dfuzz-target=\([^[:space:]]*\).*/\1/p') EFFECTIVE_TARGET="${TARGET}" if [ -n "${LINE_FUZZ_TARGET}" ]; then EFFECTIVE_TARGET="${LINE_FUZZ_TARGET}" fi TRACK_SOURCE="${OWNER}/${REPO}|${EFFECTIVE_TARGET}|${BUILD_ARGS}" TRACK_KEY=$(printf '%s' "${TRACK_SOURCE}" | sha256sum | awk '{print $1}') echo "Track key: ${TRACK_KEY}" # ── 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}" # ── Seed corpus ── SEEDS="afl-seeds-${TARGET_NUM}" rm -rf "${SEEDS}" mkdir -p "${SEEDS}" if [ -n "${CORPUS_DIR}" ] && [ -d "${CORPUS_DIR}" ]; then cp -a "${CORPUS_DIR}/." "${SEEDS}/" 2>/dev/null || true fi LATEST_CORPUS_ID=$(curl -fsSL "${CAIRN_SERVER}/api/v1/artifacts?type=fuzz&limit=200" \ | jq -r --arg track "${TRACK_KEY}" --arg repo "${REPO}" \ '.artifacts[]? | select(.repo_name == $repo and (.metadata.kind // "") == "corpus" and (.metadata.track_key // "") == $track) | .id' \ | head -n1) if [ -n "${LATEST_CORPUS_ID}" ] && [ "${LATEST_CORPUS_ID}" != "null" ]; then echo "Downloading prior corpus artifact: ${LATEST_CORPUS_ID}" if cairn download -server "${CAIRN_SERVER}" -id "${LATEST_CORPUS_ID}" -o "prior-corpus-${TARGET_NUM}.tar.gz"; then tar xzf "prior-corpus-${TARGET_NUM}.tar.gz" -C "${SEEDS}" || true rm -f "prior-corpus-${TARGET_NUM}.tar.gz" fi fi if [ "$(find "${SEEDS}" -maxdepth 1 -type f | wc -l)" -eq 0 ]; then printf 'A' > "${SEEDS}/seed-0" 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}" \ || 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 echo "Uploading crash: ${CRASH_NAME}" set -- -server "${CAIRN_SERVER}" -repo "${REPO}" -owner "${OWNER}" \ -commit "${COMMIT}" -campaign-id "${CAMPAIGN_ID}" -type fuzz -file "${crash_file}" \ -kind crash -track-key "${TRACK_KEY}" \ -crash-message "AFL++ crash (${BUILD_ARGS}): ${CRASH_NAME}" if [ -n "${EFFECTIVE_TARGET}" ]; then set -- "$@" -target "${EFFECTIVE_TARGET}" fi if [ -n "${SIG}" ]; then set -- "$@" -signal "${SIG}" fi cairn upload "$@" TOTAL_CRASHES=$((TOTAL_CRASHES + 1)) done fi # ── 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 echo "Uploading corpus (${QUEUE_COUNT} entries)..." tar czf "corpus-${TARGET_NUM}.tar.gz" -C "${QUEUE_DIR}" . set -- -server "${CAIRN_SERVER}" -repo "${REPO}" -owner "${OWNER}" \ -commit "${COMMIT}" -campaign-id "${CAMPAIGN_ID}" -type fuzz \ -kind corpus -track-key "${TRACK_KEY}" \ -file "corpus-${TARGET_NUM}.tar.gz" if [ -n "${EFFECTIVE_TARGET}" ]; then set -- "$@" -target "${EFFECTIVE_TARGET}" fi cairn upload "$@" rm -f "corpus-${TARGET_NUM}.tar.gz" fi fi done <