Skip to content

Commit 0c2410c

Browse files
authored
Merge pull request #13 from danielmarv/fuzzle-sdk-br
feat: add fuzzing support with Atheris and Docker configuration
2 parents a046740 + fa80dcb commit 0c2410c

9 files changed

Lines changed: 287 additions & 0 deletions

File tree

.clusterfuzzlite/Dockerfile

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
FROM gcr.io/oss-fuzz-base/base-builder-python
2+
3+
RUN apt-get update && apt-get install -y --no-install-recommends \
4+
protobuf-compiler \
5+
&& rm -rf /var/lib/apt/lists/*
6+
7+
COPY . $SRC/hiero-sdk-python
8+
WORKDIR $SRC/hiero-sdk-python
9+
COPY .clusterfuzzlite/build.sh $SRC/

.clusterfuzzlite/build.sh

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
#!/bin/bash -eu
2+
3+
# Install grpcio-tools first so generate_proto.py can compile the protobufs.
4+
pip3 install grpcio-tools
5+
6+
# Generate the protobuf Python bindings required by the SDK.
7+
python3 generate_proto.py
8+
9+
# Install the SDK and all runtime dependencies (uses current CFLAGS/CXXFLAGS so
10+
# any C extensions such as grpcio and cryptography are built with the right flags).
11+
pip3 install .
12+
13+
# Install Atheris (the Python fuzzing engine) and PyInstaller (used to produce
14+
# self-contained fuzzer executables that ClusterFuzzLite can run reliably).
15+
pip3 install atheris pyinstaller
16+
17+
# Build every *_fuzzer.py target found in the .clusterfuzzlite directory.
18+
for fuzzer in $(find "$SRC/hiero-sdk-python/.clusterfuzzlite" -name '*_fuzzer.py'); do
19+
fuzzer_basename=$(basename -s .py "$fuzzer")
20+
fuzzer_package="${fuzzer_basename}.pkg"
21+
22+
# Bundle the fuzzer and all its dependencies into a single portable package.
23+
pyinstaller --distpath "$OUT" --onefile --name "$fuzzer_package" "$fuzzer"
24+
25+
# Write a thin shell wrapper that ClusterFuzzLite uses to invoke the fuzzer.
26+
# The wrapper preloads the sanitizer library and sets ASAN_OPTIONS.
27+
# LD_PRELOAD is required here because the SDK uses C extensions (grpcio,
28+
# cryptography, protobuf) that must be covered by the sanitizer.
29+
cat > "$OUT/$fuzzer_basename" << EOF
30+
#!/bin/sh
31+
# LLVMFuzzerTestOneInput for fuzzer detection.
32+
this_dir=\$(dirname "\$0")
33+
LD_PRELOAD=\$this_dir/sanitizer_with_fuzzer.so \\
34+
PYCRYPTODOME_DISABLE_DEEPBIND=1 \\
35+
ASAN_OPTIONS=\$ASAN_OPTIONS:symbolize=1:external_symbolizer_path=\$this_dir/llvm-symbolizer:detect_leaks=0 \\
36+
"\$this_dir/$fuzzer_package" "\$@"
37+
EOF
38+
chmod +x "$OUT/$fuzzer_basename"
39+
done
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Atheris fuzz target: ContractFunctionParameters ABI encoding."""
2+
3+
from __future__ import annotations
4+
5+
import sys
6+
7+
import atheris
8+
9+
10+
with atheris.instrument_imports():
11+
from hiero_sdk_python import ContractFunctionParameters
12+
13+
14+
def TestOneInput(data: bytes) -> None:
15+
"""Feed arbitrary bytes into ContractFunctionParameters encoding paths."""
16+
fdp = atheris.FuzzedDataProvider(data)
17+
choice = fdp.ConsumeIntInRange(0, 7)
18+
19+
try:
20+
params = ContractFunctionParameters()
21+
22+
if choice == 0:
23+
params.add_bool(fdp.ConsumeBool())
24+
elif choice == 1:
25+
# add_address expects a 20-byte hex string or bytes
26+
params.add_address(fdp.ConsumeBytes(20).hex())
27+
elif choice == 2:
28+
params.add_string(fdp.ConsumeUnicodeNoSurrogates(256))
29+
elif choice == 3:
30+
params.add_bytes(fdp.ConsumeBytes(256))
31+
elif choice == 4:
32+
params.add_bytes32(fdp.ConsumeBytes(32))
33+
elif choice == 5:
34+
count = fdp.ConsumeIntInRange(0, 8)
35+
params.add_bool_array([fdp.ConsumeBool() for _ in range(count)])
36+
elif choice == 6:
37+
count = fdp.ConsumeIntInRange(0, 8)
38+
params.add_string_array([fdp.ConsumeUnicodeNoSurrogates(64) for _ in range(count)])
39+
else:
40+
count = fdp.ConsumeIntInRange(0, 8)
41+
params.add_bytes_array([fdp.ConsumeBytes(32) for _ in range(count)])
42+
43+
params.to_bytes()
44+
45+
except Exception:
46+
pass
47+
48+
49+
atheris.Setup(sys.argv, TestOneInput)
50+
atheris.Fuzz()
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""Atheris fuzz target: entity ID string parsing (AccountId, TokenId, ContractId)."""
2+
3+
from __future__ import annotations
4+
5+
import sys
6+
7+
import atheris
8+
9+
10+
with atheris.instrument_imports():
11+
from hiero_sdk_python import AccountId, TokenId
12+
from hiero_sdk_python.contract.contract_id import ContractId
13+
14+
_CLASSES = (AccountId, TokenId, ContractId)
15+
16+
17+
def TestOneInput(data: bytes) -> None:
18+
"""Feed arbitrary strings into entity ID parsers."""
19+
fdp = atheris.FuzzedDataProvider(data)
20+
text = fdp.ConsumeUnicodeNoSurrogates(256)
21+
22+
for cls in _CLASSES:
23+
try:
24+
cls.from_string(text)
25+
except Exception: # noqa: PERF203
26+
pass
27+
28+
29+
atheris.Setup(sys.argv, TestOneInput)
30+
atheris.Fuzz()

.clusterfuzzlite/keys_fuzzer.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Atheris fuzz target: PrivateKey and PublicKey parsing."""
2+
3+
from __future__ import annotations
4+
5+
import sys
6+
import warnings
7+
8+
import atheris
9+
10+
11+
with atheris.instrument_imports():
12+
from hiero_sdk_python import PrivateKey, PublicKey
13+
14+
15+
def _quiet(fn, *args):
16+
"""Call *fn* with *args*, suppressing UserWarning."""
17+
with warnings.catch_warnings():
18+
warnings.simplefilter("ignore", UserWarning)
19+
return fn(*args)
20+
21+
22+
def TestOneInput(data: bytes) -> None:
23+
"""Feed arbitrary bytes/strings into key parsers."""
24+
fdp = atheris.FuzzedDataProvider(data)
25+
choice = fdp.ConsumeIntInRange(0, 3)
26+
27+
try:
28+
if choice == 0:
29+
text = fdp.ConsumeUnicodeNoSurrogates(256)
30+
_quiet(PrivateKey.from_string, text)
31+
elif choice == 1:
32+
raw = fdp.ConsumeBytes(128)
33+
_quiet(PrivateKey.from_bytes, raw)
34+
elif choice == 2:
35+
text = fdp.ConsumeUnicodeNoSurrogates(256)
36+
_quiet(PublicKey.from_string, text)
37+
else:
38+
raw = fdp.ConsumeBytes(128)
39+
_quiet(PublicKey.from_bytes, raw)
40+
except Exception:
41+
pass
42+
43+
44+
atheris.Setup(sys.argv, TestOneInput)
45+
atheris.Fuzz()

.clusterfuzzlite/project.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
language: python
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""Atheris fuzz target: Transaction.from_bytes deserialization."""
2+
3+
from __future__ import annotations
4+
5+
import sys
6+
7+
import atheris
8+
9+
10+
with atheris.instrument_imports():
11+
from hiero_sdk_python import Transaction
12+
13+
14+
def TestOneInput(data: bytes) -> None:
15+
"""Feed arbitrary bytes into Transaction.from_bytes and re-serialise."""
16+
try:
17+
tx = Transaction.from_bytes(data)
18+
tx.to_bytes()
19+
except Exception:
20+
# All parsing / validation failures are expected; only unhandled
21+
# exceptions that escape this function are reported as fuzzer crashes.
22+
pass
23+
24+
25+
atheris.Setup(sys.argv, TestOneInput)
26+
atheris.Fuzz()
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
name: ClusterFuzzLite
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- "**"
7+
push:
8+
branches:
9+
- main
10+
11+
permissions: read-all
12+
13+
jobs:
14+
# ── PR fuzzing ────────────────────────────────────────────────────────────
15+
# Runs on every pull request and reports any new crashes found in the
16+
# changed code paths (code-change mode).
17+
PR:
18+
if: github.event_name == 'pull_request'
19+
runs-on: ubuntu-latest
20+
concurrency:
21+
group: ${{ github.workflow }}-pr-${{ matrix.sanitizer }}-${{ github.ref }}
22+
cancel-in-progress: true
23+
strategy:
24+
fail-fast: false
25+
matrix:
26+
sanitizer:
27+
- address
28+
steps:
29+
- name: Harden the runner (Audit all outbound calls)
30+
uses: step-security/harden-runner@6c3c2f2c1c457b00c10c4848d6f5491db3b629df # v2.18.0
31+
with:
32+
egress-policy: audit
33+
34+
- name: Build Fuzzers (${{ matrix.sanitizer }})
35+
id: build
36+
uses: google/clusterfuzzlite/actions/build_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1
37+
with:
38+
language: python
39+
github-token: ${{ secrets.GITHUB_TOKEN }}
40+
sanitizer: ${{ matrix.sanitizer }}
41+
42+
- name: Run Fuzzers (${{ matrix.sanitizer }})
43+
id: run
44+
uses: google/clusterfuzzlite/actions/run_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1
45+
with:
46+
github-token: ${{ secrets.GITHUB_TOKEN }}
47+
fuzz-seconds: 600
48+
mode: code-change
49+
sanitizer: ${{ matrix.sanitizer }}
50+
output-sarif: true
51+
52+
# ── Continuous builds ─────────────────────────────────────────────────────
53+
# Uploads a fresh fuzzer build artifact on every push to main so that PR
54+
# fuzzing can determine whether a crash was newly introduced.
55+
Build:
56+
if: github.event_name == 'push'
57+
runs-on: ubuntu-latest
58+
concurrency:
59+
group: ${{ github.workflow }}-build-${{ matrix.sanitizer }}-${{ github.ref }}
60+
cancel-in-progress: true
61+
strategy:
62+
fail-fast: false
63+
matrix:
64+
sanitizer:
65+
- address
66+
steps:
67+
- name: Harden the runner (Audit all outbound calls)
68+
uses: step-security/harden-runner@6c3c2f2c1c457b00c10c4848d6f5491db3b629df # v2.18.0
69+
with:
70+
egress-policy: audit
71+
72+
- name: Build Fuzzers (${{ matrix.sanitizer }})
73+
id: build
74+
uses: google/clusterfuzzlite/actions/build_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1
75+
with:
76+
language: python
77+
sanitizer: ${{ matrix.sanitizer }}
78+
upload-build: true

pyproject.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,15 @@ eth = [
4343

4444
tck = ["flask>=3.0.0,<4"]
4545

46+
fuzz = [
47+
# Atheris (Linux + libFuzzer only) and PyInstaller are used exclusively by
48+
# the ClusterFuzzLite Docker build (.clusterfuzzlite/build.sh).
49+
# They are intentionally not part of the standard dev group because Atheris
50+
# requires a libFuzzer-instrumented build environment.
51+
"atheris>=2.3.0",
52+
"pyinstaller>=6.0.0",
53+
]
54+
4655
[dependency-groups]
4756
dev = [
4857
"grpcio-tools>=1.76.0,<2",

0 commit comments

Comments
 (0)