Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
197fe00
ci: add fuzz regression testing and continuous fuzzing infrastructure
thepastaclaw Feb 25, 2026
ee9eeca
test(fuzz): add Dash-specific deserialization and roundtrip fuzz targets
thepastaclaw Feb 27, 2026
49489ed
test(fuzz): add BLS cryptographic operations fuzz target
thepastaclaw Feb 27, 2026
42401f5
test(fuzz): add CoinJoin protocol fuzz targets
thepastaclaw Feb 27, 2026
346088c
test(fuzz): add governance proposal validation fuzz target
thepastaclaw Feb 27, 2026
c2e5d30
test(fuzz): add special transaction and asset lock/unlock fuzz targets
thepastaclaw Feb 27, 2026
3d5cbc8
test(fuzz): add LLMQ message and deterministic MN list fuzz targets
thepastaclaw Feb 27, 2026
aa0c9c2
test(fuzz): add Dash P2P message processing fuzz target
thepastaclaw Feb 27, 2026
7586593
build: register new Dash fuzz targets in Makefile.test.include
thepastaclaw Mar 15, 2026
caa51c8
fix: resolve signed integer overflow UB in CoinJoin priority and timeout
thepastaclaw Mar 18, 2026
d77da18
test(fuzz): fix crashes and UB in new Dash fuzz targets
thepastaclaw Mar 18, 2026
dc5e9fe
test(fuzz): fix crashes in pre-existing upstream fuzz targets for Dash
thepastaclaw Mar 18, 2026
6b23e82
fix(fuzz): address remaining review issues
thepastaclaw Apr 14, 2026
0a92dc6
fix(fuzz): apply follow-up review bundle
thepastaclaw Apr 19, 2026
f366d17
fix(fuzz): make MN list BLS mutations deterministic
thepastaclaw Apr 23, 2026
d41ed26
fix(fuzz): seed blockmerkleroot corpus from chain
thepastaclaw Apr 23, 2026
11dedc4
fix(fuzz): seed governance and quorum corpus data
thepastaclaw Apr 23, 2026
93ff82d
fix(fuzz): harden regression infrastructure
thepastaclaw Apr 24, 2026
441bbff
fix(fuzz): address regression review follow-ups
thepastaclaw May 4, 2026
ecb73ac
fix(fuzz): tighten daemon validation and preserve leak signal
thepastaclaw May 4, 2026
c11cf3c
fix(fuzz): address latest review feedback
thepastaclaw May 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,17 @@ jobs:
depends-dep-opts: ${{ needs.depends-win64.outputs.dep-opts }}
runs-on: ${{ needs.check-skip.outputs['runner-amd64'] }}

test-linux64_fuzz:
name: linux64_fuzz-test
uses: ./.github/workflows/test-fuzz.yml
needs: [check-skip, container, src-linux64_fuzz]
if: ${{ vars.SKIP_LINUX64_FUZZ == '' }}
with:
bundle-key: ${{ needs.src-linux64_fuzz.outputs.key }}
build-target: linux64_fuzz
container-path: ${{ needs.container.outputs.path }}
runs-on: ${{ needs.check-skip.outputs['runner-amd64'] }}

test-linux64:
name: linux64-test
uses: ./.github/workflows/test-src.yml
Expand Down
237 changes: 237 additions & 0 deletions .github/workflows/test-fuzz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
name: Fuzz regression

on:
workflow_call:
inputs:
bundle-key:
description: "Key needed to access bundle of fuzz build artifacts"
required: true
type: string
build-target:
description: "Target name as defined by inputs.sh"
required: true
type: string
container-path:
description: "Path to built container at registry"
required: true
type: string
runs-on:
description: "Runner label to use"
required: false
default: ubuntu-24.04
type: string

jobs:
fuzz-regression:
name: Fuzz regression
runs-on: ${{ inputs.runs-on }}
Comment thread
thepastaclaw marked this conversation as resolved.
container:
image: ${{ inputs.container-path }}
options: --user root
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 1

- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: ${{ inputs.bundle-key }}

- name: Extract build artifacts
run: |
git config --global --add safe.directory "$PWD"
export BUILD_TARGET="${{ inputs.build-target }}"
export BUNDLE_KEY="${{ inputs.bundle-key }}"
./ci/dash/bundle-artifacts.sh extract
shell: bash

- name: Download corpus
run: |
mkdir -p /tmp/fuzz_corpus

BITCOIN_QA_ASSETS_SHA=ef5d7720f2a1ac20607bd5fba16137f4bfbcfec6
DASH_QA_ASSETS_SHA=b4d1a7ce41a9a21d522381944ba84b60b1ad5b60

# Fail the job if neither external qa-assets source is reachable — otherwise we
# silently degrade to synthetic-only/empty-corpus smoke runs and CI goes green on
# no signal. Synthetic seeds are additive only; they do not substitute for the
# curated external corpora.
loaded_external=0

fetch_pinned_repo() {
local repo_url="$1"
local repo_sha="$2"
local dest_dir="$3"
local resolved_sha

rm -rf "$dest_dir"
git init --quiet "$dest_dir"
git -C "$dest_dir" remote add origin "$repo_url"
git -C "$dest_dir" fetch --depth=1 origin "$repo_sha" || return 1
git -C "$dest_dir" checkout --quiet --detach FETCH_HEAD || return 1
resolved_sha=$(git -C "$dest_dir" rev-parse HEAD) || return 1
echo "Resolved ${repo_url} to ${resolved_sha}"
[ "$resolved_sha" = "$repo_sha" ]
}

# Layer 1: bitcoin-core inherited corpus
if fetch_pinned_repo https://github.com/bitcoin-core/qa-assets "$BITCOIN_QA_ASSETS_SHA" /tmp/qa-assets; then
if [ -d "/tmp/qa-assets/fuzz_seed_corpus" ]; then
cp -r /tmp/qa-assets/fuzz_seed_corpus/. /tmp/fuzz_corpus/
echo "Loaded bitcoin-core corpus"
loaded_external=1
fi
Comment thread
thepastaclaw marked this conversation as resolved.
else
echo "::warning::Failed to fetch bitcoin-core/qa-assets at ${BITCOIN_QA_ASSETS_SHA}"
fi

# Layer 2: Dash-specific corpus (overlays on top)
if fetch_pinned_repo https://github.com/dashpay/qa-assets "$DASH_QA_ASSETS_SHA" /tmp/dash-qa-assets; then
if [ -d "/tmp/dash-qa-assets/fuzz/corpora" ]; then
cp -r /tmp/dash-qa-assets/fuzz/corpora/. /tmp/fuzz_corpus/
echo "Loaded Dash-specific corpus"
loaded_external=1
fi
else
echo "::warning::Failed to fetch dashpay/qa-assets at ${DASH_QA_ASSETS_SHA}"
fi

if [ "$loaded_external" -eq 0 ]; then
echo "::error::No external corpus sources reachable - refusing to run synthetic-only/empty-corpus smoke tests that produce no signal"
exit 1
fi

# Layer 3: Generate synthetic seeds for Dash-specific targets (additive only —
# gated on external corpus being present so we never pass on synthetic-only runs).
if [ -f "contrib/fuzz/seed_corpus_from_chain.py" ]; then
python3 contrib/fuzz/seed_corpus_from_chain.py --synthetic-only -o /tmp/fuzz_corpus
fi
Comment thread
thepastaclaw marked this conversation as resolved.
shell: bash

- name: Run fuzz regression tests
id: fuzz-test
run: |
export BUILD_TARGET="${{ inputs.build-target }}"
source ./ci/dash/matrix.sh

BUILD_DIR="build-ci/dashcore-${BUILD_TARGET}"
FUZZ_BIN="${BUILD_DIR}/src/test/fuzz/fuzz"

