diff --git a/.github/workflows/cflite.yml b/.github/workflows/cflite.yml index e634d78..96527f6 100644 --- a/.github/workflows/cflite.yml +++ b/.github/workflows/cflite.yml @@ -1,12 +1,12 @@ -# ClusterFuzzLite — continuous fuzzing of browser-bridge's trust boundary: -# the CDP proxy's pure request guards (token extraction, the DNS-rebinding -# Host gate, secret stripping before anything is forwarded to Chromium) and -# the UA pool fed by client-controlled ?session= ids. Runs weekly + on -# demand; a discovered crash fails the job and is uploaded as an artifact. -# Fuzz targets live in ./fuzz, built by .clusterfuzzlite/build.sh (Jazzer.js). -# OpenSSF Scorecard credits the Fuzzing check from the .clusterfuzzlite/ -# config. -name: ClusterFuzzLite +# ClusterFuzzLite COVERAGE REPORT of the fuzz targets in ./fuzz. This action version cannot fuzz +# JavaScript: the OSS-Fuzz builder rejects every sanitizer for JS ("JavaScript projects cannot be +# fuzzed with sanitizers"), the action's config rejects `none`, and `coverage` selects its +# coverage-report runner, not the fuzzer (oss-fuzz infra/cifuzz/config_utils.py forces +# mode=coverage for it). Proven 2026-09-26 on plumbline runs 36204398437 (address) and +# 36204619235 (none). Every run of this workflow builds the targets, replays the corpus for a +# few seconds and uploads a coverage report; the fuzzing itself is fuzz.yml (Jazzer.js). +# The .clusterfuzzlite/ config stays: OpenSSF Scorecard credits the Fuzzing check from it. +name: ClusterFuzzLite coverage on: schedule: @@ -17,15 +17,14 @@ permissions: read-all jobs: Fuzzing: + name: Coverage report (${{ matrix.sanitizer }}) runs-on: ubuntu-latest timeout-minutes: 20 strategy: fail-fast: false matrix: - # JavaScript (Jazzer.js) has no native sanitizer — OSS-Fuzz rejects - # address/memory/undefined for JS ("cannot be fuzzed with sanitizers"), - # and the action's config rejects `none`. `coverage` is the value real - # JS ClusterFuzzLite projects use with this action version. + # `coverage` is the only value this action accepts for JavaScript that builds at all, and it + # means: report, not fuzz (see the header). The fuzzing runs in fuzz.yml. sanitizer: [coverage] steps: - name: Build fuzzers (${{ matrix.sanitizer }}) diff --git a/.github/workflows/fleet-status.yml b/.github/workflows/fleet-status.yml index 20e5448..74ca784 100644 --- a/.github/workflows/fleet-status.yml +++ b/.github/workflows/fleet-status.yml @@ -15,7 +15,7 @@ on: issue_comment: types: [created, edited, deleted] workflow_run: - workflows: [actionlint, build, CodeQL, 'fieldpass ci', hygiene, labels, stealth-score, 'PR triage', Redline] + workflows: [actionlint, build, CodeQL, 'fieldpass ci', hygiene, labels, stealth-score, 'PR triage', Redline, Fuzz] types: [completed] permissions: {} diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000..1c0c187 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,80 @@ +# Fuzzing of browser-bridge's trust boundary with Jazzer.js (libFuzzer): every target in ./fuzz runs +# for FUZZ_SECONDS against a corpus that persists between runs through the Actions cache. A +# crash, an uncaught throw or a timeout fails the job and the reproducing input is uploaded as +# an artifact. This workflow is the fuzzer; cflite.yml is only the coverage report, because +# ClusterFuzzLite's action cannot fuzz JavaScript at all: it rejects every sanitizer for JS, +# rejects `none`, and `coverage` selects its report runner (proven 2026-09-26 on plumbline runs +# 36204398437 and 36204619235). Weekly, on demand, and on pull requests that touch the targets +# or the code under them, with a short budget there. +name: Fuzz + +on: + schedule: + - cron: '57 6 * * 1' # weekly, 06:57 UTC, after the coverage report's slot + workflow_dispatch: + inputs: + seconds: + description: 'Seconds per target' + required: false + default: '300' + pull_request: + paths: + - 'fuzz/**' + - 'cdp-proxy.mjs' + - 'ua.mjs' + - 'package.json' + - 'package-lock.json' + - '.github/workflows/fuzz.yml' + +permissions: + contents: read + +concurrency: + group: fuzz-${{ github.ref }} + cancel-in-progress: true + +jobs: + fuzz: + name: Fuzz (Jazzer.js) + # Never from a fork: the schedule is inherited by every fork and means nothing there. + if: github.repository == 'askalf/browser-bridge' + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + - name: Install dependencies + run: npm ci --no-audit --no-fund + # The newest saved corpus, whichever run saved it; the save below writes a fresh key each + # run so the restore-keys prefix always finds the latest. + - name: Restore the corpus + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: fuzz/corpus + key: fuzz-corpus-${{ github.run_id }} + restore-keys: fuzz-corpus- + - name: Fuzz every target + env: + FUZZ_SECONDS: ${{ github.event_name == 'pull_request' && '60' || inputs.seconds || '300' }} + FUZZ_CORPUS_DIR: fuzz/corpus + FUZZ_ARTIFACT_DIR: fuzz/crashes + run: node fuzz/run.mjs + - name: Upload the crashing input + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: fuzz-crashes-${{ github.run_id }} + path: fuzz/crashes + if-no-files-found: ignore + # Only runs on the default branch feed the shared corpus; a pull request's cache is scoped + # to its branch anyway. + - name: Save the corpus + if: always() && github.event_name != 'pull_request' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: fuzz/corpus + key: fuzz-corpus-${{ github.run_id }} diff --git a/.gitignore b/.gitignore index 201129f..61901a9 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ node_modules/ .DS_Store .env .env.* +# fuzz.yml / fuzz/run.mjs working dirs +fuzz/corpus/ +fuzz/crashes/ diff --git a/fuzz/run.mjs b/fuzz/run.mjs index e41cf75..4932c30 100644 --- a/fuzz/run.mjs +++ b/fuzz/run.mjs @@ -1,27 +1,41 @@ -// `npm run fuzz` — run every Jazzer.js target in ./fuzz for a short burst. -// Continuous fuzzing is done in CI by ClusterFuzzLite (.github/workflows/ -// cflite.yml); this is the fast local repro loop. Targets import the runtime -// .mjs modules directly (no build step). Override the per-target budget with -// FUZZ_SECONDS (default 30). +// `node fuzz/run.mjs` (also `npm run fuzz`) runs every Jazzer.js target in ./fuzz. This is the +// fuzzer CI runs (.github/workflows/fuzz.yml) and the local repro loop. Environment: +// FUZZ_SECONDS per-target budget in seconds (default 30) +// FUZZ_CORPUS_DIR root of per-target corpus dirs, created on demand; libFuzzer reads its +// seeds from / and saves every interesting input there, so a +// corpus that persists between runs keeps getting deeper. Unset: no corpus. +// FUZZ_ARTIFACT_DIR where a crashing input is written, created on demand. Unset: the cwd. import { spawnSync } from 'node:child_process'; -import { readdirSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; +import { mkdirSync, readdirSync } from 'node:fs'; import { createRequire } from 'node:module'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; const dir = path.dirname(fileURLToPath(import.meta.url)); const targets = readdirSync(dir).filter((f) => f.endsWith('.fuzz.js')).sort(); const secs = process.env.FUZZ_SECONDS || '30'; -// Run Jazzer's JS CLI directly under `node` — no .cmd wrapper, no shell, so a -// space in the repo path can't break the invocation. +const corpusRoot = process.env.FUZZ_CORPUS_DIR || ''; +const artifactDir = process.env.FUZZ_ARTIFACT_DIR || ''; +// Run Jazzer's JS CLI directly under `node`: no .cmd wrapper, no shell, so a space in the repo +// path cannot break the invocation. const jazzerCli = createRequire(import.meta.url).resolve('@jazzer.js/core/dist/cli.js'); +if (artifactDir) mkdirSync(artifactDir, { recursive: true }); for (const t of targets) { - console.log(`\n=== fuzzing ${t} (${secs}s) ===`); - const r = spawnSync( - process.execPath, - [jazzerCli, `fuzz/${t.replace(/\.js$/, '')}`, '--sync', '--', `-max_total_time=${secs}`], - { stdio: 'inherit' }, - ); - if (r.status !== 0) process.exit(r.status || 1); + const name = t.replace(/\.fuzz\.js$/, ''); + // The targets are synchronous (they never return a promise), so Jazzer runs in --sync mode. + const args = [jazzerCli, `fuzz/${name}.fuzz`, '--sync']; + if (corpusRoot) { + const corpus = path.join(corpusRoot, name); + mkdirSync(corpus, { recursive: true }); + args.push(corpus); + } + args.push('--', `-max_total_time=${secs}`, '-print_final_stats=1'); + if (artifactDir) args.push(`-artifact_prefix=${artifactDir}${path.sep}`); + console.log(`\n=== fuzzing ${name} (${secs}s) ===`); + const r = spawnSync(process.execPath, args, { stdio: 'inherit' }); + if (r.status !== 0) { + console.error(`\n${name}: jazzer exited with ${r.status ?? r.signal}; a reproducing input is in ${artifactDir || 'the working directory'}`); + process.exit(r.status || 1); + } }