-
Notifications
You must be signed in to change notification settings - Fork 0
feat(fuzz): mutation fuzzer with schema-conformance oracle #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 5 commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
68c8b73
feat(fuzz): mutation fuzzer with schema-conformance oracle
jarvis9443 7784173
ci: trigger workflows
jarvis9443 ae88834
fix: address review comments on fuzz harness PR
jarvis9443 7d9af9d
fix(ci): use luarocks 3.12.0 from GitHub with OpenSSL config
jarvis9443 0b09794
fix: address all open review comments on fuzz harness
jarvis9443 3cc7110
fix: address second round of review comments
jarvis9443 5ff631d
fix: address third round of review comments
jarvis9443 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| name: fuzz-nightly | ||
|
|
||
| on: | ||
| schedule: | ||
| - cron: '0 18 * * *' # 18:00 UTC = 02:00 China next day | ||
| workflow_dispatch: | ||
| inputs: | ||
| budget: | ||
| description: 'Fuzz budget in seconds' | ||
| required: false | ||
| default: '600' | ||
|
|
||
| permissions: | ||
| contents: read | ||
| issues: write | ||
|
|
||
| jobs: | ||
| fuzz: | ||
| runs-on: ubuntu-22.04 | ||
| env: | ||
| OPENRESTY_PREFIX: "/usr/local/openresty" | ||
| FUZZ_BUDGET: ${{ github.event.inputs.budget || '600' }} | ||
|
|
||
| steps: | ||
| - name: Check out code | ||
| uses: actions/checkout@v4 | ||
|
|
||
| - name: Install system dependencies | ||
| run: | | ||
| sudo apt-get update | ||
| sudo apt-get install -y build-essential libncurses5-dev libreadline-dev libssl-dev perl lua5.1 liblua5.1-0-dev | ||
|
|
||
| - name: Install OpenResty | ||
| run: | | ||
| wget -qO - https://openresty.org/package/pubkey.gpg | sudo gpg --dearmor -o /usr/share/keyrings/openresty.gpg | ||
| echo "deb [signed-by=/usr/share/keyrings/openresty.gpg] http://openresty.org/package/ubuntu $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/openresty.list | ||
| sudo apt-get update | ||
| sudo apt-get install -y openresty | ||
|
|
||
| - name: Install LuaRocks | ||
| run: | | ||
| LUAROCKS_VER=3.12.0 | ||
| wget -q "https://github.com/luarocks/luarocks/archive/v${LUAROCKS_VER}.tar.gz" | ||
| tar xzf "v${LUAROCKS_VER}.tar.gz" | ||
| cd "luarocks-${LUAROCKS_VER}" | ||
| ./configure --with-lua=$OPENRESTY_PREFIX/luajit | ||
| make build && sudo make install | ||
| cd .. && rm -rf "luarocks-${LUAROCKS_VER}" "v${LUAROCKS_VER}.tar.gz" | ||
|
|
||
| # Configure OpenSSL paths for rocks that need it | ||
| OPENSSL_PREFIX=$OPENRESTY_PREFIX/openssl3 | ||
| if [ ! -d "$OPENSSL_PREFIX" ]; then | ||
| OPENSSL_PREFIX=$OPENRESTY_PREFIX/openssl111 | ||
| fi | ||
| if [ ! -d "$OPENSSL_PREFIX" ]; then | ||
| OPENSSL_PREFIX=$OPENRESTY_PREFIX/openssl | ||
| fi | ||
| if [ -d "$OPENSSL_PREFIX" ]; then | ||
| luarocks config variables.OPENSSL_LIBDIR ${OPENSSL_PREFIX}/lib | ||
| luarocks config variables.OPENSSL_INCDIR ${OPENSSL_PREFIX}/include | ||
| fi | ||
|
|
||
| - name: Install Lua dependencies | ||
| run: | | ||
| sudo luarocks install jsonschema | ||
| sudo luarocks install lua-resty-radixtree | ||
|
jarvis9443 marked this conversation as resolved.
|
||
|
|
||
| - name: Run mutation fuzzer | ||
| id: fuzz | ||
| continue-on-error: true | ||
| run: | | ||
| export PATH=$OPENRESTY_PREFIX/nginx/sbin:$OPENRESTY_PREFIX/bin:$PATH | ||
| case "$FUZZ_BUDGET" in | ||
| ''|*[!0-9]*) | ||
| echo "FUZZ_BUDGET must be an integer number of seconds" >&2 | ||
| exit 2 | ||
| ;; | ||
| esac | ||
| make fuzz "FUZZ_BUDGET=$FUZZ_BUDGET" | ||
|
|
||
| - name: Upload findings | ||
| if: steps.fuzz.outcome == 'failure' | ||
| uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: fuzz-findings-${{ github.run_id }} | ||
| path: fuzz/out/ | ||
| retention-days: 90 | ||
|
|
||
| - name: Open / update tracking issue | ||
| if: steps.fuzz.outcome == 'failure' | ||
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| run: | | ||
| set -euo pipefail | ||
| DATE=$(date -u +%Y-%m-%d) | ||
| TITLE="Nightly fuzz failure: $DATE" | ||
| RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" | ||
| SUMMARY=$(cat fuzz/out/summary.json 2>/dev/null || echo '{}') | ||
| FIRST5=$(head -n 5 fuzz/out/crashes.jsonl 2>/dev/null || echo '(no crashes.jsonl found)') | ||
| BODY=$(cat <<EOF | ||
| The nightly fuzz run failed. | ||
|
|
||
| - Run: $RUN_URL | ||
| - Summary: \`$SUMMARY\` | ||
| - Findings artifact: \`fuzz-findings-${{ github.run_id }}\` (attached to the run, retained 90 days) | ||
|
|
||
| First 5 findings: | ||
| \`\`\` | ||
| $FIRST5 | ||
| \`\`\` | ||
|
|
||
| To reproduce locally: | ||
| \`\`\` | ||
| make fuzz FUZZ_BUDGET=$FUZZ_BUDGET | ||
| \`\`\` | ||
| EOF | ||
| ) | ||
|
|
||
| # De-dup: reuse any open issue with the fuzz-nightly label. | ||
| existing=$(gh issue list --label fuzz-nightly --state open \ | ||
| --json number --jq '.[0].number // empty' || echo "") | ||
| if [ -n "$existing" ]; then | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| gh issue comment "$existing" --body "$BODY" | ||
| gh issue edit "$existing" --add-assignee jarvis9443 || true | ||
| else | ||
| # Ensure label exists (idempotent). | ||
| gh label create fuzz-nightly --color FBCA04 \ | ||
| --description "Findings from the nightly fuzz job" 2>/dev/null || true | ||
| gh issue create --title "$TITLE" \ | ||
| --label fuzz-nightly,bug \ | ||
| --assignee jarvis9443 \ | ||
| --body "$BODY" | ||
| fi | ||
|
|
||
| - name: Fail the job if fuzz failed | ||
| if: steps.fuzz.outcome == 'failure' | ||
| run: exit 1 | ||
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| name: fuzz | ||
|
|
||
| on: | ||
| push: | ||
| branches: [ main ] | ||
| pull_request: | ||
| branches: [ main ] | ||
|
|
||
| jobs: | ||
| fuzz: | ||
| runs-on: ubuntu-22.04 | ||
| env: | ||
| OPENRESTY_PREFIX: "/usr/local/openresty" | ||
| FUZZ_BUDGET: "120" | ||
|
|
||
| steps: | ||
| - name: Check out code | ||
| uses: actions/checkout@v4 | ||
|
|
||
| - name: Install system dependencies | ||
| run: | | ||
| sudo apt-get update | ||
| sudo apt-get install -y build-essential libncurses5-dev libreadline-dev libssl-dev perl lua5.1 liblua5.1-0-dev | ||
|
|
||
| - name: Install OpenResty | ||
| run: | | ||
| wget -qO - https://openresty.org/package/pubkey.gpg | sudo gpg --dearmor -o /usr/share/keyrings/openresty.gpg | ||
| echo "deb [signed-by=/usr/share/keyrings/openresty.gpg] http://openresty.org/package/ubuntu $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/openresty.list | ||
| sudo apt-get update | ||
| sudo apt-get install -y openresty | ||
|
|
||
| - name: Install LuaRocks | ||
| run: | | ||
| LUAROCKS_VER=3.12.0 | ||
| wget -q "https://github.com/luarocks/luarocks/archive/v${LUAROCKS_VER}.tar.gz" | ||
| tar xzf "v${LUAROCKS_VER}.tar.gz" | ||
| cd "luarocks-${LUAROCKS_VER}" | ||
| ./configure --with-lua=$OPENRESTY_PREFIX/luajit | ||
| make build && sudo make install | ||
| cd .. && rm -rf "luarocks-${LUAROCKS_VER}" "v${LUAROCKS_VER}.tar.gz" | ||
|
|
||
| # Configure OpenSSL paths for rocks that need it | ||
| OPENSSL_PREFIX=$OPENRESTY_PREFIX/openssl3 | ||
| if [ ! -d "$OPENSSL_PREFIX" ]; then | ||
| OPENSSL_PREFIX=$OPENRESTY_PREFIX/openssl111 | ||
| fi | ||
| if [ ! -d "$OPENSSL_PREFIX" ]; then | ||
| OPENSSL_PREFIX=$OPENRESTY_PREFIX/openssl | ||
| fi | ||
| if [ -d "$OPENSSL_PREFIX" ]; then | ||
| luarocks config variables.OPENSSL_LIBDIR ${OPENSSL_PREFIX}/lib | ||
| luarocks config variables.OPENSSL_INCDIR ${OPENSSL_PREFIX}/include | ||
| fi | ||
|
|
||
| - name: Install Lua dependencies | ||
| run: | | ||
| sudo luarocks install jsonschema | ||
| sudo luarocks install lua-resty-radixtree | ||
|
|
||
| - name: Run mutation fuzzer | ||
| run: | | ||
| export PATH=$OPENRESTY_PREFIX/nginx/sbin:$OPENRESTY_PREFIX/bin:$PATH | ||
| make fuzz FUZZ_BUDGET=$FUZZ_BUDGET | ||
|
|
||
| - name: Upload findings | ||
| if: failure() | ||
| uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: fuzz-findings-${{ github.run_id }} | ||
| path: fuzz/out/ | ||
| retention-days: 30 |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| fuzz/__pycache__/ |
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| out/ | ||
| out_*/ |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| # Mutation Fuzzer for `lua-resty-openapi-validator` | ||
|
|
||
| A small mutation fuzzer that runs the validator against AST-mutated copies of | ||
| real-world OpenAPI specs and checks two oracles: | ||
|
|
||
| 1. **No crashes**: `validate_request` must not throw a Lua error | ||
| (caught with `pcall`). | ||
| 2. **Schema conformance**: a request **generated to satisfy** an operation's | ||
| schema must be **accepted** by the validator. A rejection is a candidate | ||
| false-negative bug. | ||
|
|
||
| The fuzzer is the productionised form of the harness used during v1.0.3 QA. | ||
| It reproduces the bugs that QA found (path-extension Bug 1 against the | ||
| unfixed validator, `utf8_len(table)` Bug 3 against the unfixed jsonschema). | ||
|
|
||
| ## Architecture | ||
|
|
||
| ```text | ||
| mutate_fuzz.py (Python orchestrator) | ||
| ├─ pick a seed spec from fuzz/seeds/ | ||
| ├─ apply N random mutations (mutators below) | ||
| ├─ generate schema-conforming positive requests | ||
| └─ resty -e RUNNER_LUA (validator subprocess, one per round) | ||
| └─ for each case: pcall(v:validate_request, req) | ||
| └─ JSONL result on stdout: {phase, accepted, err} | ||
| ``` | ||
|
|
||
| Mutators (`fuzz/mutate_fuzz.py`): | ||
|
|
||
| | name | what it does | targets | | ||
| |---|---|---| | ||
| | `path_extension` | append `.json` / `.xml` / `.txt` / `.v2` to a random path | path-routing edge cases (Bug 1) | | ||
| | `nullable_enum` | inject `null` into an enum + flip `nullable: true` | nullable-enum handling (Bug 2) | | ||
| | `length_on_array` | move `maxLength` onto an `array`/`object` schema | type-inappropriate keywords (Bug 3) | | ||
|
jarvis9443 marked this conversation as resolved.
Outdated
|
||
| | `param_style` | flip parameter `style`/`explode` | parameter parsing (Bug 4 family) | | ||
| | `required_phantom` | add a non-existent property name to `required` | schema-validation edge cases | | ||
| | `swap_scalar_type` | swap `type: integer` ↔ `type: string` | coercion paths | | ||
|
|
||
| Generator (`sample_value`): produces JSON values that match a JSON Schema | ||
| fragment (string / integer / number / boolean / array / object / enum), with | ||
| a depth limit. Path/query/header parameters that are `required: true` are | ||
| filled in; the request body is sampled from the operation's | ||
| `requestBody` schema if present. | ||
|
|
||
| ## Run locally | ||
|
|
||
| ```bash | ||
| make fuzz # 60s budget | ||
| make fuzz FUZZ_BUDGET=300 # 5 min | ||
| python3 fuzz/mutate_fuzz.py --budget 60 --seed 7 # reproducible | ||
| ``` | ||
|
|
||
| Output: | ||
|
|
||
| - `fuzz/out/crashes.jsonl` — one JSON object per finding | ||
| - `fuzz/out/summary.json` — `{rounds, cases_run, elapsed_s, crash_count, false_negative_count, total_findings}` | ||
| - exits non-zero on any crash or candidate false-negative (CI-friendly) | ||
|
|
||
| ## Add a seed | ||
|
|
||
| Drop any OpenAPI 3.x spec into `fuzz/seeds/`. Smaller specs (50–100 ops) | ||
| give more mutation rounds per second; very large specs (>500 ops) slow | ||
| each round. Recommended size: 30 KB – 300 KB. | ||
|
|
||
| ## Add a mutator | ||
|
|
||
| 1. Add `def my_mutator(spec, rng): ...` near the other mutators. | ||
| 2. Append `("name", my_mutator)` to the `MUTATORS` list. | ||
| 3. Mutator must mutate `spec` **in place** and return `True` if a mutation | ||
| was applied, `False` otherwise. The label is taken from the tuple name. | ||
|
|
||
| ## Noise filter | ||
|
|
||
| `gen_cases` does not try to satisfy every JSON Schema construct — `oneOf` / | ||
| `allOf` / `discriminator` / complex `pattern` are common in real specs but | ||
| hard to satisfy generically. Errors mentioning these are filtered as | ||
| generator artefacts, not validator bugs. The list lives near the bottom | ||
| of `mutate_fuzz.py`. If the filter masks a real bug you find by other | ||
| means, narrow / shrink it; if it lets through too much noise, widen it. | ||
|
|
||
| ## CI | ||
|
|
||
| - **PR**: `.github/workflows/fuzz.yml` — 120s budget, fails the PR on any finding. | ||
| - **Nightly**: `.github/workflows/fuzz-nightly.yml` — 600s budget, on | ||
| failure uploads `fuzz/out/` as an artifact and opens (or comments on) | ||
| a `fuzz-nightly` issue assigned to `@jarvis9443`. | ||
Oops, something went wrong.
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.