Fuzz untrusted input boundaries¶
Vexcalibur combines deterministic Hypothesis properties with bounded Atheris campaigns. Use the property tests while developing. Use Atheris when a parser, source client, package-URL rule, or terminal-safety boundary changes.
Fuzzing finds counterexamples; it does not prove that a parser is safe. The normal regression suite, input-size limits, interoperability tests, dependency audit, and CodeQL remain separate controls.
What the harness covers¶
Every target calls production parsing code. The shared oracle runs the same input twice and compares a normalized outcome. Documented boundary exceptions are rejections. Any other exception, assertion failure, timeout, or process failure is a crash to investigate.
Target |
Production boundary |
Additional invariant |
|---|---|---|
|
strict UTF-8 JSON decoder |
Duplicate keys, excessive nesting, oversized integers, and non-finite numbers have typed failures. |
|
CycloneDX JSON/XML loader |
XML defenses and component-count, depth, reference, version, and package-URL rules remain fail-closed. |
|
GitHub SPDX 2.3 response mapper |
Malformed shapes, ambiguous package URLs, duplicate references, and conflicting versions have typed failures. |
|
Local findings loader |
Selectors, URLs, timestamps, enums, and component matching have typed failures. |
|
OSV response transport and query parsers |
Identity, valid and malformed gzip, HTTP errors, pagination, evolving fields, and terminal-safe vulnerability IDs are covered without network access. |
|
CycloneDX component normalization |
Equivalent generated JSON and XML produce the same component identity. |
Inputs are synthetic. The harness never calls GitHub, OSV, or another service. Do not add private SBOMs, credentials, embargoed vulnerability data, or customer identifiers to a corpus or crash artifact.
GitHub Actions logs and artifacts in a public repository are not a confidential
disclosure channel. Reproduce an embargoed or sensitive case only on a local or
approved private runner, then report it through the private route in
SECURITY.md.
Run deterministic property tests¶
Install the normal locked development environment and run the required smoke profile:
uv sync --frozen
make fuzz-smoke
The profile disables Hypothesis’s example database, derives examples
deterministically, runs 50 examples for each target, caps an example at 64 KiB,
and applies a one-second deadline. Pull requests run this profile on CPython
3.14 in the Parser fuzz smoke job. The job has a five-minute wall-clock limit
and is part of the protected CI result check.
The ordinary Python 3.10–3.14 matrix excludes tests marked fuzz; this avoids
running the same deterministic campaign five times. make check includes the
smoke profile once.
Run coverage-guided fuzzing¶
Atheris 3.1.0 is isolated in the non-default fuzz dependency group. Its
published wheels support CPython 3.12–3.14 on Linux x86-64. The scheduled job
uses CPython 3.14 on an x86-64 Ubuntu runner. Other platforms can run the
Hypothesis layer but cannot install the locked Atheris group.
Run every target locally:
make fuzz-coverage
Select one target while developing:
FUZZ_TARGET=osv FUZZ_MAX_TOTAL_TIME=60 make fuzz-coverage
Defaults are deliberately finite:
Limit |
Local default |
Scheduled value |
|---|---|---|
Input length |
65,536 bytes |
65,536 bytes |
One input |
5 seconds |
5 seconds |
Resident memory |
2,048 MiB |
2,048 MiB |
Campaign per target |
30 seconds |
120 seconds |
Whole CI job |
Not applicable |
20 minutes |
Lower the local input cap with FUZZ_MAX_LEN. The shared oracle always rejects
inputs above 65,536 bytes, so the runner rejects a larger value. Override the
other local integer limits with FUZZ_TIMEOUT_SECONDS, FUZZ_RSS_LIMIT_MB,
and FUZZ_MAX_TOTAL_TIME. The runner rejects zero, negative, or non-integer
values.
The weekly Parser fuzzing workflow has only contents: read. It installs and
audits the frozen fuzz dependency group, runs targets sequentially, and uploads
synthetic crash reproducers only after a failure. CI keeps generated corpus and
artifacts below its isolated runner-temporary directory. Local generated corpus
entries live in .fuzz-corpus/; local crash, timeout, leak, and out-of-memory
reproducers live below fuzz-artifacts/. Both local directories are ignored. Set
FUZZ_CORPUS_ROOT or FUZZ_ARTIFACT_ROOT to choose another local location.
Maintain seeds and regressions¶
Repository maintainers own the initial classification of every scheduled-run
failure. Move a security-sensitive case to the private SECURITY.md route as
soon as that classification is plausible.
Keep a small seed for each distinct parser shape under
tests/fuzz/corpus/<target>/. A seed should reach useful production code, not
enumerate every invalid spelling. Keep it human-inspectable when possible and
well below the 64 KiB limit.
When a campaign finds a crash:
Download the artifact without opening or executing it.
Confirm that it contains only synthetic data.
Replay it against the exact failing commit and target. For example:
FUZZ_TARGET=osv uv run --frozen --group fuzz python -m tests.fuzz.fuzz_boundaries \ fuzz-artifacts/osv/crash-example -runs=1
Classify the result: security impact, correctness defect, resource-limit defect, harness defect, or expected typed rejection.
Minimize the input with Atheris or by hand.
Add the minimal case as an ordinary named regression test. Add it to the seed corpus only when it improves exploration.
Fix the production boundary without broadening the list of swallowed exceptions.
Report a security-sensitive crash through the private route in SECURITY.md.
Do not attach it to a public issue before the impact is understood.
Know the limits¶
Atheris instruments Python execution. It does not provide native-code coverage inside XML, compression, HTTP, or package-URL dependencies. The OSV target uses an in-memory HTTP transport and deliberately covers response handling rather than DNS, TLS, proxy, or socket behavior. The identity target generates valid, bounded package identities; arbitrary package-URL spellings still enter through the raw SBOM, GitHub, and local-findings targets.
The checked-in corpus is a starting point, not a coverage claim. Review target scope whenever an input format, source provider, normalization rule, or output presentation boundary changes.