nyx/.github/workflows/fuzz.yml
2026-06-05 10:16:30 -05:00

217 lines
6.7 KiB
YAML

name: Fuzz
on:
pull_request:
branches: ["master"]
paths:
- "src/**"
- "fuzz/**"
- "Cargo.toml"
- "Cargo.lock"
- ".github/workflows/fuzz.yml"
schedule:
# Long-form weekly run, Sundays at 06:00 UTC.
- cron: "0 6 * * 0"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
fuzz:
name: fuzz-${{ matrix.target }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
target: [scan_bytes, extract_summaries, cross_file_taint]
steps:
- uses: actions/checkout@v6
# cargo-fuzz needs nightly for the libFuzzer codegen flags.
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: nightly
cache: true
cache-workspaces: |
.
fuzz
- uses: taiki-e/install-action@v2
with:
tool: cargo-fuzz
- uses: actions/setup-node@v6
with:
node-version: 20
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Build frontend
working-directory: frontend
run: |
npm ci
npm run build
- name: Restore fuzz corpus
uses: actions/cache@v5
with:
path: fuzz/corpus/${{ matrix.target }}
key: fuzz-corpus-${{ matrix.target }}-${{ github.sha }}
restore-keys: |
fuzz-corpus-${{ matrix.target }}-
# The harness reads inputs as <lang_idx_byte><source>, so we prefix
# each seed with its language index here at stage time. Files in
# fuzz/seed_corpus/ are committed as plain source without the byte
# because some IDEs strip 0x00 on save.
- name: Layer seed corpus
run: |
set -euo pipefail
target=${{ matrix.target }}
dest="fuzz/corpus/$target"
mkdir -p "$dest"
ext_to_idx() {
case "$1" in
rs) echo 0 ;;
js) echo 1 ;;
ts) echo 2 ;;
py) echo 3 ;;
go) echo 4 ;;
java) echo 5 ;;
rb) echo 6 ;;
php) echo 7 ;;
c) echo 8 ;;
cpp) echo 9 ;;
*) return 1 ;;
esac
}
stage() {
src="$1"
ext="${src##*.}"
idx=$(ext_to_idx "$ext") || return 0
hash=$(sha256sum "$src" | cut -c1-16)
out="$dest/seed-${ext}-${hash}"
[ -e "$out" ] && return 0
printf '%b' "$(printf '\\%03o' "$idx")" > "$out"
cat "$src" >> "$out"
}
for f in benches/fixtures/sample.*; do
[ -e "$f" ] && stage "$f"
done
while IFS= read -r f; do
stage "$f"
done < <(find tests/benchmark/corpus -type f \( \
-name '*.rs' -o -name '*.js' -o -name '*.ts' \
-o -name '*.py' -o -name '*.go' -o -name '*.java' \
-o -name '*.rb' -o -name '*.php' -o -name '*.c' \
-o -name '*.cpp' \))
if [ -d "fuzz/seed_corpus/$target" ]; then
while IFS= read -r f; do
stage "$f"
done < <(find "fuzz/seed_corpus/$target" -type f \( \
-name '*.rs' -o -name '*.js' -o -name '*.ts' \
-o -name '*.py' -o -name '*.go' -o -name '*.java' \
-o -name '*.rb' -o -name '*.php' -o -name '*.c' \
-o -name '*.cpp' \))
fi
echo "Corpus dir: $(ls "$dest" | wc -l) files"
- name: Choose fuzz duration
id: budget
run: |
if [ "${{ github.event_name }}" = "schedule" ] || [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "seconds=18000" >> "$GITHUB_OUTPUT"
else
echo "seconds=600" >> "$GITHUB_OUTPUT"
fi
- name: Run fuzz target
run: |
cargo fuzz run --target x86_64-unknown-linux-gnu ${{ matrix.target }} -- \
-max_total_time=${{ steps.budget.outputs.seconds }} \
-max_len=65536 \
-timeout=60 \
-rss_limit_mb=8192 \
-dict=fuzz/dict/all.dict
- name: Upload crash artifacts
if: failure()
uses: actions/upload-artifact@v7
with:
name: fuzz-artifacts-${{ matrix.target }}-${{ github.run_id }}
path: fuzz/artifacts/${{ matrix.target }}/
if-no-files-found: ignore
retention-days: 14
harness-fuzz:
name: harness-fuzz-${{ matrix.cap }}
runs-on: ubuntu-latest
# Run only on schedule and manual dispatch — 50 k iterations per cap is
# too slow for PR checks but is the right cadence for weekly corpus growth.
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
strategy:
fail-fast: false
matrix:
include:
- cap: sql_query
harness: tests/dynamic_fixtures/python/sqli_positive.py
- cap: code_exec
harness: tests/dynamic_fixtures/python/cmdi_positive.py
- cap: file_io
harness: tests/dynamic_fixtures/python/fileio_positive.py
- cap: ssrf
harness: tests/dynamic_fixtures/python/ssrf_positive.py
- cap: html_escape
harness: tests/dynamic_fixtures/python/xss_positive.py
steps:
- uses: actions/checkout@v6
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache: true
cache-workspaces: |
.
fuzz/dynamic_corpus
- uses: actions/setup-node@v6
with:
node-version: 20
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Build frontend
working-directory: frontend
run: |
npm ci
npm run build
- name: Build nyx-dynamic-corpus
working-directory: fuzz/dynamic_corpus
run: cargo build
- uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Run harness fuzzer — ${{ matrix.cap }}
run: |
fuzz/dynamic_corpus/target/debug/nyx-dynamic-corpus run \
--cap ${{ matrix.cap }} \
--spec-hash "ci-${{ matrix.cap }}" \
--harness-cmd "python3 ${{ matrix.harness }}" \
--iterations 50000 \
--output fuzz-discovered
- name: Upload discovered candidates
if: always()
uses: actions/upload-artifact@v7
with:
name: harness-fuzz-${{ matrix.cap }}-${{ github.run_id }}
path: fuzz-discovered/
if-no-files-found: ignore
retention-days: 30