Skip to content

feat(security): fuzz the parsers that sit on a trust boundary - #19

Merged
g-cqd merged 3 commits into
mainfrom
security/fuzzing-clusterfuzzlite
Aug 5, 2026
Merged

feat(security): fuzz the parsers that sit on a trust boundary#19
g-cqd merged 3 commits into
mainfrom
security/fuzzing-clusterfuzzlite

Conversation

@g-cqd

@g-cqd g-cqd commented Aug 5, 2026

Copy link
Copy Markdown
Owner

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":

Target Under test Property
fuzz-storage-key validateStorageKey Oracle is acceptance, not rejection: if it returns, the key can't be absolute, Windows-rooted, or carry an empty/./.. segment, backslash or NUL. This is the path-traversal guard for every on-disk corpus key.
fuzz-tar-line parseTarVerboseLine Reads tar -tv output for a just-downloaded archive. The validator keys its containment checks off type, so a d/l line coming back as another type would skip the strict path rules.
fuzz-zstd-header zstdContentSize Raw offset arithmetic over an attacker-supplied frame header; the result feeds needed = size * 2.05 in the disk preflight. Never throws, never negative/NaN/fractional.
fuzz-markdown extractFrontmatter Slices on delimiters over arbitrary bodies; the returned body is never longer than the input, and declining to parse must not alter it.

Verified before wiring CI

  • fuzz-storage-key: 2,216,697 executions, no crashes
  • fuzz-tar-line: 3,103,417 executions, no crashes
  • fuzz-zstd-header / fuzz-markdown: 20k each, no crashes (the zstd target independently rediscovered the 28 b5 2f fd magic)

CI

  • PR (cflite-pr.yml): 5 min per sanitizer, code-change mode so only new crashes fail. Path-filtered to src/, fuzz/, .clusterfuzzlite/.
  • Batch (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.
  • Both run address + undefined. Actions SHA-pinned.

Notes

The build skips npm install on 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.md records 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 (a crash-* belongs in a unit test, not the repo).

Suite: 2533 pass, 16 skip, 0 fail.

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.
Comment thread fuzz/fuzz-markdown.js
const text = data.toString('utf8')
const result = extractFrontmatter(text)

if (result == null || typeof result !== 'object') {
g-cqd added 2 commits August 5, 2026 23:00
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.
@g-cqd
g-cqd merged commit fe9367f into main Aug 5, 2026
9 checks passed
@g-cqd
g-cqd deleted the security/fuzzing-clusterfuzzlite branch August 5, 2026 21:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants