blockchain security

Auditing Foundry Smart Contract Security

Pre-deployment security audit of Solidity smart contracts in a Foundry project. Combines static analysis (Slither, Aderyn), symbolic execution (Mythril), and property-based testing (forge fuzz + invariant tests with handlers) to catch reentrancy, access-control, oracle/price manipulation, and arithmetic bugs BEFORE deploying to an EVM chain. Also enforces key hygiene (no plaintext private keys, encrypted cast keystore) and a secure deploy workflow. Use when writing, reviewing, testing, or deploying Solidity/Foundry contracts, building a dApp, or working with forge/cast/anvil, MetaMask, or Web3/DeFi code.

aderynauditdefiforgefoundryinvariant-testingmythrilreentrancy
Install this skill
npx skills add mukul975/Anthropic-Cybersecurity-Skills
Framework mappings

Overview

Deployed smart contracts are immutable and custody real funds, so a bug shipped to mainnet cannot be patched — it can only be exploited. Most catastrophic DeFi losses come from a small set of recurring classes: reentrancy, broken access control, oracle/price manipulation, and unchecked arithmetic or external calls.

This skill runs a defense-in-depth, pre-deployment audit of a Foundry project, layering four independent techniques that each catch what the others miss:

  1. Static analysisslither (90+ detectors) and aderyn (Cyfrin, Rust) scan the AST/IR in seconds for known anti-patterns.
  2. Symbolic executionmythril (optional, slow) explores execution paths and SMT-solves for deep arithmetic/reentrancy bugs.
  3. Property-based testingforge test with fuzzing (testFuzz_*) and invariant tests (invariant_* + handler contracts with ghost variables) proves protocol-level properties hold across millions of random sequences.
  4. Manual review + key hygiene — a structured checklist (see references/vulnerability-checklist.md) and a secrets/keystore audit so no private key ever lives in plaintext and deployment goes through an encrypted cast keystore (see references/secure-deployment-and-keys.md).

The skill is dev-side and pre-deployment — it is run by the engineer building the contract, not by a SOC after an incident. Findings gate the deploy: any high/critical static finding, failing test, leaked key, or low coverage = FAIL.

When to Use

  • Before deploying any Solidity contract to a testnet or mainnet EVM chain.
  • When writing or reviewing a Foundry project (foundry.toml, src/, test/, script/).
  • When a contract handles value: tokens (ERC-20/721/1155), vaults, staking, AMMs, bridges, governance.
  • When adding fuzz or invariant tests, or when coverage of value-moving functions is unknown.
  • When wiring deployment scripts — to verify keys are in an encrypted keystore, not .env plaintext.
  • When integrating a price oracle, external call, delegatecall, or upgradeable proxy.
  • When triaging a Slither/Aderyn report and needing to separate real bugs from false positives.

Prerequisites

  • Foundry installed (forge, cast, anvil): curl -L https://foundry.paradigm.xyz | bash && foundryup
  • Slither + solc: pip install slither-analyzer and solc-select install <ver> && solc-select use <ver>
  • Aderyn (recommended): cargo install aderyn (or npm i -g @cyfrin/aderyn)
  • Mythril (optional, slow symbolic exec): pip install mythril
  • gitleaks (key-leak scan): see the companion implementing-secret-scanning-with-gitleaks skill
  • A Foundry project that compiles (forge build succeeds) — analyzers need build artifacts.
  • Solidity ^0.8.x is assumed (built-in overflow checks); pre-0.8 contracts need extra SafeMath review.

Install the Python tools in a virtualenv (recommended on externally-managed distros). Never run analysis against untrusted contract source on a machine with funded wallets unlocked.

Steps

Step 1: Build and sanity-check the project

forge build                    # analyzers require fresh artifacts
forge fmt --check              # style gate (optional)
cat foundry.toml               # note solc version, optimizer, remappings, evm_version

Step 2: Static analysis (fast, run every time)

# Slither — full project (uses foundry.toml + remappings automatically)
slither . --json slither-report.json
 
# Aderyn — Cyfrin Rust analyzer, complementary detectors
aderyn . -o aderyn-report.json

Or run the bundled orchestrator that runs both, deduplicates, and gates the result:

python3 scripts/agent.py --project . --output audit-report.json

Step 3: Symbolic execution on critical contracts (optional, slow)

# Only on the highest-value contract(s) — Mythril is path-explosive
myth analyze src/Vault.sol --solc-json mythril.config.json --execution-timeout 300 -o json
# or: python3 scripts/agent.py --project . --mythril src/Vault.sol

Step 4: Property-based testing — fuzz + invariants

forge test -vvv                                  # unit + fuzz tests
forge coverage --report summary                  # coverage of value-moving code
forge test --match-test invariant_ -vvv          # invariant suite (handler-based)

Every value-moving contract should have invariant tests with a handler (bounded inputs, ghost variables, targetContract(handler)) — not just unit tests. See references/api-reference.md for the handler pattern, and write a test_RevertWhen_* (with vm.expectRevert) for each access-control guard.

Step 5: Manual review against the checklist

Walk references/vulnerability-checklist.md for every contract: reentrancy (checks-effects-interactions / nonReentrant), access control, oracle manipulation, delegatecall/proxy storage layout, unchecked return values, tx.origin, weak randomness, DoS, front-running/MEV, and ERC-specific pitfalls (approve race, fee-on-transfer, rebasing).

Step 6: Key hygiene & secure deploy

gitleaks detect --no-banner            # no private keys / mnemonics / .env committed
git ls-files | grep -E '\.env$|keystore' && echo "WARN: secrets tracked by git"
 
# Import the deploy key ONCE into an encrypted keystore — never a plaintext PRIVATE_KEY env
cast wallet import deployer --interactive
 
# Deploy via the keystore account (testnet first), simulate before --broadcast
forge script script/Deploy.s.sol --account deployer --rpc-url <testnet> --broadcast --verify

See references/secure-deployment-and-keys.md for the full hardening rules (MetaMask hygiene, hardware wallet for mainnet, RPC trust, post-deploy verification).

Step 7: Triage and report

Combine Slither + Aderyn + Mythril + test results, deduplicate by (file, line), drop confirmed false positives, rank by exploitability × financial impact, and map each to its SWC id. The orchestrator emits audit-report.json with a PASS/FAIL gate.

Expected Output

A JSON audit report listing findings with SWC identifiers, severity, tool source, affected contract/function/line, and remediation; plus the test/coverage summary and a single PASS / FAIL deploy gate. FAIL on any high/critical static finding, failing test, leaked secret, or coverage below the configured threshold on value-moving code.

Source materials

References and resources

Everything below is rendered for inspection. Script files are read-only and never run.

References 3

api-reference.md6.4 KB

API Reference — Tooling

Slither (static analysis)

slither .                                  # whole project (reads foundry.toml + remappings)
slither . --json slither-report.json       # machine-readable
slither . --json -                          # JSON to stdout (used by agent.py)
slither . --print human-summary             # quick overview
slither . --print inheritance-graph         # inheritance / proxy layout
slither . --detect reentrancy-eth,unprotected-upgrade   # specific detectors
slither --list-detectors                    # all 90+ detectors
slither . --exclude-informational --exclude-low          # focus high/medium
slither . --triage-mode                     # interactively suppress false positives -> slither.db.json

Severity matrix (impact × confidence):

Impact Confidence Example detectors
High High reentrancy-eth, suicidal, arbitrary-send-eth
High Medium controlled-delegatecall, reentrancy-no-eth
Medium High locked-ether, incorrect-equality, tx-origin
Medium Medium uninitialized-state, shadowing-state, unchecked-transfer
Low High naming-convention, solc-version, low-level-calls
Informational High pragma, dead-code, assembly

Aderyn (Cyfrin, Rust static analyzer — complementary to Slither)

aderyn .                                    # markdown report.md by default
aderyn . -o aderyn-report.json              # JSON (used by agent.py)
aderyn . --scope src/                       # limit scope

Mythril (symbolic execution — slow, use on critical contracts only)

myth analyze src/Vault.sol -o json
myth analyze src/Vault.sol --execution-timeout 300 --max-depth 50 -o json
myth analyze --address 0x... --rpc <url>    # deployed bytecode (read-only)

Foundry — testing

forge build                                 # required before static analysis
forge test -vvv                             # unit + fuzz; -vvvv shows traces
forge test --match-contract VaultTest
forge test --match-test invariant_          # invariant suite only
forge coverage --report summary             # line/branch coverage table
forge coverage --report lcov                # for CI / tooling
forge snapshot                              # gas snapshots (DoS-by-gas review)
forge fmt --check                           # style gate

Fuzz test (property over random inputs)

function testFuzz_SetNumber(uint256 x) public {
    counter.setNumber(x);
    assertEq(counter.number(), x);
}

Revert test (replaces deprecated testFail)

function test_RevertWhen_Unauthorized() public {
    vm.prank(attacker);
    vm.expectRevert("Not authorized");   // or vm.expectRevert(MyError.selector)
    target.adminOnly();
}

Key cheatcodes (vm.*)

Cheatcode Use
vm.prank(addr) / vm.startPrank impersonate caller (test access control)
vm.warp(ts) / vm.roll(n) manipulate block.timestamp / block.number
vm.deal(addr, amt) set ETH balance
vm.store(addr, slot, val) overwrite storage (test invariants under hostile state)
vm.expectRevert(...) assert a call reverts (with msg / custom error selector)
vm.expectEmit(...) assert events
bound(x, lo, hi) constrain fuzz inputs in handlers
makeAddr("name") deterministic labelled actor

Invariant testing — handler pattern (the important one)

A handler wraps the target, bounds inputs, rotates actors, and tracks ghost variables; targetContract(handler) makes the fuzzer drive only the handler so sequences stay realistic.

// test/Invariant.t.sol
contract VaultInvariantTest is Test {
    Vault vault;
    VaultHandler handler;
 
    function setUp() public {
        vault = new Vault();
        handler = new VaultHandler(vault);
        targetContract(address(handler));         // fuzz the handler, not the vault directly
    }
 
    function invariant_ConservationOfDeposits() public view {
        assertEq(address(vault).balance,
                 handler.ghost_depositSum() - handler.ghost_withdrawSum());
    }
    function invariant_Solvency() public view {
        assertGe(address(vault).balance, vault.totalDeposits());
    }
}
// test/handlers/VaultHandler.sol
contract VaultHandler is Test {
    Vault public vault;
    uint256 public ghost_depositSum;
    uint256 public ghost_withdrawSum;
    address[] public actors;
    address internal currentActor;
 
    modifier useActor(uint256 seed) {
        currentActor = actors[bound(seed, 0, actors.length - 1)];
        vm.startPrank(currentActor); _; vm.stopPrank();
    }
    constructor(Vault _v) {
        vault = _v;
        for (uint256 i; i < 10; i++) { actors.push(makeAddr(string(abi.encodePacked("actor", i)))); vm.deal(actors[i], 100 ether); }
    }
    function deposit(uint256 amt, uint256 seed) external useActor(seed) {
        amt = bound(amt, 0.01 ether, 10 ether);
        vault.deposit{value: amt}(); ghost_depositSum += amt;
    }
    function withdraw(uint256 amt, uint256 seed) external useActor(seed) {
        uint256 bal = vault.balanceOf(currentActor);
        if (bal == 0) return;
        amt = bound(amt, 1, bal);
        vault.withdraw(amt); ghost_withdrawSum += amt;
    }
}

Tune in foundry.toml:

[invariant]
runs = 256
depth = 128
fail_on_revert = false   # set true once the handler fully constrains inputs
 
[fuzz]
runs = 10000

SWC Registry (key entries)

SWC Title Detected by
SWC-101 Integer Overflow/Underflow Mythril (pre-0.8 only)
SWC-104 Unchecked Call Return Slither + Mythril
SWC-105 Unprotected Ether Withdrawal Slither + Mythril
SWC-106 Unprotected SELFDESTRUCT Slither + Mythril
SWC-107 Reentrancy Slither + Mythril
SWC-112 Delegatecall to Untrusted Callee Slither
SWC-114 Transaction Order Dependence (front-running) manual
SWC-115 tx.origin Authentication Slither
SWC-116 Block Timestamp Dependence Mythril
SWC-120 Weak Randomness Slither + manual

References

secure-deployment-and-keys.md4.1 KB

Secure Deployment & Key Hygiene

The contract code can be flawless and you still lose everything if a private key leaks or you sign a malicious transaction. This is the part most smart-contract guides skip. Treat keys as the highest-severity asset.

Golden rules

  1. A real private key or seed phrase NEVER touches a file, env var, shell history, or git.
  2. Plaintext PRIVATE_KEY=0x... in .env is the #1 leak vector — use an encrypted keystore instead.
  3. Separate wallets: a throwaway dev wallet (testnet only) ≠ the mainnet deployer ≠ your personal MetaMask with real funds.
  4. Hardware wallet (Ledger/Trezor) for any mainnet deploy or admin action that controls funds.
  5. Simulate before broadcasting; verify after.

Foundry encrypted keystore (cast wallet)

Import the key once into an encrypted, password-protected keystore — then reference it by name. The raw key never appears in commands or files again.

# Import interactively (key is typed, not in argv/history), set a strong password
cast wallet import deployer --interactive
 
# Or generate a fresh dev key directly into the keystore
cast wallet new
 
# List / inspect (addresses only)
cast wallet list

Deploy by account name, never by --private-key:

# Testnet first — simulate (no --broadcast) then broadcast + verify
forge script script/Deploy.s.sol --account deployer --rpc-url <testnet_rpc>
forge script script/Deploy.s.sol --account deployer --rpc-url <testnet_rpc> --broadcast --verify
 
# Mainnet (prefer a Ledger):
forge script script/Deploy.s.sol --ledger --hd-paths "m/44'/60'/0'/0/0" --rpc-url <mainnet_rpc> --broadcast --verify

In deploy scripts, use vm.startBroadcast() with no argument (it uses the --account/--ledger signer). Avoid vm.envUint("PRIVATE_KEY").

Anti-leak controls (wire into the project)

# 1. .gitignore the usual suspects
printf '.env\n.env.*\n*.key\nkeystore/\nbroadcast/\n' >> .gitignore
 
# 2. Scan history + working tree for secrets (see the implementing-secret-scanning-with-gitleaks skill)
gitleaks detect --no-banner
gitleaks detect --no-banner --log-opts="--all"   # full git history
 
# 3. Confirm nothing sensitive is tracked
git ls-files | grep -E '\.env$|\.key$|keystore' && echo "REMOVE THESE FROM GIT"

If a key was ever committed (even and then deleted): consider it compromised — generate a new one, move funds, and purge history (BFG / git filter-repo).

MetaMask / wallet operational security

  • Dedicated browser profile for Web3; review every signature — read what you sign.
  • Beware blind signing and eth_sign/personal_sign phishing; reject opaque hex.
  • Token approval hygiene: avoid unlimited approve; periodically revoke (revoke.cash); prefer permit with deadlines.
  • Verify the contract address and chain id before interacting; bookmark dApps, don't follow links.
  • Add networks/RPCs only from trusted sources — a malicious RPC can lie about state and simulate fake balances.

RPC & dependency trust

  • Pin a reputable RPC (your own node, or a known provider); a hostile RPC can feed false data to scripts and frontends.
  • Pin dependency versions (forge install with a tag/commit; lock OpenZeppelin version). Re-audit on bumps.
  • Verify deployed bytecode matches source on the explorer (forge verify-contract / --verify).

Post-deploy checklist

  • Source verified on the block explorer.
  • Ownership/admin transferred to a multisig (Safe), not an EOA, for anything controlling funds.
  • Timelock on privileged upgrades/parameter changes.
  • Monitoring/alerting on critical events (large withdrawals, ownership changes, pause).
  • Emergency runbook: pause + emergency-withdraw path tested on testnet.
  • Deploy key rotated/retired if it ever touched a less-trusted machine.

References

vulnerability-checklist.md4.8 KB

Manual Review Checklist — Solidity / EVM

Walk this for every contract that moves value. Each item: what to look for → how to confirm (Foundry test / Slither detector) → fix. Tools catch the known patterns; this catches the logic bugs they can't.

1. Reentrancy (SWC-107)

  • External call (call, transfer, ERC-777 hooks, ERC-721 onERC...Received, arbitrary token) before state updates?
  • Cross-function / read-only reentrancy: a view used by another protocol mid-call?
  • Confirm: Slither reentrancy-eth/reentrancy-no-eth; write an attacker contract test that re-enters in its receive().
  • Fix: Checks-Effects-Interactions order; nonReentrant (OpenZeppelin ReentrancyGuard); pull-over-push payments. ("OZ" = OpenZeppelin throughout.)

