1 Setup ~2 min
You need Foundry installed. If you already have forge, skip to the install step.
curl -L https://foundry.paradigm.xyz | bash
foundryup
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:
forge install kcolbchain/audit-checklist
Verify your remappings.txt (or foundry.toml) includes the mapping. Forge usually adds it automatically:
forge remappings | grep audit-checklist
# expected: audit-checklist/=lib/audit-checklist/src/
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:
_setTarget(address)— tells the base check which contract to probe.performDeposit— seeds the pre-attack state. The check calls this for both an honest depositor and the attacker.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
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:
forge test --match-contract MyFullAudit -vvv
Test result: FAILED. 0 passed; 2 failed;
[FAIL] test_reentrancy()
[FAIL] test_accessControl()
5 Interpret results
| Outcome | Badge | Meaning |
|---|---|---|
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. |
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:
- Name the test function
test_<pattern>so Forge discovers it. - If the target reverts, that usually means the guard works — return early (PASS).
- Use
assertLt/assertEq/assertTruewith a descriptive message prefixed by the check name. - 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
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: