From fa80dcb9b6358e7347cabe62ee8182a59dde7a8a Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Sun, 19 Apr 2026 18:03:48 +0300 Subject: [PATCH] feat: add fuzzing support with Atheris and Docker configuration Signed-off-by: Ntege Daniel --- .clusterfuzzlite/Dockerfile | 9 +++ .clusterfuzzlite/build.sh | 39 ++++++++++ .clusterfuzzlite/contract_params_fuzzer.py | 50 ++++++++++++ .clusterfuzzlite/entity_id_fuzzer.py | 30 +++++++ .clusterfuzzlite/keys_fuzzer.py | 45 +++++++++++ .clusterfuzzlite/project.yaml | 1 + .../transaction_from_bytes_fuzzer.py | 26 +++++++ .github/workflows/clusterfuzzlite.yml | 78 +++++++++++++++++++ pyproject.toml | 9 +++ 9 files changed, 287 insertions(+) create mode 100644 .clusterfuzzlite/Dockerfile create mode 100644 .clusterfuzzlite/build.sh create mode 100644 .clusterfuzzlite/contract_params_fuzzer.py create mode 100644 .clusterfuzzlite/entity_id_fuzzer.py create mode 100644 .clusterfuzzlite/keys_fuzzer.py create mode 100644 .clusterfuzzlite/project.yaml create mode 100644 .clusterfuzzlite/transaction_from_bytes_fuzzer.py create mode 100644 .github/workflows/clusterfuzzlite.yml diff --git a/.clusterfuzzlite/Dockerfile b/.clusterfuzzlite/Dockerfile new file mode 100644 index 000000000..0a89c8dc8 --- /dev/null +++ b/.clusterfuzzlite/Dockerfile @@ -0,0 +1,9 @@ +FROM gcr.io/oss-fuzz-base/base-builder-python + +RUN apt-get update && apt-get install -y --no-install-recommends \ + protobuf-compiler \ + && rm -rf /var/lib/apt/lists/* + +COPY . $SRC/hiero-sdk-python +WORKDIR $SRC/hiero-sdk-python +COPY .clusterfuzzlite/build.sh $SRC/ diff --git a/.clusterfuzzlite/build.sh b/.clusterfuzzlite/build.sh new file mode 100644 index 000000000..730807d55 --- /dev/null +++ b/.clusterfuzzlite/build.sh @@ -0,0 +1,39 @@ +#!/bin/bash -eu + +# Install grpcio-tools first so generate_proto.py can compile the protobufs. +pip3 install grpcio-tools + +# Generate the protobuf Python bindings required by the SDK. +python3 generate_proto.py + +# Install the SDK and all runtime dependencies (uses current CFLAGS/CXXFLAGS so +# any C extensions such as grpcio and cryptography are built with the right flags). +pip3 install . + +# Install Atheris (the Python fuzzing engine) and PyInstaller (used to produce +# self-contained fuzzer executables that ClusterFuzzLite can run reliably). +pip3 install atheris pyinstaller + +# Build every *_fuzzer.py target found in the .clusterfuzzlite directory. +for fuzzer in $(find "$SRC/hiero-sdk-python/.clusterfuzzlite" -name '*_fuzzer.py'); do + fuzzer_basename=$(basename -s .py "$fuzzer") + fuzzer_package="${fuzzer_basename}.pkg" + + # Bundle the fuzzer and all its dependencies into a single portable package. + pyinstaller --distpath "$OUT" --onefile --name "$fuzzer_package" "$fuzzer" + + # Write a thin shell wrapper that ClusterFuzzLite uses to invoke the fuzzer. + # The wrapper preloads the sanitizer library and sets ASAN_OPTIONS. + # LD_PRELOAD is required here because the SDK uses C extensions (grpcio, + # cryptography, protobuf) that must be covered by the sanitizer. + cat > "$OUT/$fuzzer_basename" << EOF +#!/bin/sh +# LLVMFuzzerTestOneInput for fuzzer detection. +this_dir=\$(dirname "\$0") +LD_PRELOAD=\$this_dir/sanitizer_with_fuzzer.so \\ +PYCRYPTODOME_DISABLE_DEEPBIND=1 \\ +ASAN_OPTIONS=\$ASAN_OPTIONS:symbolize=1:external_symbolizer_path=\$this_dir/llvm-symbolizer:detect_leaks=0 \\ + "\$this_dir/$fuzzer_package" "\$@" +EOF + chmod +x "$OUT/$fuzzer_basename" +done diff --git a/.clusterfuzzlite/contract_params_fuzzer.py b/.clusterfuzzlite/contract_params_fuzzer.py new file mode 100644 index 000000000..a1c133ff8 --- /dev/null +++ b/.clusterfuzzlite/contract_params_fuzzer.py @@ -0,0 +1,50 @@ +"""Atheris fuzz target: ContractFunctionParameters ABI encoding.""" + +from __future__ import annotations + +import sys + +import atheris + + +with atheris.instrument_imports(): + from hiero_sdk_python import ContractFunctionParameters + + +def TestOneInput(data: bytes) -> None: + """Feed arbitrary bytes into ContractFunctionParameters encoding paths.""" + fdp = atheris.FuzzedDataProvider(data) + choice = fdp.ConsumeIntInRange(0, 7) + + try: + params = ContractFunctionParameters() + + if choice == 0: + params.add_bool(fdp.ConsumeBool()) + elif choice == 1: + # add_address expects a 20-byte hex string or bytes + params.add_address(fdp.ConsumeBytes(20).hex()) + elif choice == 2: + params.add_string(fdp.ConsumeUnicodeNoSurrogates(256)) + elif choice == 3: + params.add_bytes(fdp.ConsumeBytes(256)) + elif choice == 4: + params.add_bytes32(fdp.ConsumeBytes(32)) + elif choice == 5: + count = fdp.ConsumeIntInRange(0, 8) + params.add_bool_array([fdp.ConsumeBool() for _ in range(count)]) + elif choice == 6: + count = fdp.ConsumeIntInRange(0, 8) + params.add_string_array([fdp.ConsumeUnicodeNoSurrogates(64) for _ in range(count)]) + else: + count = fdp.ConsumeIntInRange(0, 8) + params.add_bytes_array([fdp.ConsumeBytes(32) for _ in range(count)]) + + params.to_bytes() + + except Exception: + pass + + +atheris.Setup(sys.argv, TestOneInput) +atheris.Fuzz() diff --git a/.clusterfuzzlite/entity_id_fuzzer.py b/.clusterfuzzlite/entity_id_fuzzer.py new file mode 100644 index 000000000..36496fb6f --- /dev/null +++ b/.clusterfuzzlite/entity_id_fuzzer.py @@ -0,0 +1,30 @@ +"""Atheris fuzz target: entity ID string parsing (AccountId, TokenId, ContractId).""" + +from __future__ import annotations + +import sys + +import atheris + + +with atheris.instrument_imports(): + from hiero_sdk_python import AccountId, TokenId + from hiero_sdk_python.contract.contract_id import ContractId + +_CLASSES = (AccountId, TokenId, ContractId) + + +def TestOneInput(data: bytes) -> None: + """Feed arbitrary strings into entity ID parsers.""" + fdp = atheris.FuzzedDataProvider(data) + text = fdp.ConsumeUnicodeNoSurrogates(256) + + for cls in _CLASSES: + try: + cls.from_string(text) + except Exception: # noqa: PERF203 + pass + + +atheris.Setup(sys.argv, TestOneInput) +atheris.Fuzz() diff --git a/.clusterfuzzlite/keys_fuzzer.py b/.clusterfuzzlite/keys_fuzzer.py new file mode 100644 index 000000000..4c9fbe7b7 --- /dev/null +++ b/.clusterfuzzlite/keys_fuzzer.py @@ -0,0 +1,45 @@ +"""Atheris fuzz target: PrivateKey and PublicKey parsing.""" + +from __future__ import annotations + +import sys +import warnings + +import atheris + + +with atheris.instrument_imports(): + from hiero_sdk_python import PrivateKey, PublicKey + + +def _quiet(fn, *args): + """Call *fn* with *args*, suppressing UserWarning.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + return fn(*args) + + +def TestOneInput(data: bytes) -> None: + """Feed arbitrary bytes/strings into key parsers.""" + fdp = atheris.FuzzedDataProvider(data) + choice = fdp.ConsumeIntInRange(0, 3) + + try: + if choice == 0: + text = fdp.ConsumeUnicodeNoSurrogates(256) + _quiet(PrivateKey.from_string, text) + elif choice == 1: + raw = fdp.ConsumeBytes(128) + _quiet(PrivateKey.from_bytes, raw) + elif choice == 2: + text = fdp.ConsumeUnicodeNoSurrogates(256) + _quiet(PublicKey.from_string, text) + else: + raw = fdp.ConsumeBytes(128) + _quiet(PublicKey.from_bytes, raw) + except Exception: + pass + + +atheris.Setup(sys.argv, TestOneInput) +atheris.Fuzz() diff --git a/.clusterfuzzlite/project.yaml b/.clusterfuzzlite/project.yaml new file mode 100644 index 000000000..d1ad0ae50 --- /dev/null +++ b/.clusterfuzzlite/project.yaml @@ -0,0 +1 @@ +language: python diff --git a/.clusterfuzzlite/transaction_from_bytes_fuzzer.py b/.clusterfuzzlite/transaction_from_bytes_fuzzer.py new file mode 100644 index 000000000..7e5ce5852 --- /dev/null +++ b/.clusterfuzzlite/transaction_from_bytes_fuzzer.py @@ -0,0 +1,26 @@ +"""Atheris fuzz target: Transaction.from_bytes deserialization.""" + +from __future__ import annotations + +import sys + +import atheris + + +with atheris.instrument_imports(): + from hiero_sdk_python import Transaction + + +def TestOneInput(data: bytes) -> None: + """Feed arbitrary bytes into Transaction.from_bytes and re-serialise.""" + try: + tx = Transaction.from_bytes(data) + tx.to_bytes() + except Exception: + # All parsing / validation failures are expected; only unhandled + # exceptions that escape this function are reported as fuzzer crashes. + pass + + +atheris.Setup(sys.argv, TestOneInput) +atheris.Fuzz() diff --git a/.github/workflows/clusterfuzzlite.yml b/.github/workflows/clusterfuzzlite.yml new file mode 100644 index 000000000..1d039a7d6 --- /dev/null +++ b/.github/workflows/clusterfuzzlite.yml @@ -0,0 +1,78 @@ +name: ClusterFuzzLite + +on: + pull_request: + paths: + - "**" + push: + branches: + - main + +permissions: read-all + +jobs: + # ── PR fuzzing ──────────────────────────────────────────────────────────── + # Runs on every pull request and reports any new crashes found in the + # changed code paths (code-change mode). + PR: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + concurrency: + group: ${{ github.workflow }}-pr-${{ matrix.sanitizer }}-${{ github.ref }} + cancel-in-progress: true + strategy: + fail-fast: false + matrix: + sanitizer: + - address + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@6c3c2f2c1c457b00c10c4848d6f5491db3b629df # v2.18.0 + with: + egress-policy: audit + + - name: Build Fuzzers (${{ matrix.sanitizer }}) + id: build + uses: google/clusterfuzzlite/actions/build_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1 + with: + language: python + github-token: ${{ secrets.GITHUB_TOKEN }} + sanitizer: ${{ matrix.sanitizer }} + + - name: Run Fuzzers (${{ matrix.sanitizer }}) + id: run + uses: google/clusterfuzzlite/actions/run_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + fuzz-seconds: 600 + mode: code-change + sanitizer: ${{ matrix.sanitizer }} + output-sarif: true + + # ── Continuous builds ───────────────────────────────────────────────────── + # Uploads a fresh fuzzer build artifact on every push to main so that PR + # fuzzing can determine whether a crash was newly introduced. + Build: + if: github.event_name == 'push' + runs-on: ubuntu-latest + concurrency: + group: ${{ github.workflow }}-build-${{ matrix.sanitizer }}-${{ github.ref }} + cancel-in-progress: true + strategy: + fail-fast: false + matrix: + sanitizer: + - address + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@6c3c2f2c1c457b00c10c4848d6f5491db3b629df # v2.18.0 + with: + egress-policy: audit + + - name: Build Fuzzers (${{ matrix.sanitizer }}) + id: build + uses: google/clusterfuzzlite/actions/build_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1 + with: + language: python + sanitizer: ${{ matrix.sanitizer }} + upload-build: true diff --git a/pyproject.toml b/pyproject.toml index aa743c99f..3dc04e4ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,15 @@ eth = [ tck = ["flask>=3.0.0,<4"] +fuzz = [ + # Atheris (Linux + libFuzzer only) and PyInstaller are used exclusively by + # the ClusterFuzzLite Docker build (.clusterfuzzlite/build.sh). + # They are intentionally not part of the standard dev group because Atheris + # requires a libFuzzer-instrumented build environment. + "atheris>=2.3.0", + "pyinstaller>=6.0.0", +] + [dependency-groups] dev = [ "grpcio-tools>=1.76.0,<2",