#!/usr/bin/env python3 # Scans a repo for the three Claude Code vulnerability patterns. # Usage: python3 scanner.py import json import os import sys import re from pathlib import Path # ANSI colors RED = "\033[91m" GREEN = "\033[92m" YELLOW = "\033[93m" CYAN = "\033[96m" RESET = "\033[0m" BOLD = "\033[1m" class Finding: def __init__(self, severity, cve, title, file_path, detail, recommendation): self.severity = severity # CRITICAL, HIGH, MEDIUM, LOW, INFO self.cve = cve self.title = title self.file_path = file_path self.detail = detail self.recommendation = recommendation def __str__(self): colors = { "CRITICAL": RED + BOLD, "HIGH": RED, "MEDIUM": YELLOW, "LOW": CYAN, "INFO": GREEN, } c = colors.get(self.severity, RESET) return ( f"\n{c}[{self.severity}]{RESET} {BOLD}{self.title}{RESET}\n" f" CVE: {self.cve}\n" f" File: {self.file_path}\n" f" Detail: {self.detail}\n" f" Recommendation: {self.recommendation}\n" ) def scan_hooks_bypass(repo_path): findings = [] settings_path = repo_path / ".claude" / "settings.json" if not settings_path.exists(): return findings try: data = json.loads(settings_path.read_text()) except (json.JSONDecodeError, OSError): return findings hooks = data.get("hooks", {}) if not hooks: return findings for event_name, event_config in hooks.items(): if not isinstance(event_config, list): continue for matcher_block in event_config: if not isinstance(matcher_block, dict): continue for hook in matcher_block.get("hooks", []): if not isinstance(hook, dict): continue cmd = hook.get("command", "") if cmd: severity = "CRITICAL" dangerous_patterns = [ r"curl\s", r"wget\s", r"nc\s", r"ncat\s", r"bash\s+-[ic]", r"/dev/tcp/", r"mkfifo", r"python.*-c", r"eval\s", r"base64", r"\bssh\b", r"reverse", r"bind.*shell", ] is_extra_dangerous = any( re.search(p, cmd, re.IGNORECASE) for p in dangerous_patterns ) findings.append(Finding( severity="CRITICAL" if is_extra_dangerous else "HIGH", cve="No CVE (CVSS 8.7)", title=f"Project hook executes shell command on {event_name}", file_path=str(settings_path), detail=f"Command: {cmd[:120]}{'...' if len(cmd)>120 else ''}", recommendation=( "Remove project-level hooks or audit each command. " "Update Claude Code to v1.0.87+ where consent is required." ), )) return findings def scan_mcp_injection(repo_path): findings = [] settings_path = repo_path / ".claude" / "settings.json" auto_enable = False if settings_path.exists(): try: data = json.loads(settings_path.read_text()) if data.get("enableAllProjectMcpServers") is True: auto_enable = True findings.append(Finding( severity="HIGH", cve="CVE-2025-59536 (CVSS 8.7)", title="enableAllProjectMcpServers is set to true", file_path=str(settings_path), detail=( "This flag causes all project-defined MCP servers to start " "automatically without user consent." ), recommendation=( "Remove this flag. Update Claude Code to v1.0.111+ " "where this bypass is patched." ), )) except (json.JSONDecodeError, OSError): pass mcp_path = repo_path / ".mcp.json" if mcp_path.exists(): try: mcp_data = json.loads(mcp_path.read_text()) servers = mcp_data.get("mcpServers", {}) for name, config in servers.items(): if not isinstance(config, dict): continue cmd = config.get("command", "") args = config.get("args", []) full_cmd = f"{cmd} {' '.join(str(a) for a in args)}" if args else cmd severity = "CRITICAL" if auto_enable else "MEDIUM" findings.append(Finding( severity=severity, cve="CVE-2025-59536 (CVSS 8.7)", title=f"MCP server '{name}' executes: {cmd}", file_path=str(mcp_path), detail=f"Full command: {full_cmd[:150]}{'...' if len(full_cmd)>150 else ''}", recommendation=( "Audit MCP server commands. Remove untrusted servers. " "Never set enableAllProjectMcpServers=true in shared repos." ), )) env = config.get("env", {}) for k, v in env.items(): if any( s in k.upper() for s in ["KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL"] ): findings.append(Finding( severity="MEDIUM", cve="CVE-2025-59536", title=f"MCP server '{name}' sets suspicious env var: {k}", file_path=str(mcp_path), detail=f"Env var {k} may be used to override credentials.", recommendation="Audit environment variables in MCP configs.", )) except (json.JSONDecodeError, OSError): pass return findings def scan_api_exfil(repo_path): findings = [] settings_path = repo_path / ".claude" / "settings.json" if not settings_path.exists(): return findings try: data = json.loads(settings_path.read_text()) except (json.JSONDecodeError, OSError): return findings env = data.get("env", {}) if not isinstance(env, dict): return findings suspicious_env_vars = { "ANTHROPIC_BASE_URL": "Redirects all API traffic (including API key) to attacker", "ANTHROPIC_API_KEY": "Overrides/captures the user's API key", "CLAUDE_CODE_API_KEY": "May override API key configuration", "HTTP_PROXY": "Routes all HTTP traffic through attacker proxy", "HTTPS_PROXY": "Routes all HTTPS traffic through attacker proxy", "NODE_EXTRA_CA_CERTS": "Could enable MITM by injecting attacker CA certificate", } for var, description in suspicious_env_vars.items(): value = env.get(var, "") if not value: continue severity = "CRITICAL" if var == "ANTHROPIC_BASE_URL": if "anthropic.com" not in value.lower(): severity = "CRITICAL" else: severity = "INFO" findings.append(Finding( severity=severity, cve="CVE-2026-21852 (CVSS 5.3)", title=f"Environment override: {var}", file_path=str(settings_path), detail=f"{description}. Value: {value[:80]}{'...' if len(value)>80 else ''}", recommendation=( "Remove env overrides from project settings. " "Update Claude Code to v2.0.65+ where env is not loaded before trust prompt." ), )) return findings def scan_repo(repo_path_str): repo_path = Path(repo_path_str).resolve() if not repo_path.is_dir(): print(f"{RED}[!] Error: '{repo_path}' is not a directory{RESET}") sys.exit(1) print(f""" {BOLD}{'='*70} Claude Code Malicious Repository Scanner EDUCATIONAL USE ONLY {'='*70}{RESET} {CYAN}[*] Scanning: {repo_path}{RESET} """) all_findings = [] scanners = [ ("Hooks Consent Bypass (no CVE)", scan_hooks_bypass), ("MCP Server Injection (CVE-2025-59536)", scan_mcp_injection), ("API Key Exfiltration (CVE-2026-21852)", scan_api_exfil), ] for name, scanner_fn in scanners: print(f"{CYAN}[*] Checking: {name}...{RESET}") findings = scanner_fn(repo_path) all_findings.extend(findings) if findings: print(f" {RED}Found {len(findings)} issue(s){RESET}") else: print(f" {GREEN}Clean{RESET}") print(f"\n{BOLD}{'='*70}") print(f" SCAN RESULTS") print(f"{'='*70}{RESET}\n") if not all_findings: print(f"{GREEN}{BOLD}[+] No Claude Code supply-chain indicators found.{RESET}\n") return 0 severity_order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "INFO": 4} all_findings.sort(key=lambda f: severity_order.get(f.severity, 5)) for finding in all_findings: print(finding) by_severity = {} for f in all_findings: by_severity[f.severity] = by_severity.get(f.severity, 0) + 1 print(f"\n{BOLD}Total findings: {len(all_findings)}{RESET}") for sev in ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"]: count = by_severity.get(sev, 0) if count: colors = { "CRITICAL": RED + BOLD, "HIGH": RED, "MEDIUM": YELLOW, "LOW": CYAN, "INFO": GREEN, } print(f" {colors.get(sev, '')}{sev}: {count}{RESET}") print(f"\n{YELLOW}[!] Recommendation: Do NOT open this repo with Claude Code < v2.0.65{RESET}") print(f"{YELLOW}[!] Review and remove all suspicious configuration files.{RESET}\n") return len(all_findings) if __name__ == "__main__": if len(sys.argv) < 2: print(f"Usage: {sys.argv[0]} ") print(f"Example: {sys.argv[0]} ./suspicious-repo") sys.exit(1) count = scan_repo(sys.argv[1]) sys.exit(1 if count > 0 else 0)