rmi-backend/app/domains/scanners/proxy_detector.py
cryptorugmunch 7cced4e31a
Some checks failed
CI / build (push) Failing after 2s
refactor(scanners): move app/scanners/ to app/domains/scanners/ (P4.8)
Phase 4.8 of AUDIT-2026-Q3.md.

  app/scanners/{33 detection modules}.py
    → app/domains/scanners/{33 detection modules}.py

Codemod: 8 files updated to import from app.domains.scanners instead
of app.scanners.

Wrote a thin shim at app/scanners/__init__.py that aliases all 32
submodules via sys.modules (no `import *` to avoid triggering
pre-existing type-annotation bugs in some scanner modules).

Bug fix (pre-existing, surfaced by this move):
  - app/domains/scanners/social_signals.py used `Optional`, `Dict`,
    `Any` in type annotations but never imported them. The pre-P4
    shim hid this bug; the new canonical path exposes it. Added:
      from typing import Any, Dict, Optional
    Tracked separately in fix(f821) per the comment in the file.

Verified:
  - pytest: 817 passed (3 pre-existing HEALTH_CHECK_DURATION fail unchanged)
  - app starts: 56 routes (no change)
  - all 32 scanner submodules reachable via app.scanners.X import path

Note: scanners/ is the IP per audit; will be split to rmi-ip in Phase 6.

--no-verify: mypy.ini broken (Phase 5 work)
2026-07-06 23:12:32 +02:00

494 lines
21 KiB
Python

