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
25 changes: 12 additions & 13 deletions .github/workflows/cflite.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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 }})
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/fleet-status.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: {}
Expand Down
80 changes: 80 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -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 }}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ node_modules/
.DS_Store
.env
.env.*
# fuzz.yml / fuzz/run.mjs working dirs
fuzz/corpus/
fuzz/crashes/
46 changes: 30 additions & 16 deletions fuzz/run.mjs
Original file line number Diff line number Diff line change
@@ -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 <dir>/<target> 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);
}
}
Loading