2025-03-25·8 min read

How to Set Up Continuous Security Testing with CI/CD and Fuzzing

By Kn0t · Lead Invariants Engineer

How 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:

  1. Week 1: Protocol is audited. All properties pass.
  2. Week 3: A developer adds a new fee mechanism.
  3. Week 5: Another developer refactors the withdrawal logic.
  4. 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:

TriggerDurationToolTest Limit
Every PR2 minFoundry10,000 runs
PRs to main30 minMedusa500,000 tests
Release branch4 hoursEchidna + Medusa5,000,000 tests
Pre-deployment24-48 hoursRecon Pro Cloud50,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

  1. Not building before fuzzing: Both Echidna and Medusa need compiled artifacts. Always run forge build first.
  2. Ignoring timeouts: Set reasonable timeouts to prevent runaway CI costs.
  3. Forgetting submodules: Chimera and other dependencies are often Git submodules. Use submodules: recursive in checkout.
  4. 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.

Related Posts

Related Glossary Terms

Need help securing your protocol?