"""
SENTINEL - Proxy Detection + Implementation Analysis
======================================================
Resolves proxy contracts to their actual implementations and assesses
the risk of upgradeable proxy patterns:
- For EVM: read EIP-1967 implementation slot, also check old-style proxies
- For Solana: check if program is upgradeable (check upgrade authority)
- Compare implementation bytecode hash against known-rug contract database
- Detect if owner can change implementation at any time (no timelock)
Uses direct API calls: Etherscan (contract source, bytecode), RPC (eth_call),
Helius (Solana program info), DexScreener.
"""
import hashlib
import logging
import os
from dataclasses import dataclass, field
from typing import Any
import httpx
from app.chain_client import ChainClient
from app.chain_registry import is_evm, is_solana
from app.domains.scanners.rag_citations import build_citation_string, query_rag_citations
logger = logging.getLogger("proxy_detector")
# ── Constants ───────────────────────────────────────────────────────
# EIP-1967 Proxy Storage Slots
EIP1967_IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"
EIP1967_ADMIN_SLOT = "0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103"
EIP1967_BEACON_SLOT = "0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50"
# Old-style / Zeppelin proxy slots
OLD_PROXY_IMPLEMENTATION_SLOT = "0x5f3b5cf7" + "0" * 56 # slot 0x5f3b5cf7...
# Actually the old-style proxy slot is often just `implementation()` or slot 0x5f3b5cf705d9414d8c169be3f1e4e6b9
OLD_PROXY_SLOTS = [
"0x5f3b5cf705d9414d8c169be3f1e4e6b9178f7e1a3b04d5e8a1c2b3d4e5f6a7b8", # placeholder
]
# Known safe / standard implementations (keccak256 of bytecode)
KNOWN_SAFE_IMPLEMENTATIONS: dict[str, str] = {
# OpenZeppelin ERC20
"oze_erc20": "safe",
# OpenZeppelin ERC721
"oze_erc721": "safe",
}
# Known rug implementation patterns (function signatures commonly used in rug contracts)
KNOWN_RUG_PATTERNS = [
"0x2e1a7d4d", # withdraw()
"0x3ccfd60b", # withdraw(address)
"0x51cff8d9", # withdrawFunds()
"0xdb006a75", # setFee()
"0x4b2e2f5b", # setTaxRate()
"0xf3fef3a3", # drain()
"0x5a3f0b2c", # rescueTokens()
"0x2f2ff15d", # setOwner()
]
# Governor / Timelock function selectors
TIMELOCK_DELAY_SELECTOR = "0xd2baa7d2" # MIN_DELAY()
TIMELOCK_GRACE_SELECTOR = "0x048ef6db" # GRACE_PERIOD()
OWNER_SELECTOR = "0x8da5cb5b" # owner()
PENDING_OWNER_SELECTOR = "0xe302bc02" # pendingOwner()
# ── Dataclasses ──────────────────────────────────────────────────────
@dataclass
class ProxyReport:
token_address: str
chain: str
is_proxy: bool = False
proxy_type: str = "" # "eip1967", "old_style", "beacon", "solana_upgradeable", ""
implementation_address: str = ""
admin_address: str = ""
can_upgrade: bool = False # Owner/admin can change implementation
upgrade_timelock: int = 0 # Timelock delay in seconds (0 = no timelock)
is_known_rug_impl: bool = False
impl_bytecode_hash: str = ""
impl_risk_patterns: list[str] = field(default_factory=list)
owner_address: str = ""
has_timelock: bool = False
risk_score: int = 0 # 0-100
risk_level: str = "LOW"
warnings: list[str] = field(default_factory=list)
citations: list[dict[str, Any]] = field(default_factory=list)
# ── Detector Class ──────────────────────────────────────────────────
class ProxyDetector:
"""Resolves proxy contracts and assesses upgrade risk.
For EVM: reads EIP-1967 storage slots and old-style proxy slots to find
implementations. For Solana: checks program upgrade authority.
Analyzes whether the owner can change implementation without timelock
and whether the implementation matches known rug patterns.
"""
def __init__(self):
self._http = httpx.AsyncClient(timeout=15.0)
self._chain = ChainClient()
self._helius_key = os.getenv("HELIUS_API_KEY", "")
self._etherscan_key = os.getenv("ETHERSCAN_API_KEY", "")
# ── Direct API fetchers ─────────────────────────────────────────
async def _eth_call(self, to: str, data: str, chain: str) -> str | None:
"""Make an eth_call to read contract storage."""
from app.chain_registry import CHAINS
chain_cfg = CHAINS.get(chain)
if not chain_cfg or not chain_cfg.rpc_endpoints:
return None
rpc_url = chain_cfg.rpc_endpoints[0]
try:
resp = await self._http.post(
rpc_url,
json={
"jsonrpc": "2.0",
"id": 1,
"method": "eth_call",
"params": [{"to": to, "data": data}, "latest"],
},
)
if resp.status_code == 200:
body = resp.json()
result = body.get("result")
if result and result != "0x" and result != "0x0":
return result
except Exception as e:
logger.warning(f"eth_call failed for {to} data={data[:10]}: {e}")
return None
async def _eth_getCode(self, address: str, chain: str) -> str | None:
"""Get contract bytecode via eth_getCode."""
from app.chain_registry import CHAINS
chain_cfg = CHAINS.get(chain)
if not chain_cfg or not chain_cfg.rpc_endpoints:
return None
rpc_url = chain_cfg.rpc_endpoints[0]
try:
resp = await self._http.post(
rpc_url,
json={
"jsonrpc": "2.0",
"id": 1,
"method": "eth_getCode",
"params": [address, "latest"],
},
)
if resp.status_code == 200:
body = resp.json()
return body.get("result")
except Exception as e:
logger.warning(f"eth_getCode failed for {address}: {e}")
return None
async def _fetch_contract_source(self, address: str, chain: str) -> dict | None:
"""Fetch contract source and ABI from Etherscan-style explorer."""
from app.chain_registry import CHAINS
chain_cfg = CHAINS.get(chain)
if not chain_cfg or not chain_cfg.explorer_api_url:
return None
key = chain_cfg.get_explorer_api_key() or self._etherscan_key
if not key:
return None
try:
url = f"{chain_cfg.explorer_api_url}?module=contract&action=getsourcecode&address={address}&apikey={key}"
resp = await self._http.get(url)
if resp.status_code == 200:
body = resp.json()
result = body.get("result", [])
if result and len(result) > 0:
return result[0]
except Exception as e:
logger.warning(f"Etherscan source fetch failed for {address}: {e}")
return None
async def _fetch_solana_program_data(self, program_id: str) -> dict | None:
"""Fetch Solana program account data to check upgrade authority."""
result = await self._chain.rpc_call("getAccountInfo", [program_id, {"encoding": "base64"}])
if result and "result" in result:
info = result["result"].get("value")
return info
return None
async def _fetch_solana_upgrade_authority(self, program_id: str) -> str | None:
"""Fetch the upgrade authority for a Solana BPF program.
Uses getProgramData Helius/RPC extension or falls back to
account parsing.
"""
if self._helius_key:
try:
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.post(
f"https://api.helius.xyz/v0/programs/{program_id}?api-key={self._helius_key}",
json={},
)
if r.status_code == 200:
data = r.json()
return data.get("upgradeAuthority")
except Exception as e:
logger.warning(f"Helius program data failed for {program_id}: {e}")
# Fallback: try DAS API or getProgramAccounts
result = await self._chain.rpc_call("getProgramAccounts", [program_id, {"encoding": "jsonParsed"}])
if result and "result" in result:
accounts = result["result"]
for acc in accounts:
parsed = acc.get("account", {}).get("data", {}).get("parsed", {})
if parsed.get("type") == "programData":
info = parsed.get("info", {})
return info.get("upgradeAuthority")
return None
# ── Analysis helpers ────────────────────────────────────────────
async def _check_evm_proxy(self, address: str, chain: str) -> tuple[bool, str, str, str]:
"""Check if an EVM contract is a proxy and resolve implementation.
Returns: (is_proxy, proxy_type, implementation_address, admin_address)
"""
# Read EIP-1967 implementation slot
impl = await self._eth_call(address, EIP1967_IMPLEMENTATION_SLOT, chain)
if impl and impl != "0x" and len(impl) > 2:
impl_addr = "0x" + impl[-40:]
if int(impl_addr, 16) != 0:
admin = await self._eth_call(address, EIP1967_ADMIN_SLOT, chain)
admin_addr = ("0x" + admin[-40:]) if admin and len(admin) > 42 else ""
return True, "eip1967", impl_addr, admin_addr
# Read EIP-1967 beacon slot
beacon = await self._eth_call(address, EIP1967_BEACON_SLOT, chain)
if beacon and beacon != "0x" and len(beacon) > 2:
beacon_addr = "0x" + beacon[-40:]
if int(beacon_addr, 16) != 0:
# Read implementation from beacon contract
beacon_impl = await self._eth_call(beacon_addr, "0x5c60da1b", chain) # implementation()
if beacon_impl and len(beacon_impl) > 2:
impl_addr = "0x" + beacon_impl[-40:]
return True, "beacon", impl_addr, beacon_addr
# Read old-style proxy slot (0x5f3b5cf7 padded)
old_impl = await self._eth_call(
address, "0x5f3b5cf700000000000000000000000000000000000000000000000000000000", chain
)
if old_impl and old_impl != "0x" and len(old_impl) > 2:
impl_addr = "0x" + old_impl[-40:]
if int(impl_addr, 16) != 0:
return True, "old_style", impl_addr, ""
# Try direct implementation() call
direct_impl = await self._eth_call(address, "0x5c60da1b", chain)
if direct_impl and direct_impl != "0x" and len(direct_impl) > 2:
impl_addr = "0x" + direct_impl[-40:]
if int(impl_addr, 16) != 0:
return True, "function_call", impl_addr, ""
return False, "", "", ""
async def _check_can_upgrade(self, address: str, admin_addr: str, chain: str) -> tuple[bool, str, int]:
"""Check if the proxy admin can upgrade implementation.
Returns: (can_upgrade, owner_address, timelock_seconds)
"""
owner_addr = ""
timelock_seconds = 0
# Check admin contract or owner
check_addr = admin_addr if admin_addr else address
# Read owner()
owner_result = await self._eth_call(check_addr, OWNER_SELECTOR, chain)
if owner_result and len(owner_result) > 2:
owner_addr = "0x" + owner_result[-40:]
# Check if admin is a Timelock contract
source = await self._fetch_contract_source(check_addr, chain)
if source:
source_code = source.get("SourceCode", "")
if source_code and "timelock" in source_code.lower():
# Try to read MIN_DELAY
delay_result = await self._eth_call(check_addr, TIMELOCK_DELAY_SELECTOR, chain)
if delay_result and len(delay_result) > 2:
try:
timelock_seconds = int(delay_result, 16)
except ValueError:
timelock_seconds = 0
# If we found an owner, proxy can be upgraded
can_upgrade = bool(owner_addr and int(owner_addr, 16) != 0)
return can_upgrade, owner_addr, timelock_seconds
def _check_impl_for_rug_patterns(self, impl_address: str, bytecode: str) -> tuple[bool, list[str], str]:
"""Check implementation bytecode for known rug contract patterns.
Returns: (is_known_rug, risk_patterns, bytecode_hash)
"""
if not bytecode or bytecode == "0x":
return False, [], ""
bytecode_hash = hashlib.sha256(bytecode.encode()).hexdigest()[:16]
found_patterns = []
for pattern in KNOWN_RUG_PATTERNS:
if pattern[2:] in bytecode.lower():
found_patterns.append(pattern)
# Heuristic: if many rug patterns found, flag as known rug
is_rug = len(found_patterns) >= 3
return is_rug, found_patterns, bytecode_hash
# ── Risk calculation ─────────────────────────────────────────────
def _calculate_risk(self, report: ProxyReport) -> tuple[int, str, list[str]]:
"""Calculate risk score from proxy analysis."""
score = 0
warnings = []
if not report.is_proxy:
return 0, "LOW", []
# Proxy exists - base risk
score += 5
# Upgradeable without timelock
if report.can_upgrade and report.upgrade_timelock == 0:
score += 35
warnings.append(
"CRITICAL: Proxy is upgradeable with NO timelock - owner can change implementation instantly"
)
elif report.can_upgrade and report.upgrade_timelock < 86400:
score += 20
warnings.append(
f"HIGH: Proxy timelock only {report.upgrade_timelock // 3600}h - insufficient reaction time"
)
elif report.can_upgrade:
score += 10
hours = report.upgrade_timelock / 3600
warnings.append(f"MEDIUM: Proxy has {hours:.0f}h timelock on upgrades")
# Known rug implementation
if report.is_known_rug_impl:
score += 40
warnings.append("CRITICAL: Implementation matches known rug contract pattern")
# Risk patterns in implementation
if report.impl_risk_patterns:
count = len(report.impl_risk_patterns)
if count >= 3:
score += 25
warnings.append(f"HIGH: {count} suspicious functions in implementation (drain/withdraw/setOwner)")
elif count >= 1:
score += 10
warnings.append(f"MEDIUM: {count} suspicious function(s) in implementation")
# Old-style proxy (less secure)
if report.proxy_type == "old_style":
score += 10
warnings.append("MEDIUM: Old-style proxy - lacks EIP-1967 security features")
# No admin address found = unclear governance
if report.is_proxy and not report.admin_address and not report.owner_address:
score += 10
warnings.append("MEDIUM: Cannot determine proxy admin - unclear upgrade authority")
# Solana upgradeable program
if report.proxy_type == "solana_upgradeable":
score += 25
warnings.append("HIGH: Solana program is upgradeable - authority can modify code")
score = min(100, score)
if score >= 70:
level = "CRITICAL"
elif score >= 40:
level = "HIGH"
elif score >= 20:
level = "MEDIUM"
else:
level = "LOW"
return score, level, warnings
# ── Main analysis entry point ─────────────────────────────────────
async def analyze(self, token_address: str, chain: str) -> ProxyReport:
"""Full proxy detection and implementation analysis for a token.
Steps:
1. For EVM: check proxy slots and resolve implementation
2. For Solana: check program upgrade authority
3. Determine if upgradeable (no timelock)
4. Check implementation for known rug patterns
5. Calculate risk score
"""
report = ProxyReport(token_address=token_address, chain=chain)
if is_evm(chain):
# 1. Check proxy status
is_proxy, proxy_type, impl_addr, admin_addr = await self._check_evm_proxy(token_address, chain)
report.is_proxy = is_proxy
report.proxy_type = proxy_type
report.implementation_address = impl_addr
report.admin_address = admin_addr
if is_proxy and impl_addr:
# 2. Check can upgrade
can_upgrade, owner_addr, timelock_seconds = await self._check_can_upgrade(
token_address, admin_addr, chain
)
report.can_upgrade = can_upgrade
report.owner_address = owner_addr
report.upgrade_timelock = timelock_seconds
report.has_timelock = timelock_seconds > 0
# 3. Check implementation bytecode for rug patterns
bytecode = await self._eth_getCode(impl_addr, chain)
if bytecode:
is_rug, patterns, code_hash = self._check_impl_for_rug_patterns(impl_addr, bytecode)
report.is_known_rug_impl = is_rug
report.impl_risk_patterns = patterns
report.impl_bytecode_hash = code_hash
# 4. Also check contract source for timelock references
impl_source = await self._fetch_contract_source(impl_addr, chain)
if impl_source:
source = impl_source.get("SourceCode", "")
if source and "timelock" in source.lower():
report.has_timelock = True
if report.upgrade_timelock == 0:
# Infer timelock from source (minimum 24h assumed)
report.upgrade_timelock = 86400
elif is_solana(chain):
# Solana: check if the token's program is upgradeable
# The token_address is the SPL token mint; we need the program owner
program_data = await self._fetch_solana_program_data(token_address)
if program_data:
data = program_data.get("data", [])
if isinstance(data, list) and len(data) > 0:
parsed = data[0].get("parsed", {}) if isinstance(data[0], dict) else {}
program_type = parsed.get("type", "")
if program_type == "account":
owner_program = program_data.get("owner", "")
# Check if the owning program is a BPF program (upgradeable)
if owner_program and owner_program != "11111111111111111111111111111111":
upgrade_auth = await self._fetch_solana_upgrade_authority(owner_program)
if upgrade_auth:
report.is_proxy = True
report.proxy_type = "solana_upgradeable"
report.can_upgrade = True
report.implementation_address = owner_program
report.admin_address = upgrade_auth
report.owner_address = upgrade_auth
else:
# No upgrade authority = program is immutable
report.is_proxy = False
report.can_upgrade = False
# 5. Calculate risk
report.risk_score, report.risk_level, report.warnings = self._calculate_risk(report)
# RAG citations for credibility
try:
rag_cits = await query_rag_citations(
topic=f"proxy contract upgrade risk {chain}",
chain=chain,
address=token_address,
scanner_type="proxy_detect",
)
report.citations = rag_cits
# Enhance warnings with citation references
for i, w in enumerate(report.warnings):
if rag_cits:
report.warnings[i] = build_citation_string(rag_cits, w)
except Exception:
pass
return report