| #!/bin/sh
#
# Coverage-guided fuzzing of npro's fuzz targets with libFuzzer, by hand or
# under sai. docs/fuzzing.md says what the targets check, and how to read
# and replay what this finds.
#
# scripts/fuzz.sh # 60s per target, every target
# scripts/fuzz.sh 600 # 10 minutes per target
# scripts/fuzz.sh 600 utf8 sha1 # only the named targets
# BUILD=~/npro-fuzz scripts/fuzz.sh # build somewhere else
# CORPUS=~/corpus scripts/fuzz.sh # keep the corpora somewhere lasting
#
# The targets are the bins of the fuzz/ workspace, built by cargo-fuzz with
# nightly, AddressSanitizer and debug assertions. FUZZ_OPTS adds libFuzzer
# flags, eg, FUZZ_OPTS=-verbosity=0 to leave out its line for every new
# unit and its periodic status, as scripts/sai.sh does for sai's logs.
#
# Each target's corpus is <corpus>/corpus-<target>, grown from its seeds in
# fuzz/seeds/<target>/ (and, for transcript, the vendored transcripts).
# <corpus> is <build>/corpus unless CORPUS says otherwise.
#
# This follows the C tree's fuzz/run.sh, in how it works with sai:
#
# - Under a sai idle task (sai's READMEs/README-idle.md), SAI_IDLE_SECS is
# the length of the slice, build included. The time left after the build
# is shared by as many targets as can each have IDLE_MIN_TARGET_SECS,
# taking turns across slices.
#
# - Under a configuration naming a pool (README-pool.md), SAI_POOL_DIR is
# kept synced with every builder fuzzing npro, and the corpora live there.
# The first time, any corpora CORPUS already held are copied in.
#
# - With the pool come SAI_POOL_KNOWN, the reproducers of the bugs found so
# far, and SAI_POOL_FINDINGS, where findings go to sai-server
# (README-findings.md). A CI run (not an idle slice) first replays each
# target's known reproducers and fails if any still crash. Reports go
# only to sai-server, which shows them only to admins: they can be
# unfixed security bugs, and CI logs are public. An idle slice reports
# its findings that way, but does not fail.
#
# Findings (crash-*, leak-*, timeout-*, oom-*, slow-unit-*) are written into
# <build>/artifacts/ with each target's output in log-<target>.txt; those
# this run produced are listed at the end, and make it exit nonzero.
set -eu
REPO=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$REPO"
# rustup's tools, for the user the builder runs as
PATH="$HOME/.cargo/bin:$PATH"
export PATH
. scripts/require.sh
require_rust
require_toolchain nightly "rustup toolchain install nightly --profile minimal"
require_cargo fuzz "cargo install --locked cargo-fuzz"
require_cargo deny "cargo install --locked cargo-deny"
require_cmd c++ "dnf install gcc-c++ (or apt install g++): libFuzzer's runtime is C++"
require_done
BUILD="${BUILD:-${CARGO_TARGET_DIR:-$REPO/target}/fuzz}"
CORPUS="${CORPUS:-$BUILD/corpus}"
ARTIFACTS="$BUILD/artifacts"
if [ -n "${SAI_POOL_DIR:-}" ] && [ -d "$SAI_POOL_DIR" ]; then
if [ "$CORPUS" != "$SAI_POOL_DIR" ] && [ -d "$CORPUS" ] &&
[ ! -e "$CORPUS/.sai-pool-copied" ]; then
for d in "$CORPUS"/corpus-*; do
if [ -d "$d" ]; then
mkdir -p "$SAI_POOL_DIR/${d##*/}"
cp -Rn "$d/." "$SAI_POOL_DIR/${d##*/}/"
fi
done
touch "$CORPUS/.sai-pool-copied"
fi
CORPUS="$SAI_POOL_DIR"
fi
SECS="${1:-60}"
case "$SECS" in
''|*[!0-9]*) echo "usage: $0 [seconds per target] [target...]" >&2; exit 1 ;;
esac
if [ "$#" -gt 0 ]; then
shift
fi
START=$(date +%s)
IDLE_SECS="${SAI_IDLE_SECS:-}"
case "$IDLE_SECS" in
''|*[!0-9]*) IDLE_SECS="" ;;
esac
# least time a target gets in an idle slice, since each run begins by
# replaying its whole corpus
IDLE_MIN_TARGET_SECS=120
# time an idle slice keeps back for runs starting and stopping, and reporting
IDLE_MARGIN_SECS=30
IDLE_PER_TARGET_OVERHEAD_SECS=5
# the fuzz workspace's dependencies are let in only by name, like the main
# workspace's (docs/dependencies.md): check before building anything
echo "== cargo deny, fuzz workspace"
cargo deny --manifest-path fuzz/Cargo.toml check
# and build only what fuzz/Cargo.lock says
cargo +nightly fetch --locked --manifest-path fuzz/Cargo.toml
echo "== build"
cargo +nightly fuzz build --fuzz-dir fuzz -O --debug-assertions \
--target-dir "$BUILD"
host=$(rustc +nightly -vV | sed -n 's/^host: //p')
BIN="$BUILD/$host/release"
ALL=$(cargo +nightly fuzz list --fuzz-dir fuzz)
if [ "$#" -gt 0 ]; then
TARGETS="$*"
for t in $TARGETS; do
case " $(echo $ALL) " in
*" $t "*) ;;
*) echo "no fuzz target $t; there are: $(echo $ALL)" >&2; exit 1 ;;
esac
done
else
TARGETS=$(echo $ALL)
fi
# the sanitizer reports need function names, for people and for sai-server's
# grouping of findings into bugs
if [ -z "${ASAN_SYMBOLIZER_PATH:-}" ]; then
for s in llvm-symbolizer llvm-symbolizer-21 llvm-symbolizer-20 \
llvm-symbolizer-19 llvm-symbolizer-18; do
if command -v "$s" >/dev/null 2>&1; then
ASAN_SYMBOLIZER_PATH=$(command -v "$s")
export ASAN_SYMBOLIZER_PATH
break
fi
done
fi
mkdir -p "$ARTIFACTS" "$CORPUS"
if [ -n "$IDLE_SECS" ]; then
set -- $TARGETS
n=$#
left=$(( IDLE_SECS - ($(date +%s) - START) - IDLE_MARGIN_SECS ))
count=$(( left / (IDLE_MIN_TARGET_SECS + IDLE_PER_TARGET_OVERHEAD_SECS) ))
if [ "$count" -gt "$n" ]; then
count=$n
fi
if [ "$count" -lt 1 ]; then
echo "idle slice of ${IDLE_SECS}s has no time left after the build"
exit 0
fi
SECS=$(( left / count - IDLE_PER_TARGET_OVERHEAD_SECS ))
# whose turn it is, kept with the corpora so it lasts between slices
next=0
if [ -r "$CORPUS/.idle-next" ]; then
read -r next < "$CORPUS/.idle-next" || next=0
case "$next" in
''|*[!0-9]*) next=0 ;;
esac
fi
next=$(( next % n ))
echo $(( (next + count) % n )) > "$CORPUS/.idle-next"
TARGETS=""
i=0
while [ "$i" -lt "$count" ]; do
k=$(( (next + i) % n + 1 ))
eval "TARGETS=\"\$TARGETS \${$k}\""
i=$(( i + 1 ))
done
echo "idle slice of ${IDLE_SECS}s: ${SECS}s each for$TARGETS"
fi
# the seed directories of target $1
seeds() {
echo "$REPO/fuzz/seeds/$1"
if [ "$1" = transcript ]; then
echo "$REPO/crates/npro-test/transcripts"
fi
}
# so this run's findings can be told from earlier ones in $ARTIFACTS
STAMP="$ARTIFACTS/.run-stamp"
touch "$STAMP"
rc=0
# send a finding to sai-server through the pool: $1 target, $2 the input,
# $3 the name to give it, $4 the report. Each is written under a hidden name
# first, so the builder never sends one half written.
sai_finding() {
d="$SAI_POOL_FINDINGS/$1"
mkdir -p "$d"
tail -c 1048576 "$4" > "$d/.$3.log" && mv "$d/.$3.log" "$d/$3.log"
cp "$2" "$d/.$3" && mv "$d/.$3" "$d/$3"
}
# replay the known reproducers of target $1, failing for any that still
# crash, and telling sai-server about those that no longer do
replay_known() {
kdir="$SAI_POOL_KNOWN/$1"
[ -d "$kdir" ] || return 0
for f in "$kdir"/*; do
h=${f##*/}
case "$h" in
*[!0-9a-f]*|'') continue ;;
esac
[ ${#h} -eq 40 ] || continue
rlog="$ARTIFACTS/replay-$1-$h.txt"
if "$BIN/$1" -timeout=60 "$f" > "$rlog" 2>&1; then
mkdir -p "$SAI_POOL_FINDINGS/$1"
: > "$SAI_POOL_FINDINGS/$1/.ok-$h" &&
mv "$SAI_POOL_FINDINGS/$1/.ok-$h" \
"$SAI_POOL_FINDINGS/$1/ok-$h"
else
echo "$1: known bug $h still crashes (report sent to sai)"
sai_finding "$1" "$f" "replay-$h" "$rlog"
rc=1
fi
done
}
# the first corpus dir receives new discoveries; the seed dirs are only read
run_target() {
# shellcheck disable=SC2046,SC2086
"$BIN/$1" "$CORPUS/corpus-$1" $(seeds "$1") \
-max_total_time="$SECS" \
-print_final_stats=1 \
-artifact_prefix="$ARTIFACTS/" \
${FUZZ_OPTS:-}
}
for t in $TARGETS; do
echo
echo "=== $t: ${SECS}s ==="
mkdir -p "$CORPUS/corpus-$t"
log="$ARTIFACTS/log-$t.txt"
if [ -n "${SAI_POOL_KNOWN:-}" ] && [ -n "${SAI_POOL_FINDINGS:-}" ] &&
[ -z "$IDLE_SECS" ]; then
replay_known "$t"
fi
TSTAMP="$ARTIFACTS/.target-stamp"
touch "$TSTAMP"
if [ -t 1 ]; then
# interactive: live output, and a copy next to the artifacts
{ run_target "$t"; echo $? > "$log.rc"; } 2>&1 | tee "$log"
[ "$(cat "$log.rc")" = 0 ] || rc=1
rm -f "$log.rc"
else
# not a terminal, eg, a sai log: libFuzzer writes each status line
# in many small writes, which a log collector counting chunks
# charges for one by one; gather the output and emit it at once
run_target "$t" > "$log" 2>&1 || rc=1
if [ -n "${SAI_POOL_FINDINGS:-}" ]; then
# the reports go to sai-server; the public log only gets
# how it went
grep -aE '^(#[0-9]+[[:space:]]+(INITED|DONE)|Done [0-9]+ runs|stat::)' \
"$log" || true
else
cat "$log"
fi
fi
if [ -n "${SAI_POOL_FINDINGS:-}" ]; then
for f in $(find "$ARTIFACTS" -maxdepth 1 -type f \
-newer "$TSTAMP" \( -name 'crash-*' \
-o -name 'leak-*' -o -name 'timeout-*' \
-o -name 'oom-*' -o -name 'slow-unit-*' \)); do
echo "$t: finding ${f##*/} (report sent to sai)"
sai_finding "$t" "$f" "${f##*/}" "$log"
done
fi
done
# what this run found, by absolute path, so it can be collected from the log
# even when the run was somewhere else
FOUND=$(find "$ARTIFACTS" -maxdepth 1 -type f -newer "$STAMP" \
\( -name 'crash-*' -o -name 'leak-*' -o -name 'timeout-*' \
-o -name 'oom-*' -o -name 'slow-unit-*' \) | sort)
echo
if [ -n "$FOUND" ]; then
echo "=== FINDINGS: replay each with $BIN/<target> <file> ==="
echo "$FOUND"
rc=1
else
echo "=== no findings ==="
fi
if [ -n "$IDLE_SECS" ] && [ -n "${SAI_POOL_FINDINGS:-}" ]; then
# an idle slice has reported what it found; it carries on next slice
rc=0
fi
exit $rc
|