2. Access control (SWC-105/106/115)

  • Every state-changing/admin function gated (onlyOwner, roles, custom modifier)?
  • initialize() on upgradeable contracts protected against re-init and front-running?
  • No tx.origin for auth (phishable) — use msg.sender.
  • selfdestruct / delegatecall reachable only by trusted roles?
  • Confirm: a test_RevertWhen_* with vm.prank(attacker) + vm.expectRevert for each guard.
  • Fix: OZ Ownable2Step / AccessControl; initializer modifier; remove tx.origin.

3. Oracle / price manipulation (DeFi #1 exploit class)

  • Price from spot getReserves() / a single AMM pool? (flash-loan manipulable)
  • Using Chainlink: checked updatedAt staleness, answeredInRound, min/max bounds, L2 sequencer uptime?
  • Confirm: fork test that manipulates the pool within one tx and asserts your protocol stays solvent.
  • Fix: TWAP / Chainlink with staleness+deviation checks; never trust spot for pricing.

4. Arithmetic & rounding (SWC-101)

  • Solidity ^0.8 (built-in checked math) — and any unchecked{} block justified?
  • Division before multiplication (precision loss)? Rounding always in protocol's favor?
  • Share-inflation / first-depositor attack on ERC-4626-style vaults?
  • Confirm: testFuzz_* over amounts; invariant assertGe(assets, shares-implied).
  • Fix: mulDiv (OZ Math.mulDiv); virtual shares/offset for vaults; explicit rounding direction.

5. Unchecked external calls / return values (SWC-104)

  • Return value of low-level call/send and ERC-20 transfer/transferFrom checked?
  • Non-standard ERC-20s (no return, fee-on-transfer, rebasing) handled?
  • Confirm: Slither unchecked-transfer, unchecked-lowlevel.
  • Fix: OZ SafeERC20; measure balance delta for fee-on-transfer; require success.

6. delegatecall / proxy storage (SWC-112)

  • Storage layout identical across implementation upgrades (no reordered/removed vars)?
  • No delegatecall to user-supplied address; implementation can't be re-initialized/self-destructed?
  • Confirm: Slither controlled-delegatecall, unprotected-upgrade; storage-layout diff between versions.
  • Fix: OZ UUPS/Transparent proxies + storage gaps; _disableInitializers() in constructor.

7. Front-running / MEV / ordering (SWC-114)

  • Approve race (ERC-20 approve from non-zero to non-zero)?
  • Slippage / deadline params on swaps and mints? Commit-reveal where needed?
  • Fix: increaseAllowance/permit; enforce minOut + deadline; commit-reveal for sensitive ordering.

8. Randomness (SWC-120)

  • Any block.timestamp/blockhash/prevrandao used as randomness for value?
  • Fix: Chainlink VRF; never on-chain pseudo-randomness for payouts.

9. Denial of service (SWC-113/128)

  • Unbounded loops over user-growable arrays? Push payments that can revert and brick the contract?
  • Single external dependency whose revert blocks all users?
  • Fix: pull payments; bounded iteration / pagination; isolate failures.

10. Token / standard pitfalls

  • ERC-20: decimals assumptions, fee-on-transfer, rebasing, missing return.
  • ERC-721/1155: safe-transfer reentrancy hooks; approval scope.
  • Permit (EIP-2612): replay, deadline, signature malleability.

11. General hygiene

  • pragma pinned (pragma solidity 0.8.26; not ^)? Latest stable solc?
  • Events emitted for every state-changing action (off-chain monitoring)?
  • No leftover console.log / test backdoors / hardcoded addresses?
  • Pausability + emergency withdraw for value-holding contracts?
  • Invariants written for every conservation law (total supply, solvency, accounting)?

Severity triage (impact × likelihood)

  • Critical/High → direct loss/lock of funds, unauthorized mint/withdraw, broken access control. Blocks deploy.
  • Medium → loss under specific conditions, griefing, precision drift.
  • Low/Info → best-practice, gas, style.

Cross-check real-world findings on Solodit (https://solodit.xyz/) for the contract type you're shipping (vault, AMM, staking, bridge, governance).

Scripts 1

agent.py13.5 KB
Display-only source. This catalog never executes bundled scripts.
#!/usr/bin/env python3
"""Foundry Smart Contract Security Agent.

Pre-deployment audit orchestrator for a Foundry project. Runs static analysis
(Slither, Aderyn), optional symbolic execution (Mythril), Foundry tests/coverage,
and a key-leak scan, then aggregates everything into a single JSON report with a
PASS/FAIL deploy gate.

Design constraints (mirrors the upstream repo's style, hardened):
  - subprocess always called with an ARGUMENT LIST, never shell=True
  - no outbound network calls; every tool runs locally on local source
  - every external tool guarded by timeout and graceful degradation if absent
  - read-only with respect to the project (only writes the report file)
"""

import os
import re
import json
import shutil
import argparse
import logging
import subprocess
from collections import defaultdict
from datetime import datetime, timezone

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)

SWC_REGISTRY = {
    "SWC-101": "Integer Overflow and Underflow",
    "SWC-104": "Unchecked Call Return Value",
    "SWC-105": "Unprotected Ether Withdrawal",
    "SWC-106": "Unprotected SELFDESTRUCT",
    "SWC-107": "Reentrancy",
    "SWC-110": "Assert Violation",
    "SWC-112": "Delegatecall to Untrusted Callee",
    "SWC-113": "DoS with Failed Call",
    "SWC-114": "Transaction Order Dependence (front-running)",
    "SWC-115": "Authorization through tx.origin",
    "SWC-116": "Block values as a proxy for time",
    "SWC-120": "Weak Sources of Randomness",
    "SWC-128": "DoS with Block Gas Limit",
}

SEVERITY_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3, "informational": 4, "optimization": 5}

# Directories that are dependencies / build output, not the audited code.
SKIP_DIRS = {"lib", "out", "cache", "node_modules", ".git", "broadcast", "artifacts"}

# A raw 32-byte hex private key (with or without 0x). High-precision signal: a
# 64-hex literal in source is almost always a key. Broader secret detection
# (mnemonics, API tokens, generic secrets) is intentionally delegated to gitleaks
# (see references/secure-deployment-and-keys.md) rather than reinvented noisily here.
PRIVKEY_RE = re.compile(r"\b(0x)?[0-9a-fA-F]{64}\b")


def _which(tool):
    return shutil.which(tool) is not None


def _run(cmd, timeout):
    """Run a command (list args), return (returncode, stdout, stderr). Never raises."""
    try:
        p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
        return p.returncode, p.stdout, p.stderr
    except subprocess.TimeoutExpired:
        logger.warning("Timeout: %s", " ".join(cmd))
        return 124, "", "timeout"
    except FileNotFoundError:
        return 127, "", "not found"


# --------------------------------------------------------------------------- #
# Static analysis
# --------------------------------------------------------------------------- #
def run_slither(project):
    if not _which("slither"):
        logger.warning("slither not installed - skipping static analysis")
        return None
    rc, out, err = _run(["slither", project, "--json", "-"], timeout=300)
    # Slither exits non-zero when it finds issues; JSON still lands on stdout.
    if not out:
        logger.error("slither produced no JSON (%s)", err.strip()[:200])
        return {}
    try:
        return json.loads(out)
    except json.JSONDecodeError:
        logger.error("slither JSON parse failed")
        return {}


def analyze_slither(slither_output):
    findings, by_severity, by_detector = [], defaultdict(int), defaultdict(int)
    for det in (slither_output or {}).get("results", {}).get("detectors", []):
        severity = det.get("impact", "informational").lower()
        by_severity[severity] += 1
        name = det.get("check", "unknown")
        by_detector[name] += 1
        loc = ""
        elems = det.get("elements", [])
        if elems:
            sm = elems[0].get("source_mapping", {})
            lines = sm.get("lines") or [0]
            loc = f"{sm.get('filename_short', '')}:L{lines[0]}"
        findings.append({
            "source": "slither", "detector": name, "severity": severity,
            "confidence": det.get("confidence", ""), "location": loc,
            "description": (det.get("description", "") or "").strip()[:240],
        })
    return {
        "total": len(findings),
        "by_severity": dict(by_severity),
        "top_detectors": dict(sorted(by_detector.items(), key=lambda x: -x[1])[:15]),
        "findings": sorted(findings, key=lambda f: SEVERITY_RANK.get(f["severity"], 9)),
    }


def run_aderyn(project):
    if not _which("aderyn"):
        logger.info("aderyn not installed - skipping (recommended: cargo install aderyn)")
        return None
    report = os.path.join(project, "aderyn-report.json")
    rc, out, err = _run(["aderyn", project, "-o", report], timeout=300)
    try:
        with open(report) as fh:
            data = json.load(fh)
        return data
    except (OSError, json.JSONDecodeError):
        logger.error("aderyn report not readable")
        return {}


def analyze_aderyn(aderyn_output):
    findings, by_severity = [], defaultdict(int)
    if not aderyn_output:
        return {"total": 0, "by_severity": {}, "findings": []}
    for sev_key, sev in (("high_issues", "high"), ("low_issues", "low")):
        block = aderyn_output.get(sev_key, {}) or {}
        for issue in block.get("issues", []) if isinstance(block, dict) else []:
            by_severity[sev] += 1
            inst = (issue.get("instances") or [{}])[0]
            loc = f"{inst.get('contract_path', '')}:L{inst.get('line_no', 0)}"
            findings.append({
                "source": "aderyn", "detector": issue.get("title", ""), "severity": sev,
                "location": loc, "description": (issue.get("description", "") or "").strip()[:240],
            })
    return {
        "total": len(findings),
        "by_severity": dict(by_severity),
        "findings": sorted(findings, key=lambda f: SEVERITY_RANK.get(f["severity"], 9)),
    }


# --------------------------------------------------------------------------- #
# Symbolic execution (optional)
# --------------------------------------------------------------------------- #
def run_mythril(target, timeout):
    if not _which("myth"):
        logger.info("mythril not installed - skipping symbolic execution")
        return None
    rc, out, err = _run(
        ["myth", "analyze", target, "--execution-timeout", str(timeout), "-o", "json"],
        timeout=timeout + 60,
    )
    if not out:
        return {}
    try:
        return json.loads(out)
    except json.JSONDecodeError:
        logger.error("mythril JSON parse failed")
        return {}


def analyze_mythril(mythril_output):
    findings, by_swc = [], defaultdict(int)
    for issue in (mythril_output or {}).get("issues", []):
        swc = f"SWC-{issue.get('swc-id')}" if issue.get("swc-id") else "unknown"
        by_swc[swc] += 1
        findings.append({
            "source": "mythril", "swc_id": swc,
            "swc_title": SWC_REGISTRY.get(swc, issue.get("title", "")),
            "severity": issue.get("severity", "Medium").lower(),
            "location": f"{issue.get('contract', '')}:L{issue.get('lineno', 0)}",
            "description": (issue.get("description", "") or "").strip()[:240],
        })
    return {"total": len(findings), "by_swc": dict(by_swc), "findings": findings}


# --------------------------------------------------------------------------- #
# Foundry tests + coverage
# --------------------------------------------------------------------------- #
def run_forge_tests(project):
    if not _which("forge"):
        logger.warning("forge not installed - skipping tests")
        return {"available": False}
    rc, out, err = _run(["forge", "test"], timeout=900)
    text = out + err
    passed = sum(int(n) for n in re.findall(r"(\d+)\s+passed", text))
    failed = sum(int(n) for n in re.findall(r"(\d+)\s+failed", text))
    return {"available": True, "exit_code": rc, "passed": passed,
            "failed": failed, "all_passed": rc == 0}


def run_forge_coverage(project):
    if not _which("forge"):
        return {"available": False}
    rc, out, err = _run(["forge", "coverage", "--report", "summary"], timeout=1200)
    m = re.search(r"^\|\s*Total\s*\|\s*([\d.]+)%", out, re.M)
    lines_pct = float(m.group(1)) if m else None
    return {"available": True, "lines_pct": lines_pct}


