Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
137 changes: 137 additions & 0 deletions .github/workflows/fuzz-nightly.yml
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' }}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
Comment thread
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
Comment thread
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
71 changes: 71 additions & 0 deletions .github/workflows/fuzz.yml
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
fuzz/__pycache__/
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ test-conformance:
lint:
luacheck -q lib/

### fuzz: Run mutation fuzzer (FUZZ_BUDGET seconds, default 60)
FUZZ_BUDGET ?= 60
fuzz:
python3 fuzz/mutate_fuzz.py --budget $(FUZZ_BUDGET) --out fuzz/out

### clean: Remove build artifacts
clean:
rm -rf *.rock
rm -rf *.rock fuzz/out
2 changes: 2 additions & 0 deletions fuzz/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
out/
out_*/
86 changes: 86 additions & 0 deletions fuzz/README.md
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) |
Comment thread
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`.
Loading
Loading