if [ ! -x "$FUZZ_BIN" ]; then
echo "ERROR: Fuzz binary not found at $FUZZ_BIN"
exit 1
fi

# Leak detection stays enabled; known-noisy dependency leaks are filtered via
# LSAN_OPTIONS suppressions instead of being globally disabled.
export ASAN_OPTIONS="detect_stack_use_after_return=1:check_initialization_order=1:strict_init_order=1"
export LSAN_OPTIONS="suppressions=${BUILD_DIR}/test/sanitizer_suppressions/lsan"
export UBSAN_OPTIONS="suppressions=${BUILD_DIR}/test/sanitizer_suppressions/ubsan:print_stacktrace=1:halt_on_error=1:report_error_type=1"

# Get list of all targets
TARGETS=$(PRINT_ALL_FUZZ_TARGETS_AND_ABORT=1 "$FUZZ_BIN" 2>/tmp/fuzz_target_discovery.err || true)
TARGET_COUNT=$(echo "$TARGETS" | grep -c '[^[:space:]]' || true)
if [ "$TARGET_COUNT" -eq 0 ]; then
if [ -s /tmp/fuzz_target_discovery.err ]; then
cat /tmp/fuzz_target_discovery.err
fi
echo "::error::No fuzz targets found — binary may have failed to start"
exit 1
fi
echo "Found $TARGET_COUNT fuzz targets"

FAILED=0
PASSED=0
FAILED_TARGETS=""
# libFuzzer writes crash-/leak-/oom-/timeout- files to this directory on failure
# via -artifact_prefix, so the "Upload crash artifacts" step below can collect them.
ARTIFACT_DIR=/tmp/fuzz_artifacts
mkdir -p "$ARTIFACT_DIR"

while IFS= read -r target; do
[ -z "$target" ] && continue
corpus_dir="/tmp/fuzz_corpus/${target}"
artifact_prefix="${ARTIFACT_DIR}/${target}-"

# Classify a non-zero exit code from `timeout`/libFuzzer. timeout(1) reports
# 124 when the time budget elapsed, and 128+SIGNAL when the child was killed
# (137 = SIGKILL, 143 = SIGTERM). Treat those as "timeout/kill" and any other
# non-zero status as a generic crash. Both still fail the job.
classify_exit() {
case "$1" in
124|137|143) echo "timeout" ;;
*) echo "crash" ;;
esac
}

if [ ! -d "$corpus_dir" ] || [ -z "$(ls -A "$corpus_dir" 2>/dev/null)" ]; then
# No corpus for this target — run with empty input for 10s
# This catches basic initialization crashes
echo "::group::${target} (empty corpus, 10s run)"
mkdir -p "$corpus_dir"
# timeout(30) intentionally exceeds -max_total_time=10 to absorb startup/teardown jitter
# while still terminating genuinely hung processes.
if FUZZ="$target" timeout 30 "$FUZZ_BIN" \
-rss_limit_mb=4000 \
-max_total_time=10 \
-reload=0 \
-artifact_prefix="$artifact_prefix" \
"$corpus_dir" 2>&1; then
echo "PASS: $target (empty corpus)"
PASSED=$((PASSED + 1))
else
EXIT_CODE=$?
KIND=$(classify_exit "$EXIT_CODE")
echo "::error::FAIL: $target exited with code $EXIT_CODE (${KIND})"
FAILED=$((FAILED + 1))
FAILED_TARGETS="${FAILED_TARGETS} - ${target} (${KIND}, exit code ${EXIT_CODE})\n"
fi
echo "::endgroup::"
continue
Comment on lines +170 to +193
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Dash-only targets without synthetic seeds can silently fall through to empty-input smoke runs

