feat(security): fuzz the parsers that sit on a trust boundary - #19
Merged
Conversation
Closes the Scorecard Fuzzing gap (#62), and does it on the code that
actually reads bytes we didn't write.
Four Jazzer.js targets under fuzz/, each asserting a property rather than
merely "doesn't throw":
fuzz-storage-key validateStorageKey is the path-traversal guard for
every on-disk corpus key. The oracle is acceptance, not
rejection: if it returns, the key must not be absolute,
Windows-rooted, or contain an empty / "." / ".." segment
or a backslash or NUL. Rejecting is always fine.
fuzz-tar-line parseTarVerboseLine reads `tar -tv` output for a
just-downloaded archive, and the validator keys its
containment checks off the type it returns — so a `d`
or `l` line coming back as anything else would skip the
strict path rules.
fuzz-zstd-header zstdContentSize does raw offset arithmetic over an
attacker-supplied frame header, and its result feeds
`needed = size * 2.05` in the disk preflight. Must never
throw, and never yield negative / NaN / fractional.
fuzz-markdown extractFrontmatter slices on delimiters over arbitrary
document bodies; the body it returns must never be
longer than its input, and declining to parse must not
alter the body.
Ran locally before wiring any CI: 2.2M executions on storage-key and 3.1M on
tar-line, plus 20k each on the other two, no crashes.
ClusterFuzzLite runs them — 5 min per sanitizer on PRs that touch src/ or
fuzz/ (code-change mode, so only new crashes fail), and an hour weekly in
batch mode to grow the corpus that seeds the PR runs. Both address and
undefined sanitizers. Actions pinned by SHA like everything else.
The build deliberately skips `npm install`: the targets reach 13 project
modules and zero third-party packages (verified), so installing the tree
would add minutes and drag in heavy optional native deps no target imports.
Also: fuzz/ joins the biome lint scope, knip learns the targets are entry
points and that jazzer is a CLI harness rather than an import, and
libFuzzer reproducers are gitignored — a crash-* belongs in a unit test,
not in the repo.
Suite: 2533 pass, 16 skip, 0 fail.
| const text = data.toString('utf8') | ||
| const result = extractFrontmatter(text) | ||
|
|
||
| if (result == null || typeof result !== 'object') { |
The first PR run failed both legs at build time:
ERROR: JavaScript projects cannot be fuzzed with sanitizers.
address/undefined are C/C++ concepts; the OSS-Fuzz JavaScript builder
refuses any sanitizer but `none`. Jazzer.js reports uncaught exceptions and
our own assertion failures instead, which is exactly what the four targets
assert against — so the matrix was buying nothing even in principle.
One job per workflow now, sanitizer: none, with the reason recorded inline
so it does not get "fixed" back.
…cript
CFL's two layers contradict each other for JS, confirmed against the real
action across two pushes:
build step ERROR: JavaScript projects cannot be fuzzed with sanitizers.
CIFuzz cfg Invalid SANITIZER: none. Must be one of:
['address', 'memory', 'undefined', 'coverage'].
`compile` accepts only `none`; the config validator accepts everything
except `none`. Every permitted value fails one side, so no CFL integration
here can build at all — the sanitizer matrix I started with was wrong, and
so was `none`.
Dropped .clusterfuzzlite/ and the two cflite workflows and run Jazzer.js —
the engine CFL would have driven — straight from .github/workflows/fuzz.yml:
60 s per target on PRs touching src/ or fuzz/, 10 min per target weekly,
corpus cached via actions/cache so coverage compounds and PRs seed from the
last scheduled run. Crash reproducers upload as artifacts. Verified with the
exact command the workflow runs: 842,475 executions, 12 corpus entries kept.
The four targets are unchanged — they were never the problem.
Consequence worth stating: Scorecard's Fuzzing check may keep reporting 0.
It detects integrations (OSS-Fuzz membership, a .clusterfuzzlite/Dockerfile)
rather than whether fuzzing happens, and keeping dead CFL config around to
satisfy that detector would be scoring points rather than fuzzing. The
fuzzing is real; the badge may not follow.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the Scorecard Fuzzing gap (alert #62) on the code that actually reads bytes we didn't write.
Targets
Four Jazzer.js targets under
fuzz/, each asserting a property rather than merely "doesn't throw":fuzz-storage-keyvalidateStorageKey./..segment, backslash or NUL. This is the path-traversal guard for every on-disk corpus key.fuzz-tar-lineparseTarVerboseLinetar -tvoutput for a just-downloaded archive. The validator keys its containment checks offtype, so ad/lline coming back as another type would skip the strict path rules.fuzz-zstd-headerzstdContentSizeneeded = size * 2.05in the disk preflight. Never throws, never negative/NaN/fractional.fuzz-markdownextractFrontmatterVerified before wiring CI
fuzz-storage-key: 2,216,697 executions, no crashesfuzz-tar-line: 3,103,417 executions, no crashesfuzz-zstd-header/fuzz-markdown: 20k each, no crashes (the zstd target independently rediscovered the28 b5 2f fdmagic)CI
cflite-pr.yml): 5 min per sanitizer,code-changemode so only new crashes fail. Path-filtered tosrc/,fuzz/,.clusterfuzzlite/.cflite-batch.yml): 1 hour weekly, grows the corpus that seeds PR runs. Sunday 04:00 UTC, ahead of the 06:00 snapshot build so they don't contend for runners.address+undefined. Actions SHA-pinned.Notes
The build skips
npm installon purpose: the targets reach 13 project modules and zero third-party packages, so installing the tree would add minutes and pull in heavy optional native deps (@huggingface/transformers,playwright,sharp) no target imports.fuzz/README.mdrecords that constraint — a target that pulls in a Bun global fails at import time inside the OSS-Fuzz image, which is easy to miss.fuzz/joins the biome lint scope; knip learns the targets are entry points and that jazzer is a CLI harness, not an import; libFuzzer reproducers are gitignored (acrash-*belongs in a unit test, not the repo).Suite: 2533 pass, 16 skip, 0 fail.