audit-checklist. tutorial

From zero to a composable vulnerability harness in under 15 minutes.

source docs

1 Setup ~2 min

You need Foundry installed. If you already have forge, skip to the install step.

shell
curl -L https://foundry.paradigm.xyz | bash
foundryup
Hardhat note

audit-checklist is a native Foundry library. For Hardhat projects, use hardhat-foundry to run Forge tests alongside your existing suite:

npm install --save-dev @nomicfoundation/hardhat-foundry
# add to hardhat.config.js:
require("@nomicfoundation/hardhat-foundry");

Install the library into your Foundry project:

shell
forge install kcolbchain/audit-checklist

Verify your remappings.txt (or foundry.toml) includes the mapping. Forge usually adds it automatically:

shell
forge remappings | grep audit-checklist
# expected: audit-checklist/=lib/audit-checklist/src/
Tip

If the remapping is missing, add audit-checklist/=lib/audit-checklist/src/ to remappings.txt at the project root.

2 Your first check ~5 min

Create test/MyAudit.t.sol. We will write a reentrancy check against the shipped VulnerableVault.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {Test}              from "forge-std/Test.sol";
import {ReentrancyCheck}   from "audit-checklist/checks/ReentrancyCheck.sol";
import {VulnerableVault}   from "audit-checklist/examples/VulnerableVault.sol";

contract MyReentrancyAudit is Test, ReentrancyCheck {
    VulnerableVault vault;

    function setUp() public {
        vault = new VulnerableVault();
        // Point the check at the contract under test
        _setTarget(address(vault));
    }

    // ── Hook 1: how to deposit funds for a given address ──
    function performDeposit(
        address depositor, uint256 amount
    ) internal override {
        vm.deal(depositor, amount);
        vm.prank(depositor);
        vault.deposit{value: amount}();
    }

    // ── Hook 2: the calldata that triggers the withdrawal ──
    function getWithdrawCalldata()
        internal pure override returns (bytes memory)
    {
        return abi.encodeWithSignature("withdraw()");
    }
}

Three things happened here:

  1. _setTarget(address) — tells the base check which contract to probe.
  2. performDeposit — seeds the pre-attack state. The check calls this for both an honest depositor and the attacker.
  3. getWithdrawCalldata — returns the raw calldata that the attacker's malicious receiver will re-enter. The check deploys the receiver for you.
Show the full ReentrancyCheck base contract
abstract contract ReentrancyCheck {
    address internal _target;

    function _setTarget(address t) internal { _target = t; }

    // ── Abstract hooks (you implement these) ──
    function performDeposit(address, uint256) internal virtual;
    function getWithdrawCalldata() internal virtual returns (bytes memory);

    // ── Optional overrides ──
    function getDepositValue() internal virtual returns (uint256) {
        return 1 ether;
    }

    // ── Core test (inherited, runs automatically) ──
    function test_reentrancy() public { /* ... */ }
}

3 Run it

shell
forge test --match-contract MyReentrancyAudit -vvv

Expected output — the test fails, because VulnerableVault.withdraw() has the reentrancy bug. That is the whole point: a failing check means a real finding.

[FAIL. Reason: ReentrancyCheck: balance drained below honest share]

Traces:
  [245312] MyReentrancyAudit::test_reentrancy()
    ├─ [0] VM::deal(attacker, 1000000000000000000)
    ├─ [24531] VulnerableVault::deposit{value: 1 ether}()
    ├─ [0] VM::deal(honest, 1000000000000000000)
    ├─ [24531] VulnerableVault::deposit{value: 1 ether}()
    ├─ [48210] VulnerableVault::withdraw()
    │   ├─ [36112] MaliciousReceiver::receive()
    │   │   └─ [24531] VulnerableVault::withdraw()  ← re-entry
    │   │       └─ ...
    └─ ← "ReentrancyCheck: balance drained below honest share"

Test result: FAILED. 0 passed; 1 failed;

4 Add AccessControlCheck

Checks compose by multiple inheritance. Add AccessControlCheck to the same file — Forge discovers both test_ functions automatically.

import {AccessControlCheck} from "audit-checklist/checks/AccessControlCheck.sol";

contract MyFullAudit is Test, ReentrancyCheck, AccessControlCheck {
    VulnerableVault vault;

    function setUp() public {
        vault = new VulnerableVault();
        _setTarget(address(vault));
    }

    // ── ReentrancyCheck hooks (same as before) ──
    function performDeposit(address d, uint256 a) internal override {
        vm.deal(d, a);
        vm.prank(d);
        vault.deposit{value: a}();
    }
    function getWithdrawCalldata() internal pure override returns (bytes memory) {
        return abi.encodeWithSignature("withdraw()");
    }

    // ── AccessControlCheck hook ──
    function getAdminFunctions()
        internal pure override returns (bytes[] memory)
    {
        bytes[] memory fns = new bytes[](1);
        fns[0] = abi.encodeWithSignature(
            "emergencyWithdraw(address)",
            address(0xbeef)
        );
        return fns;
    }
}

Run again and both checks fire:

shell
forge test --match-contract MyFullAudit -vvv
Test result: FAILED. 0 passed; 2 failed;
  [FAIL] test_reentrancy()
  [FAIL] test_accessControl()

5 Interpret results

OutcomeBadgeMeaning
PASS PASS The vulnerability pattern was not detected. The contract resisted this specific attack vector.
FAIL FAIL Vulnerability lead found. The check's assertion broke, meaning the contract exhibited the dangerous behavior. Investigate the trace.
Revert in setUp REVERT Your hook implementation is wrong — the target contract could not be initialized. Fix setUp, performDeposit, or the constructor call.
Revert in test REVERT The calldata returned by your hook does not match the target's ABI, or a precondition is unmet. Check getWithdrawCalldata / getAdminFunctions.
Tip

Always run with -vvv or -vvvv. The trace output names the exact re-entry point, the un-guarded admin call, or the oracle drift delta.

6 Write your own check

Every check extends ChecklistBase and declares abstract hooks. Here is the pattern for a custom check that detects uncapped minting:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {ChecklistBase} from "audit-checklist/ChecklistBase.sol";

abstract contract UncappedMintCheck is ChecklistBase {

    // ── Hooks for the consumer to implement ──
    function getMintCalldata(uint256 amount)
        internal virtual returns (bytes memory);

    function getTotalSupply()
        internal virtual returns (uint256);

    // ── The test ──
    function test_uncappedMint() public {
        uint256 huge = type(uint128).max;
        bytes memory cd = getMintCalldata(huge);

        (bool ok,) = _target.call(cd);
        if (!ok) return; // reverted = cap enforced, PASS

        assertLt(
            getTotalSupply(), huge,
            "UncappedMintCheck: minted unbounded amount"
        );
    }
}

The rules:

  1. Name the test function test_<pattern> so Forge discovers it.
  2. If the target reverts, that usually means the guard works — return early (PASS).
  3. Use assertLt / assertEq / assertTrue with a descriptive message prefixed by the check name.
  4. Keep hooks minimal. The consumer should only supply contract-specific wiring, not attack logic.
Show example consumer for UncappedMintCheck
contract TokenAudit is Test, UncappedMintCheck {
    MyToken token;

    function setUp() public {
        token = new MyToken();
        _setTarget(address(token));
    }

    function getMintCalldata(uint256 amount)
        internal pure override returns (bytes memory)
    {
        return abi.encodeWithSignature(
            "mint(address,uint256)",
            address(0xcafe), amount
        );
    }

    function getTotalSupply()
        internal view override returns (uint256)
    {
        return token.totalSupply();
    }
}

7 Integrate with CI

Add this workflow to .github/workflows/audit.yml. Every PR that introduces a new vulnerability pattern will fail the build.

name: audit-checklist
on: [pull_request]

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive

      - name: Install Foundry
        uses: foundry-rs/foundry-toolchain@v1

      - name: Run audit checks
        run: forge test --match-path "test/*Audit*" -vvv
Convention

Name your audit files *Audit*.t.sol so the CI glob catches them without running your entire test suite. Regular unit tests stay fast; audit checks run as a separate job.

8 Next steps

You have a working audit harness with composable checks and CI. Here is where to go from here: