- name
- sc-audit
- description
- Deep security audit of a smart-contract repo OR a live on-chain contract by address - detect Solidity, model the protocol invariants and trust boundaries first, run Slither (best-effort) plus a bounded agentic pass that hunts for a path breaking each invariant, triage, adversarially verify, prove with a fuzzer, and drive each finding through the shared responsible-disclosure routing. The dedicated contract arm split out of vuln-scanner.
- metadata
- {"title":"SC Audit","category":"dev","var":"","tags":["dev","security","contracts"],"depends_on":["github-trending"],"requires":["GH_GLOBAL?","ETHERSCAN_API_KEY?","BLOCKSCOUT_API_KEY?","RESEND_API_KEY?","RESEND_FROM?","RESEND_REPLY_TO?"]}
> **${var}** - Target selector. Four forms:
> - `` (empty) -> auto-select the day's fresh feed target, audit **only if it contains Solidity**, else exit clean. See §S1 for the selection order (an optional `sc-targets.json` ledger, else the `github-trending` feed).
> - `owner/repo` -> audit that GitHub repo (e.g. `Uniswap/v4-core`, `aave/aave-v3-origin`).
> - `<chain>:0x<address>` -> **on-chain mode**: audit a **live deployed contract by address**. It fetches the verified source from the block explorer (Etherscan V2) or Sourcify, materializes it as a Foundry project, and runs the same pipeline - plus on-chain context (proxy/implementation, owner/admin, funds at risk). `chain` in `eth`/`base`/`arbitrum`/`optimism`/`polygon`/`bsc`/... (bare `0x<address>` defaults to `eth`). Examples: `base:0x4200000000000000000000000000000000000006`, `eth:0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2`. On-chain findings are **operator-gated** - never auto-filed/auto-emailed (see §S7). See §S1/§S2.
> - `fixture:<name>` -> **local regression mode**: audit the bundled fixture at `skills/sc-audit/fixtures/<name>/` (e.g. `fixture:vault`). No fork, no dedup, no disclosure - a self-contained way to exercise the whole pipeline including the fuzz arm, with no external repo. See §S1/§S2.
Today is ${today}. Read `memory/MEMORY.md` and the last 30 days of `memory/logs/` before starting.
## Why this skill exists
Smart-contract bugs are logic and economics, not syntax. Slither matches known static patterns; it is weak on **access control, protocol invariants, oracle/price manipulation, rounding/precision, upgradeability storage collisions, and cross-contract reentrancy** - the classes that actually drain funds, and where exploitation on-chain is immediate and irreversible. That whole class is what an agentic reviewer catches by reading the source and reasoning about who can call what and which invariant breaks.
This skill is the **contract arm split out of `vuln-scanner`**. vuln-scanner detects Solidity and hands the repo here rather than running Slither inline; this skill owns the deep audit and then routes findings through vuln-scanner's shared disclosure machinery. It does **not** duplicate the disclosure/PVR/email logic - see §S7.
It audits two kinds of target: a **GitHub repo** (source in a repo you fork), and a **live contract deployed on-chain by address** (`<chain>:0x<addr>`) - the latter fetches the verified source from the block explorer/Sourcify and adds on-chain context (proxy/implementation, owner, funds at risk). A live deployed contract is where a bug is *already exploitable with real money at stake*, so on-chain findings are treated as the highest-stakes disclosure and are **operator-gated** - staged for a human, never auto-filed (§S7).
The agentic source pass is the **reliable core**. Slither is best-effort: a headless run has `slither` allow-listed but **not** `solc` / `forge` / `solc-select`, so contracts that need a compiler to build may not compile in-run. The source pass needs no compiler, so a clean audit never depends on Slither succeeding.
## S1. Detect Solidity and select the target
```bash
REPO="${var}" # owner/repo | empty (auto) | fixture:<name> | <chain>:0x<addr> | 0x<addr>
if [ "${REPO#fixture:}" != "$REPO" ]; then MODE=fixture; FIXTURE="${REPO#fixture:}"
elif [[ "$REPO" =~ ^([a-zA-Z0-9-]+:)?0x[0-9a-fA-F]{40}$ ]]; then MODE=onchain # deployed-contract selector
else MODE=repo; fi # owner/repo, or empty -> auto-select
```
- **`MODE=fixture`** -> audit the bundled fixture `skills/sc-audit/fixtures/$FIXTURE/` (see §S2). **Skip the dedup ledger and skip disclosure entirely** (§S7/§S8) - fixtures are deliberately-vulnerable regression targets meant to be re-run on demand, never disclosed. If the fixture dir is missing, log `no-fixture: $FIXTURE` and exit clean.
- **`MODE=repo`, `$REPO` set** -> that is the target. Confirm it holds Solidity before forking: `gh api /search/code?q=repo:$REPO+extension:sol --jq '.total_count'` (or just proceed and detect after clone in S2). If the repo has **no** `*.sol`, log `no-solidity: $REPO` and exit clean - this is the wrong skill for it.
- **`MODE=repo`, `$REPO` empty** -> auto-select a repo target, best-first:
1. **Optional ledger first.** If an `sc-source`-style ledger exists at `memory/sc-targets.json` (schema `{updated, repos:[{repo, stars, tier, desc, first_seen}]}`, best-first, already Solidity-scoped and deduped), read it and take `repos[0].repo`. If that repo was scanned within 30 days per `memory/vuln-scanned.json`, or turns out to hold no real Solidity after clone (S2), walk down `repos[]`. This ledger is optional; the skill stands alone without it.
2. **Trending feed fallback.** If no ledger is present or it is exhausted, read the `github-trending` feed at `output/.chains/github-trending.md` (most recent by ISO header date) and walk its repos for one that contains Solidity.
3. If **none** contain Solidity, log `no-solidity-target` and exit clean.
On-chain candidates never enter auto-mode - the empty-`$REPO` path is repo-only. Audit a live contract by passing an explicit `<chain>:0x<addr>` selector.
- **`MODE=onchain`** -> resolve the chain to an Etherscan V2 chain id and normalize the address, then fetch verified source in §S2:
```bash
ADDR="${REPO##*:}" # after last ':' (or the whole string if none)
CHAIN="${REPO%:*}"; [ "$CHAIN" = "$REPO" ] && CHAIN="eth" # before ':' or default eth
CHAIN=$(printf '%s' "$CHAIN" | tr 'A-Z' 'a-z')
ADDR=$(printf '%s' "$ADDR" | tr 'A-Z' 'a-z') # lowercase; explorer/Sourcify are checksum-insensitive
case "$CHAIN" in
eth|ethereum|mainnet) CID=1 ;; base) CID=8453 ;;
arbitrum|arb) CID=42161 ;; optimism|op) CID=10 ;;
polygon|matic) CID=137 ;; bsc|bnb) CID=56 ;;
avalanche|avax) CID=43114 ;; gnosis|xdai) CID=100 ;;
scroll) CID=534352 ;; linea) CID=59144 ;;
zksync) CID=324 ;; blast) CID=81457 ;;
sepolia) CID=11155111 ;; base-sepolia) CID=84532 ;;
*) echo "unknown-chain: $CHAIN (add its Etherscan V2 chainid to S1 to support it)"; exit 0 ;;
esac
echo "onchain target: chain=$CHAIN cid=$CID addr=$ADDR"
```
For a Blockscout-only chain not on Etherscan V2, add its `BLOCKSCOUT` host in §S2b and the Etherscan calls no-op cleanly (verified source comes from the Sourcify/Blockscout fallback).
On-chain findings are **operator-gated** - a live contract holding funds is the highest-stakes disclosure, so this mode NEVER auto-files a PVR, auto-sends an email, or opens any public channel; it stages an operator-gated draft and notifies (see §S7).
**Dedup (mandatory in `MODE=repo` and `MODE=onchain`, same ledger as vuln-scanner).** Before auditing, skip a target already covered in the last 30 days: read `memory/vuln-scanned.json` and skip any row inside the window - keyed on `$REPO` for a repo, on `onchain:$CHAIN:$ADDR` for an address. For a repo, also check `gh api /repos/$REPO/security-advisories` - a repo with a published/credited advisory for the same finding class is already handled; skip and log. This is the identical dedup contract described in vuln-scanner §A1 / §A6. **`MODE=fixture` bypasses dedup** (re-runnable).
## S2. Get the code (fork a repo, copy a fixture, or fetch on-chain source)
Capture `$WORKDIR` first so every write lands in the real repo, not the throwaway target. Every mode works inside gitignored `.scan/`, so the build artifacts (`out/`, `cache/`, `crytic-export/`, fuzz `corpus/`) never touch the tracked tree.
```bash
WORKDIR="$(git rev-parse --show-toplevel)" # aeon repo root - memory/ and state live here
mkdir -p "$WORKDIR/.scan"
if [ "$MODE" = fixture ]; then
# Local regression: copy the bundled fixture into gitignored .scan/ and audit the COPY
# (never the tracked fixture) so forge's out/cache stay out of the working tree. No fork.
SRC="$WORKDIR/skills/sc-audit/fixtures/$FIXTURE"
[ -d "$SRC" ] || { echo "no-fixture: $FIXTURE"; exit 0; }
rm -rf "$WORKDIR/.scan/$FIXTURE"; cp -r "$SRC" "$WORKDIR/.scan/$FIXTURE"
# If the sandbox refuses `cp`, replicate the fixture files with the Read/Write tools instead
# and verify byte-identical with `diff -r "$SRC" "$WORKDIR/.scan/$FIXTURE"`.
cd "$WORKDIR/.scan/$FIXTURE"
elif [ "$MODE" = onchain ]; then
# Live contract by address: fetch the VERIFIED source from the explorer/Sourcify and
# materialize it as a Foundry project under .scan/. See §S2b for the fetch + materialize +
# on-chain-context steps; it lands you in the project dir. If no verified source exists,
# §S2b exits clean (bytecode-only audit is out of scope).
PROJ="$WORKDIR/.scan/onchain-$CHAIN-$ADDR"
echo "onchain project dir: $PROJ (materialize per §S2b, then cd there)"
# >>> run §S2b here <<< - after it, you are in "$PROJ" with src/ + foundry.toml written.
else
cd "$WORKDIR/.scan"
gh repo fork "$REPO" --clone --default-branch-only -- --depth 50 --quiet
cd "$(basename "$REPO")" # now in <workdir>/.scan/<repo>
fi
# --- Scratch dir for this run's intermediate files (scan JSON, sources.txt, fuzz harness).
# Prefer /tmp; fall back to a gitignored dir beside the clone under .scan/ when the skill
# sandbox blocks /tmp. RE-RUN these three lines at the top of every later Bash block that
# touches scratch (S4, S6.5) - claude -p spawns a FRESH shell per Bash call, so $SCRATCH does
# NOT persist (cwd does, shell vars don't). `$(cd .. && pwd)` is .scan/ (you're in .scan/<target>).
SCRATCH=/tmp/sc-audit
mkdir -p "$SCRATCH" 2>/dev/null && [ -w "$SCRATCH" ] || SCRATCH="$(cd .. && pwd)/_sc-audit"
mkdir -p "$SCRATCH"; echo "scratch: $SCRATCH"
# Confirm Solidity actually present (auto-select already filtered, but a direct $REPO may not have):
if ! ls **/*.sol >/dev/null 2>&1 && [ -z "$(find . -name '*.sol' -not -path '*/node_modules/*' 2>/dev/null | head -1)" ]; then
echo "no-solidity after clone: $REPO" # log a clean no-op row in S8 and exit
fi
```
## S2b. On-chain source fetch, materialize, and context (`MODE=onchain` only)
Run this only when `MODE=onchain`. It fetches the contract's **verified** source, writes it as a Foundry project under `$PROJ`, and records on-chain context that drives severity. **If no verified source exists, log it and exit clean - a bytecode-only audit is out of scope** (decompilation is unreliable and would produce unfalsifiable findings; the honest output is "source not verified, cannot audit").
**1. Fetch the verified source.** Prefer Etherscan V2 (one key, ~60 chains, best coverage); fall back to Sourcify (keyless), then to Blockscout's keyless REST for Blockscout-explorer chains that are on neither. The Etherscan key goes through `./secretcurl` as a `{ETHERSCAN_API_KEY}` placeholder (it ends `_KEY`, so it substitutes) - never put the raw key on the command line. Presence-check the key with `${VAR:+x}`, not a bare `$VAR` (a bare secret expansion is blocked by the Bash layer):
```bash
SCRATCH=/tmp/sc-audit; mkdir -p "$SCRATCH" 2>/dev/null && [ -w "$SCRATCH" ] || SCRATCH="$WORKDIR/.scan/_sc-audit"; mkdir -p "$SCRATCH"
# Blockscout base URL for chains NOT on Etherscan V2 (keyless REST). One line per chain.
case "$CHAIN" in
*) BLOCKSCOUT="" ;;
esac
VERIFIED=no
# (a) Etherscan V2 getsourcecode - only if a key is configured
if [ -n "${ETHERSCAN_API_KEY:+x}" ]; then
./secretcurl -s -w 'http=%{http_code}\n' -o "$SCRATCH/etherscan.json" \
"https://api.etherscan.io/v2/api?chainid=$CID&module=contract&action=getsourcecode&address=$ADDR&apikey={ETHERSCAN_API_KEY}"
# verified iff result[0].ABI is real source (NOT the literal "Contract source code not verified")
if python3 - "$SCRATCH/etherscan.json" <<'PY'
import json,sys
try: r=json.load(open(sys.argv[1]))["result"][0]
except Exception: sys.exit(1)
sys.exit(0 if r.get("ABI","").strip() and r["ABI"]!="Contract source code not verified" and r.get("SourceCode","").strip() else 1)
PY
then VERIFIED=etherscan; fi
fi
# (b) Sourcify keyless fallback (no key, or Etherscan had no verified source).
# Use API **v2**. The old v1 route (/server/files/any/$CID/$ADDR) is deprecated; do not fall
# back to it. v2 returns ONE object (not a file list) whose `sources` maps path -> {content},
# so it needs its own parse branch below.
if [ "$VERIFIED" = no ]; then
curl -s -o "$SCRATCH/sourcify.json" \
"https://sourcify.dev/server/v2/contract/$CID/$ADDR?fields=sources,compilation,proxyResolution,deployment" \
2>/dev/null || true
grep -q '"sources"' "$SCRATCH/sourcify.json" 2>/dev/null && VERIFIED=sourcify || true
fi
# (c) Blockscout v2 fallback - Blockscout-explorer chains that are on NEITHER Etherscan V2 nor
# Sourcify. Keyless GET /api/v2/smart-contracts/<addr> returns ONE object: source_code + file_path
# + additional_sources[{file_path,source_code}] + proxy_type + implementations
# + compiler_version/evm_version. Parsed by its own branch in step 2.
if [ "$VERIFIED" = no ] && [ -n "$BLOCKSCOUT" ]; then
curl -s -o "$SCRATCH/blockscout.json" "$BLOCKSCOUT/api/v2/smart-contracts/$ADDR" 2>/dev/null || true
if python3 - "$SCRATCH/blockscout.json" <<'PY'
import json,sys
try: d=json.load(open(sys.argv[1]))
except Exception: sys.exit(1)
sys.exit(0 if d.get("is_verified") and (d.get("source_code") or "").strip() else 1)
PY
then VERIFIED=blockscout; fi
fi
echo "verified-source: $VERIFIED"
if [ "$VERIFIED" = no ]; then
echo "onchain: source NOT verified for $CHAIN:$ADDR - cannot audit (bytecode-only out of scope)."
GitHubで見る