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
14 changes: 14 additions & 0 deletions .clusterfuzzlite/Dockerfile
Original file line number Diff line number Diff line change
@@ -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/
55 changes: 55 additions & 0 deletions .clusterfuzzlite/README.md
Original file line number Diff line number Diff line change
@@ -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
<https://google.github.io/clusterfuzzlite/running-clusterfuzzlite/github-actions/>.

## Upstream OSS-Fuzz

The same three files, moved to `projects/linuxgsm-panel/` in a PR against
<https://github.com/google/oss-fuzz>, 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.
43 changes: 43 additions & 0 deletions .clusterfuzzlite/build.sh
Original file line number Diff line number Diff line change
@@ -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 <fuzz_target>_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
10 changes: 10 additions & 0 deletions .clusterfuzzlite/project.yaml
Original file line number Diff line number Diff line change
@@ -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
59 changes: 59 additions & 0 deletions .github/workflows/cflite_pr.yml
Original file line number Diff line number Diff line change
@@ -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