Compare commits
11 Commits
54ec7cac27
...
ee58658648
| Author | SHA1 | Date |
|---|---|---|
|
|
ee58658648 | |
|
|
bbecd40ce9 | |
|
|
aa0c47d9e7 | |
|
|
98d4e904aa | |
|
|
f57231bdea | |
|
|
965f48a3e7 | |
|
|
7a47e66651 | |
|
|
5c542ff9c8 | |
|
|
c73b67f46e | |
|
|
57d6448179 | |
|
|
2370d666c0 |
|
|
@ -16,7 +16,7 @@ jobs:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.25.7"
|
go-version: "1.26.1"
|
||||||
- run: go mod download
|
- run: go mod download
|
||||||
- run: go test -v -race -coverprofile=coverage.out ./...
|
- run: go test -v -race -coverprofile=coverage.out ./...
|
||||||
- run: go tool cover -func=coverage.out
|
- run: go tool cover -func=coverage.out
|
||||||
|
|
@ -28,7 +28,7 @@ jobs:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.25.7"
|
go-version: "1.26.1"
|
||||||
- run: go vet ./...
|
- run: go vet ./...
|
||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -66,7 +66,7 @@ jobs:
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.25.7"
|
go-version: "1.26.1"
|
||||||
|
|
||||||
- name: Compute next version
|
- name: Compute next version
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ jobs:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.25.7"
|
go-version: "1.26.1"
|
||||||
- run: go mod download
|
- run: go mod download
|
||||||
- run: go test -v -race -coverprofile=coverage.out ./...
|
- run: go test -v -race -coverprofile=coverage.out ./...
|
||||||
- run: go tool cover -func=coverage.out
|
- run: go tool cover -func=coverage.out
|
||||||
|
|
@ -26,7 +26,7 @@ jobs:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.25.7"
|
go-version: "1.26.1"
|
||||||
- run: go vet ./...
|
- run: go vet ./...
|
||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -59,7 +59,7 @@ jobs:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.25.7"
|
go-version: "1.26.1"
|
||||||
- name: Build Go binaries
|
- name: Build Go binaries
|
||||||
run: |
|
run: |
|
||||||
CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o ./bin/cairn-server ./cmd/cairn-server
|
CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o ./bin/cairn-server ./cmd/cairn-server
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
FROM golang:1.25-alpine AS builder
|
FROM golang:1.26-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
name: 'Cairn Zig Fuzz (AFL++)'
|
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>`.'
|
description: 'Build and run Zig AFL++ fuzz targets, reporting crashes and corpus to Cairn. Each target is built twice: `zig build fuzz -Dfuzz-target=<name>` (AFL-instrumented) and `zig build fuzz-replay -Dfuzz-target=<name>` (plain, for crash replay).'
|
||||||
|
|
||||||
inputs:
|
inputs:
|
||||||
cairn_server:
|
cairn_server:
|
||||||
|
|
@ -24,7 +24,7 @@ inputs:
|
||||||
varint_decode
|
varint_decode
|
||||||
required: true
|
required: true
|
||||||
fuzz_binary:
|
fuzz_binary:
|
||||||
description: 'Binary name in zig-out/bin/ (auto-detected if only one)'
|
description: 'Binary name in the build output bin/ directory (auto-detected if only one)'
|
||||||
required: false
|
required: false
|
||||||
default: ''
|
default: ''
|
||||||
seed_dir:
|
seed_dir:
|
||||||
|
|
@ -90,65 +90,136 @@ runs:
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
echo "Cairn CLI version: $(cairn version)"
|
echo "Cairn CLI version: $(cairn version)"
|
||||||
|
NCPU=$(nproc 2>/dev/null || echo 1)
|
||||||
|
echo "Available cores: ${NCPU}"
|
||||||
|
|
||||||
TOTAL_CRASHES=0
|
RESULTS_DIR=$(mktemp -d)
|
||||||
TARGET_NUM=0
|
trap 'rm -rf "${RESULTS_DIR}"' EXIT
|
||||||
ACTIVE_RUN_ID=""
|
|
||||||
|
|
||||||
# Single EXIT trap that finishes whatever run is active.
|
# ── Collect target names ──
|
||||||
cleanup() {
|
TARGET_NAMES=()
|
||||||
if [ -n "${ACTIVE_RUN_ID}" ]; then
|
while IFS= read -r line; do
|
||||||
echo "Finishing run ${ACTIVE_RUN_ID} (cleanup)..."
|
line=$(echo "${line}" | sed 's/#.*//' | xargs)
|
||||||
cairn run finish -server "${CAIRN_SERVER}" -id "${ACTIVE_RUN_ID}" || true
|
[ -z "${line}" ] && continue
|
||||||
ACTIVE_RUN_ID=""
|
TARGET_NAMES+=("${line}")
|
||||||
fi
|
done <<EOF
|
||||||
}
|
${TARGETS}
|
||||||
trap cleanup EXIT
|
EOF
|
||||||
|
|
||||||
# ── Iterate over each target name ──
|
TARGET_COUNT=${#TARGET_NAMES[@]}
|
||||||
while IFS= read -r FUZZ_TARGET; do
|
echo "Targets: ${TARGET_COUNT}, parallel slots: ${NCPU}"
|
||||||
# Skip empty lines and comments
|
|
||||||
FUZZ_TARGET=$(echo "${FUZZ_TARGET}" | sed 's/#.*//' | xargs)
|
|
||||||
[ -z "${FUZZ_TARGET}" ] && continue
|
|
||||||
|
|
||||||
TARGET_NUM=$((TARGET_NUM + 1))
|
# ── Phase 1: Build all targets sequentially ──
|
||||||
BUILD_ARGS="fuzz -Dfuzz-target=${FUZZ_TARGET}"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo "Target ${TARGET_NUM}: ${FUZZ_TARGET} (zig build ${BUILD_ARGS})"
|
echo "Building ${TARGET_COUNT} target(s)"
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
|
|
||||||
# ── Ensure Cairn target ──
|
# Helper to find the single executable in a bin/ directory.
|
||||||
|
find_bin() {
|
||||||
|
local BIN_DIR="$1"
|
||||||
|
local LABEL="$2"
|
||||||
|
if [ -n "${FUZZ_BINARY}" ]; then
|
||||||
|
echo "${BIN_DIR}/${FUZZ_BINARY}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
local BIN
|
||||||
|
BIN=$(find "${BIN_DIR}" -maxdepth 1 -type f -executable)
|
||||||
|
local COUNT
|
||||||
|
COUNT=$(echo "${BIN}" | wc -l)
|
||||||
|
if [ "${COUNT}" -eq 0 ] || [ -z "${BIN}" ]; then
|
||||||
|
echo "ERROR: No executable found in ${BIN_DIR} (${LABEL})" >&2
|
||||||
|
return 1
|
||||||
|
elif [ "${COUNT}" -gt 1 ]; then
|
||||||
|
echo "ERROR: Multiple executables in ${BIN_DIR} (${LABEL}), specify fuzz_binary input" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "${BIN}"
|
||||||
|
}
|
||||||
|
|
||||||
|
declare -A TARGET_BINS
|
||||||
|
declare -A REPLAY_BINS
|
||||||
|
for i in "${!TARGET_NAMES[@]}"; do
|
||||||
|
FUZZ_TARGET="${TARGET_NAMES[$i]}"
|
||||||
|
NUM=$((i + 1))
|
||||||
|
WORK="work/${FUZZ_TARGET}"
|
||||||
|
mkdir -p "${WORK}"
|
||||||
|
|
||||||
|
echo "[${NUM}/${TARGET_COUNT}] Building ${FUZZ_TARGET}..."
|
||||||
|
|
||||||
|
# Each target gets its own output dir to avoid binary name collisions.
|
||||||
|
# 1) AFL-instrumented binary for fuzzing.
|
||||||
|
AFL_OUT="${WORK}/afl-out"
|
||||||
|
rm -rf "${AFL_OUT}"
|
||||||
|
zig build fuzz -Dfuzz-target="${FUZZ_TARGET}" --prefix "${AFL_OUT}"
|
||||||
|
|
||||||
|
FUZZ_BIN=$(find_bin "${AFL_OUT}/bin" "afl")
|
||||||
|
if [ ! -x "${FUZZ_BIN}" ]; then
|
||||||
|
echo "ERROR: Fuzz binary not found or not executable: ${FUZZ_BIN}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
TARGET_BINS["${FUZZ_TARGET}"]="${FUZZ_BIN}"
|
||||||
|
echo " AFL binary: ${FUZZ_BIN}"
|
||||||
|
|
||||||
|
# 2) Plain binary for crash replay (no AFL instrumentation).
|
||||||
|
REPLAY_OUT="${WORK}/replay-out"
|
||||||
|
rm -rf "${REPLAY_OUT}"
|
||||||
|
zig build fuzz-replay -Dfuzz-target="${FUZZ_TARGET}" --prefix "${REPLAY_OUT}"
|
||||||
|
|
||||||
|
REPLAY_BIN=$(find_bin "${REPLAY_OUT}/bin" "replay")
|
||||||
|
if [ ! -x "${REPLAY_BIN}" ]; then
|
||||||
|
echo "ERROR: Replay binary not found or not executable: ${REPLAY_BIN}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
REPLAY_BINS["${FUZZ_TARGET}"]="${REPLAY_BIN}"
|
||||||
|
echo " Replay binary: ${REPLAY_BIN}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Phase 2: Prepare seeds and Cairn targets (sequential, network I/O) ──
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Preparing corpus for ${TARGET_COUNT} target(s)"
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
declare -A CAIRN_TARGET_IDS
|
||||||
|
declare -A RUN_IDS
|
||||||
|
|
||||||
|
for i in "${!TARGET_NAMES[@]}"; do
|
||||||
|
FUZZ_TARGET="${TARGET_NAMES[$i]}"
|
||||||
|
NUM=$((i + 1))
|
||||||
|
FUZZ_BIN="${TARGET_BINS[${FUZZ_TARGET}]}"
|
||||||
|
SEEDS="work/${FUZZ_TARGET}/seeds"
|
||||||
|
mkdir -p "${SEEDS}"
|
||||||
|
|
||||||
|
echo "[${NUM}/${TARGET_COUNT}] Preparing ${FUZZ_TARGET}..."
|
||||||
|
|
||||||
|
# Ensure Cairn target.
|
||||||
CAIRN_TARGET_ID=$(cairn target ensure \
|
CAIRN_TARGET_ID=$(cairn target ensure \
|
||||||
-server "${CAIRN_SERVER}" \
|
-server "${CAIRN_SERVER}" \
|
||||||
-repo "${REPO}" \
|
-repo "${REPO}" \
|
||||||
-owner "${OWNER}" \
|
-owner "${OWNER}" \
|
||||||
-name "${FUZZ_TARGET}" \
|
-name "${FUZZ_TARGET}" \
|
||||||
-type fuzz)
|
-type fuzz)
|
||||||
echo "Cairn target ID: ${CAIRN_TARGET_ID}"
|
CAIRN_TARGET_IDS["${FUZZ_TARGET}"]="${CAIRN_TARGET_ID}"
|
||||||
|
|
||||||
# ── Start a run ──
|
# Start a run.
|
||||||
RUN_ID=$(cairn run start \
|
RUN_ID=$(cairn run start \
|
||||||
-server "${CAIRN_SERVER}" \
|
-server "${CAIRN_SERVER}" \
|
||||||
-target-id "${CAIRN_TARGET_ID}" \
|
-target-id "${CAIRN_TARGET_ID}" \
|
||||||
-commit "${COMMIT}")
|
-commit "${COMMIT}")
|
||||||
ACTIVE_RUN_ID="${RUN_ID}"
|
RUN_IDS["${FUZZ_TARGET}"]="${RUN_ID}"
|
||||||
echo "Run ID: ${RUN_ID}"
|
echo " Target ID: ${CAIRN_TARGET_ID}, Run ID: ${RUN_ID}"
|
||||||
|
|
||||||
# ── Download existing corpus ──
|
# Copy per-target seeds from repo.
|
||||||
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
|
if [ -n "${SEED_DIR}" ] && [ -d "${SEED_DIR}/${FUZZ_TARGET}" ]; then
|
||||||
REPO_SEED_COUNT=$(find "${SEED_DIR}/${FUZZ_TARGET}" -maxdepth 1 -type f | wc -l)
|
REPO_SEED_COUNT=$(find "${SEED_DIR}/${FUZZ_TARGET}" -maxdepth 1 -type f | wc -l)
|
||||||
if [ "${REPO_SEED_COUNT}" -gt 0 ]; then
|
if [ "${REPO_SEED_COUNT}" -gt 0 ]; then
|
||||||
cp "${SEED_DIR}/${FUZZ_TARGET}"/* "${SEEDS}/" 2>/dev/null || true
|
cp "${SEED_DIR}/${FUZZ_TARGET}"/* "${SEEDS}/" 2>/dev/null || true
|
||||||
echo "Copied ${REPO_SEED_COUNT} repo seeds from ${SEED_DIR}/${FUZZ_TARGET}"
|
echo " Copied ${REPO_SEED_COUNT} repo seeds"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Download existing corpus.
|
||||||
DL_START=$(date +%s)
|
DL_START=$(date +%s)
|
||||||
cairn corpus download \
|
cairn corpus download \
|
||||||
-server "${CAIRN_SERVER}" \
|
-server "${CAIRN_SERVER}" \
|
||||||
|
|
@ -156,64 +227,53 @@ runs:
|
||||||
-dir "${SEEDS}" || true
|
-dir "${SEEDS}" || true
|
||||||
DL_ELAPSED=$(( $(date +%s) - DL_START ))
|
DL_ELAPSED=$(( $(date +%s) - DL_START ))
|
||||||
DL_COUNT=$(find "${SEEDS}" -maxdepth 1 -type f | wc -l)
|
DL_COUNT=$(find "${SEEDS}" -maxdepth 1 -type f | wc -l)
|
||||||
echo "Downloaded ${DL_COUNT} corpus entries (${DL_ELAPSED}s)"
|
echo " Corpus: ${DL_COUNT} entries (${DL_ELAPSED}s)"
|
||||||
|
|
||||||
if [ "$(find "${SEEDS}" -maxdepth 1 -type f | wc -l)" -eq 0 ]; then
|
if [ "$(find "${SEEDS}" -maxdepth 1 -type f | wc -l)" -eq 0 ]; then
|
||||||
printf 'A' > "${SEEDS}/seed-0"
|
printf 'A' > "${SEEDS}/seed-0"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Build ──
|
# Minimize corpus.
|
||||||
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)
|
SEED_COUNT=$(find "${SEEDS}" -maxdepth 1 -type f | wc -l)
|
||||||
if [ "${SEED_COUNT}" -gt 1 ]; then
|
if [ "${SEED_COUNT}" -gt 1 ]; then
|
||||||
echo "Minimizing corpus (${SEED_COUNT} inputs)..."
|
echo " Minimizing corpus (${SEED_COUNT} inputs)..."
|
||||||
CMIN_START=$(date +%s)
|
CMIN_START=$(date +%s)
|
||||||
MINIMIZED="afl-cmin-${TARGET_NUM}"
|
MINIMIZED="work/${FUZZ_TARGET}/minimized"
|
||||||
rm -rf "${MINIMIZED}"
|
rm -rf "${MINIMIZED}"
|
||||||
mkdir -p "${MINIMIZED}"
|
mkdir -p "${MINIMIZED}"
|
||||||
if afl-cmin -i "${SEEDS}" -o "${MINIMIZED}" -- "${FUZZ_BIN}" >/dev/null 2>&1; then
|
if afl-cmin -i "${SEEDS}" -o "${MINIMIZED}" -- "${FUZZ_BIN}" >/dev/null 2>&1; then
|
||||||
CMIN_ELAPSED=$(( $(date +%s) - CMIN_START ))
|
CMIN_ELAPSED=$(( $(date +%s) - CMIN_START ))
|
||||||
MINIMIZED_COUNT=$(find "${MINIMIZED}" -maxdepth 1 -type f | wc -l)
|
MINIMIZED_COUNT=$(find "${MINIMIZED}" -maxdepth 1 -type f | wc -l)
|
||||||
echo "Corpus minimized: ${SEED_COUNT} -> ${MINIMIZED_COUNT} inputs (${CMIN_ELAPSED}s)"
|
echo " Minimized: ${SEED_COUNT} -> ${MINIMIZED_COUNT} inputs (${CMIN_ELAPSED}s)"
|
||||||
rm -rf "${SEEDS}"
|
rm -rf "${SEEDS}"
|
||||||
mv "${MINIMIZED}" "${SEEDS}"
|
mv "${MINIMIZED}" "${SEEDS}"
|
||||||
else
|
else
|
||||||
echo "afl-cmin failed, using unminimized corpus"
|
echo " afl-cmin failed, using unminimized corpus"
|
||||||
rm -rf "${MINIMIZED}"
|
rm -rf "${MINIMIZED}"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Phase 3: Fuzz all targets in parallel ──
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Fuzzing ${TARGET_COUNT} target(s) in parallel (${NCPU} cores, ${DURATION}s each)"
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
# Function that runs in a background subshell per target.
|
||||||
|
fuzz_target() {
|
||||||
|
local FUZZ_TARGET="$1"
|
||||||
|
local FUZZ_BIN="$2"
|
||||||
|
local SEEDS="$3"
|
||||||
|
local FINDINGS="$4"
|
||||||
|
local RESULT_FILE="$5"
|
||||||
|
local SEED_COUNT="$6"
|
||||||
|
|
||||||
# ── Run AFL++ ──
|
|
||||||
FINDINGS="findings-${TARGET_NUM}"
|
|
||||||
rm -rf "${FINDINGS}"
|
|
||||||
mkdir -p "${FINDINGS}"
|
mkdir -p "${FINDINGS}"
|
||||||
|
echo "[${FUZZ_TARGET}] Starting AFL++ (${SEED_COUNT} seeds, ${DURATION}s)"
|
||||||
|
|
||||||
AFL_EXIT=0
|
local FUZZ_START=$(date +%s)
|
||||||
|
local AFL_EXIT=0
|
||||||
{
|
{
|
||||||
AFL_NO_UI=1 \
|
AFL_NO_UI=1 \
|
||||||
AFL_SKIP_CPUFREQ=1 \
|
AFL_SKIP_CPUFREQ=1 \
|
||||||
|
|
@ -226,16 +286,76 @@ runs:
|
||||||
${EXTRA_AFL_ARGS} \
|
${EXTRA_AFL_ARGS} \
|
||||||
-- "${FUZZ_BIN}"
|
-- "${FUZZ_BIN}"
|
||||||
} >/dev/null 2>&1 || AFL_EXIT=$?
|
} >/dev/null 2>&1 || AFL_EXIT=$?
|
||||||
|
local FUZZ_ELAPSED=$(( $(date +%s) - FUZZ_START ))
|
||||||
|
|
||||||
if [ "${AFL_EXIT}" -eq 0 ]; then
|
# Summary from fuzzer_stats.
|
||||||
echo "AFL++ exited normally (completed run)"
|
local STATS_FILE="${FINDINGS}/default/fuzzer_stats"
|
||||||
elif [ "${AFL_EXIT}" -eq 1 ]; then
|
if [ -f "${STATS_FILE}" ]; then
|
||||||
echo "AFL++ exited after reaching duration limit (${DURATION}s)"
|
local EXECS=$(grep -oP 'execs_done\s*:\s*\K\d+' "${STATS_FILE}" || echo "?")
|
||||||
|
local PATHS=$(grep -oP 'corpus_count\s*:\s*\K\d+' "${STATS_FILE}" || echo "?")
|
||||||
|
local CRASHES=$(grep -oP 'saved_crashes\s*:\s*\K\d+' "${STATS_FILE}" || echo "?")
|
||||||
|
local SPEED=$(grep -oP 'execs_per_sec\s*:\s*\K[\d.]+' "${STATS_FILE}" || echo "?")
|
||||||
|
local COVERAGE=$(grep -oP 'bitmap_cvg\s*:\s*\K[\d.]+%' "${STATS_FILE}" || echo "?")
|
||||||
|
echo "[${FUZZ_TARGET}] Finished in ${FUZZ_ELAPSED}s: ${EXECS} execs (${SPEED}/s), ${PATHS} paths, ${CRASHES} crashes, ${COVERAGE} coverage (exit ${AFL_EXIT})"
|
||||||
else
|
else
|
||||||
echo "AFL++ exited with code ${AFL_EXIT}"
|
echo "[${FUZZ_TARGET}] AFL++ exited with code ${AFL_EXIT} after ${FUZZ_ELAPSED}s"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Upload crashes ──
|
# Count crashes for the result file.
|
||||||
|
local CRASH_COUNT=0
|
||||||
|
local CRASH_DIR="${FINDINGS}/default/crashes"
|
||||||
|
if [ -d "${CRASH_DIR}" ]; then
|
||||||
|
CRASH_COUNT=$(find "${CRASH_DIR}" -maxdepth 1 -type f -name 'id:*' | wc -l)
|
||||||
|
fi
|
||||||
|
echo "${CRASH_COUNT}" > "${RESULT_FILE}"
|
||||||
|
}
|
||||||
|
|
||||||
|
FUZZ_PHASE_START=$(date +%s)
|
||||||
|
PIDS=()
|
||||||
|
for i in "${!TARGET_NAMES[@]}"; do
|
||||||
|
FUZZ_TARGET="${TARGET_NAMES[$i]}"
|
||||||
|
FUZZ_BIN="${TARGET_BINS[${FUZZ_TARGET}]}"
|
||||||
|
SEEDS="work/${FUZZ_TARGET}/seeds"
|
||||||
|
FINDINGS="work/${FUZZ_TARGET}/findings"
|
||||||
|
RESULT_FILE="${RESULTS_DIR}/${FUZZ_TARGET}"
|
||||||
|
SEED_COUNT=$(find "${SEEDS}" -maxdepth 1 -type f | wc -l)
|
||||||
|
|
||||||
|
fuzz_target "${FUZZ_TARGET}" "${FUZZ_BIN}" "${SEEDS}" "${FINDINGS}" "${RESULT_FILE}" "${SEED_COUNT}" &
|
||||||
|
PIDS+=($!)
|
||||||
|
|
||||||
|
# Limit parallelism to available cores.
|
||||||
|
if [ "${#PIDS[@]}" -ge "${NCPU}" ]; then
|
||||||
|
wait "${PIDS[0]}"
|
||||||
|
PIDS=("${PIDS[@]:1}")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Wait for remaining jobs.
|
||||||
|
for pid in "${PIDS[@]}"; do
|
||||||
|
wait "${pid}"
|
||||||
|
done
|
||||||
|
FUZZ_PHASE_ELAPSED=$(( $(date +%s) - FUZZ_PHASE_START ))
|
||||||
|
echo "Fuzzing phase completed in ${FUZZ_PHASE_ELAPSED}s"
|
||||||
|
|
||||||
|
# ── Phase 4: Upload results sequentially ──
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Uploading results"
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
TOTAL_CRASHES=0
|
||||||
|
for i in "${!TARGET_NAMES[@]}"; do
|
||||||
|
FUZZ_TARGET="${TARGET_NAMES[$i]}"
|
||||||
|
NUM=$((i + 1))
|
||||||
|
FUZZ_BIN="${TARGET_BINS[${FUZZ_TARGET}]}"
|
||||||
|
REPLAY_BIN="${REPLAY_BINS[${FUZZ_TARGET}]}"
|
||||||
|
RUN_ID="${RUN_IDS[${FUZZ_TARGET}]}"
|
||||||
|
CAIRN_TARGET_ID="${CAIRN_TARGET_IDS[${FUZZ_TARGET}]}"
|
||||||
|
FINDINGS="work/${FUZZ_TARGET}/findings"
|
||||||
|
|
||||||
|
echo "[${NUM}/${TARGET_COUNT}] ${FUZZ_TARGET}..."
|
||||||
|
|
||||||
|
# Upload crashes.
|
||||||
CRASH_DIR="${FINDINGS}/default/crashes"
|
CRASH_DIR="${FINDINGS}/default/crashes"
|
||||||
if [ -d "${CRASH_DIR}" ]; then
|
if [ -d "${CRASH_DIR}" ]; then
|
||||||
for crash_file in "${CRASH_DIR}"/id:*; do
|
for crash_file in "${CRASH_DIR}"/id:*; do
|
||||||
|
|
@ -249,20 +369,23 @@ runs:
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# Replay the crash input to capture the stack trace.
|
# Replay using the non-AFL binary to get a proper stack trace.
|
||||||
STACK_TRACE=""
|
STACK_TRACE=""
|
||||||
CRASH_MSG="AFL++ crash (${FUZZ_TARGET}): ${CRASH_NAME}"
|
CRASH_MSG="AFL++ crash (${FUZZ_TARGET}): ${CRASH_NAME}"
|
||||||
REPLAY_OUTPUT=$(timeout 10 "${FUZZ_BIN}" < "${crash_file}" 2>&1 || true)
|
REPLAY_OUTPUT=$(timeout 10 "${REPLAY_BIN}" < "${crash_file}" 2>&1 || true)
|
||||||
if [ -n "${REPLAY_OUTPUT}" ]; then
|
if [ -n "${REPLAY_OUTPUT}" ]; then
|
||||||
STACK_TRACE="${REPLAY_OUTPUT}"
|
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)
|
FIRST_LINE=$(echo "${REPLAY_OUTPUT}" | grep -m1 -iE 'panic|error|fault|abort|overflow|undefined|sanitizer|SUMMARY' || true)
|
||||||
if [ -n "${FIRST_LINE}" ]; then
|
if [ -n "${FIRST_LINE}" ]; then
|
||||||
CRASH_MSG="${FIRST_LINE}"
|
CRASH_MSG="${FIRST_LINE}"
|
||||||
fi
|
fi
|
||||||
|
echo " Replay (${CRASH_NAME}):"
|
||||||
|
echo "${REPLAY_OUTPUT}" | head -10 | sed 's/^/ /'
|
||||||
|
else
|
||||||
|
echo " Replay produced no output for ${CRASH_NAME}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Uploading crash: ${CRASH_NAME}"
|
echo " Uploading crash: ${CRASH_NAME}"
|
||||||
set -- -server "${CAIRN_SERVER}" -repo "${REPO}" -owner "${OWNER}" \
|
set -- -server "${CAIRN_SERVER}" -repo "${REPO}" -owner "${OWNER}" \
|
||||||
-commit "${COMMIT}" -run-id "${RUN_ID}" -type fuzz -file "${crash_file}" \
|
-commit "${COMMIT}" -run-id "${RUN_ID}" -type fuzz -file "${crash_file}" \
|
||||||
-kind crash \
|
-kind crash \
|
||||||
|
|
@ -283,47 +406,42 @@ runs:
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Minimize and upload corpus ──
|
# Minimize and upload corpus.
|
||||||
QUEUE_DIR="${FINDINGS}/default/queue"
|
QUEUE_DIR="${FINDINGS}/default/queue"
|
||||||
if [ -d "${QUEUE_DIR}" ]; then
|
if [ -d "${QUEUE_DIR}" ]; then
|
||||||
QUEUE_COUNT=$(find "${QUEUE_DIR}" -maxdepth 1 -type f -name 'id:*' | wc -l)
|
QUEUE_COUNT=$(find "${QUEUE_DIR}" -maxdepth 1 -type f -name 'id:*' | wc -l)
|
||||||
if [ "${QUEUE_COUNT}" -gt 0 ]; then
|
if [ "${QUEUE_COUNT}" -gt 0 ]; then
|
||||||
UPLOAD_DIR="corpus-upload-${TARGET_NUM}"
|
UPLOAD_DIR="work/${FUZZ_TARGET}/corpus-upload"
|
||||||
rm -rf "${UPLOAD_DIR}"
|
rm -rf "${UPLOAD_DIR}"
|
||||||
mkdir -p "${UPLOAD_DIR}"
|
mkdir -p "${UPLOAD_DIR}"
|
||||||
CMIN_START=$(date +%s)
|
CMIN_START=$(date +%s)
|
||||||
if afl-cmin -i "${QUEUE_DIR}" -o "${UPLOAD_DIR}" -- "${FUZZ_BIN}" >/dev/null 2>&1; then
|
if afl-cmin -i "${QUEUE_DIR}" -o "${UPLOAD_DIR}" -- "${FUZZ_BIN}" >/dev/null 2>&1; then
|
||||||
CMIN_ELAPSED=$(( $(date +%s) - CMIN_START ))
|
CMIN_ELAPSED=$(( $(date +%s) - CMIN_START ))
|
||||||
UPLOAD_COUNT=$(find "${UPLOAD_DIR}" -maxdepth 1 -type f | wc -l)
|
UPLOAD_COUNT=$(find "${UPLOAD_DIR}" -maxdepth 1 -type f | wc -l)
|
||||||
echo "Corpus for upload minimized: ${QUEUE_COUNT} -> ${UPLOAD_COUNT} entries (${CMIN_ELAPSED}s)"
|
echo " Corpus minimized: ${QUEUE_COUNT} -> ${UPLOAD_COUNT} entries (${CMIN_ELAPSED}s)"
|
||||||
else
|
else
|
||||||
echo "afl-cmin failed, uploading full queue"
|
echo " afl-cmin failed, uploading full queue"
|
||||||
rm -rf "${UPLOAD_DIR}"
|
rm -rf "${UPLOAD_DIR}"
|
||||||
UPLOAD_DIR="${QUEUE_DIR}"
|
UPLOAD_DIR="${QUEUE_DIR}"
|
||||||
UPLOAD_COUNT="${QUEUE_COUNT}"
|
UPLOAD_COUNT="${QUEUE_COUNT}"
|
||||||
fi
|
fi
|
||||||
echo "Uploading corpus (${UPLOAD_COUNT} entries)..."
|
echo " Uploading corpus (${UPLOAD_COUNT} entries)..."
|
||||||
cairn corpus upload \
|
cairn corpus upload \
|
||||||
-server "${CAIRN_SERVER}" \
|
-server "${CAIRN_SERVER}" \
|
||||||
-target-id "${CAIRN_TARGET_ID}" \
|
-target-id "${CAIRN_TARGET_ID}" \
|
||||||
-run-id "${RUN_ID}" \
|
-run-id "${RUN_ID}" \
|
||||||
-dir "${UPLOAD_DIR}"
|
-dir "${UPLOAD_DIR}"
|
||||||
rm -rf "corpus-upload-${TARGET_NUM}"
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Finish run ──
|
# Finish run.
|
||||||
cairn run finish -server "${CAIRN_SERVER}" -id "${RUN_ID}" || true
|
cairn run finish -server "${CAIRN_SERVER}" -id "${RUN_ID}" || true
|
||||||
ACTIVE_RUN_ID=""
|
done
|
||||||
|
|
||||||
done <<EOF
|
|
||||||
${TARGETS}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# ── Final report ──
|
# ── Final report ──
|
||||||
echo ""
|
echo ""
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo "Fuzzed ${TARGET_NUM} target(s), ${TOTAL_CRASHES} total crash(es)"
|
echo "Fuzzed ${TARGET_COUNT} target(s), ${TOTAL_CRASHES} total crash(es)"
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
|
|
||||||
if [ "${TOTAL_CRASHES}" -gt 0 ]; then
|
if [ "${TOTAL_CRASHES}" -gt 0 ]; then
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ func main() {
|
||||||
Store: store,
|
Store: store,
|
||||||
ForgejoClient: forgejoClient,
|
ForgejoClient: forgejoClient,
|
||||||
ForgejoURL: cfg.ForgejoURL,
|
ForgejoURL: cfg.ForgejoURL,
|
||||||
|
CairnURL: cfg.ExternalURL,
|
||||||
WebhookSecret: cfg.ForgejoWebhookSecret,
|
WebhookSecret: cfg.ForgejoWebhookSecret,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
2
go.mod
2
go.mod
|
|
@ -1,6 +1,6 @@
|
||||||
module github.com/mattnite/cairn
|
module github.com/mattnite/cairn
|
||||||
|
|
||||||
go 1.25.7
|
go 1.26.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
|
|
|
||||||
|
|
@ -111,5 +111,6 @@ type CrashGroup struct {
|
||||||
RepoName string `json:"repo_name,omitempty"`
|
RepoName string `json:"repo_name,omitempty"`
|
||||||
Fingerprint string `json:"fingerprint,omitempty"`
|
Fingerprint string `json:"fingerprint,omitempty"`
|
||||||
OccurrenceCount uint `json:"occurrence_count,omitempty"`
|
OccurrenceCount uint `json:"occurrence_count,omitempty"`
|
||||||
|
SampleTrace *string `json:"sample_trace,omitempty"`
|
||||||
ForgejoIssueURL *string `json:"forgejo_issue_url,omitempty"`
|
ForgejoIssueURL *string `json:"forgejo_issue_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ type Config struct {
|
||||||
S3SecretKey string `envconfig:"CAIRN_S3_SECRET_KEY" default:"minioadmin"`
|
S3SecretKey string `envconfig:"CAIRN_S3_SECRET_KEY" default:"minioadmin"`
|
||||||
S3UseSSL bool `envconfig:"CAIRN_S3_USE_SSL" default:"false"`
|
S3UseSSL bool `envconfig:"CAIRN_S3_USE_SSL" default:"false"`
|
||||||
|
|
||||||
|
ExternalURL string `envconfig:"CAIRN_EXTERNAL_URL"`
|
||||||
ForgejoURL string `envconfig:"CAIRN_FORGEJO_URL"`
|
ForgejoURL string `envconfig:"CAIRN_FORGEJO_URL"`
|
||||||
ForgejoToken string `envconfig:"CAIRN_FORGEJO_TOKEN"`
|
ForgejoToken string `envconfig:"CAIRN_FORGEJO_TOKEN"`
|
||||||
ForgejoWebhookSecret string `envconfig:"CAIRN_FORGEJO_WEBHOOK_SECRET"`
|
ForgejoWebhookSecret string `envconfig:"CAIRN_FORGEJO_WEBHOOK_SECRET"`
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import (
|
||||||
type Sync struct {
|
type Sync struct {
|
||||||
Client *Client
|
Client *Client
|
||||||
DB *gorm.DB
|
DB *gorm.DB
|
||||||
|
CairnURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateIssueForCrashGroup creates a Forgejo issue for a new crash group.
|
// CreateIssueForCrashGroup creates a Forgejo issue for a new crash group.
|
||||||
|
|
@ -29,8 +30,14 @@ func (s *Sync) CreateIssueForCrashGroup(ctx context.Context, group *cairnapi.Cra
|
||||||
return fmt.Errorf("getting repository: %w", err)
|
return fmt.Errorf("getting repository: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
crashGroupLink := fmt.Sprintf("%d", group.ID)
|
||||||
|
if s.CairnURL != "" {
|
||||||
|
crashGroupLink = fmt.Sprintf("[%d](%s/crashgroups/%d)", group.ID, strings.TrimRight(s.CairnURL, "/"), group.ID)
|
||||||
|
}
|
||||||
|
|
||||||
body := fmt.Sprintf(`## Crash Group
|
body := fmt.Sprintf(`## Crash Group
|
||||||
|
|
||||||
|
**Crash Group:** %s
|
||||||
**Fingerprint:** `+"`%s`"+`
|
**Fingerprint:** `+"`%s`"+`
|
||||||
**First seen:** %s
|
**First seen:** %s
|
||||||
**Type:** %s
|
**Type:** %s
|
||||||
|
|
@ -43,7 +50,7 @@ func (s *Sync) CreateIssueForCrashGroup(ctx context.Context, group *cairnapi.Cra
|
||||||
|
|
||||||
---
|
---
|
||||||
*Auto-created by [Cairn](/) — crash artifact aggregator*
|
*Auto-created by [Cairn](/) — crash artifact aggregator*
|
||||||
`, group.Fingerprint, group.FirstSeenAt.Format("2006-01-02 15:04:05"), group.Title, sampleTrace)
|
`, crashGroupLink, group.Fingerprint, group.FirstSeenAt.Format("2006-01-02 15:04:05"), group.Title, sampleTrace)
|
||||||
|
|
||||||
issue, err := s.Client.CreateIssue(ctx, repo.Owner, repo.Name, CreateIssueRequest{
|
issue, err := s.Client.CreateIssue(ctx, repo.Owner, repo.Name, CreateIssueRequest{
|
||||||
Title: "[Cairn] " + group.Title,
|
Title: "[Cairn] " + group.Title,
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,14 @@ func (h *IngestHandler) Create(c *gin.Context) {
|
||||||
if len(result.Frames) > 0 && strings.TrimSpace(result.Frames[0].Function) != "" {
|
if len(result.Frames) > 0 && strings.TrimSpace(result.Frames[0].Function) != "" {
|
||||||
title = req.Type + ": " + result.Frames[0].Function
|
title = req.Type + ": " + result.Frames[0].Function
|
||||||
}
|
}
|
||||||
|
shortFP := result.Fingerprint
|
||||||
|
if len(shortFP) > 8 {
|
||||||
|
shortFP = shortFP[:8]
|
||||||
|
}
|
||||||
|
if req.CrashMessage != "" {
|
||||||
|
title += " — " + req.CrashMessage
|
||||||
|
}
|
||||||
|
title += " [" + shortFP + "]"
|
||||||
group, groupErr := models.CreateCrashGroup(ctx, h.DB, sig.ID, repo.ID, title)
|
group, groupErr := models.CreateCrashGroup(ctx, h.DB, sig.ID, repo.ID, title)
|
||||||
if groupErr == nil && h.ForgejoSync != nil {
|
if groupErr == nil && h.ForgejoSync != nil {
|
||||||
_ = h.ForgejoSync.CreateIssueForCrashGroup(ctx, group, req.StackTrace)
|
_ = h.ForgejoSync.CreateIssueForCrashGroup(ctx, group, req.StackTrace)
|
||||||
|
|
|
||||||
|
|
@ -246,7 +246,10 @@ func (h *CorpusHandler) Upload(c *gin.Context) {
|
||||||
runID = &uid
|
runID = &uid
|
||||||
}
|
}
|
||||||
|
|
||||||
blobKey := fmt.Sprintf("corpus/%s/%s/%s", target.RepoName, target.Name, header.Filename)
|
// Use a unique prefix to avoid filename collisions across runs.
|
||||||
|
var entryCount int64
|
||||||
|
h.DB.WithContext(ctx).Model(&models.CorpusEntry{}).Where("target_id = ?", targetID).Count(&entryCount)
|
||||||
|
blobKey := fmt.Sprintf("corpus/%s/%s/%d-%s", target.RepoName, target.Name, entryCount, header.Filename)
|
||||||
|
|
||||||
if err := h.Store.Put(ctx, blobKey, file, header.Size); err != nil {
|
if err := h.Store.Put(ctx, blobKey, file, header.Size); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "storing blob: " + err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "storing blob: " + err.Error()})
|
||||||
|
|
@ -362,7 +365,7 @@ func (h *CorpusHandler) DownloadAll(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
hdr := &tar.Header{
|
hdr := &tar.Header{
|
||||||
Name: filepath.Base(entry.BlobKey),
|
Name: fmt.Sprintf("%d-%s", entry.ID, filepath.Base(entry.BlobKey)),
|
||||||
Mode: 0o644,
|
Mode: 0o644,
|
||||||
Size: int64(len(data)),
|
Size: int64(len(data)),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,7 @@ func enrichCrashGroup(ctx context.Context, db *gorm.DB, model CrashGroup) (*cair
|
||||||
group.RepoName = repo.Name
|
group.RepoName = repo.Name
|
||||||
group.Fingerprint = sig.Fingerprint
|
group.Fingerprint = sig.Fingerprint
|
||||||
group.OccurrenceCount = sig.OccurrenceCount
|
group.OccurrenceCount = sig.OccurrenceCount
|
||||||
|
group.SampleTrace = sig.SampleTrace
|
||||||
return &group, nil
|
return &group, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ type RouterConfig struct {
|
||||||
Store blob.Store
|
Store blob.Store
|
||||||
ForgejoClient *forgejo.Client
|
ForgejoClient *forgejo.Client
|
||||||
ForgejoURL string
|
ForgejoURL string
|
||||||
|
CairnURL string
|
||||||
WebhookSecret string
|
WebhookSecret string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,7 +27,7 @@ func NewRouter(cfg RouterConfig) (*gin.Engine, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
forgejoSync := &forgejo.Sync{Client: cfg.ForgejoClient, DB: cfg.DB}
|
forgejoSync := &forgejo.Sync{Client: cfg.ForgejoClient, DB: cfg.DB, CairnURL: cfg.CairnURL}
|
||||||
|
|
||||||
pages := &PageHandler{DB: cfg.DB, Templates: templates, ForgejoURL: cfg.ForgejoURL}
|
pages := &PageHandler{DB: cfg.DB, Templates: templates, ForgejoURL: cfg.ForgejoURL}
|
||||||
ingest := &handler.IngestHandler{DB: cfg.DB, Store: cfg.Store, ForgejoSync: forgejoSync}
|
ingest := &handler.IngestHandler{DB: cfg.DB, Store: cfg.Store, ForgejoSync: forgejoSync}
|
||||||
|
|
|
||||||
|
|
@ -445,6 +445,12 @@ code {
|
||||||
box-shadow: 0 0 10px var(--accent-glow-soft);
|
box-shadow: 0 0 10px var(--accent-glow-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ======= CRASH GROUP LIST ======= */
|
||||||
|
.crashgroup-row { cursor: pointer; }
|
||||||
|
.trace-row td { padding: 0 !important; border-bottom: 1px solid var(--border); }
|
||||||
|
.trace-row .code-block { margin: 0.5rem 0.75rem; max-height: 300px; overflow-y: auto; }
|
||||||
|
.trace-row:hover td { background: none; box-shadow: none; }
|
||||||
|
|
||||||
/* ======= CRASH GROUP DETAIL ======= */
|
/* ======= CRASH GROUP DETAIL ======= */
|
||||||
.crashgroup-title {
|
.crashgroup-title {
|
||||||
font-family: var(--font-heading);
|
font-family: var(--font-heading);
|
||||||
|
|
|
||||||
|
|
@ -19,18 +19,34 @@
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .CrashGroups}}
|
{{range .CrashGroups}}
|
||||||
<tr>
|
<tr class="crashgroup-row" onclick="toggleTrace(this)">
|
||||||
<td><span class="badge badge-status-{{.Status}}">{{.Status}}</span></td>
|
<td><span class="badge badge-status-{{.Status}}">{{.Status}}</span></td>
|
||||||
<td>{{.Title}}</td>
|
<td>{{.Title}}</td>
|
||||||
<td>{{.RepoName}}</td>
|
<td>{{.RepoName}}</td>
|
||||||
<td>{{.OccurrenceCount}}</td>
|
<td>{{.OccurrenceCount}}</td>
|
||||||
<td>{{timeAgo .FirstSeenAt}}</td>
|
<td>{{timeAgo .FirstSeenAt}}</td>
|
||||||
<td>{{timeAgo .LastSeenAt}}</td>
|
<td>{{timeAgo .LastSeenAt}}</td>
|
||||||
<td><a href="/crashgroups/{{.ID}}" class="btn btn-sm">View</a></td>
|
<td><a href="/crashgroups/{{.ID}}" class="btn btn-sm" onclick="event.stopPropagation()">View</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{{if .SampleTrace}}
|
||||||
|
<tr class="trace-row" style="display: none;">
|
||||||
|
<td colspan="7">
|
||||||
|
<div class="code-block">{{.SampleTrace}}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleTrace(row) {
|
||||||
|
var traceRow = row.nextElementSibling;
|
||||||
|
if (traceRow && traceRow.classList.contains('trace-row')) {
|
||||||
|
traceRow.style.display = traceRow.style.display === 'none' ? '' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="empty-state">No crash groups yet. Crash groups are created automatically when artifacts with stack traces are uploaded.</p>
|
<p class="empty-state">No crash groups yet. Crash groups are created automatically when artifacts with stack traces are uploaded.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue