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
82 changes: 82 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
name: Fuzz

# Runs the Jazzer.js targets in fuzz/ against the parsers that sit on a
# trust boundary. See fuzz/README.md for the property each target asserts.
#
# Why not ClusterFuzzLite: its two layers contradict each other for
# JavaScript. OSS-Fuzz's `compile` refuses to build a JS project with any
# sanitizer ("JavaScript projects cannot be fuzzed with sanitizers"), while
# CIFuzz's config validator rejects `none` — "Must be one of: ['address',
# 'memory', 'undefined', 'coverage']". Every permitted value fails one side
# or the other, so a CFL integration here cannot build at all. Jazzer.js is
# the engine CFL would have used; this runs it directly.

on:
pull_request:
branches: [main]
paths:
- 'src/**'
- 'fuzz/**'
- '.github/workflows/fuzz.yml'
schedule:
# Sundays 04:00 UTC — ahead of the 06:00 snapshot build so the two
# don't contend for runners.
- cron: '0 4 * * 0'
workflow_dispatch:

permissions:
contents: read

concurrency:
group: fuzz-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
fuzz:
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
target: [fuzz-storage-key, fuzz-tar-line, fuzz-zstd-header, fuzz-markdown]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ./.github/actions/setup

# Corpus persists across runs so coverage compounds instead of
# restarting cold every time. restore-keys lets a PR seed from the
# last scheduled run's corpus.
- name: Restore corpus
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: fuzz/corpus/${{ matrix.target }}
key: fuzz-corpus-${{ matrix.target }}-${{ github.run_id }}
restore-keys: |
fuzz-corpus-${{ matrix.target }}-

- name: Fuzz ${{ matrix.target }}
env:
# 60s per target on a PR keeps the gate quick; the weekly run digs
# for 10 minutes and hands its corpus to subsequent PRs.
DURATION: ${{ github.event_name == 'pull_request' && '60' || '600' }}
run: |
mkdir -p "fuzz/corpus/${{ matrix.target }}"
bun x jazzer "fuzz/${{ matrix.target }}.js" \
"fuzz/corpus/${{ matrix.target }}" \
--sync \
-- -max_total_time="$DURATION" -print_final_stats=1

# A crash file IS the bug report — keep it even though the step above
# already failed the job.
- name: Upload crash reproducers
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: fuzz-crashes-${{ matrix.target }}
path: |
crash-*
oom-*
timeout-*
leak-*
if-no-files-found: ignore
retention-days: 30
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,13 @@ docs/.vitepress/cache/
# Claude Code working tree (agent worktrees, plans, etc.)
.claude/
dist-beta/

# Fuzzing artifacts — libFuzzer reproducers and corpora are run-local.
# A crash-* file is a bug report, not something to commit; copy the input
# into a unit test instead so the regression is pinned by the suite.
crash-*
leak-*
timeout-*
oom-*
slow-unit-*
fuzz/corpus/
221 changes: 217 additions & 4 deletions bun.lock

Large diffs are not rendered by default.

62 changes: 62 additions & 0 deletions fuzz/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Fuzz targets

Jazzer.js fuzz targets for the parsers that handle bytes we did not write.
Run by `.github/workflows/fuzz.yml`: 60 s per target on pull requests that
touch `src/` or `fuzz/`, 10 minutes per target weekly, with the corpus
cached between runs so coverage compounds.

## Why not ClusterFuzzLite

It cannot build a JavaScript project. Its two layers disagree: OSS-Fuzz's
`compile` refuses any sanitizer for JS —

ERROR: JavaScript projects cannot be fuzzed with sanitizers.

— while CIFuzz's config validator rejects the only value `compile` accepts:

Invalid SANITIZER: none. Must be one of:
['address', 'memory', 'undefined', 'coverage'].

Every permitted value fails one side or the other; both were confirmed
against the real action. Jazzer.js is the engine ClusterFuzzLite would have
driven, so the workflow runs it directly and loses nothing but the hosted
corpus storage, which `actions/cache` covers.

Note this means Scorecard's Fuzzing check may keep reporting 0: it detects
integrations (OSS-Fuzz membership, a `.clusterfuzzlite/Dockerfile`) rather
than whether fuzzing actually happens. Keeping non-functional CFL config
around purely to satisfy that detector would be scoring points, not
fuzzing.

## What is fuzzed, and why

Everything here sits on a trust boundary — the input arrives from a
downloaded archive, a tar listing, or a document body:

| Target | Under test | The property that must hold |
| --- | --- | --- |
| `fuzz-storage-key.js` | `validateStorageKey` | If it returns, the key cannot traverse. A key that survives validation and still contains `..`, a leading `/`, a NUL, or a backslash is a path-traversal hole. |
| `fuzz-tar-line.js` | `parseTarVerboseLine` | Never throws on arbitrary `tar -tv` output, and never reports a directory entry as a file (the archive validator keys its path checks off `type`). |
| `fuzz-zstd-header.js` | `zstdContentSize` | Never throws on a malformed frame header, and never returns a negative or non-finite size — the value feeds the disk preflight's arithmetic. |
| `fuzz-markdown.js` | `extractFrontmatter` | Never throws, and never returns a body longer than its input. |

