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
35 changes: 29 additions & 6 deletions .github/workflows/fuzz.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,17 +49,40 @@ jobs:
enable-cache: false
- name: ✅ Run fuzz (deep)
# the Monday 04:00 slot runs each target longer; the daily 06:00 slot and manual dispatch use the shorter budget
run: uvx --with tox-uv tox run -e fuzz -- --minutes "${MINUTES}"
# The seed is secret: with the public code it regenerates every crasher, so it stays out of the command line
# tox echoes and reaches fuzz.py only through the environment and the encrypted .replay files.
run: |
FUZZ_RNG_SEED="$(python3 -c 'import secrets; print(secrets.randbits(32))')"
export FUZZ_RNG_SEED
uvx --with tox-uv tox run -e fuzz -- --minutes "${MINUTES}"
env:
UV_PYTHON_PREFERENCE: only-managed
MINUTES: ${{ github.event.schedule == '0 4 * * 1' && '20' || '8' }}
- name: 📤 Upload crash corpus
if: failure()
# A crasher of an unfixed bug is an undisclosed vulnerability, and artifacts on a public repository are readable
# by anyone, so crashers leave the runner only encrypted to the maintainers' age key; without the key nothing
# leaves.
- name: 🔐 Encrypt crashers
if: failure() && vars.FUZZ_AGE_RECIPIENT != ''
run: |
curl -sSfL -o "${RUNNER_TEMP}/age.tar.gz" \
"https://github.com/FiloSottile/age/releases/download/${AGE_VERSION}/age-${AGE_VERSION}-linux-amd64.tar.gz"
echo "${AGE_SHA256} ${RUNNER_TEMP}/age.tar.gz" | sha256sum --check --strict
tar -xzf "${RUNNER_TEMP}/age.tar.gz" -C "${RUNNER_TEMP}" age/age
mkdir -p "${RUNNER_TEMP}/fuzz-crashes"
shopt -s nullglob
for crasher in .fuzz-crashes/crash-*; do
"${RUNNER_TEMP}/age/age" --recipient "${RECIPIENT}" \
--output "${RUNNER_TEMP}/fuzz-crashes/$(basename "${crasher}").age" "${crasher}"
done
env:
AGE_VERSION: v1.3.2
AGE_SHA256: cbe24006683f8eb669266162894b9a522a1af52f2665fbc63a4bb032ed26ac10
RECIPIENT: ${{ vars.FUZZ_AGE_RECIPIENT }}
- name: 📤 Upload encrypted crashers
if: failure() && vars.FUZZ_AGE_RECIPIENT != ''
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: fuzz-crashes
path: |
**/crash-*
**/*.crash
path: ${{ runner.temp }}/fuzz-crashes/*.age
if-no-files-found: ignore
retention-days: 14
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,6 @@ tools/bench/node/node_modules/

# the conformance oracles build in place
tests/conformance/oracles/*/target/

# tools/fuzz/fuzz.py stores crashing inputs here; they stay off the public repository
/.fuzz-crashes
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ repos:
rev: "v6.0.0" # v6.0.0
hooks:
- id: end-of-file-fixer
exclude: ^tools/fuzz/corpus/.*$ # byte-exact crash inputs
exclude: ^(tools/fuzz/corpus|tests/fuzz_regressions)/.*$ # byte-exact crash inputs
- id: trailing-whitespace
exclude: ^tools/fuzz/corpus/.*$ # byte-exact crash inputs
exclude: ^(tools/fuzz/corpus|tests/fuzz_regressions)/.*$ # byte-exact crash inputs
- repo: https://github.com/python-jsonschema/check-jsonschema
rev: "0.38.2" # 0.37.2
hooks:
Expand Down
46 changes: 37 additions & 9 deletions docs/development/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -128,20 +128,48 @@ Two mechanisms share one driver (``tools/fuzz/fuzz.py``):
runs against an extension built with the sanitizers and calls the public API, so a C fault aborts the interpreter with
a stack trace and the harness survives internal C refactors.

