Some checks failed
CI / build (push) Failing after 2s
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)
494 lines
21 KiB
Python
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
|