## Node, not Bun

Jazzer.js runs on Node. The modules above are Node-clean: every `Bun.*`
reference in them lives inside a function these targets do not call
(`safeFilename`'s hasher, `requiredBytesForArchive`'s `Bun.file`, the async
archive validators' `Bun.spawn`). Keep it that way — a target that pulls in
a Bun global fails at import time inside the OSS-Fuzz image, not at runtime,
so it is easy to miss.

## Running one locally

```bash
bun x @jazzer.js/core fuzz/fuzz-storage-key.js --sync -- -runs=100000
```

A crash writes a `crash-<sha>` file; feed it back with:

```bash
bun x @jazzer.js/core fuzz/fuzz-storage-key.js --sync -- crash-<sha>
```
35 changes: 35 additions & 0 deletions fuzz/fuzz-markdown.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* extractFrontmatter runs over every Markdown body in the corpus, including
* the ones an enrichment or crawl pulled off the network. It does index
* arithmetic on delimiters (`---`, `\n---`) and hands the remainder to a
* YAML parse.
*
* Properties: it never throws on arbitrary text, it always returns the
* documented shape, and the body it returns is a suffix of the input — a
* body longer than the input would mean the slicing invented content.
*/

import { extractFrontmatter } from '../src/content/parse-markdown.js'

export function fuzz(data) {
const text = data.toString('utf8')
const result = extractFrontmatter(text)

if (result == null || typeof result !== 'object') {
throw new Error(`extractFrontmatter returned ${JSON.stringify(result)}`)
}
if (typeof result.body !== 'string') {
throw new Error(`body is not a string: ${JSON.stringify(result.body)}`)
}
if (result.body.length > text.length) {
throw new Error(`body (${result.body.length}) longer than input (${text.length})`)
}
// No frontmatter means the body is the input verbatim — the parser must
// not silently drop leading content when it declines to parse.
if (result.frontmatter === null && result.body !== text) {
throw new Error('declined frontmatter but still altered the body')
}
if (result.frontmatter !== null && typeof result.frontmatter !== 'object') {
throw new Error(`frontmatter is neither null nor an object: ${JSON.stringify(result.frontmatter)}`)
}
}
48 changes: 48 additions & 0 deletions fuzz/fuzz-storage-key.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* validateStorageKey is the path-traversal guard for every on-disk corpus
* key. keyPath() calls it before resolving a key into `raw-json/` or
* `markdown/`, so a key that survives validation and still escapes is a
* write-anywhere primitive.
*
* The oracle is not "does it throw" — it is "if it ACCEPTS, is the key
* actually safe". Rejection is always fine; acceptance is what we check.
*/

import { ValidationError } from '../src/lib/errors.js'
import { validateStorageKey } from '../src/lib/safe-path.js'

/** Properties that must hold for every key validateStorageKey returns. */
function assertKeyCannotTraverse(key) {
if (key.startsWith('/') || key.startsWith('~')) {
throw new Error(`accepted an absolute key: ${JSON.stringify(key)}`)
}
if (/^[A-Za-z]:[\\/]/.test(key)) {
throw new Error(`accepted a Windows-rooted key: ${JSON.stringify(key)}`)
}
for (const segment of key.split('/')) {
if (segment === '' || segment === '.' || segment === '..') {
throw new Error(`accepted a traversing segment ${JSON.stringify(segment)} in ${JSON.stringify(key)}`)
}
if (segment.includes('\\') || segment.includes('\0')) {
throw new Error(`accepted a smuggling character in ${JSON.stringify(key)}`)
}
}
}

export function fuzz(data) {
const raw = data.toString('utf8')
let accepted
try {
accepted = validateStorageKey(raw)
} catch (err) {
// Rejection is a correct outcome for anything unsafe. Only a rejection
// that isn't our typed error indicates a real defect (a TypeError from
// an unhandled shape, say).
if (err instanceof ValidationError) return
throw err
}
if (accepted !== raw) {
throw new Error(`validateStorageKey mutated its input: ${JSON.stringify(raw)} -> ${JSON.stringify(accepted)}`)
}
assertKeyCannotTraverse(accepted)
}
41 changes: 41 additions & 0 deletions fuzz/fuzz-tar-line.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* parseTarVerboseLine reads `tar -tv` output for an archive we just
* downloaded, and the archive validator keys its containment checks off the
* `type` and `path` it returns. Malformed or hostile listing text must not
* crash the parse, and must not let an entry present itself as a benign
* file type.
*/

import { parseTarVerboseLine } from '../src/commands/setup/validate-archive.js'

export function fuzz(data) {
const text = data.toString('utf8')
// Real callers feed one line at a time; a NUL or newline inside the buffer
// is exactly the kind of smuggling we want to explore, so split the way
// the caller does rather than sanitising first.
for (const line of text.split('\n')) {
const entry = parseTarVerboseLine(line)
if (entry == null) continue

if (typeof entry.type !== 'string' || entry.type.length !== 1) {
throw new Error(`entry.type is not a single char: ${JSON.stringify(entry.type)}`)
}
if (typeof entry.path !== 'string') {
throw new Error(`entry.path is not a string: ${JSON.stringify(entry.path)}`)
}
// A `d` line describing a directory must never come back as a regular
// file: the validator applies its strictest path rules to non-'-' types,
// so a type downgrade would skip them.
if (line.startsWith('d') && entry.type !== 'd') {
throw new Error(`directory line parsed as type ${JSON.stringify(entry.type)}: ${JSON.stringify(line)}`)
}
if (line.startsWith('l') && entry.type !== 'l') {
throw new Error(`symlink line parsed as type ${JSON.stringify(entry.type)}: ${JSON.stringify(line)}`)
}
// The link target is either absent or a string — the validator
// dereferences it when present.
if (entry.link != null && typeof entry.link !== 'string') {
throw new Error(`entry.link is neither null nor a string: ${JSON.stringify(entry.link)}`)
}
}
}
47 changes: 47 additions & 0 deletions fuzz/fuzz-zstd-header.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* zstdContentSize parses the frame header of a freshly downloaded archive to
* decide how much disk the extraction needs. It does raw offset arithmetic
* over attacker-supplied bytes (RFC 8878 Frame_Content_Size), and its result
* feeds `needed = size * 2.05` in the setup preflight.
*
* Two properties matter. It must never throw — a crash here aborts an
* install before extraction. And it must never hand back a value that makes
* the preflight nonsense: negative, NaN, or non-integer.
*/

import { closeSync, mkdtempSync, openSync, rmSync, writeSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { zstdContentSize } from '../src/commands/setup/disk-space.js'

// One staging dir for the whole campaign — mkdtemp per iteration would make
// the filesystem, not the parser, the bottleneck.
const dir = mkdtempSync(join(tmpdir(), 'apple-docs-fuzz-zstd-'))
const path = join(dir, 'frame.tar.zst')

process.on('exit', () => { try { rmSync(dir, { recursive: true, force: true }) } catch {} })

export function fuzz(data) {
const fd = openSync(path, 'w')
try {
writeSync(fd, data, 0, data.length, 0)
} finally {
closeSync(fd)
}

const size = zstdContentSize(path)
if (size === null) return

if (typeof size !== 'number') {
throw new Error(`non-numeric frame size: ${JSON.stringify(size)}`)
}
if (!Number.isFinite(size)) {
throw new Error(`non-finite frame size: ${size}`)
}
if (size < 0) {
throw new Error(`negative frame size: ${size}`)
}
if (!Number.isInteger(size)) {
throw new Error(`fractional frame size: ${size}`)
}
}
9 changes: 6 additions & 3 deletions knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,21 @@
"src/**/*.js",
"test/**/*.js",
"scripts/**/*.js",
"docs/.vitepress/config.mjs"
"docs/.vitepress/config.mjs",
"fuzz/fuzz-*.js"
],
"project": [
"src/**/*.js",
"test/**/*.js",
"scripts/**/*.js",
"docs/.vitepress/**/*.{js,mjs}",
"!test/fixtures/**"
"!test/fixtures/**",
"fuzz/**/*.js"
],
"ignoreDependencies": [
"bun-types",
"onnxruntime-node",
"onnxruntime-web"
"onnxruntime-web",
"@jazzer.js/core"
]
}
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@g-cqd/apple-docs",
"version": "1.0.0",
"description": "Apple Developer Documentation CLI and MCP server search, read, and browse Apple docs locally",
"description": "Apple Developer Documentation CLI and MCP server \u2014 search, read, and browse Apple docs locally",
"type": "module",
"bin": {
"apple-docs": "./cli.js",
Expand Down Expand Up @@ -45,7 +45,7 @@
"start": "bun run index.js",
"eval:search": "bun scripts/eval-search.js",
"typecheck": "bun x tsc --noEmit",
"lint": "biome check --diagnostic-level=error ./src ./test ./cli.js ./index.js",
"lint": "biome check --diagnostic-level=error ./src ./test ./fuzz ./cli.js ./index.js",
"lint:web": "biome check --diagnostic-level=error ./src/web/",
"lint:unused": "bunx knip",
"lint:unused:fix": "bunx knip --fix",
Expand All @@ -64,6 +64,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.4.15",
"@jazzer.js/core": "^4.0.0",
"@types/bun": "^1.3.14",
"jscpd": "^5.0.4",
"knip": "^6.14.2",
Expand Down
Loading