From b9e1c1ab78df92e578816ea77f5e8d83733f3831 Mon Sep 17 00:00:00 2001 From: Matthew Knight Date: Tue, 3 Mar 2026 10:56:00 -0800 Subject: [PATCH] Add actions --- actions/cairn-zig-fuzz/action.yml | 242 ++++++++++++++++++++++++++++++ actions/setup-afl/action.yml | 83 ++++++++++ actions/setup-cairn/action.yml | 44 ++++++ 3 files changed, 369 insertions(+) create mode 100644 actions/cairn-zig-fuzz/action.yml create mode 100644 actions/setup-afl/action.yml create mode 100644 actions/setup-cairn/action.yml diff --git a/actions/cairn-zig-fuzz/action.yml b/actions/cairn-zig-fuzz/action.yml new file mode 100644 index 0000000..98f4678 --- /dev/null +++ b/actions/cairn-zig-fuzz/action.yml @@ -0,0 +1,242 @@ +name: 'Cairn Zig Fuzz (AFL++)' +description: 'Build and run AFL++ fuzz targets, report 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: 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. Install AFL++ before using this action (e.g. mattnite/cairn/actions/setup-afl@main)."; exit 1; } + echo "zig $(zig version), afl-cc found" + + - name: Setup Cairn CLI + uses: 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 }} + AFL_ARGS: ${{ inputs.afl_args }} + TARGET: ${{ inputs.target }} + run: | + set -eu + + # ── Start Cairn campaign ── + SHORT_SHA=$(printf '%.8s' "${COMMIT}") + CAMPAIGN_OUTPUT=$(cairn campaign start \ + -server "${CAIRN_SERVER}" \ + -repo "${REPO}" \ + -owner "${OWNER}" \ + -name "fuzz-${SHORT_SHA}" \ + -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 "==========================================" + + # ── 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 ── + if [ -n "${CORPUS_DIR}" ] && [ -d "${CORPUS_DIR}" ]; then + SEEDS="${CORPUS_DIR}" + else + SEEDS="afl-seeds" + mkdir -p "${SEEDS}" + 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_EXIT_ON_TIME=60 \ + timeout "${DURATION}s" \ + afl-fuzz \ + -i "${SEEDS}" \ + -o "${FINDINGS}" \ + ${AFL_ARGS} \ + -- "${FUZZ_BIN}" \ + || AFL_EXIT=$? + + if [ "${AFL_EXIT}" -eq 124 ]; then + echo "AFL++ ran for full duration (${DURATION}s)" + elif [ "${AFL_EXIT}" -eq 0 ]; then + echo "AFL++ exited normally (coverage stagnated)" + 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}" -type fuzz -file "${crash_file}" \ + -crash-message "AFL++ crash (${BUILD_ARGS}): ${CRASH_NAME}" + + if [ -n "${TARGET}" ]; then + set -- "$@" -target "${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}" -type fuzz -file "corpus-${TARGET_NUM}.tar.gz" + if [ -n "${TARGET}" ]; then + set -- "$@" -target "${TARGET}" + fi + + cairn upload "$@" + rm -f "corpus-${TARGET_NUM}.tar.gz" + fi + fi + + done </dev/null | wc -l)" -gt 0 ]; then + echo "Installing LLVM 20 from cached debs..." + dpkg -i /opt/llvm-20-debs/*.deb 2>/dev/null || apt-get install -f -y -qq >/dev/null 2>&1 + else + echo "Installing LLVM 20 from apt.llvm.org..." + apt-get update -qq + apt-get install -y -qq wget gnupg lsb-release >/dev/null 2>&1 + wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key \ + | tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc >/dev/null + echo "deb http://apt.llvm.org/$(lsb_release -cs)/ llvm-toolchain-$(lsb_release -cs)-20 main" \ + > /etc/apt/sources.list.d/llvm-20.list + apt-get update -qq + apt-get install -y -qq clang-20 llvm-20-dev zlib1g-dev make git >/dev/null 2>&1 + + # Cache the downloaded debs for next run + mkdir -p /opt/llvm-20-debs + cp /var/cache/apt/archives/*llvm*20* /var/cache/apt/archives/*clang*20* \ + /var/cache/apt/archives/*lib*20* /opt/llvm-20-debs/ 2>/dev/null || true + fi + + # Ensure unversioned symlinks exist + ln -sf /usr/bin/llvm-config-20 /usr/bin/llvm-config + ln -sf /usr/bin/clang-20 /usr/bin/clang + ln -sf /usr/bin/clang++-20 /usr/bin/clang++ + echo "LLVM $(llvm-config --version) ready" + + - name: Install AFL++ + shell: bash + run: | + set -eu + + if [ "${{ steps.afl-cache.outputs.cache-hit }}" = "true" ]; then + echo "Restoring AFL++ from cache..." + cp -a /opt/afl/bin/* /usr/local/bin/ + cp -a /opt/afl/lib/* /usr/local/lib/ 2>/dev/null || true + cp -a /opt/afl/share/* /usr/local/share/ 2>/dev/null || true + echo "AFL++ restored from cache" + else + echo "Building AFL++ from source..." + apt-get install -y -qq make git zlib1g-dev >/dev/null 2>&1 + + cd /tmp + git clone --depth 1 https://github.com/AFLplusplus/AFLplusplus.git + cd AFLplusplus + LLVM_CONFIG=llvm-config-20 CC=clang-20 CXX=clang++-20 \ + make -j"$(nproc)" source-only >/dev/null 2>&1 + make install PREFIX=/usr/local >/dev/null 2>&1 + + # Cache the install for next run + mkdir -p /opt/afl + make install PREFIX=/opt/afl >/dev/null 2>&1 + + rm -rf /tmp/AFLplusplus + fi + + echo "AFL++ $(afl-fuzz --version 2>&1 | head -1) ready" diff --git a/actions/setup-cairn/action.yml b/actions/setup-cairn/action.yml new file mode 100644 index 0000000..4ae2c42 --- /dev/null +++ b/actions/setup-cairn/action.yml @@ -0,0 +1,44 @@ +name: 'Setup Cairn CLI' +description: 'Downloads and caches the Cairn CLI tool' + +inputs: + version: + description: 'Cairn CLI version' + required: false + default: 'latest' + server_url: + description: 'Cairn server URL (exported as CAIRN_SERVER_URL)' + required: false + +runs: + using: 'composite' + steps: + - name: Setup Cairn CLI + shell: sh + env: + CAIRN_VERSION: ${{ inputs.version }} + CAIRN_SERVER_URL: ${{ inputs.server_url }} + run: | + set -eu + + CACHE_DIR="${RUNNER_TOOL_CACHE:-/tmp/cairn-tool-cache}" + TOOL_DIR="${CACHE_DIR}/cairn/${CAIRN_VERSION}/x64" + + if [ -f "${TOOL_DIR}/cairn" ] && [ -f "${TOOL_DIR}/.complete" ]; then + echo "Cairn CLI ${CAIRN_VERSION} found in tool cache" + else + echo "Downloading Cairn CLI ${CAIRN_VERSION}..." + mkdir -p "${TOOL_DIR}" + curl -sfL \ + "${{ github.server_url }}/api/packages/${{ github.repository_owner }}/generic/cairn/${CAIRN_VERSION}/cairn-x86_64-linux" \ + -o "${TOOL_DIR}/cairn" + chmod +x "${TOOL_DIR}/cairn" + touch "${TOOL_DIR}/.complete" + echo "Cairn CLI ${CAIRN_VERSION} installed" + fi + + echo "${TOOL_DIR}" >> "${GITHUB_PATH}" + + if [ -n "${CAIRN_SERVER_URL}" ]; then + echo "CAIRN_SERVER_URL=${CAIRN_SERVER_URL}" >> "${GITHUB_ENV}" + fi