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..406ccb8e9 --- /dev/null +++ b/.clusterfuzzlite/build.sh @@ -0,0 +1,32 @@ +#!/bin/bash -eu + +pip3 install grpcio-tools + +python3 generate_proto.py + +pip3 install . + +pip3 install atheris pyinstaller + +find "$SRC/hiero-sdk-python/.clusterfuzzlite" -name '*_fuzzer.py' -print0 | while IFS= read -r -d '' fuzzer; 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..f859ed95e --- /dev/null +++ b/.clusterfuzzlite/contract_params_fuzzer.py @@ -0,0 +1,55 @@ +"""Atheris fuzz target: ContractFunctionParameters ABI encoding.""" + +from __future__ import annotations + +import sys + +import atheris + + +with atheris.instrument_imports(): + from eth_abi.exceptions import EncodingTypeError, ValueOutOfBounds + + 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, 9) + 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)]) + elif choice == 7: + count = fdp.ConsumeIntInRange(0, 8) + params.add_bytes_array([fdp.ConsumeBytes(32) for _ in range(count)]) + elif choice == 8: + count = fdp.ConsumeIntInRange(0, 8) + params.add_address_array([fdp.ConsumeBytes(20).hex() for _ in range(count)]) + else: + count = fdp.ConsumeIntInRange(0, 8) + params.add_bytes32_array([fdp.ConsumeBytes(32) for _ in range(count)]) + try: + params.to_bytes() + except (TypeError, ValueError, EncodingTypeError, ValueOutOfBounds): + # Malformed fuzz input can trigger expected validation/ABI encoding failures. + 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..fdbbc509f --- /dev/null +++ b/.clusterfuzzlite/entity_id_fuzzer.py @@ -0,0 +1,34 @@ +"""Atheris fuzz target: entity ID string parsing.""" + +from __future__ import annotations + +import sys + +import atheris + + +with atheris.instrument_imports(): + from hiero_sdk_python import AccountId, ContractId, FileId, TokenId, TopicId + +_CLASSES = (AccountId, TokenId, ContractId, FileId, TopicId) + + +def _try_parse_entity_id(entity_id_class, text: str) -> None: + try: + entity_id_class.from_string(text) + except (TypeError, ValueError, IndexError, OverflowError): + # Expected errors from malformed string input (parsing, indexing, conversions) + pass + + +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_parse_entity_id(cls, text) + + +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..c60540bd0 --- /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 ValueError: + 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..c86bec7ba --- /dev/null +++ b/.clusterfuzzlite/transaction_from_bytes_fuzzer.py @@ -0,0 +1,27 @@ +"""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: + # Protobuf deserialization and transaction parsing can raise many + # exception types (DecodeError, ValueError, TypeError, KeyError, etc.); + # only unhandled exceptions that escape are reported as fuzzer crashes. + pass + + +atheris.Setup(sys.argv, TestOneInput) +atheris.Fuzz() diff --git a/.codacy.yml b/.codacy.yml index d83fecdd1..d54a0239e 100644 --- a/.codacy.yml +++ b/.codacy.yml @@ -1,6 +1,7 @@ --- exclude_paths: - "tck/**" + - ".clusterfuzzlite/**" engines: bandit: diff --git a/.github/workflows/clusterfuzzlite.yml b/.github/workflows/clusterfuzzlite.yml new file mode 100644 index 000000000..56390b9e0 --- /dev/null +++ b/.github/workflows/clusterfuzzlite.yml @@ -0,0 +1,76 @@ +name: ClusterFuzzLite + +on: + pull_request: + 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: hl-sdk-py-lin-md + 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: hl-sdk-py-lin-md + 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/src/hiero_sdk_python/__init__.py b/src/hiero_sdk_python/__init__.py index 329f6f0a4..b890f3330 100644 --- a/src/hiero_sdk_python/__init__.py +++ b/src/hiero_sdk_python/__init__.py @@ -31,6 +31,7 @@ from .contract.contract_execute_transaction import ContractExecuteTransaction from .contract.contract_function_parameters import ContractFunctionParameters from .contract.contract_function_result import ContractFunctionResult +from .contract.contract_id import ContractId from .contract.contract_info import ContractInfo from .contract.contract_info_query import ContractInfoQuery from .contract.contract_update_transaction import ContractUpdateTransaction @@ -52,6 +53,7 @@ from .file.file_contents_query import FileContentsQuery from .file.file_create_transaction import FileCreateTransaction from .file.file_delete_transaction import FileDeleteTransaction +from .file.file_id import FileId from .file.file_info import FileInfo from .file.file_info_query import FileInfoQuery from .file.file_update_transaction import FileUpdateTransaction @@ -249,6 +251,7 @@ # File "FileCreateTransaction", "FileAppendTransaction", + "FileId", "FileInfoQuery", "FileInfo", "FileContentsQuery", @@ -257,6 +260,7 @@ # Contract "ContractCreateTransaction", "ContractCallQuery", + "ContractId", "ContractInfoQuery", "ContractBytecodeQuery", "ContractExecuteTransaction",