feat: fuzz harness for the untrusted-input parsers + nightly sanitizer CI (LEO-414) (#37)
* feat: fuzz harness for the untrusted-input parsers + nightly sanitizer CI (LEO-414)
Adds the fuzzing the repo's own docs prescribe (C_CODING_STANDARDS "Fuzzing the
Parser", SECURITY.md control row 5, the dormant SW_BUILD_FUZZ stub): a fuzz target
for each untrusted-input surface, both under AddressSanitizer + UBSan, plus the
repo's first scheduled CI lane.
- C libFuzzer (tests/fuzz/fuzz_parse.c) over sw_parse_buffer, wired behind the
now-live SW_BUILD_FUZZ CMake option (clang-only guard; fuzzer coverage +
ASan/UBSan instrumentation; links sensorwatch_static to reach the internal
parser, the same way the cmocka tests do).
- Rust cargo-fuzz (rust/sensorwatch-cli/fuzz) with parse_line and
fixup_python_tokens targets over the JSONL replay parser. To reach the private
parsers, sensorwatch-cli becomes lib+bin (src/lib.rs owns the modules plus a
narrow `fuzz` surface; main.rs is a one-line shim). The fuzz crate is a
detached workspace, so the MSRV and publish jobs are untouched.
- Three adversarial C parser cases (tests/c/test_parse.c) the malformed-header
suite missed — 32-bit count*size wrap, an oversized single element, and
unterminated name/unit fields — which double as fuzz seeds.
- Committed seed corpora: C seeds generated from the shared synthetic-buffer
builder (tests/fuzz/gen_corpus.c), Rust seeds from the golden logs plus hostile
lines. tests/fuzz/README.md documents running both harnesses locally.
- Nightly .github/workflows/fuzz.yml (schedule + workflow_dispatch) — the repo's
first scheduled trigger; installs clang/nightly and runs both targets bounded.
- Doc truth-up: stale HWI_BUILD_FUZZ -> SW_BUILD_FUZZ (C_CODING_STANDARDS),
SECURITY.md control row flipped from "fuzzing planned" to done, plus CHANGELOG
and CONTRIBUTING entries.
Acceptance (mutation self-test): deleting the region-bounds guard in sw_parse.c
made ASan catch a heap-buffer-overflow read in sw_read_u32 (via sw_parse_buffer's
entry loop) on the wrap_count_size seed within seconds; reverted before commit.
Verified locally: both harnesses fuzz clean (C 200k+, Rust 2.4M / 8.5M runs), full
ctest green under ASan, and cargo test --workspace + fmt + clippy -D warnings green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U75tUN33ajrkTcxL5X1ak4
* fix: PR review — precise README guard ref, gen_corpus write-error checks, pin cargo-fuzz
Copilot review (PR #37):
- tests/fuzz/README.md: the mutation self-test named a guard that does not exist
verbatim (`sensor_end / entry_end > len`); quote the real check
`if (sensor_end > len || entry_end > len)` so the repro is not misleading.
- tests/fuzz/gen_corpus.c: write_file now fails fast on a short fwrite or a
deferred flush error at fclose, so a truncated seed can never be committed
while still printing "wrote ...". Seeds regenerate byte-identical.
- .github/workflows/fuzz.yml: pin `cargo install cargo-fuzz --version 0.13.2`
(like ci.yml's bindgen-cli pin) so the nightly lane is reproducible and a
cargo-fuzz release cannot break it unbidden.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U75tUN33ajrkTcxL5X1ak4
* fix: review round 2 (claude-reviewer) — corpus-hygiene consistency + crash-artifact ignores
Addresses the three still-open [low] docs/dev-hygiene findings from the shared
claude-reviewer round-2 pass on fb3cec4 (all non-blocking):
- .gitignore: ignore libFuzzer reproducers (crash-*/leak-*/timeout-*/oom-*) that
the C harness — and the documented mutation self-test — write to the CWD / repo
root.
- tests/fuzz/README.md + fuzz.yml: the Rust `cargo fuzz run` commands now fuzz into
a scratch dir with the committed seeds passed as read-only input, so
fuzz/corpus/<target> stays curated (mirrors the C target's existing pattern). A
40 s run previously added ~2,281 untracked files under the committed corpus.
Verified locally: committed seeds unchanged, all discovered inputs land in the
scratch dir.
Finding 1 (the PR-body note implying fuzz.yml could be workflow_dispatch-triggered
pre-merge — GitHub only dispatches workflows on the default branch) is corrected in
the PR description; the lane's first GitHub run is the post-merge shakeout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U75tUN33ajrkTcxL5X1ak4
* fix: review round 3 (codex) — fixup oracle, fuzz-only link, accurate Rust sanitizer claim
Addresses the three P2 findings from the codex-reviewer rollups (fb3cec4 / 1084174),
each confirmed against the code:
1. fixup_python_tokens fuzz target had no semantic oracle — it caught crashes but
not the string-boundary corruption it exists to test (a fixup that rewrites an
in-string token passed the fuzzer while failing the unit test). The wrapper now
asserts two invariants: already-valid JSON must pass through unchanged (None),
and the rewrite is idempotent. Verified: 3.8M runs clean on the correct code,
and the disposable in-string mutation now trips the oracle on the token_in_string
seed within the first replay.
2. SW_BUILD_FUZZ linked sensorwatch_static but did not guarantee it was built:
`-DSW_BUILD_FUZZ=ON -DSW_BUILD_STATIC=OFF -DSW_BUILD_TESTS=OFF` configured then
failed to link (`cannot find -lsensorwatch_static`). Added SW_BUILD_FUZZ to the
static-target condition; the fuzz-only configuration now builds and links.
3. Scoped the ASan+UBSan claim to the C harness. cargo-fuzz 0.13.2 supports only
address/leak/memory/thread/none — not undefined — so the Rust targets run under
ASan (plus Rust's debug assertions + overflow checks), not UBSan. Corrected
fuzz.yml, CHANGELOG, CONTRIBUTING, and the README Rust section; the C target's
ASan+UBSan wording is unchanged (accurate).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U75tUN33ajrkTcxL5X1ak4
* fix: review round 4 (copilot) — gen_corpus NULL-buffer hard-fail + README grammar
Two Copilot comments on 6905d80:
- tests/fuzz/gen_corpus.c: write_file now rejects a NULL buffer (a failed
sw_test_build_buffer) before creating the file, so a build failure can't leave a
truncated/empty seed behind with a bogus byte count; `len` is also initialized so
it is never read indeterminate at the call site. Seeds regenerate byte-identical.
- tests/fuzz/README.md: "Both run any crash..." -> "Both treat any crash... as a
bug" (the harnesses treat such findings as bugs; they don't run them).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U75tUN33ajrkTcxL5X1ak4
* fix: review round 5 (codex + copilot) — metamorphic fixup oracle + gen_corpus NULL-safety
codex (changes requested) on 7832b81:
- [P2] The fixup fuzz oracle missed the production shape where the input needs
fixup AND carries in-string tokens (a bare token defeats the valid-JSON check;
rewriting the in-string token too still leaves an idempotent result). Added a
metamorphic invariant: embed the fuzzer bytes in a JSON string field, append a
bare NaN to force the rewrite, then parse the fixed output and assert the string
field decodes back byte-for-byte. Verified: 1.9M runs clean on the correct code,
and the in-string mutation now trips it on the seed replay ("fixup corrupted a
JSON string field").
- [P3] Truth-up: the fuzz.rs module doc no longer says the wrappers discard their
results (fixup now asserts), and the PR description's ASan+UBSan claim is scoped
to the C target (Rust runs under ASan).
copilot (x4) on gen_corpus.c: blocks patched/memset the buffer before write_file's
NULL check. Added a build_or_die() helper used by every block, so a failed
sw_test_build_buffer exits cleanly before any deref. gen_corpus compiles under
-Wall -Wextra -Wpedantic -Werror and the seven seeds regenerate byte-identical.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U75tUN33ajrkTcxL5X1ak4
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> L
lcj-claude-coder committed
0019eb6140733747bdb8cdc90b4414c36b4a63c4
Parent: 8a53826
Committed by GitHub <noreply@github.com>
on 7/15/2026, 1:09:08 AM