cairn/actions/cairn-zig-fuzz-afl/action.yml

335 lines
12 KiB
YAML

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=<name>`.'
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=<name>.
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 <<EOF
${TARGETS}
EOF
# ── Final report ──
echo ""
echo "=========================================="
echo "Fuzzed ${TARGET_NUM} target(s), ${TOTAL_CRASHES} total crash(es)"
echo "=========================================="
if [ "${TOTAL_CRASHES}" -gt 0 ]; then
echo "FAIL: ${TOTAL_CRASHES} crash(es) found"
exit 1
fi
echo "OK: No crashes found"