Repository navigation
248 lines (218 loc) · 10.2 KB
/
Copy pathfuzz.yml
File metadata and controls
248 lines (218 loc) · 10.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
name: Fuzz Testing
on:
schedule:
# Run nightly at 02:00 UTC
- cron: '0 2 * * *'
workflow_dispatch:
inputs:
duration:
description: 'Fuzzing duration per target (seconds)'
required: false
default: '600'
type: string
targets:
description: 'Targets to fuzz (comma-separated, or "all")'
required: false
default: 'all'
type: string
env:
RUST_BACKTRACE: 1
# cargo-fuzz defaults `--target` to the triple its own binary was built
# for. The prebuilt release binary installed below is a static musl build,
# and the address sanitizer cannot link against static musl libc, so the
# target is named explicitly on every build and run.
FUZZ_TARGET_TRIPLE: x86_64-unknown-linux-gnu
CARGO_TERM_COLOR: always
jobs:
fuzz:
name: Fuzz ${{ matrix.target }}
runs-on: ubuntu-latest
strategy:
fail-fast: false # Continue fuzzing other targets even if one crashes
matrix:
target:
- fuzz_tcp_parser
- fuzz_udp_parser
- fuzz_ipv6_parser
- fuzz_icmpv6_parser
- fuzz_tls_parser
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Install Rust nightly
uses: dtolnay/rust-toolchain@nightly
- name: Cache cargo registry
uses: actions/cache@v6
with:
path: ~/.cargo/registry/index
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo git
uses: actions/cache@v6
with:
path: ~/.cargo/git
key: ${{ runner.os }}-cargo-git-${{ hashFiles('**/Cargo.lock') }}
- name: Cache target directory
uses: actions/cache@v6
with:
path: target
key: ${{ runner.os }}-fuzz-target-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-fuzz-target-${{ matrix.target }}-
${{ runner.os }}-fuzz-target-
# Install the prebuilt release binary rather than compiling cargo-fuzz.
# `cargo install cargo-fuzz --version 0.13.1` (no --locked) resolved a
# fresh dependency graph on every run and compiled it with the nightly
# toolchain selected above. From 2026-09 that picked up a `rustix` whose
# nightly-only build path uses `rustc_*` attributes current nightly
# rejects ("attributes starting with `rustc` are reserved"), so every
# scheduled run died in this step before fuzzing anything. A prebuilt
# binary cannot be broken by a nightly compiler change. The version is
# pinned so a cargo-fuzz release is adopted deliberately.
- name: Install cargo-fuzz
uses: taiki-e/install-action@v2
with:
tool: cargo-fuzz@0.13.2
- name: Validate duration input
env:
DURATION_INPUT: ${{ github.event.inputs.duration || '600' }}
run: |
# Zero is rejected too: libFuzzer reads -max_total_time=0 as "no
# limit", so the run would only end at the outer timeout backstop.
case "$DURATION_INPUT" in
''|*[!0-9]*|0|0*)
echo "::error::duration must be a positive whole number of seconds without leading zeros, got: $DURATION_INPUT"
exit 1 ;;
esac
- name: Build fuzz target
run: cargo +nightly fuzz build --target "$FUZZ_TARGET_TRIPLE" ${{ matrix.target }}
- name: Run fuzzer
id: fuzz_run
# The dispatch input reaches the script through the environment, not
# by template expansion into the script text, so it cannot inject
# shell. Its format is checked by "Validate duration input" above,
# which (unlike this step) is not continue-on-error.
env:
DURATION_INPUT: ${{ github.event.inputs.duration || '600' }}
run: |
DURATION="$DURATION_INPUT"
echo "Running ${{ matrix.target }} for ${DURATION} seconds..."
# libFuzzer refuses to start when the -artifact_prefix directory is
# missing ("The required directory ... does not exist"). Before this
# mkdir the fuzzer exited 1 at once on every run, and the job still
# went green -- see "Fail job if the fuzzer did not run" below.
mkdir -p /tmp/fuzzing-artifacts
# Run fuzzer and capture output
set +e # Don't fail on fuzzer crashes (we want to capture them)
# pipefail: without it $? is tee's status, always 0, so the fuzzer's
# own exit code was never observed. The outer timeout is a backstop
# with headroom over -max_total_time, so it cannot cut a run that
# libFuzzer is about to finish on its own.
set -o pipefail
timeout "$((DURATION + 300))s" cargo +nightly fuzz run --target "$FUZZ_TARGET_TRIPLE" ${{ matrix.target }} -- \
-max_total_time=${DURATION} \
-print_final_stats=1 \
-print_corpus_stats=1 \
-artifact_prefix=/tmp/fuzzing-artifacts/ \
-verbosity=1 \
2>&1 | tee /tmp/fuzz_output.txt
EXIT_CODE=$?
set +o pipefail
set -e
echo "fuzzer_exit=${EXIT_CODE}" >> "$GITHUB_OUTPUT"
# -print_final_stats=1 prints these only when libFuzzer ran to
# completion (or crashed). Their absence means it never fuzzed.
if grep -q "stat::number_of_executed_units" /tmp/fuzz_output.txt; then
echo "fuzzer_ran=true" >> "$GITHUB_OUTPUT"
else
echo "fuzzer_ran=false" >> "$GITHUB_OUTPUT"
fi
# Parse fuzzing statistics
echo "## Fuzzing Statistics for ${{ matrix.target }}" > /tmp/fuzz_stats.txt
echo "" >> /tmp/fuzz_stats.txt
# Extract key metrics
if grep -q "stat::number_of_executed_units" /tmp/fuzz_output.txt; then
EXECUTIONS=$(grep "stat::number_of_executed_units" /tmp/fuzz_output.txt | tail -1 | awk '{print $2}')
echo "- Executions: $EXECUTIONS" >> /tmp/fuzz_stats.txt
fi
if grep -q "stat::average_exec_per_sec" /tmp/fuzz_output.txt; then
EXEC_PER_SEC=$(grep "stat::average_exec_per_sec" /tmp/fuzz_output.txt | tail -1 | awk '{print $2}')
echo "- Average exec/sec: $EXEC_PER_SEC" >> /tmp/fuzz_stats.txt
fi
if grep -q "stat::new_units_added" /tmp/fuzz_output.txt; then
NEW_UNITS=$(grep "stat::new_units_added" /tmp/fuzz_output.txt | tail -1 | awk '{print $2}')
echo "- New corpus entries: $NEW_UNITS" >> /tmp/fuzz_stats.txt
fi
if grep -q "stat::corpus_size" /tmp/fuzz_output.txt; then
CORPUS_SIZE=$(grep "stat::corpus_size" /tmp/fuzz_output.txt | tail -1 | awk '{print $2}')
echo "- Corpus size: $CORPUS_SIZE" >> /tmp/fuzz_stats.txt
fi
# Check for crashes
if [ -d "/tmp/fuzzing-artifacts" ] && [ "$(ls -A /tmp/fuzzing-artifacts 2>/dev/null)" ]; then
echo "- **Crashes found:** Yes" >> /tmp/fuzz_stats.txt
CRASH_COUNT=$(find /tmp/fuzzing-artifacts -type f | wc -l)
echo "- Crash artifacts: $CRASH_COUNT" >> /tmp/fuzz_stats.txt
echo "crash_found=true" >> $GITHUB_OUTPUT
else
echo "- **Crashes found:** No" >> /tmp/fuzz_stats.txt
echo "crash_found=false" >> $GITHUB_OUTPUT
fi
# Store statistics as output
cat /tmp/fuzz_stats.txt >> $GITHUB_STEP_SUMMARY
# Exit with fuzzer's exit code
exit $EXIT_CODE
continue-on-error: true
- name: Upload crash artifacts
if: steps.fuzz_run.outputs.crash_found == 'true'
uses: actions/upload-artifact@v7
with:
name: fuzz-crashes-${{ matrix.target }}-${{ github.run_number }}
path: /tmp/fuzzing-artifacts/
retention-days: 90
if-no-files-found: ignore
- name: Upload corpus updates
if: success() || failure()
uses: actions/upload-artifact@v7
with:
name: fuzz-corpus-${{ matrix.target }}-${{ github.run_number }}
path: fuzz/corpus/${{ matrix.target }}/
retention-days: 30
if-no-files-found: ignore
- name: Fail job if crashes found
if: steps.fuzz_run.outputs.crash_found == 'true'
run: |
echo "::error::Fuzzing discovered crashes in ${{ matrix.target }}"
exit 1
# "Run fuzzer" is continue-on-error so that a crash still reaches the
# artifact upload above, which also meant a fuzzer that never started
# passed silently. A clean run must have printed its final stats and
# exited 0; anything else that is not a recorded crash fails here.
- name: Fail job if the fuzzer did not run
if: steps.fuzz_run.outputs.crash_found != 'true' && (steps.fuzz_run.outputs.fuzzer_ran != 'true' || steps.fuzz_run.outputs.fuzzer_exit != '0')
env:
FUZZER_RAN: ${{ steps.fuzz_run.outputs.fuzzer_ran }}
FUZZER_EXIT: ${{ steps.fuzz_run.outputs.fuzzer_exit }}
run: |
echo "::error::${{ matrix.target }} did not complete a fuzzing run (stats printed: ${FUZZER_RAN:-unknown}, exit code: ${FUZZER_EXIT:-unknown})"
exit 1
summary:
name: Fuzzing Summary
runs-on: ubuntu-latest
needs: fuzz
if: always()
steps:
- name: Generate summary
env:
DURATION_INPUT: ${{ github.event.inputs.duration || '600' }}
run: |
echo "## Fuzzing Run Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Date:** $(date -u +'%Y-%m-%d %H:%M:%S UTC')" >> $GITHUB_STEP_SUMMARY
echo "**Duration:** ${DURATION_INPUT} seconds per target" >> $GITHUB_STEP_SUMMARY
echo "**Trigger:** ${{ github.event_name }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "All fuzz targets executed. Check individual job outputs for statistics." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Next Steps" >> $GITHUB_STEP_SUMMARY
echo "- Review crash artifacts (if any)" >> $GITHUB_STEP_SUMMARY
echo "- Investigate failures and add regression tests" >> $GITHUB_STEP_SUMMARY
echo "- Update corpus with newly discovered inputs" >> $GITHUB_STEP_SUMMARY