# --------------------------------------------------------------------------- #
# Key hygiene
# --------------------------------------------------------------------------- #
def scan_key_leaks(project):
    """Heuristic scan for plaintext private keys / mnemonics in source-controlled files."""
    hits = []
    for root, dirs, files in os.walk(project):
        dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
        for f in files:
            # Match source/config files plus all dotenv variants (.env, .env.local, .env.prod...)
            if not (f.endswith((".sol", ".env", ".json", ".js", ".ts", ".toml", ".txt", ".md", ".sh", ".yaml", ".yml"))
                    or f.startswith(".env")):
                continue
            path = os.path.join(root, f)
            try:
                with open(path, encoding="utf-8", errors="ignore") as fh:
                    content = fh.read()
            except OSError:
                continue
            for m in PRIVKEY_RE.finditer(content):
                # Exclude the well-known Anvil/Hardhat test mnemonic-derived keys & all-zero.
                val = m.group(0).lower().removeprefix("0x")
                if val == "0" * 64 or "test test test" in content[max(0, m.start() - 80):m.start()]:
                    continue
                hits.append({"file": os.path.relpath(path, project), "type": "possible_private_key"})
                break
    return {"leaked_secret_candidates": len(hits), "hits": hits[:20],
            "note": "high-precision private-key scan only; run gitleaks for full secret coverage"}


# --------------------------------------------------------------------------- #
# Aggregation + gate
# --------------------------------------------------------------------------- #
def deduplicate(*finding_lists):
    seen, combined = set(), []
    for lst in finding_lists:
        for f in lst:
            key = (f.get("location", ""), f.get("detector", f.get("swc_id", "")))
            if key not in seen:
                seen.add(key)
                combined.append(f)
    return combined


def build_report(project, slither, aderyn, mythril, tests, coverage, keys, min_coverage):
    combined = deduplicate(slither["findings"], aderyn["findings"], mythril["findings"])
    crit_high = sum(1 for f in combined if f.get("severity") in ("critical", "high"))

    gate_fail = []
    if crit_high > 0:
        gate_fail.append(f"{crit_high} high/critical static finding(s)")
    if tests.get("available") and not tests.get("all_passed"):
        gate_fail.append(f"{tests.get('failed', '?')} failing test(s)")
    if keys["leaked_secret_candidates"] > 0:
        gate_fail.append(f"{keys['leaked_secret_candidates']} possible leaked secret(s)")
    cov = coverage.get("lines_pct")
    if cov is not None and cov < min_coverage:
        gate_fail.append(f"line coverage {cov}% < {min_coverage}% threshold")

    return {
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "project": os.path.abspath(project),
        "static_analysis": {
            "slither": {"total": slither["total"], "by_severity": slither["by_severity"],
                        "top_detectors": slither["top_detectors"]},
            "aderyn": {"total": aderyn["total"], "by_severity": aderyn["by_severity"]},
        },
        "symbolic_execution": {"mythril": {"total": mythril["total"], "by_swc": mythril.get("by_swc", {})}},
        "testing": {"forge_test": tests, "coverage": coverage},
        "key_hygiene": keys,
        "combined_findings": len(combined),
        "critical_high_findings": crit_high,
        "deploy_gate": "PASS" if not gate_fail else "FAIL",
        "gate_failures": gate_fail,
        "findings": combined[:40],
    }


def main():
    ap = argparse.ArgumentParser(description="Foundry Smart Contract Security Audit Agent")
    ap.add_argument("--project", default=".", help="Path to the Foundry project root")
    ap.add_argument("--mythril", metavar="FILE", help="Run Mythril symbolic execution on this .sol file")
    ap.add_argument("--mythril-timeout", type=int, default=300)
    ap.add_argument("--min-coverage", type=float, default=80.0, help="Min line coverage %% for PASS")
    ap.add_argument("--output", default="audit-report.json")
    args = ap.parse_args()

    logger.info("Auditing Foundry project: %s", os.path.abspath(args.project))
    slither = analyze_slither(run_slither(args.project))
    aderyn = analyze_aderyn(run_aderyn(args.project))
    mythril = analyze_mythril(run_mythril(args.mythril, args.mythril_timeout) if args.mythril else {})
    tests = run_forge_tests(args.project)
    coverage = run_forge_coverage(args.project)
    keys = scan_key_leaks(args.project)

    report = build_report(args.project, slither, aderyn, mythril, tests, coverage, keys, args.min_coverage)
    with open(args.output, "w") as fh:
        json.dump(report, fh, indent=2, default=str)
    logger.info("Audit: %d findings (%d high/critical) | gate=%s",
                report["combined_findings"], report["critical_high_findings"], report["deploy_gate"])
    if report["gate_failures"]:
        logger.warning("Gate failures: %s", "; ".join(report["gate_failures"]))
    print(json.dumps(report, indent=2, default=str))


if __name__ == "__main__":
    main()
Keep exploring