Fuzzing #159
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
| name: Fuzzing | |
| on: | |
| schedule: | |
| # Run daily at 2 AM UTC | |
| - cron: '0 2 * * *' | |
| workflow_dispatch: | |
| inputs: | |
| duration: | |
| description: 'Fuzzing duration in seconds per target' | |
| required: false | |
| default: '300' | |
| env: | |
| CARGO_TERM_COLOR: always | |
| jobs: | |
| fuzz: | |
| name: Fuzz Testing | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read # to checkout the repo | |
| issues: write # to create/comment on issues | |
| # Only run fuzzing for non-fork repositories to save folks CI credits. | |
| if: github.event.repository.fork == false | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| target: ['eval_sexpr', 'eval_jsonlogic'] | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v6 | |
| - name: Install Rust nightly | |
| uses: dtolnay/rust-toolchain@nightly | |
| - name: Install cargo-fuzz | |
| run: cargo install cargo-fuzz | |
| - name: Cache dependencies | |
| uses: Swatinem/rust-cache@v2 | |
| # Corpus caching: | |
| # - Save key uses run_id so each run creates a new cache (caches are immutable) | |
| # - Restore key uses prefix match to find the most recent cache from any prior run | |
| # - Daily runs ensure we always have a recent corpus to build on | |
| - name: Restore corpus cache | |
| uses: actions/cache@v5 | |
| with: | |
| path: fuzz/corpus/${{ matrix.target }} | |
| key: fuzz-corpus-${{ matrix.target }}-${{ github.run_id }} | |
| restore-keys: | | |
| fuzz-corpus-${{ matrix.target }}- | |
| - name: Run fuzzer | |
| run: | | |
| DURATION=${{ github.event.inputs.duration || '300' }} | |
| cargo fuzz run ${{ matrix.target }} -- -max_total_time=$DURATION | |
| continue-on-error: true | |
| - name: Check for crashes | |
| id: check_crashes | |
| run: | | |
| if [ -d "fuzz/artifacts/${{ matrix.target }}" ] && [ "$(ls -A fuzz/artifacts/${{ matrix.target }})" ]; then | |
| echo "crashes_found=true" >> $GITHUB_OUTPUT | |
| echo "::warning::Crashes found in ${{ matrix.target }}" | |
| else | |
| echo "crashes_found=false" >> $GITHUB_OUTPUT | |
| fi | |
| - name: Upload crash artifacts | |
| if: steps.check_crashes.outputs.crashes_found == 'true' | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: fuzz-crashes-${{ matrix.target }} | |
| path: fuzz/artifacts/${{ matrix.target }}/ | |
| - name: Upload corpus | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: fuzz-corpus-${{ matrix.target }} | |
| path: fuzz/corpus/${{ matrix.target }}/ | |
| retention-days: 7 | |
| - name: Create issue for crashes | |
| if: steps.check_crashes.outputs.crashes_found == 'true' | |
| uses: actions/github-script@v8 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const target = '${{ matrix.target }}'; | |
| const artifactsPath = `fuzz/artifacts/${target}`; | |
| // List crash files | |
| let crashFiles = []; | |
| try { | |
| crashFiles = fs.readdirSync(artifactsPath); | |
| } catch (e) { | |
| crashFiles = ['(unable to read crash files)']; | |
| } | |
| const body = `## Fuzzing Crash Detected | |
| **Target:** \`${target}\` | |
| **Workflow Run:** ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| **Branch:** \`${{ github.ref_name }}\` | |
| **Commit:** ${{ github.sha }} | |
| ### Crash Files | |
| ${crashFiles.map(f => `- \`${f}\``).join('\n')} | |
| ### Next Steps | |
| 1. Download the crash artifacts from the workflow run | |
| 2. Reproduce locally: \`cargo +nightly fuzz run ${target} fuzz/artifacts/${target}/<crash-file>\` | |
| 3. Fix the underlying issue | |
| 4. Re-run fuzzing to verify the fix | |
| Crash artifacts are available in the workflow artifacts for 90 days.`; | |
| // Check if issue already exists for this target | |
| const issues = await github.rest.issues.listForRepo({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| labels: 'fuzzing', | |
| state: 'open' | |
| }); | |
| const existingIssue = issues.data.find(issue => | |
| issue.title.includes(target) && issue.labels.some(l => l.name === 'fuzzing') | |
| ); | |
| if (existingIssue) { | |
| // Add comment to existing issue | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: existingIssue.number, | |
| body: `### New crash detected\n\n${body}` | |
| }); | |
| } else { | |
| // Create new issue | |
| await github.rest.issues.create({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| title: `Fuzzing crash in ${target}`, | |
| body: body, | |
| labels: ['bug', 'fuzzing'] | |
| }); | |
| } | |
| - name: Fail if crashes found | |
| if: steps.check_crashes.outputs.crashes_found == 'true' | |
| run: | | |
| echo "::error::Fuzzing discovered crashes in ${{ matrix.target }}" | |
| exit 1 |