diff --git a/docs/src/detectors/Detector-Documentation.md b/docs/src/detectors/Detector-Documentation.md index 2b109c1134..6c7e809a49 100644 --- a/docs/src/detectors/Detector-Documentation.md +++ b/docs/src/detectors/Detector-Documentation.md @@ -936,6 +936,59 @@ N/A Review codex's message. +## Claude + +### Configuration + +- Check: `claude` +- Severity: `High` +- Confidence: `Low` + +### Description + +Use [Claude](https://www.anthropic.com/claude) to find vulnerabilities in smart contracts. This detector leverages Claude's AI capabilities to analyze Solidity code for security issues. + +The detector supports two modes of operation: +- **API Mode**: Uses the Anthropic API with `ANTHROPIC_API_KEY` +- **Claude Code CLI Mode**: Uses Claude Code CLI with `CLAUDE_CODE_OAUTH_TOKEN` (no API cost for MAX subscribers) + +### Options + +- `--claude`: Enable Claude-based vulnerability detection +- `--claude-use-code`: Use Claude Code CLI instead of API +- `--claude-model`: Model to use (e.g., 'opus', 'sonnet'). Defaults to 'sonnet' +- `--claude-contracts`: Comma-separated list of contracts to analyze (default: all) +- `--claude-max-tokens`: Maximum tokens for API response (default: 4096) +- `--claude-log`: Log queries and responses to `crytic_export/claude/` + +### Usage + +```bash +# Using Claude Code CLI (recommended for MAX subscribers) +slither . --claude --claude-use-code --claude-model opus + +# Using Anthropic API +export ANTHROPIC_API_KEY=your_key +slither . --claude +``` + +### Exploit Scenario: + +N/A + +### Recommendation + +Review Claude's analysis and recommendations. Claude may identify vulnerabilities including: +- Reentrancy vulnerabilities +- Access control issues +- Integer overflow/underflow +- Unchecked external calls +- Front-running vulnerabilities +- Logic errors +- Signature replay attacks +- Oracle manipulation +- Flash loan attack vectors + ## Domain separator collision ### Configuration diff --git a/slither/__main__.py b/slither/__main__.py index a1a671e262..46f322f04c 100644 --- a/slither/__main__.py +++ b/slither/__main__.py @@ -25,6 +25,7 @@ from slither.printers.abstract_printer import AbstractPrinter from slither.slither import Slither from slither.utils import codex +from slither.utils import claude from slither.utils.output import ( output_to_json, output_to_zip, @@ -620,6 +621,7 @@ def parse_args( ) codex.init_parser(parser) + claude.init_parser(parser) # debugger command parser.add_argument("--debug", help=argparse.SUPPRESS, action="store_true", default=False) diff --git a/slither/detectors/all_detectors.py b/slither/detectors/all_detectors.py index a30d2b3c00..db3385b489 100644 --- a/slither/detectors/all_detectors.py +++ b/slither/detectors/all_detectors.py @@ -87,6 +87,7 @@ from .functions.protected_variable import ProtectedVariables from .functions.permit_domain_signature_collision import DomainSeparatorCollision from .functions.codex import Codex +from .functions.claude import Claude from .functions.cyclomatic_complexity import CyclomaticComplexity from .operations.cache_array_length import CacheArrayLength from .statements.incorrect_using_for import IncorrectUsingFor diff --git a/slither/detectors/functions/claude.py b/slither/detectors/functions/claude.py new file mode 100644 index 0000000000..c5dbf56eca --- /dev/null +++ b/slither/detectors/functions/claude.py @@ -0,0 +1,177 @@ +""" +Claude-based vulnerability detector for Slither +Supports both Anthropic API and Claude Code CLI (for MAX subscribers) +""" +import logging +import uuid +from typing import List, Union + +from slither.detectors.abstract_detector import AbstractDetector, DetectorClassification +from slither.utils import claude +from slither.utils.output import Output, SupportedOutput + +logger = logging.getLogger("Slither") + +VULN_FOUND = "VULNERABILITY_DETECTED" + + +class Claude(AbstractDetector): + """ + Use Claude to detect vulnerabilities in smart contracts + """ + + ARGUMENT = "claude" + HELP = "Use Claude to find vulnerabilities." + IMPACT = DetectorClassification.HIGH + CONFIDENCE = DetectorClassification.LOW + + WIKI = "https://github.com/crytic/slither/wiki/Detector-Documentation#claude" + + WIKI_TITLE = "Claude" + WIKI_DESCRIPTION = "Use [Claude](https://www.anthropic.com/claude) to find vulnerabilities" + + # region wiki_exploit_scenario + WIKI_EXPLOIT_SCENARIO = """N/A""" + # endregion wiki_exploit_scenario + + WIKI_RECOMMENDATION = "Review Claude's analysis and recommendations." + + def _run_claude(self, logging_file: str, prompt: str) -> str: + """ + Handle the Claude logic - supports both API and Claude Code CLI + + Args: + logging_file (str): file where to log the queries + prompt (str): prompt to send to Claude + + Returns: + Claude answer (str) + """ + if self.slither.claude_log: + claude.log_claude(logging_file, f"Model: {self.slither.claude_model}") + claude.log_claude(logging_file, "Q: " + prompt) + + answer = "" + + # Try Claude Code CLI first if enabled (no API cost for MAX subscribers) + if self.slither.claude_use_code: + if claude.check_claude_code_available(): + response = claude.run_claude_code(prompt, model=self.slither.claude_model) + if response: + if self.slither.claude_log: + claude.log_claude(logging_file, "A: " + response) + if VULN_FOUND in response: + answer = response.replace(VULN_FOUND, "").strip() + return answer + else: + logger.info( + "Claude Code not available. Set CLAUDE_CODE_OAUTH_TOKEN or install Claude Code CLI" + ) + return "" + + # Fall back to API + client = claude.get_claude_client() + if client is None: + return "" + + response = claude.run_claude_api( + client, + prompt, + model=self.slither.claude_model, + max_tokens=self.slither.claude_max_tokens, + ) + + if response: + if self.slither.claude_log: + claude.log_claude(logging_file, "A: " + response) + if VULN_FOUND in response: + answer = response.replace(VULN_FOUND, "").strip() + else: + if self.slither.claude_log: + claude.log_claude(logging_file, "A: Claude request failed") + + return answer + + def _detect(self) -> List[Output]: + results: List[Output] = [] + + if not self.slither.claude_enabled: + return [] + + logging_file = str(uuid.uuid4()) + + # Filter contracts to analyze + contracts_to_analyze = [] + for contract in self.compilation_unit.contracts: + if ( + self.slither.claude_contracts != "all" + and contract.name not in self.slither.claude_contracts.split(",") + ): + continue + contracts_to_analyze.append(contract) + + total = len(contracts_to_analyze) + logger.info(f"Claude: Using model '{self.slither.claude_model}'") + logger.info(f"Claude: Analyzing {total} contract(s)...") + + for idx, contract in enumerate(contracts_to_analyze, 1): + logger.info(f"Claude: [{idx}/{total}] Analyzing {contract.name}...") + + prompt = self._build_prompt(contract.source_mapping.content) + answer = self._run_claude(logging_file, prompt) + + if answer: + logger.info( + f"Claude: [{idx}/{total}] Found potential vulnerability in {contract.name}" + ) + else: + logger.info(f"Claude: [{idx}/{total}] No issues found in {contract.name}") + + if answer: + info: List[Union[str, SupportedOutput]] = [ + "Claude detected a potential vulnerability in ", + contract, + "\n", + answer, + "\n", + ] + + new_result = self.generate_result(info) + results.append(new_result) + + return results + + def _build_prompt(self, source_code: str) -> str: + """ + Build the prompt for Claude analysis + + Args: + source_code: The Solidity source code to analyze + + Returns: + The formatted prompt + """ + return f"""You are a smart contract security expert. Analyze the following Solidity contract for security vulnerabilities. + +Focus on: +1. Reentrancy vulnerabilities +2. Access control issues +3. Integer overflow/underflow (for Solidity < 0.8.0) +4. Unchecked external calls +5. Front-running vulnerabilities +6. Logic errors +7. Gas optimization issues that could lead to DoS +8. Signature replay attacks +9. Oracle manipulation +10. Flash loan attack vectors + +If you find any vulnerabilities, begin your response with "{VULN_FOUND}" followed by a detailed explanation of each vulnerability, its severity (Critical/High/Medium/Low), and recommended fixes. + +If no vulnerabilities are found, respond with "No vulnerabilities detected." + +Contract source code: +```solidity +{source_code} +``` + +Analyze this contract thoroughly and provide your security assessment:""" diff --git a/slither/slither.py b/slither/slither.py index 7adc0694ca..7e90985823 100644 --- a/slither/slither.py +++ b/slither/slither.py @@ -125,6 +125,15 @@ def __init__(self, target: Union[str, CryticCompile], **kwargs) -> None: self.codex_log = kwargs.get("codex_log", False) self.codex_organization: Optional[str] = kwargs.get("codex_organization", None) + # Indicate if Claude related features should be used + self.claude_use_code = kwargs.get("claude_use_code", False) + # Enable Claude if --claude or --claude-use-code is set + self.claude_enabled = kwargs.get("claude", False) or self.claude_use_code + self.claude_contracts = kwargs.get("claude_contracts", "all") + self.claude_model = kwargs.get("claude_model", "sonnet") + self.claude_max_tokens = kwargs.get("claude_max_tokens", 4096) + self.claude_log = kwargs.get("claude_log", False) + self.no_fail = kwargs.get("no_fail", False) self._parsers: List[SlitherCompilationUnitSolc] = [] diff --git a/slither/utils/claude.py b/slither/utils/claude.py new file mode 100644 index 0000000000..8b36ebf4d8 --- /dev/null +++ b/slither/utils/claude.py @@ -0,0 +1,205 @@ +""" +Claude integration for Slither +Supports both ANTHROPIC_API_KEY and CLAUDE_CODE_OAUTH_TOKEN (for Claude Code MAX users) +""" +import logging +import os +import subprocess +from argparse import ArgumentParser +from pathlib import Path +from typing import Optional, Any + +from slither.utils.command_line import defaults_flag_in_config + +logger = logging.getLogger("Slither") + + +def init_parser(parser: ArgumentParser, always_enable_claude: bool = False) -> None: + """ + Init the cli arg with Claude features + + Args: + parser: + always_enable_claude (Optional(bool)): if true, --claude is not added + + Returns: + + """ + group_claude = parser.add_argument_group("Claude (https://www.anthropic.com/claude)") + + if not always_enable_claude: + group_claude.add_argument( + "--claude", + help="Enable Claude (requires ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN)", + action="store_true", + default=defaults_flag_in_config["claude"], + ) + + group_claude.add_argument( + "--claude-log", + help="Log Claude queries (in crytic_export/claude/)", + action="store_true", + default=False, + ) + + group_claude.add_argument( + "--claude-contracts", + help="Comma separated list of contracts to submit to Claude", + action="store", + default=defaults_flag_in_config["claude_contracts"], + ) + + group_claude.add_argument( + "--claude-model", + help="Claude model to use (passed to Claude Code CLI's --model option, e.g., 'opus', 'sonnet'). Defaults to 'sonnet'", + action="store", + default=defaults_flag_in_config["claude_model"], + ) + + group_claude.add_argument( + "--claude-max-tokens", + help="Maximum amount of tokens to use on the response. Defaults to 4096", + action="store", + default=defaults_flag_in_config["claude_max_tokens"], + ) + + group_claude.add_argument( + "--claude-use-code", + help="Use Claude Code CLI instead of API (uses CLAUDE_CODE_OAUTH_TOKEN, no API cost for MAX subscribers)", + action="store_true", + default=False, + ) + + +def get_claude_client() -> Optional[Any]: + """ + Return the Anthropic client using ANTHROPIC_API_KEY + + Returns: + Optional[Anthropic client] + """ + try: + # pylint: disable=import-outside-toplevel + import anthropic + + api_key = os.getenv("ANTHROPIC_API_KEY") + if api_key is None: + logger.info( + "Please provide an Anthropic API Key in ANTHROPIC_API_KEY (https://console.anthropic.com/)" + ) + return None + return anthropic.Anthropic(api_key=api_key) + except ImportError: + logger.info("Anthropic SDK was not installed") + logger.info('run "pip install anthropic"') + return None + + +def check_claude_code_available() -> bool: + """ + Check if Claude Code CLI is available and authenticated + + Returns: + bool: True if Claude Code is available + """ + oauth_token = os.getenv("CLAUDE_CODE_OAUTH_TOKEN") + if oauth_token: + return True + + # Check if claude CLI is available + try: + result = subprocess.run( + ["claude", "--version"], capture_output=True, text=True, timeout=10, check=False + ) + return result.returncode == 0 + except (subprocess.TimeoutExpired, FileNotFoundError): + return False + + +def run_claude_code(prompt: str, model: str = "sonnet", timeout: int = 120) -> Optional[str]: + """ + Run prompt through Claude Code CLI + This uses CLAUDE_CODE_OAUTH_TOKEN for MAX subscribers (no API cost) + + Args: + prompt: The prompt to send + model: Model to use (e.g., 'opus', 'sonnet', or full model name) + timeout: Timeout in seconds + + Returns: + Optional[str]: Claude's response or None on failure + """ + try: + env = os.environ.copy() + oauth_token = os.getenv("CLAUDE_CODE_OAUTH_TOKEN") + if oauth_token: + env["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token + + logger.info(f"Claude Code: Sending request with model '{model}' (this may take a while)...") + + result = subprocess.run( + ["claude", "-p", prompt, "--model", model, "--output-format", "text"], + capture_output=True, + text=True, + timeout=timeout, + env=env, + check=False, + ) + + if result.returncode == 0: + logger.info("Claude Code: Response received") + return result.stdout.strip() + logger.info(f"Claude Code failed: {result.stderr}") + return None + except subprocess.TimeoutExpired: + logger.info(f"Claude Code request timed out after {timeout}s") + return None + except FileNotFoundError: + logger.info( + "Claude Code CLI not found. Install with: npm install -g @anthropic-ai/claude-code" + ) + return None + + +def run_claude_api( + client: Any, prompt: str, model: str = "claude-sonnet-4-20250514", max_tokens: int = 4096 +) -> Optional[str]: + """ + Run prompt through Claude API + + Args: + client: Anthropic client + prompt: The prompt to send + model: Model to use + max_tokens: Maximum tokens for response + + Returns: + Optional[str]: Claude's response or None on failure + """ + try: + message = client.messages.create( + model=model, max_tokens=max_tokens, messages=[{"role": "user", "content": prompt}] + ) + return message.content[0].text + except Exception as e: # pylint: disable=broad-except + logger.info(f"Claude API request failed: {str(e)}") + return None + + +def log_claude(filename: str, content: str) -> None: + """ + Log the prompt/response in crytic_export/claude/filename + Append to the file + + Args: + filename: filename to write to + content: content to write + + Returns: + None + """ + Path("crytic_export/claude").mkdir(parents=True, exist_ok=True) + + with open(Path("crytic_export/claude", filename), "a", encoding="utf8") as file: + file.write(content) + file.write("\n") diff --git a/slither/utils/command_line.py b/slither/utils/command_line.py index bdd6539dbe..4e6c009f95 100644 --- a/slither/utils/command_line.py +++ b/slither/utils/command_line.py @@ -45,6 +45,10 @@ class FailOnLevel(enum.Enum): "codex_temperature": 0, "codex_max_tokens": 300, "codex_log": False, + "claude": False, + "claude_contracts": "all", + "claude_model": "sonnet", + "claude_max_tokens": 4096, "detectors_to_run": "all", "printers_to_run": None, "detectors_to_exclude": None, diff --git a/tests/e2e/detectors/test_data/claude/0.8.0/claude_test.sol b/tests/e2e/detectors/test_data/claude/0.8.0/claude_test.sol new file mode 100644 index 0000000000..4c31e4d165 --- /dev/null +++ b/tests/e2e/detectors/test_data/claude/0.8.0/claude_test.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/** + * @title ClaudeTest + * @dev A simple contract with intentional vulnerabilities for testing Claude detector + */ +contract ClaudeTest { + mapping(address => uint256) public balances; + address public owner; + + constructor() { + owner = msg.sender; + } + + // Potential reentrancy vulnerability + function withdraw(uint256 amount) external { + require(balances[msg.sender] >= amount, "Insufficient balance"); + + // State change after external call - reentrancy risk + (bool success, ) = msg.sender.call{value: amount}(""); + require(success, "Transfer failed"); + + balances[msg.sender] -= amount; + } + + function deposit() external payable { + balances[msg.sender] += msg.value; + } + + // Missing access control + function setOwner(address newOwner) external { + owner = newOwner; + } +} diff --git a/tests/unit/utils/test_claude.py b/tests/unit/utils/test_claude.py new file mode 100644 index 0000000000..cf7285ebb5 --- /dev/null +++ b/tests/unit/utils/test_claude.py @@ -0,0 +1,106 @@ +""" +Tests for Claude integration utilities +""" + +import os +import subprocess +from unittest.mock import patch, MagicMock + +from slither.utils.claude import ( + check_claude_code_available, + get_claude_client, + run_claude_code, +) + + +class TestClaudeCodeAvailability: + """Tests for Claude Code CLI availability check""" + + def test_oauth_token_available(self): + """Test that OAuth token makes Claude Code available""" + with patch.dict(os.environ, {"CLAUDE_CODE_OAUTH_TOKEN": "test_token"}): + assert check_claude_code_available() is True + + def test_oauth_token_not_available_cli_works(self): + """Test fallback to CLI check when no OAuth token""" + with patch.dict(os.environ, {}, clear=True): + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + # Remove CLAUDE_CODE_OAUTH_TOKEN if it exists + os.environ.pop("CLAUDE_CODE_OAUTH_TOKEN", None) + result = check_claude_code_available() + assert result is True + mock_run.assert_called_once() + + def test_oauth_token_not_available_cli_fails(self): + """Test that CLI check failure returns False""" + with patch.dict(os.environ, {}, clear=True): + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=1) + os.environ.pop("CLAUDE_CODE_OAUTH_TOKEN", None) + result = check_claude_code_available() + assert result is False + + def test_cli_not_found(self): + """Test handling when CLI is not installed""" + with patch.dict(os.environ, {}, clear=True): + with patch("subprocess.run") as mock_run: + mock_run.side_effect = FileNotFoundError() + os.environ.pop("CLAUDE_CODE_OAUTH_TOKEN", None) + result = check_claude_code_available() + assert result is False + + +class TestClaudeClient: + """Tests for Anthropic client initialization""" + + def test_no_api_key(self): + """Test that missing API key returns None""" + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("ANTHROPIC_API_KEY", None) + result = get_claude_client() + assert result is None + + def test_anthropic_not_installed(self): + """Test handling when anthropic package is not installed""" + with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test_key"}): + with patch.dict("sys.modules", {"anthropic": None}): + # This will raise ImportError + with patch( + "slither.utils.claude.get_claude_client", + side_effect=ImportError("No module named 'anthropic'"), + ): + pass # Just verify no crash + + +class TestRunClaudeCode: + """Tests for Claude Code CLI runner""" + + def test_run_claude_code_success(self): + """Test successful Claude Code CLI execution""" + with patch("slither.utils.claude.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="Test response", stderr="") + result = run_claude_code("test prompt", model="sonnet") + assert result == "Test response" + mock_run.assert_called_once() + + def test_run_claude_code_failure(self): + """Test Claude Code CLI execution failure""" + with patch("slither.utils.claude.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="Error message") + result = run_claude_code("test prompt", model="sonnet") + assert result is None + + def test_run_claude_code_timeout(self): + """Test Claude Code CLI timeout handling""" + with patch("slither.utils.claude.subprocess.run") as mock_run: + mock_run.side_effect = subprocess.TimeoutExpired(cmd="claude", timeout=120) + result = run_claude_code("test prompt", timeout=120) + assert result is None + + def test_run_claude_code_not_found(self): + """Test handling when Claude CLI is not installed""" + with patch("slither.utils.claude.subprocess.run") as mock_run: + mock_run.side_effect = FileNotFoundError() + result = run_claude_code("test prompt") + assert result is None