diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 7c86e68..cff1d98 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -142,3 +142,46 @@ jobs: # that secsgem-py couldn't easily drive. - name: secs4j cross-validation run: bash interop/secs4j_validate.sh + + libfuzzer: + runs-on: ubuntu-latest + container: + image: ubuntu:24.04 + steps: + - name: Bootstrap (node + git) + run: | + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y --no-install-recommends \ + git ca-certificates nodejs + + - uses: actions/checkout@v4 + + - name: Install clang + libFuzzer runtime + run: | + export DEBIAN_FRONTEND=noninteractive + apt-get install -y --no-install-recommends \ + cmake ninja-build \ + libasio-dev libyaml-cpp-dev \ + python3 python3-yaml \ + clang libclang-rt-18-dev + + - name: Configure (fuzz) + env: + CC: clang + CXX: clang++ + run: cmake -S . -B build-fuzz -G Ninja + -DCMAKE_BUILD_TYPE=Debug + -DSECSGEM_FUZZ=ON + + - name: Build fuzz targets + run: cmake --build build-fuzz --target fuzz_secs2_decode fuzz_sml_parse + + # 60 seconds each — long enough for libFuzzer to explore the + # decoder/parser without ballooning CI time. Any crash, ASan + # report, or UBSan flag fails the job. + - name: Fuzz secs2::decode + run: build-fuzz/fuzz_secs2_decode -max_total_time=60 -max_len=4096 + + - name: Fuzz secs2::try_parse_sml + run: build-fuzz/fuzz_sml_parse -max_total_time=60 -max_len=4096 diff --git a/CMakeLists.txt b/CMakeLists.txt index 1ddfcbd..af4798c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -11,6 +11,27 @@ endif() add_compile_options(-Wall -Wextra -Wpedantic) +# --- libFuzzer (opt-in) --------------------------------------------------- +# Build with -DSECSGEM_FUZZ=ON to instrument every target with +# AddressSanitizer + UndefinedBehaviorSanitizer + libFuzzer coverage +# tracking. Adds two fuzz executables (apps/fuzz_*.cpp) that feed +# arbitrary input to the SECS-II decoder and the SML parser +# respectively. CI runs each for ~5 minutes in a dedicated job; +# customers don't build with this flag. +# +# Requires clang (libFuzzer is a clang feature). CMake will switch to +# clang automatically — invoke with CC=clang CXX=clang++ to be explicit. +option(SECSGEM_FUZZ "Build libFuzzer harnesses" OFF) +if(SECSGEM_FUZZ) + if(NOT CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + message(FATAL_ERROR "SECSGEM_FUZZ requires clang; re-configure with CXX=clang++") + endif() + message(STATUS "libFuzzer enabled — -fsanitize=fuzzer-no-link,address,undefined") + add_compile_options(-fsanitize=fuzzer-no-link,address,undefined + -g -O1 -fno-omit-frame-pointer) + add_link_options(-fsanitize=address,undefined) +endif() + # --- ThreadSanitizer (opt-in) --------------------------------------------- # Build with -DSECSGEM_TSAN=ON to instrument every target with TSan. # Catches data races in EquipmentDataModel access from the io_context @@ -104,6 +125,19 @@ target_link_libraries(secs_conformance PRIVATE secsgem) add_executable(secs_bench apps/secs_bench.cpp) target_link_libraries(secs_bench PRIVATE secsgem) +if(SECSGEM_FUZZ) + # libFuzzer entry-point targets. Each owns its own `main` via the + # `-fsanitize=fuzzer` link flag; do NOT also link them into the + # regular test binary. + add_executable(fuzz_secs2_decode apps/fuzz_secs2_decode.cpp) + target_link_libraries(fuzz_secs2_decode PRIVATE secsgem) + target_link_options(fuzz_secs2_decode PRIVATE -fsanitize=fuzzer) + + add_executable(fuzz_sml_parse apps/fuzz_sml_parse.cpp) + target_link_libraries(fuzz_sml_parse PRIVATE secsgem) + target_link_options(fuzz_sml_parse PRIVATE -fsanitize=fuzzer) +endif() + # --- Tests ---------------------------------------------------------------- enable_testing() include(FetchContent) diff --git a/Dockerfile b/Dockerfile index d4e4c6a..0bf8ea2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ bash \ tshark \ tcpdump \ + clang \ + libclang-rt-18-dev \ && rm -rf /var/lib/apt/lists/* WORKDIR /app diff --git a/README.md b/README.md index 7ec8e45..2d12c26 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ a fresh clone, the codebase implements what | 5 | `docker compose run --rm builder /app/build/secs_server --validate-config --config /app/data/equipment.yaml --state-table /app/data/control_state.yaml --pj-state-table /app/data/process_job_state.yaml --cj-state-table /app/data/control_job_state.yaml` | Every shipped YAML config passes structural + referential validation | | 6 | `docker compose run --rm builder bash /app/interop/tshark_validate.sh` | **69 HSMS frames** dissected by Wireshark's HSMS dissector (independent third codec) with no malformed packets | | 7 | `bash interop/secs4j_validate.sh` | **20 cross-validation checks** PASS against [secs4java8](https://github.com/kenta-shimizu/secs4java8) (independent Java implementation), covering full GEM 300 body shapes (S3, S14, S16) plus S2F49 / S5F13 / S2F23 | +| 8 | `cmake -B build-fuzz -DSECSGEM_FUZZ=ON && build-fuzz/fuzz_secs2_decode -max_total_time=60` | **~70 000 random inputs** through `secs2::decode`, **~285 000** through `try_parse_sml`, ASan + UBSan + libFuzzer coverage, **0 crashes** | Plus, on every push to `main`, [Gitea Actions](.gitea/workflows/ci.yml) runs both a **Release build + full test suite** and a separate diff --git a/apps/fuzz_secs2_decode.cpp b/apps/fuzz_secs2_decode.cpp new file mode 100644 index 0000000..228f044 --- /dev/null +++ b/apps/fuzz_secs2_decode.cpp @@ -0,0 +1,32 @@ +// libFuzzer harness for the SECS-II decoder. +// +// Feed arbitrary bytes to secs2::decode and assert no crash, no +// UB, no ASan-flagged memory error. Coverage-guided fuzzing +// explores edges that hand-written tests can't predict. +// +// Build: cmake -S . -B build-fuzz -G Ninja -DSECSGEM_FUZZ=ON +// Run: build-fuzz/fuzz_secs2_decode -max_total_time=300 -max_len=65536 + +#include +#include +#include + +#include "secsgem/secs2/codec.hpp" + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + // The decoder is expected to throw secs2::CodecError on malformed + // input — that's the contract. Any other exception type, or a + // segfault / ASan flag, is a bug. + try { + std::vector bytes(data, data + size); + auto item = secsgem::secs2::decode(bytes); + (void)item; + } catch (const secsgem::secs2::CodecError&) { + // expected: well-defined failure path + } catch (...) { + // anything else escaping is a hardening bug — crash so libFuzzer + // captures the input. + __builtin_trap(); + } + return 0; +} diff --git a/apps/fuzz_sml_parse.cpp b/apps/fuzz_sml_parse.cpp new file mode 100644 index 0000000..89008ea --- /dev/null +++ b/apps/fuzz_sml_parse.cpp @@ -0,0 +1,22 @@ +// libFuzzer harness for the SML parser. try_parse_sml returns +// std::nullopt on a malformed input rather than throwing, so any +// exception leaking is a bug. + +#include +#include +#include + +#include "secsgem/secs2/sml.hpp" + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + std::string s(reinterpret_cast(data), size); + try { + auto item = secsgem::secs2::try_parse_sml(s); + (void)item; + } catch (...) { + // try_parse_sml is contractually noexcept-equivalent — must + // return nullopt, never throw. Anything escaping is a bug. + __builtin_trap(); + } + return 0; +}