Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .clusterfuzzlite/Dockerfile
Original file line number Diff line number Diff line change
@@ -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/
39 changes: 39 additions & 0 deletions .clusterfuzzlite/build.sh
Original file line number Diff line number Diff line change
@@ -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
50 changes: 50 additions & 0 deletions .clusterfuzzlite/contract_params_fuzzer.py
Original file line number Diff line number Diff line change
@@ -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()
30 changes: 30 additions & 0 deletions .clusterfuzzlite/entity_id_fuzzer.py
Original file line number Diff line number Diff line change
@@ -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()
45 changes: 45 additions & 0 deletions .clusterfuzzlite/keys_fuzzer.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions .clusterfuzzlite/project.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
language: python
26 changes: 26 additions & 0 deletions .clusterfuzzlite/transaction_from_bytes_fuzzer.py
Original file line number Diff line number Diff line change
@@ -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()
78 changes: 78 additions & 0 deletions .github/workflows/clusterfuzzlite.yml
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading