From 741651e9e49d973f58d9fd998f633b992b306c97 Mon Sep 17 00:00:00 2001 From: FMSMITH91 <12152698+FMSMITH91@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:07:29 -0500 Subject: [PATCH 1/3] ci: OSS-Fuzz build integration, run on CI via ClusterFuzzLite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the OSS-Fuzz build contract for the five Atheris harnesses already in tests/fuzz/, and runs it on pull requests. .clusterfuzzlite/Dockerfile base-builder-python + pinned requirements .clusterfuzzlite/build.sh compiles every fuzz_*.py, zips its corpus .clusterfuzzlite/project.yaml language, engine, sanitizers On the upstream program itself: OSS-Fuzz's stated bar is that a project "must have a significant user base and/or be critical to the global IT infrastructure", which a self-hosted game control panel will not clear. That is what ClusterFuzzLite is for — the same base images, the same build contract, the same crash reporting, running on this repo's own CI with no enrolment. If the project ever does qualify, these three files ARE the submission: drop them in projects/linuxgsm-panel/ with a Dockerfile that clones instead of copies. Two things the OSS-Fuzz Python guide does not cover for a repo shaped like this one: - The panel is an application, not a pip package — there is no setup.py, so `pip3 install .` does not apply. The repo root goes on PYTHONPATH instead, which is both how the harnesses' imports resolve and how PyInstaller finds those modules to bundle. - The harnesses pre-load paramiko / eventlet.tpool / config through importlib.import_module("..."), deliberately, so instrument_imports() only instruments the parser. A string is invisible to PyInstaller's static analysis, so those are named as --hidden-import; without them the targets build clean and die on first import. bad-build-check is left ON precisely because that is the failure mode here. The workflow runs in code-change mode: it fuzzes what the diff touched, not everything, and files a crash as SARIF in the Security tab next to the CodeQL and Bandit alerts. run_fuzzers gets an explicit `language: python` — it defaults to c++ and does not inherit the build step's setting. This overlaps fuzz.yml on purpose: that is the broad every-target sweep, this is the targeted one with real crash reporting. Dropping either is reasonable; keeping both is what this does. Batch fuzzing, corpus pruning and coverage are NOT enabled — all three need a separate storage repo and a PAT to keep a corpus between runs. The README says how to turn them on. Verified: shellcheck clean, both YAML files parse, every corpus directory resolves to a target. NOT verified locally — the Docker build itself; there is no Docker on this machine. The README documents the helper.py commands to check it, and CI is the first real run. Co-Authored-By: Claude Opus 5 --- .clusterfuzzlite/Dockerfile | 14 ++++++++ .clusterfuzzlite/README.md | 55 ++++++++++++++++++++++++++++++ .clusterfuzzlite/build.sh | 37 +++++++++++++++++++++ .clusterfuzzlite/project.yaml | 10 ++++++ .github/workflows/cflite_pr.yml | 59 +++++++++++++++++++++++++++++++++ 5 files changed, 175 insertions(+) create mode 100644 .clusterfuzzlite/Dockerfile create mode 100644 .clusterfuzzlite/README.md create mode 100755 .clusterfuzzlite/build.sh create mode 100644 .clusterfuzzlite/project.yaml create mode 100644 .github/workflows/cflite_pr.yml diff --git a/.clusterfuzzlite/Dockerfile b/.clusterfuzzlite/Dockerfile new file mode 100644 index 0000000..681ceef --- /dev/null +++ b/.clusterfuzzlite/Dockerfile @@ -0,0 +1,14 @@ +# Build image for ClusterFuzzLite / OSS-Fuzz. Same contract as an upstream OSS-Fuzz project, with +# the one documented difference: ClusterFuzzLite COPYs the checkout in, where OSS-Fuzz git-clones it. +FROM gcr.io/oss-fuzz-base/base-builder-python + +# The panel's runtime dependencies. They have to be present at BUILD time, not just at run time: +# PyInstaller bundles what it can import while compiling each fuzzer, so a missing dependency here +# becomes a fuzzer that dies on startup instead of a build that fails loudly. +# Copied on its own first so this layer caches across source-only changes. +COPY requirements.txt $SRC/requirements.txt +RUN pip3 install --no-cache-dir -r $SRC/requirements.txt + +COPY . $SRC/linuxgsm-panel +WORKDIR $SRC/linuxgsm-panel +COPY .clusterfuzzlite/build.sh $SRC/ diff --git a/.clusterfuzzlite/README.md b/.clusterfuzzlite/README.md new file mode 100644 index 0000000..9e029de --- /dev/null +++ b/.clusterfuzzlite/README.md @@ -0,0 +1,55 @@ +# OSS-Fuzz / ClusterFuzzLite build integration + +These three files are the OSS-Fuzz build contract for this repo: + +| file | what it does | +|---|---| +| `Dockerfile` | `base-builder-python` + the panel's pinned requirements + the checkout | +| `build.sh` | compiles every `tests/fuzz/fuzz_*.py` harness into a fuzz target and zips its seed corpus | +| `project.yaml` | language, engine and sanitizers | + +`.github/workflows/cflite_pr.yml` runs them on each PR in **code-change** mode: it fuzzes only what +the diff touched and files any crash as a SARIF finding in the Security tab, alongside the CodeQL +and Bandit alerts. + +This overlaps with `.github/workflows/fuzz.yml` on purpose. That one is the broad sweep — every +target, fixed 60s, plain Atheris. This one is targeted, uses the real OSS-Fuzz toolchain, and +dedupes/reports crashes. Dropping either is a reasonable choice; keeping both is the current one. + +## Running it locally + +Needs Docker. From the repo root: + +```bash +git clone --depth 1 https://github.com/google/oss-fuzz /tmp/oss-fuzz +python3 /tmp/oss-fuzz/infra/helper.py build_image --external $PWD +python3 /tmp/oss-fuzz/infra/helper.py build_fuzzers --external $PWD +python3 /tmp/oss-fuzz/infra/helper.py check_build --external $PWD --language python +python3 /tmp/oss-fuzz/infra/helper.py run_fuzzer --external $PWD fuzz_console +``` + +`check_build` is the one worth running after touching `build.sh`: a PyInstaller bundle can compile +cleanly and still die on its first import if a dynamically-imported module was not bundled. That is +why `build.sh` names `paramiko`, `eventlet`, `eventlet.tpool` and `config` as `--hidden-import` — +the harnesses pull them in via `importlib.import_module("...")`, a string PyInstaller cannot see. + +## Not enabled: batch fuzzing, corpus pruning, coverage + +Those three modes want somewhere to keep a corpus between runs, which means a **separate storage +repo** and a personal access token. Without one, every run starts from the seeds in +`tests/fuzz/corpus/` and learns nothing from the last run. To turn them on: create an empty repo, +add a PAT with write access to it as a secret, then pass `storage-repo` to both actions and add +workflows with `mode: batch`, `mode: prune` and `mode: coverage`. See +. + +## Upstream OSS-Fuzz + +The same three files, moved to `projects/linuxgsm-panel/` in a PR against +, are a complete submission — the only change needed is a +`Dockerfile` that `git clone`s this repo instead of `COPY`ing the checkout, plus a +`primary_contact` email on a Google account. + +Worth knowing before spending the effort: the stated bar is that a project *"must have a +significant user base and/or be critical to the global IT infrastructure"*. A self-hosted game +control panel is unlikely to clear it. ClusterFuzzLite exists precisely so that projects below that +bar get the same engine on their own CI, which is what is wired up here. diff --git a/.clusterfuzzlite/build.sh b/.clusterfuzzlite/build.sh new file mode 100755 index 0000000..4001806 --- /dev/null +++ b/.clusterfuzzlite/build.sh @@ -0,0 +1,37 @@ +#!/bin/bash -eu +# Build every Atheris harness in tests/fuzz/ into an OSS-Fuzz fuzz target. +# +# The same script works for ClusterFuzzLite and for an upstream OSS-Fuzz project — compile_python_ +# fuzzer comes from the base-builder-python image in both. + +SRC_DIR="$SRC/linuxgsm-panel" + +# The panel is an application, not a pip package: there is no setup.py, so `pip3 install .` (what the +# OSS-Fuzz Python guide shows) does not apply. Putting the repo root on PYTHONPATH is what makes the +# harnesses' `import ssh_manager` resolve, and it is what lets PyInstaller find those modules to +# bundle while compiling. +export PYTHONPATH="$SRC_DIR${PYTHONPATH:+:$PYTHONPATH}" + +# The harnesses pre-load these through importlib.import_module("...") — a string, so PyInstaller's +# static analysis cannot see them and would ship a fuzzer that dies on its first import. Named here +# for every target rather than per target: the only cost is a slightly larger binary for the one +# harness (console) that does not need them, and a uniform build is worth more than those bytes. +HIDDEN_IMPORTS=( + --hidden-import paramiko + --hidden-import eventlet + --hidden-import eventlet.tpool + --hidden-import config +) + +for fuzzer in "$SRC_DIR"/tests/fuzz/fuzz_*.py; do + name="$(basename -s .py "$fuzzer")" + compile_python_fuzzer "$fuzzer" "${HIDDEN_IMPORTS[@]}" + + # Seed corpus. The runner picks up _seed_corpus.zip sitting next to the binary in + # $OUT. The corpora are named after the target minus its fuzz_ prefix (tests/fuzz/corpus/console + # feeds fuzz_console), matching what .github/workflows/fuzz.yml already passes on the command line. + corpus_dir="$SRC_DIR/tests/fuzz/corpus/${name#fuzz_}" + if [ -d "$corpus_dir" ]; then + zip -j -q -r "$OUT/${name}_seed_corpus.zip" "$corpus_dir" + fi +done diff --git a/.clusterfuzzlite/project.yaml b/.clusterfuzzlite/project.yaml new file mode 100644 index 0000000..a08a631 --- /dev/null +++ b/.clusterfuzzlite/project.yaml @@ -0,0 +1,10 @@ +# ClusterFuzzLite project configuration. `language` is the only required key; the rest are the +# fields an upstream OSS-Fuzz submission also wants, kept here so the two stay in sync. +language: python +homepage: "https://github.com/FMSMITH91/linuxgsm-panel" +main_repo: "https://github.com/FMSMITH91/linuxgsm-panel" +fuzzing_engines: + - libfuzzer +sanitizers: + - address + - undefined diff --git a/.github/workflows/cflite_pr.yml b/.github/workflows/cflite_pr.yml new file mode 100644 index 0000000..561f6bb --- /dev/null +++ b/.github/workflows/cflite_pr.yml @@ -0,0 +1,59 @@ +name: ClusterFuzzLite + +# ClusterFuzzLite is OSS-Fuzz's engine packaged to run on any repository — same base images, same +# build contract (.clusterfuzzlite/{Dockerfile,build.sh,project.yaml}), no enrolment needed. +# +# It overlaps with fuzz.yml, deliberately and differently: fuzz.yml runs every target for a fixed +# 60s on each push, while this runs in 'code-change' mode, fuzzing only what the diff actually +# touched and reporting a crash as a SARIF finding in the Security tab next to the CodeQL and +# Bandit alerts. Keeping both is a choice — fuzz.yml is the broad sweep, this is the targeted one. +on: + pull_request: + branches: [ main ] + +permissions: + contents: read + +jobs: + code-change: + name: fuzz the diff (${{ matrix.sanitizer }}) + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write # upload-sarif writes the crash into code scanning + concurrency: + group: ${{ github.workflow }}-${{ matrix.sanitizer }}-${{ github.ref }} + cancel-in-progress: true + strategy: + fail-fast: false + matrix: + sanitizer: [ address ] + steps: + - 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 }} + # bad-build-check is left ON (the default). These targets are PyInstaller bundles, so the + # failure to worry about is a fuzzer that builds fine and then dies on its first import — + # which is exactly what that check catches. + + - name: Fuzz the changed code (${{ matrix.sanitizer }}) + id: run + uses: google/clusterfuzzlite/actions/run_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1 + with: + language: python # run_fuzzers defaults to c++ — it does not inherit the build's setting + github-token: ${{ secrets.GITHUB_TOKEN }} + fuzz-seconds: 180 # per PR, across the targets the diff touches — fuzz.yml does the sweep + mode: code-change + sanitizer: ${{ matrix.sanitizer }} + output-sarif: true + + - name: Report a crash as a code-scanning finding + if: always() && steps.build.outcome == 'success' + uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + with: + sarif_file: cifuzz-sarif/results.sarif + checkout_path: cifuzz-sarif From b13887242d850fd5658bfaf1b90a02863cb05d8e Mon Sep 17 00:00:00 2001 From: FMSMITH91 <12152698+FMSMITH91@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:13:55 -0500 Subject: [PATCH 2/3] fix: bundle eventlet's dynamically-imported hubs into the fuzz targets The first CI run failed, which is what it was there to do: bad_build_check caught 3 of 5 targets dying at startup on ModuleNotFoundError: No module named 'eventlet.hubs.epolls' eventlet.hubs picks its implementation with a string, at import time: builtin_hub_modules = tuple(importlib.import_module('eventlet.hubs.' + name) for name in ('epolls', 'kqueue', 'poll', 'selects')) so `--hidden-import eventlet.tpool` bundled tpool and none of the hubs it transitively needs. Naming each hub by hand would work today and rot the next time eventlet adds one, so this collects the whole package instead. Same for paramiko (kex/cipher backends) and dns, which arrives transitively via eventlet's greendns. Kept the harnesses as they are rather than making their eventlet pre-load optional: ssh_manager does tolerate eventlet being absent, but a fuzz target whose import graph differs from production is testing something else. --- .clusterfuzzlite/build.sh | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/.clusterfuzzlite/build.sh b/.clusterfuzzlite/build.sh index 4001806..564d075 100755 --- a/.clusterfuzzlite/build.sh +++ b/.clusterfuzzlite/build.sh @@ -12,20 +12,26 @@ SRC_DIR="$SRC/linuxgsm-panel" # bundle while compiling. export PYTHONPATH="$SRC_DIR${PYTHONPATH:+:$PYTHONPATH}" -# The harnesses pre-load these through importlib.import_module("...") — a string, so PyInstaller's -# static analysis cannot see them and would ship a fuzzer that dies on its first import. Named here -# for every target rather than per target: the only cost is a slightly larger binary for the one -# harness (console) that does not need them, and a uniform build is worth more than those bytes. -HIDDEN_IMPORTS=( - --hidden-import paramiko - --hidden-import eventlet - --hidden-import eventlet.tpool +# PyInstaller only bundles what it can see in an `import` statement, and these packages reach for +# their real implementations through strings at import time. eventlet.hubs is the one that bit: +# +# builtin_hub_modules = tuple(importlib.import_module('eventlet.hubs.' + name) +# for name in ('epolls', 'kqueue', 'poll', 'selects')) +# +# so naming --hidden-import eventlet.tpool built five targets that all died on +# `No module named 'eventlet.hubs.epolls'`. --collect-submodules takes the whole package and does +# not need updating when a new submodule shows up. dns arrives transitively (eventlet's greendns); +# paramiko resolves its kex/cipher backends the same dynamic way. +COMPILE_ARGS=( + --collect-submodules eventlet + --collect-submodules paramiko + --collect-submodules dns --hidden-import config ) for fuzzer in "$SRC_DIR"/tests/fuzz/fuzz_*.py; do name="$(basename -s .py "$fuzzer")" - compile_python_fuzzer "$fuzzer" "${HIDDEN_IMPORTS[@]}" + compile_python_fuzzer "$fuzzer" "${COMPILE_ARGS[@]}" # Seed corpus. The runner picks up _seed_corpus.zip sitting next to the binary in # $OUT. The corpora are named after the target minus its fuzz_ prefix (tests/fuzz/corpus/console From 8f6f2334bfe5992ed2ba070a5513521d8cd802fc Mon Sep 17 00:00:00 2001 From: FMSMITH91 <12152698+FMSMITH91@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:29:44 -0500 Subject: [PATCH 3/3] fix: quote $SRC in the fuzz build image (Hadolint SC2086) Codacy's gate is zero new issues and it caught the one RUN line where $SRC was unquoted. The COPY lines are not shell, so they are unaffected. --- .clusterfuzzlite/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.clusterfuzzlite/Dockerfile b/.clusterfuzzlite/Dockerfile index 681ceef..8fd52b1 100644 --- a/.clusterfuzzlite/Dockerfile +++ b/.clusterfuzzlite/Dockerfile @@ -7,7 +7,7 @@ FROM gcr.io/oss-fuzz-base/base-builder-python # becomes a fuzzer that dies on startup instead of a build that fails loudly. # Copied on its own first so this layer caches across source-only changes. COPY requirements.txt $SRC/requirements.txt -RUN pip3 install --no-cache-dir -r $SRC/requirements.txt +RUN pip3 install --no-cache-dir -r "$SRC/requirements.txt" COPY . $SRC/linuxgsm-panel WORKDIR $SRC/linuxgsm-panel