The new loaded_external gate (L62, L102-105) correctly prevents a global fall-back to synthetic-only/empty-corpus runs, but it only verifies that some external corpus loaded — it does not assert per-target presence. The synthetic generator at contrib/fuzz/seed_corpus_from_chain.py:1169-1264 covers a fixed subset (CoinJoin, LLMQ messages, DKG, MNAuth, governance vote/instance/rec/file, BLS IES, MNHF). Other newly added Dash-only targets — dash_governance_object_common_deserialize, dash_proreg_tx_deserialize, dash_quorum_data_request_deserialize, dash_quorum_snapshot_deserialize, dash_get_quorum_rotation_info_deserialize — are populated only by the live-chain extract paths (seed_corpus_from_chain.py:699, 851, 979, 1039, 1106) or by the external dashpay/qa-assets snapshot. If the pinned snapshot ever omits or renames one of those directories, the per-target check at L170 sees an empty corpus_dir, runs the harness with no inputs for 10s, and the job goes green without ever replaying a structured input for that harness. For a regression workflow this is a coverage hole — the fix is to assert that targets known to require external/live-chain inputs have a non-empty corpus directory, and fail the job otherwise.

source: ['codex']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `.github/workflows/test-fuzz.yml`:
- [SUGGESTION] lines 170-193: Dash-only targets without synthetic seeds can silently fall through to empty-input smoke runs
  The new `loaded_external` gate (L62, L102-105) correctly prevents a global fall-back to synthetic-only/empty-corpus runs, but it only verifies that *some* external corpus loaded — it does not assert per-target presence. The synthetic generator at `contrib/fuzz/seed_corpus_from_chain.py:1169-1264` covers a fixed subset (CoinJoin, LLMQ messages, DKG, MNAuth, governance vote/instance/rec/file, BLS IES, MNHF). Other newly added Dash-only targets — `dash_governance_object_common_deserialize`, `dash_proreg_tx_deserialize`, `dash_quorum_data_request_deserialize`, `dash_quorum_snapshot_deserialize`, `dash_get_quorum_rotation_info_deserialize` — are populated only by the live-chain extract paths (`seed_corpus_from_chain.py:699, 851, 979, 1039, 1106`) or by the external `dashpay/qa-assets` snapshot. If the pinned snapshot ever omits or renames one of those directories, the per-target check at L170 sees an empty `corpus_dir`, runs the harness with no inputs for 10s, and the job goes green without ever replaying a structured input for that harness. For a regression workflow this is a coverage hole — the fix is to assert that targets known to require external/live-chain inputs have a non-empty corpus directory, and fail the job otherwise.

fi

# Run corpus regression (replay all inputs)
echo "::group::${target} ($(find "$corpus_dir" -maxdepth 1 -type f | wc -l) inputs)"
if FUZZ="$target" timeout 3600 "$FUZZ_BIN" \
-rss_limit_mb=4000 \
-runs=1 \
-artifact_prefix="$artifact_prefix" \
Comment thread
thepastaclaw marked this conversation as resolved.
"$corpus_dir" 2>&1; then
Comment thread
thepastaclaw marked this conversation as resolved.
Comment thread
thepastaclaw marked this conversation as resolved.
echo "PASS: $target"
PASSED=$((PASSED + 1))
else
EXIT_CODE=$?
KIND=$(classify_exit "$EXIT_CODE")
echo "::error::FAIL: $target exited with code $EXIT_CODE (${KIND})"
FAILED=$((FAILED + 1))
FAILED_TARGETS="${FAILED_TARGETS} - ${target} (${KIND}, exit code ${EXIT_CODE})\n"
fi
Comment thread
thepastaclaw marked this conversation as resolved.
echo "::endgroup::"
done <<< "$TARGETS"
Comment thread
thepastaclaw marked this conversation as resolved.

echo ""
echo "=== Fuzz Regression Summary ==="
Comment thread
UdjinM6 marked this conversation as resolved.
echo "Passed: $PASSED"
echo "Failed: $FAILED"
echo "Total: $TARGET_COUNT"

if [ $FAILED -gt 0 ]; then
echo ""
echo "=== Failed Targets ==="
printf '%b' "$FAILED_TARGETS"
echo "::error::$FAILED fuzz target(s) failed regression testing"
exit 1
fi
shell: bash

- name: Upload crash artifacts
if: failure() && steps.fuzz-test.conclusion == 'failure'
uses: actions/upload-artifact@v4
with:
name: fuzz-crashes-${{ inputs.build-target }}
path: /tmp/fuzz_artifacts
if-no-files-found: ignore
retention-days: 30
103 changes: 103 additions & 0 deletions contrib/fuzz/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Dash Core Fuzz Testing Tools

This directory contains tools for continuous fuzz testing of Dash Core.

## Overview

Dash Core inherits ~100 fuzz targets from Bitcoin Core and adds Dash-specific
targets for:
- Special transaction serialization (ProTx, CoinJoin, Asset Lock/Unlock, etc.)
- BLS operations and IES encryption
- LLMQ/DKG message handling
- Governance object validation
- Masternode list management

Some Dash-specific fuzz targets are planned/in-progress. Corpus tooling
pre-generates synthetic seeds for those target names so coverage is ready when
the targets are added.

## Tools

### `continuous_fuzz_daemon.sh`

A daemon script that continuously cycles through all fuzz targets with persistent
corpus storage and crash detection.

```bash
# Run all targets, 10 minutes each, indefinitely
./continuous_fuzz_daemon.sh --fuzz-bin /path/to/fuzz --time-per-target 600

# Run specific targets only
./continuous_fuzz_daemon.sh --targets bls_operations,bls_ies --time-per-target 3600

# Single cycle (good for cron)
./continuous_fuzz_daemon.sh --single-cycle --time-per-target 300

# Dry run — list targets
./continuous_fuzz_daemon.sh --dry-run
```

**Output directories:**
- `~/fuzz_corpus/<target>/` — persistent corpus per target
- `~/fuzz_crashes/<target>/` — crash artifacts (crash-*, timeout-*, oom-*)
- `~/fuzz_logs/` — per-target logs and daemon log

### `seed_corpus_from_chain.py`

Extracts real-world data from a running Dash node into fuzzer-consumable corpus
files. Connects via `dash-cli` RPC.

```bash
# Extract from a running node
./seed_corpus_from_chain.py -o /path/to/corpus --blocks 500

# Generate only synthetic seeds (no running node required)
./seed_corpus_from_chain.py -o /path/to/corpus --synthetic-only
```

**What it extracts:**
- Serialized blocks and block headers
Comment thread
thepastaclaw marked this conversation as resolved.
- Special transactions (ProRegTx, ProUpServTx, CoinJoin, Asset Lock, etc.)
- Governance objects and votes
- Masternode list entries
- Quorum commitment data

## CI Integration

The `test-fuzz.yml` workflow runs fuzz regression tests on every PR:

1. Builds fuzz targets with sanitizers (ASan + UBSan + libFuzzer)
2. Downloads seed corpus from `bitcoin-core/qa-assets` + synthetic Dash seeds
3. Replays all corpus inputs against every fuzz target
4. Reports failures as CI errors

This catches regressions in seconds — any code change that causes a previously-
working input to crash will be caught.

## Building Fuzz Targets

```bash
# Configure with fuzzing + sanitizers
./configure --enable-fuzz --with-sanitizers=fuzzer,address,undefined \
CC='clang -ftrivial-auto-var-init=pattern' \
CXX='clang++ -ftrivial-auto-var-init=pattern'

# Build
make -j$(nproc)

# The fuzz binary is at src/test/fuzz/fuzz
# Select target with FUZZ=<target_name>
FUZZ=bls_operations ./src/test/fuzz/fuzz corpus_dir/
```

## Contributing Corpus Inputs

Found an interesting input? Add it to the appropriate corpus directory:

```bash
# The filename should be the sha256 of the content (for dedup)
sha256sum input_file
cp input_file fuzz_corpus/<target_name>/<sha256_prefix>
```

Crash-reproducing inputs are especially valuable — they become regression tests.
Loading
Loading