diff --git a/.clusterfuzzlite/Dockerfile b/.clusterfuzzlite/Dockerfile new file mode 100644 index 0000000..8fd52b1 --- /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..564d075 --- /dev/null +++ b/.clusterfuzzlite/build.sh @@ -0,0 +1,43 @@ +#!/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}" + +# 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" "${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 + # 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