The ``fuzz-smoke`` environment replays a benign seed corpus (``tools/fuzz/corpus/``) once per target. It is fast and
deterministic and gates every pull request in the ``🔒 fuzz`` workflow, so it seeds with valid inputs, never known
crashers. The ``fuzz`` environment adds a mutation loop and escalating-depth structural probes for a per-target budget;
it is the continuous hunt, run weekly and on demand, not a merge gate.
The ``fuzz-smoke`` environment replays every past find in ``tests/fuzz_regressions/`` through every harness, then a
benign seed corpus (``tools/fuzz/corpus/``) once per target. It is deterministic and gates every pull request in the
``🔒 fuzz`` workflow. The ``fuzz`` environment adds a mutation loop and escalating-depth structural probes for a
per-target budget; it is the continuous hunt, run daily and on demand, not a merge gate.

.. code-block:: console

$ tox r -e fuzz-smoke # the per-PR gate: benign corpus, no crash expected
$ tox r -e fuzz-smoke # the per-PR gate: past finds and benign corpus, no crash expected
$ tox r -e fuzz -- --minutes 5 # the deep run: mutation + structural probes per target

Add a target by registering a ``bytes``-taking callable in ``TARGETS`` (in-process) and dropping a representative benign
seed under ``tools/fuzz/corpus/<target>/``; add a standalone harness by mirroring ``idna_harness.c`` for any C unit that
compiles free of the CPython boundary. macOS ships no ``libFuzzer`` runtime with Apple Clang, so the coverage-guided
mode needs an LLVM Clang (``brew install llvm``); the corpus-replay and mutation modes run under Apple Clang.
The in-process driver runs each input under pymalloc and again under ``PYTHONMALLOC=malloc``, because AddressSanitizer
cannot see an over-read that stays inside a pymalloc pool. The deep run splits ``--minutes`` between the two passes.
Both environments pin ``PYTHONHASHSEED=0``. ``--rng-seed`` (default ``$FUZZ_RNG_SEED``, else 0) fixes the mutation
sequence. The scheduled run draws a random seed and keeps it out of the log, since the seed regenerates every crasher.

``fuzz.py`` stores a crashing input as ``.fuzz-crashes/crash-<sha256>``, with its seed and mutation index in
``crash-<sha256>.replay``, and logs only the SHA-256, length and harness, because anyone can read the CI logs of a
public repository. A deep run writes sanitizer reports to ``crash-sanitizer.<pid>`` beside them for the same reason.
Once the fix lands, copy the input into ``tests/fuzz_regressions/`` with the issue number in its name, and every later
run replays it first.

The scheduled run uploads crashers only when the ``FUZZ_AGE_RECIPIENT`` repository variable holds an `age
<https://github.com/FiloSottile/age>`_ public key, and it encrypts each one to that key first. Without the variable it
uploads nothing. A maintainer sets the variable once and keeps the identity file private:

.. code-block:: console

$ age-keygen -o fuzz-crashes.key # prints "Public key: age1..."
$ gh variable set FUZZ_AGE_RECIPIENT --body age1...

To read the crashers of a failed run, download its ``fuzz-crashes`` artifact and decrypt each file:

.. code-block:: console

$ gh run download <run-id> --name fuzz-crashes
$ age --decrypt --identity fuzz-crashes.key --output crash-<sha256> crash-<sha256>.age

Add a target by registering a ``bytes``-taking callable in ``_TARGETS`` (in-process) and dropping a representative
benign seed under ``tools/fuzz/corpus/<target>/``; add a standalone harness by mirroring ``idna_harness.c`` for any C
unit that compiles free of the CPython boundary. macOS ships no ``libFuzzer`` runtime with Apple Clang, so the
coverage-guided mode needs an LLVM Clang (``brew install llvm``); the corpus-replay and mutation modes run under Apple
Clang.

****************
Project layout
Expand Down
1 change: 1 addition & 0 deletions tests/fuzz_regressions/949-js-empty-member.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
t.0.
2 changes: 2 additions & 0 deletions tests/fuzz_regressions/952-css-selectorless-rule.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{--x:
}
Loading
Loading