How to Set Up Continuous Security Testing with CI/CD and Fuzzing
By Kn0t · Lead Invariants EngineerHow to Set Up Continuous Security Testing with CI/CD and Fuzzing
Security audits are point-in-time assessments. Your code changes daily. If you only fuzz before a launch, every subsequent commit goes untested against your security properties. Continuous security testing fixes this — run fuzzing on every pull request, catch regressions before they reach production.
I'll walk through setting up continuous fuzzing with Chimera in a GitHub Actions pipeline.
Why Continuous Fuzzing?
Consider this timeline:
- Week 1: Protocol is audited. All properties pass.
- Week 3: A developer adds a new fee mechanism.
- Week 5: Another developer refactors the withdrawal logic.
- Week 7: A subtle interaction between the fee mechanism and the refactored withdrawal breaks the solvency invariant.
Without continuous fuzzing, this bug ships to production. With continuous fuzzing, the failing invariant is caught in the Week 5 or Week 7 pull request, before it ever reaches mainnet.
Prerequisites
- A Foundry project with Chimera-based invariant tests
- A GitHub repository
- Familiarity with GitHub Actions
Step 1: Foundry-Based Smoke Tests
The fastest feedback loop uses Foundry's built-in fuzzing. This runs in seconds and catches obvious regressions.
Create .github/workflows/fuzz-smoke.yml:
name: Fuzz Smoke Test
on: [pull_request]
jobs:
fuzz-smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
- name: Run invariant tests
run: |
forge build
forge test --match-contract CryticTester --fuzz-runs 10000
env:
FOUNDRY_INVARIANT_DEPTH: 50
FOUNDRY_INVARIANT_RUNS: 100
This gives you a quick pass/fail signal on every PR in under 2 minutes.
Step 2: Medusa Deep Fuzzing
For more thorough testing, run Medusa on PRs targeting your main branch. This takes longer but finds deeper bugs.
Create .github/workflows/fuzz-deep.yml:
name: Deep Fuzz Campaign
on:
pull_request:
branches: [main]
jobs:
fuzz-medusa:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
- name: Build contracts
run: forge build
- name: Install Medusa
run: |
curl -L -o medusa https://github.com/crytic/medusa/releases/latest/download/medusa-linux-x64
chmod +x medusa
sudo mv medusa /usr/local/bin/
- name: Run Medusa
run: |
medusa fuzz --target-contracts CryticTester --test-limit 500000 --timeout 2400
- name: Upload corpus
if: always()
uses: actions/upload-artifact@v4
with:
name: medusa-corpus
path: corpus-medusa/
Step 3: Short vs Long Campaigns
Not every PR needs a 60-minute fuzzing campaign. Structure your CI with tiers:
| Trigger | Duration | Tool | Test Limit |
|---|---|---|---|
| Every PR | 2 min | Foundry | 10,000 runs |
| PRs to main | 30 min | Medusa | 500,000 tests |
| Release branch | 4 hours | Echidna + Medusa | 5,000,000 tests |
| Pre-deployment | 24-48 hours | Recon Pro Cloud | 50,000,000+ tests |
The short campaigns catch obvious regressions fast. The long campaigns catch subtle bugs that require deep state exploration.
Step 4: Recon Pro Cloud Integration
For maximum coverage, use Recon Pro's cloud infrastructure. This lets you run multi-hour campaigns with high parallelism without tying up your CI runners.
You can trigger Recon Pro campaigns from your CI pipeline using the Recon API:
- name: Trigger Recon Pro Campaign
run: |
curl -X POST https://api.getrecon.xyz/v1/campaigns \
-H "Authorization: Bearer ${{ secrets.RECON_API_KEY }}" \
-H "Content-Type: application/json" \
-d '{
"repository": "${{ github.repository }}",
"branch": "${{ github.head_ref }}",
"commit": "${{ github.sha }}",
"fuzzers": ["echidna", "medusa"],
"duration": 14400
}'
Results are available in your Recon Pro dashboard and can be linked back to the PR.
Step 5: Setting Failure Thresholds
Your CI should fail if any invariant is violated. Both Medusa and Echidna exit with a non-zero status code when a property fails, so GitHub Actions will automatically mark the job as failed.
For coverage-based thresholds, you can add a post-processing step:
- name: Check coverage threshold
run: |
COVERAGE=$(cat medusa-coverage.json | jq '.totalCoverage')
if (( $(echo "$COVERAGE < 0.80" | bc -l) )); then
echo "Coverage $COVERAGE is below 80% threshold"
exit 1
fi
This ensures that new code is actually being tested by the fuzzer, not just passing because the fuzzer never reached it.
Step 6: Corpus Persistence
Fuzzing is cumulative. Each run discovers new coverage that future runs can build on. Persist your corpus between CI runs:
- name: Restore corpus cache
uses: actions/cache@v4
with:
path: |
corpus-medusa/
corpus-echidna/
key: fuzz-corpus-${{ github.base_ref }}-${{ hashFiles('src/**/*.sol') }}
restore-keys: |
fuzz-corpus-${{ github.base_ref }}-
fuzz-corpus-main-
- name: Run Medusa
run: medusa fuzz --target-contracts CryticTester --test-limit 500000
Over time, your corpus grows and the fuzzer starts each run with knowledge from previous runs, making it progressively more effective.
Common Pitfalls
- Not building before fuzzing: Both Echidna and Medusa need compiled artifacts. Always run
forge buildfirst. - Ignoring timeouts: Set reasonable timeouts to prevent runaway CI costs.
- Forgetting submodules: Chimera and other dependencies are often Git submodules. Use
submodules: recursivein checkout. - Not caching the corpus: Without corpus persistence, each CI run starts from scratch, wasting coverage already achieved.
Summary
Continuous fuzzing is the difference between "we were audited once" and "every change is verified against our security properties." With GitHub Actions and Chimera, you can have:
- 2-minute smoke tests on every PR
- 30-minute deep campaigns on merges to main
- Multi-hour cloud campaigns for releases
The setup takes an afternoon. The protection it provides is ongoing.
Need help setting up continuous fuzzing for your protocol? Request an audit with Recon — we will not only find bugs but help you build the infrastructure to prevent future ones.