rmi-backend/app/domains/scanners/contract_authority.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

668 lines
27 KiB
Python

"""
SENTINEL - Contract Authority Detection
=========================================
Detects dangerous authority configurations on Solana tokens (mint, freeze,
update authority) and EVM contracts (proxy patterns, upgradable contracts,
admin functions, ownership renunciation).
Scoring:
Solana:
- Unrenounced mint authority on fixed-supply token → high risk
- Freeze authority on circulating token → medium risk
- Token itself as authority (self-referential) → critical risk
EVM:
- Proxy contract with mutable implementation → high risk
- Unrenounced ownership on proxy → critical risk
- Admin functions without timelock → medium risk
- EIP-1967 / EIP-1822 / OZ Transparent/UUPS detection
Data sources:
Solana: ChainClient (Helius RPC) → getAccountInfo → SPL token mint parsing
EVM: Etherscan-family APIs → contract bytecode, storage slots, ABI
"""
import logging
import os
from dataclasses import dataclass, field
from enum import Enum
import httpx
from app.chain_client import ChainClient
from app.chain_registry import is_solana
logger = logging.getLogger("contract_authority")
# ── API Keys ────────────────────────────────────────────────
ETHERSCAN_API_KEY = os.getenv("ETHERSCAN_API_KEY", "")
BSCSCAN_API_KEY = os.getenv("BSCSCAN_API_KEY", "")
POLYGONSCAN_API_KEY = os.getenv("POLYGONSCAN_API_KEY", "")
SNOWTRACE_API_KEY = os.getenv("SNOWTRACE_API_KEY", "")
# ── Etherscan Network Map ──────────────────────────────────
ETHERSCAN_NETWORKS = {
"ethereum": {"url": "https://api.etherscan.io/api", "key": ETHERSCAN_API_KEY, "chain_id": 1},
"eth": {"url": "https://api.etherscan.io/api", "key": ETHERSCAN_API_KEY, "chain_id": 1},
"bsc": {"url": "https://api.bscscan.com/api", "key": BSCSCAN_API_KEY, "chain_id": 56},
"polygon": {
"url": "https://api.polygonscan.com/api",
"key": POLYGONSCAN_API_KEY,
"chain_id": 137,
},
"avalanche": {
"url": "https://api.snowtrace.io/api",
"key": SNOWTRACE_API_KEY,
"chain_id": 43114,
},
"arbitrum": {"url": "https://api.arbiscan.io/api", "key": ETHERSCAN_API_KEY, "chain_id": 42161},
"base": {"url": "https://api.basescan.org/api", "key": ETHERSCAN_API_KEY, "chain_id": 8453},
}
# ── Solana Constants ────────────────────────────────────────
SPL_TOKEN_PROGRAM_ID = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
TOKEN_2022_PROGRAM_ID = "Tokenz4dSamgc6f5A2p5fdP8EjVq7MdY3F6UuF6nXK"
SYSTEM_PROGRAM_ID = "11111111111111111111111111111111"
# Renounced / burned addresses on Solana
SOLANA_ZERO_ADDRESS = "11111111111111111111111111111111"
# ── Risk Levels ────────────────────────────────────────────
class AuthorityRiskLevel(Enum):
LOW = "LOW"
MEDIUM = "MEDIUM"
HIGH = "HIGH"
CRITICAL = "CRITICAL"
# ── Data Models ─────────────────────────────────────────────
@dataclass
class SolanaAuthority:
"""Single authority field on a Solana SPL token mint."""
authority_type: str # "mint_authority", "freeze_authority", "update_authority"
address: str
is_renounced: bool
is_token_itself: bool # authority set to token's own mint address
@dataclass
class SolanaAuthorities:
"""All authority fields for a Solana token mint."""
mint_authority: SolanaAuthority | None = None
freeze_authority: SolanaAuthority | None = None
update_authority: SolanaAuthority | None = None
@dataclass
class EVMProxyInfo:
"""Proxy / upgradability details for an EVM contract."""
is_proxy: bool = False
implementation_address: str | None = None
proxy_type: str | None = None # "EIP-1967", "EIP-1822", "OZ_Transparent", "OZ_UUPS", "Minimal", "Unknown"
admin_address: str | None = None
is_ownership_renounced: bool = False
has_admin_functions: bool = False
admin_function_signatures: list[str] = field(default_factory=list)
@dataclass
class ContractAuthorityReport:
"""Full authority analysis for a token contract."""
token_address: str
chain: str
solana_authorities: SolanaAuthorities | None = None
evm_proxy: EVMProxyInfo | None = None
warnings: list[str] = field(default_factory=list)
risk_score: int = 0 # 0-100
risk_level: str = "LOW"
# ── EVM Proxy Detection Slots ───────────────────────────────
# EIP-1967 slots: keccak256("eip1967.proxy.implementation") - 1, etc.
EIP1967_IMPL_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"
EIP1967_ADMIN_SLOT = "0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103"
EIP1967_BEACON_SLOT = "0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50"
# EIP-1822: keccak256("PROXIABLE")
EIP1822_SLOT = "0xc5f16f0fcc639fa48a694782b6357e4c6042a5f0a3e2c1c1e8b6e4e8b6e4e8b6" # placeholder pattern
# OpenZeppelin-specific: _upgradeTo signature
OZ_TRANSPARENT_ADMIN_SIG = "0xf3b7dead" # changeAdmin
OZ_UUPS_UPGRADE_SIG = "0x4f1ef286" # upgradeToAndCall
class ContractAuthorityScanner:
"""Detects dangerous authority configurations on Solana and EVM chains.
Solana path: ChainClient → getAccountInfo → parse SPL Token mint data
EVM path: Etherscan API → bytecode, storage, ABI → proxy / admin detection
"""
def __init__(self):
self._chain = ChainClient()
self._http = httpx.AsyncClient(timeout=15.0)
async def close(self):
await self._http.aclose()
async def analyze(self, token_address: str, chain: str) -> ContractAuthorityReport:
"""Alias for scan() - matches SENTINEL pipeline interface."""
return await self.scan(token_address, chain)
# ── Public Entry Point ──────────────────────────────────
async def scan(self, token_address: str, chain: str) -> ContractAuthorityReport:
"""Run full authority analysis for a token on the given chain."""
chain_lower = chain.lower().strip()
if is_solana(chain_lower):
return await self._scan_solana(token_address)
elif chain_lower in ETHERSCAN_NETWORKS:
return await self._scan_evm(token_address, chain_lower)
else:
report = ContractAuthorityReport(token_address=token_address, chain=chain)
report.warnings.append(f"Unsupported chain: {chain}")
return report
# ── Solana Path ─────────────────────────────────────────
async def _scan_solana(self, token_address: str) -> ContractAuthorityReport:
"""Query Helius RPC getAccountInfo and parse SPL token mint state."""
report = ContractAuthorityReport(token_address=token_address, chain="solana")
try:
result = await self._chain.rpc_call(
"getAccountInfo",
[token_address, {"encoding": "jsonParsed", "maxSupportedTransactionVersion": 0}],
)
if not result or "result" not in result or result["result"] is None:
report.warnings.append("Token account not found on Solana")
self._compute_risk(report)
return report
account_info = result["result"]
if not account_info or "value" not in account_info:
report.warnings.append("Empty account data for token")
self._compute_risk(report)
return report
value = account_info["value"]
if not value:
report.warnings.append("Token mint account is empty")
self._compute_risk(report)
return report
value.get("owner", "")
data = value.get("data", [])
# Determine token program (Token or Token-2022)
parsed_data = None
if isinstance(data, list) and len(data) >= 1:
if data[1] == "base64" and len(data) > 0:
import base64
try:
raw = base64.b64decode(data[0])
parsed_data = self._parse_spl_mint_raw(raw, token_address)
except Exception:
pass
elif isinstance(data, dict):
parsed_data = data
# Try parsed data from RPC if raw parse didn't work
if parsed_data is None:
parsed_info = value.get("data", {})
if isinstance(parsed_info, dict) and "parsed" in parsed_info:
parsed_data = parsed_info["parsed"]
authorities = SolanaAuthorities()
if parsed_data and isinstance(parsed_data, dict):
info = parsed_data.get("info", parsed_data)
# Mint authority
mint_auth_str = info.get("mintAuthority", info.get("mint_authority", ""))
authorities.mint_authority = self._build_solana_authority(
"mint_authority", mint_auth_str, token_address
)
# Freeze authority
freeze_auth_str = info.get("freezeAuthority", info.get("freeze_authority", ""))
authorities.freeze_authority = self._build_solana_authority(
"freeze_authority", freeze_auth_str, token_address
)
# Update authority (Token-2022 extension)
update_auth_str = info.get("updateAuthority", info.get("update_authority", ""))
if update_auth_str:
authorities.update_authority = self._build_solana_authority(
"update_authority", update_auth_str, token_address
)
# Validate: if we couldn't parse any authorities, add warning
if not any([authorities.mint_authority, authorities.freeze_authority]):
report.warnings.append("Could not parse SPL token mint data; raw fallback attempted")
report.solana_authorities = authorities
self._add_solana_warnings(report)
self._compute_risk(report)
return report
except Exception as e:
logger.error(f"Solana authority scan failed for {token_address}: {e}")
report.warnings.append(f"Solana scan error: {str(e)[:120]}")
self._compute_risk(report)
return report
def _parse_spl_mint_raw(self, raw: bytes, token_address: str) -> dict | None:
"""Parse raw SPL Token Mint account data (82 bytes minimum).
Layout (Mint layout):
0-4: discriminant (optional, Token-2022)
4-36: mintAuthority (32 bytes, PublicKey; all zeros = None)
36-44: supply (u64)
44-45: decimals (u8)
45-46: isInitialized (bool)
46-78: freezeAuthority (32 bytes; all zeros = None)
"""
try:
MINT_LEN = 82
offset = 0
# Token-2022 may have extra bytes; detect by length
if len(raw) > MINT_LEN + 4:
offset = 4 # skip TLV discriminator
if len(raw) < offset + MINT_LEN:
return None
mint_auth_bytes = raw[offset : offset + 32]
freeze_auth_bytes = raw[offset + 46 : offset + 78]
def pk_from_bytes(b: bytes) -> str:
# Base58 encode 32-byte pubkey
ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
n = int.from_bytes(b, "big")
result = ""
while n > 0:
n, r = divmod(n, 58)
result = ALPHABET[r] + result
# Pad with leading 1s for leading zero bytes
for byte in b:
if byte == 0:
result = "1" + result
else:
break
return result or "1" * 32
mint_authority = pk_from_bytes(mint_auth_bytes) if any(mint_auth_bytes) else ""
freeze_authority = pk_from_bytes(freeze_auth_bytes) if any(freeze_auth_bytes) else ""
return {
"mint_authority": mint_authority,
"freeze_authority": freeze_authority,
}
except Exception as e:
logger.debug(f"Raw SPL mint parse failed: {e}")
return None
def _build_solana_authority(self, auth_type: str, address: str, token_address: str) -> SolanaAuthority:
"""Build a SolanaAuthority entry from a parsed address string."""
is_renounced = address == "" or address == SOLANA_ZERO_ADDRESS
is_token_itself = address == token_address
return SolanaAuthority(
authority_type=auth_type,
address=address if address else "RENOUNCED",
is_renounced=is_renounced,
is_token_itself=is_token_itself,
)
def _add_solana_warnings(self, report: ContractAuthorityReport):
"""Add risk warnings based on Solana authority configuration."""
auths = report.solana_authorities
if not auths:
return
if auths.mint_authority and not auths.mint_authority.is_renounced:
if auths.mint_authority.is_token_itself:
report.warnings.append("CRITICAL: Mint authority is the token itself - self-referential minting")
else:
report.warnings.append(f"Mint authority not renounced: {auths.mint_authority.address}")
if auths.freeze_authority and not auths.freeze_authority.is_renounced:
if auths.freeze_authority.is_token_itself:
report.warnings.append("HIGH: Freeze authority is the token itself - can freeze accounts")
else:
report.warnings.append(f"Freeze authority not renounced: {auths.freeze_authority.address}")
if auths.update_authority and not auths.update_authority.is_renounced:
report.warnings.append(
f"Update authority not renounced: {auths.update_authority.address} (metadata mutable)"
)
# ── EVM Path ────────────────────────────────────────────
async def _scan_evm(self, token_address: str, chain: str) -> ContractAuthorityReport:
"""Detect proxy patterns, admin functions, and ownership on EVM chains."""
report = ContractAuthorityReport(token_address=token_address, chain=chain)
net = ETHERSCAN_NETWORKS.get(chain)
if not net:
report.warnings.append(f"No Etherscan config for chain: {chain}")
self._compute_risk(report)
return report
proxy_info = EVMProxyInfo()
try:
# 1. Check if contract (has bytecode) and get proxy storage slots
bytecode = await self._etherscan_get_bytecode(token_address, net)
if not bytecode or bytecode == "0x":
report.warnings.append("Address is not a contract or has no bytecode")
report.evm_proxy = proxy_info
self._compute_risk(report)
return report
# 2. Check EIP-1967 proxy slots
impl = await self._etherscan_get_storage(token_address, EIP1967_IMPL_SLOT, net)
admin = await self._etherscan_get_storage(token_address, EIP1967_ADMIN_SLOT, net)
if impl and impl != "0x0000000000000000000000000000000000000000000000000000000000000000":
proxy_info.is_proxy = True
proxy_info.implementation_address = self._strip_to_address(impl)
proxy_info.proxy_type = "EIP-1967"
if admin and admin != "0x0000000000000000000000000000000000000000000000000000000000000000":
proxy_info.admin_address = self._strip_to_address(admin)
# 3. If not EIP-1967, check EIP-1822 (PROXIABLE slot)
if not proxy_info.is_proxy:
eip1822_impl = await self._etherscan_get_storage(token_address, EIP1822_SLOT, net)
if eip1822_impl and eip1822_impl != "0x" + "0" * 64:
proxy_info.is_proxy = True
proxy_info.implementation_address = self._strip_to_address(eip1822_impl)
proxy_info.proxy_type = "EIP-1822"
# 4. Check for OZ Transparent proxy pattern via admin slot
if not proxy_info.is_proxy:
# Try detection via minimal proxy (EIP-1167) bytecode pattern
if bytecode and self._is_minimal_proxy(bytecode):
proxy_info.is_proxy = True
proxy_info.proxy_type = "Minimal_EIP-1167"
# 5. Check ownership and admin functions from ABI
abi = await self._etherscan_get_abi(token_address, net)
if abi:
self._analyze_evm_abi(abi, proxy_info, report)
# 6. Determine specific proxy type
if proxy_info.is_proxy and proxy_info.proxy_type == "EIP-1967":
proxy_info.proxy_type = self._classify_proxy_type(proxy_info, abi)
# 7. Check ownership renunciation
if proxy_info.admin_address:
proxy_info.is_ownership_renounced = await self._check_evm_ownership_renounced(
token_address, net, proxy_info.admin_address
)
report.evm_proxy = proxy_info
self._add_evm_warnings(report)
self._compute_risk(report)
return report
except Exception as e:
logger.error(f"EVM authority scan failed for {token_address} on {chain}: {e}")
report.warnings.append(f"EVM scan error: {str(e)[:120]}")
report.evm_proxy = proxy_info
self._compute_risk(report)
return report
def _analyze_evm_abi(self, abi: list, proxy_info: EVMProxyInfo, report: ContractAuthorityReport):
"""Scan ABI for admin / ownership functions."""
admin_sigs = [
"transferOwnership",
"renounceOwnership",
"upgradeTo",
"upgradeToAndCall",
"setImplementation",
"changeAdmin",
"changeProxyAdmin",
"proposeOwner",
"acceptOwnership",
"pause",
"unpause",
"setOwner",
"setAdmin",
]
found_sigs = []
for entry in abi:
if not isinstance(entry, dict):
continue
if entry.get("type") != "function":
continue
name = entry.get("name", "")
if name in admin_sigs:
found_sigs.append(name)
if found_sigs:
proxy_info.has_admin_functions = True
proxy_info.admin_function_signatures = found_sigs
def _classify_proxy_type(self, proxy_info: EVMProxyInfo, abi: list | None) -> str:
"""Classify EIP-1967 proxy as Transparent, UUPS, or unknown."""
if not abi:
return "EIP-1967"
func_names = set()
for entry in abi:
if isinstance(entry, dict) and entry.get("type") == "function":
func_names.add(entry.get("name", ""))
# Transparent proxies have admin functions on the proxy itself
if "upgradeTo" in func_names or "upgradeToAndCall" in func_names:
if proxy_info.admin_address:
return "OZ_Transparent"
# UUPS proxies include upgrade logic in the implementation
if "upgradeTo" in func_names or "upgradeToAndCall" in func_names:
return "OZ_UUPS"
return "EIP-1967"
def _is_minimal_proxy(self, bytecode: str) -> bool:
"""Check if bytecode matches EIP-1167 minimal proxy pattern."""
# EIP-1167 minimal proxy: starts with 0x363d3d373d3d3d363d73...
return bytecode.startswith("0x363d3d373d3d3d363d73")
async def _check_evm_ownership_renounced(self, token_address: str, net: dict, admin_address: str) -> bool:
"""Check if the admin/owner is a zero address or the contract itself (renounced)."""
BURNED_ADDRESSES = {
"0x0000000000000000000000000000000000000000",
"0x000000000000000000000000000000000000dEaD",
"0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE",
}
return admin_address.lower() in BURNED_ADDRESSES
def _add_evm_warnings(self, report: ContractAuthorityReport):
"""Add risk warnings based on EVM proxy configuration."""
proxy = report.evm_proxy
if not proxy:
return
if proxy.is_proxy:
report.warnings.append(
f"Proxy contract detected (type: {proxy.proxy_type}), "
f"implementation: {proxy.implementation_address or 'unknown'}"
)
if proxy.admin_address and not proxy.is_ownership_renounced:
report.warnings.append(f"Proxy admin not renounced: {proxy.admin_address} - contract can be upgraded")
elif proxy.is_ownership_renounced:
report.warnings.append("Proxy ownership is renounced - upgrade risk mitigated")
if proxy.has_admin_functions:
sigs = ", ".join(proxy.admin_function_signatures[:5])
report.warnings.append(f"Admin functions found: {sigs}")
# ── Etherscan Helpers ───────────────────────────────────
async def _etherscan_get(self, params: dict, net: dict) -> dict | None:
"""Make a GET request to Etherscan-family API."""
url = net["url"]
key = net.get("key", "")
params["apikey"] = key
try:
r = await self._http.get(url, params=params)
if r.status_code == 200:
data = r.json()
if data.get("status") == "1" or data.get("message") == "OK":
return data
# Some endpoints return status "0" with result still present
if data.get("result") and data["result"] not in ("", "0x", None):
return data
return None
except Exception as e:
logger.debug(f"Etherscan API error: {e}")
return None
async def _etherscan_get_bytecode(self, address: str, net: dict) -> str | None:
"""Get contract bytecode via eth_getCode through Etherscan proxy module."""
data = await self._etherscan_get(
{"module": "proxy", "action": "eth_getCode", "address": address},
net,
)
if data and data.get("result"):
return data["result"]
return None
async def _etherscan_get_storage(self, address: str, slot: str, net: dict) -> str | None:
"""Read a storage slot value via Etherscan proxy module."""
data = await self._etherscan_get(
{
"module": "proxy",
"action": "eth_getStorageAt",
"address": address,
"position": slot,
"tag": "latest",
},
net,
)
if data and data.get("result"):
return data["result"]
return None
async def _etherscan_get_abi(self, address: str, net: dict) -> list | None:
"""Fetch contract ABI from Etherscan (if verified)."""
data = await self._etherscan_get(
{"module": "contract", "action": "getabi", "address": address},
net,
)
if data and data.get("result"):
import json
try:
return json.loads(data["result"])
except (json.JSONDecodeError, TypeError):
pass
return None
def _strip_to_address(self, slot_value: str) -> str:
"""Convert a 32-byte storage slot value to a 20-byte Ethereum address."""
if not slot_value:
return ""
# Remove 0x prefix, take last 40 hex chars, re-add 0x
hex_str = slot_value.replace("0x", "")
if len(hex_str) >= 40:
return "0x" + hex_str[-40:].lower()
return "0x" + hex_str.lower()
# ── Risk Scoring ────────────────────────────────────────
def _compute_risk(self, report: ContractAuthorityReport):
"""Compute risk_score (0-100) and risk_level from collected data."""
score = 0
# Solana risk contributions
if report.solana_authorities:
auths = report.solana_authorities
# Mint authority: biggest risk
if auths.mint_authority:
if auths.mint_authority.is_token_itself:
score += 45 # Self-referential minting = critical
elif not auths.mint_authority.is_renounced:
score += 30 # Active mint authority = high risk
# Renounced mint authority = safe, +0
# Freeze authority: moderate risk
if auths.freeze_authority:
if auths.freeze_authority.is_token_itself:
score += 25
elif not auths.freeze_authority.is_renounced:
score += 15
# Renounced = safe, +0
# Update authority: lower risk (metadata only)
if auths.update_authority and not auths.update_authority.is_renounced:
score += 10
# EVM risk contributions
if report.evm_proxy:
proxy = report.evm_proxy
if proxy.is_proxy:
score += 20 # Proxy itself = risk surface
if proxy.implementation_address:
score += 5 # Mutable implementation
if proxy.admin_address and not proxy.is_ownership_renounced:
score += 25 # Unrenounced proxy admin = critical
elif proxy.is_ownership_renounced:
score -= 10 # Renounced mitigates
if proxy.has_admin_functions:
score += 10
# Extra admin functions without timelock
danger_sigs = {"upgradeTo", "upgradeToAndCall", "transferOwnership"}
if danger_sigs & set(proxy.admin_function_signatures):
score += 5
# Warning count adds risk
score += min(len(report.warnings) * 3, 15)
# Clamp to 0-100
report.risk_score = max(0, min(100, score))
# Determine risk level
if report.risk_score >= 70:
report.risk_level = AuthorityRiskLevel.CRITICAL.value
elif report.risk_score >= 45:
report.risk_level = AuthorityRiskLevel.HIGH.value
elif report.risk_score >= 20:
report.risk_level = AuthorityRiskLevel.MEDIUM.value
else:
report.risk_level = AuthorityRiskLevel.LOW.value
# ── Pipeline Integration ────────────────────────────────────
async def run_contract_authority_scan(token_address: str, chain: str) -> ContractAuthorityReport:
"""Convenience function for SENTINEL pipeline integration."""
scanner = ContractAuthorityScanner()
try:
return await scanner.scan(token_address, chain)
finally:
await scanner.close()