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

474 lines
19 KiB
Python

"""
SENTINEL - Bytecode Similarity Hashing
=======================================
Hash compiled bytecode of a token and compare against a local DB of known
scam contracts. Detects recycled / cloned scam contracts via:
- Exact SHA-256 hash match → CRITICAL (recycled scam)
- Partial / fuzzy similarity scoring based on cleaned bytecode structure
- Cross-chain matching (EVM bytecode + Solana program data)
Bytecode is cleaned before hashing:
- Constructor arguments stripped (everything before init-code start)
- Solidity CBOR / metadata suffixes removed
- 0x prefix normalised
Database: scam_bytecode_db.json (auto-created, maintained in this directory)
"""
import hashlib
import json
import logging
import os
import re
from dataclasses import dataclass, field
from typing import Any
import httpx
from app.chain_registry import is_solana
logger = logging.getLogger("bytecode_similarity")
# ── Constants ──────────────────────────────────────────────────────────
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
DB_PATH = os.path.join(_THIS_DIR, "scam_bytecode_db.json")
# EVM chain_id lookup (mirrors contract_diff.CHAIN_TO_ID)
CHAIN_TO_ID: dict[str, int] = {
"ethereum": 1,
"eth": 1,
"bsc": 56,
"bnb": 56,
"polygon": 137,
"matic": 137,
"base": 8453,
"arbitrum": 42161,
"arb": 42161,
"optimism": 10,
"op": 10,
"avalanche": 43114,
"avax": 43114,
"fantom": 250,
"ftm": 250,
"linea": 59144,
"zksync": 324,
"scroll": 534352,
"mantle": 5000,
}
# Solana RPC fallback
SOLANA_RPC = "https://api.mainnet-beta.solana.com"
# Known CBOR / Solidity metadata markers to strip (near end of bytecode)
METADATA_MARKERS = ["a1", "a2", "65766d", "627a6574"]
# ── Standalone Helpers ──────────────────────────────────────────────
def hash_bytecode(hex_str: str) -> str:
"""Standalone SHA-256 hash of cleaned bytecode.
Strips ``0x`` prefix, constructor arguments, and Solidity CBOR metadata
so that functionally identical contracts produce the same hash.
"""
clean = hex_str.lower().strip()
if clean.startswith("0x"):
clean = clean[2:]
if not clean:
return hashlib.sha256(b"").hexdigest()
# ── Strip constructor arguments ──────────────────────────────────
# For creation-code (deployed) bytecode, the constructor arguments
# sit *after* the init code. We strip the tail if there's a known
# init-code boundary pattern. Heuristic: try to find solidity init
# code suffix patterns.
#
# Smart-contract creation bytecode typically looks like:
# <init code><runtime code><constructor args>
# After deployment, *runtime bytecode* is stored on-chain, so the
# constructor args have already been consumed. We keep runtime
# bytecode as-is and only strip metadata.
# ── Strip Solidity CBOR metadata ─────────────────────────────────
# Solidity >=0.6.x appends a CBOR-encoded metadata hash. We look
# for its markers near the end of the hex string.
for marker in METADATA_MARKERS:
idx = clean.rfind(marker)
if idx > len(clean) // 2: # only strip if in the second half
clean = clean[:idx]
return hashlib.sha256(clean.encode()).hexdigest()
# ── Report Dataclass ─────────────────────────────────────────────────
@dataclass
class BytecodeSimilarityReport:
"""Result of bytecode similarity analysis against the scam DB."""
sha256_hash: str = ""
exact_match: bool = False
similarity_score: int = 0 # 0-100
matched_scam_type: str | None = None
matched_addresses: list[str] = field(default_factory=list)
risk_label: str = "LOW" # LOW / MEDIUM / HIGH / CRITICAL
warnings: list[str] = field(default_factory=list)
# ── Analyzer ─────────────────────────────────────────────────────────
class BytecodeSimilarityAnalyzer:
"""Compare token bytecode against a local database of known scam contracts.
Usage::
analyzer = BytecodeSimilarityAnalyzer()
report = await analyzer.analyze(bytecode_hex, chain="ethereum")
analyzer.add_to_db(hash, {"type": "rug", "address": "0x...", ...})
"""
def __init__(self):
self._db: dict[str, dict[str, Any]] = {}
self._loaded = False
# ── Database Management ──────────────────────────────────────────
def _load_db(self) -> dict[str, dict[str, Any]]:
"""Load scam bytecode DB from disk (lazy)."""
if self._loaded:
return self._db
self._loaded = True
if not os.path.exists(DB_PATH):
logger.info(f"No scam bytecode DB at {DB_PATH}, starting fresh")
self._db = {}
return self._db
try:
with open(DB_PATH) as f:
self._db = json.load(f)
logger.info(f"Loaded {len(self._db)} scam bytecode entries from {DB_PATH}")
except (json.JSONDecodeError, OSError) as e:
logger.warning(f"Failed to load scam bytecode DB: {e}")
self._db = {}
return self._db
def _save_db(self):
"""Persist the in-memory DB to disk."""
try:
with open(DB_PATH, "w") as f:
json.dump(self._db, f, indent=2)
logger.info(f"Saved {len(self._db)} entries to {DB_PATH}")
except OSError as e:
logger.error(f"Failed to save scam bytecode DB: {e}")
def add_to_db(self, bytecode_hash: str, metadata: dict[str, Any]):
"""Add (or update) a bytecode hash entry in the scam DB.
Args:
bytecode_hash: SHA-256 hex digest returned by :func:`hash_bytecode`.
metadata: Dict with at least ``scam_type`` and ``address`` keys.
May also include ``chain``, ``name``, ``notes``, etc.
"""
self._load_db()
existing = self._db.get(bytecode_hash)
if existing:
# Merge: append new address if not already tracked
existing.setdefault("addresses", [])
addr = metadata.get("address", "")
if addr and addr not in existing["addresses"]:
existing["addresses"].append(addr)
# Update scam_type if more specific
new_type = metadata.get("scam_type", "")
if new_type and not existing.get("scam_type"):
existing["scam_type"] = new_type
existing["count"] = existing.get("count", 1) + 1
logger.info(f"Updated existing DB entry for hash {bytecode_hash[:12]}...")
else:
entry: dict[str, Any] = {
"scam_type": metadata.get("scam_type", "unknown"),
"addresses": [metadata.get("address", "unknown")],
"chain": metadata.get("chain", ""),
"name": metadata.get("name", ""),
"count": 1,
"notes": metadata.get("notes", ""),
}
self._db[bytecode_hash] = entry
logger.info(f"Added new scam bytecode entry: {bytecode_hash[:12]}... → {entry['scam_type']}")
self._save_db()
# ── Bytecode Fetching ────────────────────────────────────────────
async def _fetch_bytecode(self, address: str, chain: str) -> str | None:
"""Fetch raw bytecode for *address* on *chain*.
Returns hex string (may include ``0x`` prefix) or ``None``.
"""
chain_lower = chain.lower()
if is_solana(chain_lower):
return await self._fetch_solana_bytecode(address)
# EVM path
chain_id = CHAIN_TO_ID.get(chain_lower)
if not chain_id:
logger.warning(f"Unknown chain for bytecode fetch: {chain}")
return None
return await self._fetch_evm_bytecode(address, chain_id, chain_lower)
async def _fetch_evm_bytecode(self, address: str, chain_id: int, chain_name: str) -> str | None:
"""Fetch EVM bytecode via consensus RPC, with fallback."""
# Primary: cached consensus RPC
try:
from app.caching_shield.rpc_cache import get_rpc_cache
cache = get_rpc_cache()
result = await cache.query_with_cache(
method="eth_getCode",
params=[address, "latest"],
chain=str(chain_id),
)
code_hex = result.value if result else None
if code_hex and code_hex != "0x" and len(str(code_hex)) > 10:
logger.info(f"Fetched EVM bytecode for {address} on {chain_name} (confidence={result.confidence}%)")
return str(code_hex)
logger.debug(f"eth_getCode returned empty for {address} on {chain_name}")
except Exception as e:
logger.debug(f"Consensus RPC bytecode fetch failed: {e}")
# Fallback: direct public RPC
try:
fallback_urls = {
1: "https://ethereum-rpc.publicnode.com",
56: "https://bsc-rpc.publicnode.com",
137: "https://polygon-rpc.publicnode.com",
8453: "https://base-rpc.publicnode.com",
42161: "https://arbitrum-rpc.publicnode.com",
10: "https://optimism-rpc.publicnode.com",
43114: "https://avalanche-c-chain-rpc.publicnode.com",
250: "https://fantom-rpc.publicnode.com",
}
url = fallback_urls.get(chain_id)
if url:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
url,
json={
"jsonrpc": "2.0",
"method": "eth_getCode",
"params": [address, "latest"],
"id": 1,
},
)
data = resp.json()
code = data.get("result", "0x")
if code and code != "0x" and len(code) > 10:
logger.info(f"Got bytecode via fallback RPC for {address}")
return code
except Exception as e:
logger.debug(f"Fallback RPC failed: {e}")
return None
async def _fetch_solana_bytecode(self, address: str) -> str | None:
"""Fetch Solana program / account data as a hashable string."""
# Try cached consensus RPC for getAccountInfo
try:
from app.caching_shield.rpc_cache import get_rpc_cache
cache = get_rpc_cache()
result = await cache.query_with_cache(
method="getAccountInfo",
params=[address, {"encoding": "base64"}],
chain="solana",
)
if result and result.value:
raw = result.value
# result.value is the JSON-RPC result dict
if isinstance(raw, dict):
data_list = raw.get("data")
if isinstance(data_list, list) and len(data_list) >= 1:
return str(data_list[0]) # base64 encoded
return str(raw)
return str(raw)
except Exception as e:
logger.debug(f"Consensus RPC Solana fetch failed: {e}")
# Fallback: direct Solana RPC
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
SOLANA_RPC,
json={
"jsonrpc": "2.0",
"id": 1,
"method": "getAccountInfo",
"params": [address, {"encoding": "base64"}],
},
)
if resp.status_code == 200:
body = resp.json()
result_val = body.get("result")
if result_val and isinstance(result_val, dict):
data_list = result_val.get("data")
if isinstance(data_list, list) and len(data_list) >= 1:
return str(data_list[0])
except Exception as e:
logger.debug(f"Solana fallback RPC failed: {e}")
# Last resort: try free solscan client for token info
try:
from app.free_solscan_client import FreeSolscanClient
info = FreeSolscanClient.account_info(address)
if info:
return str(info)
except Exception as e:
logger.debug(f"FreeSolscan fetch failed: {e}")
return None
# ── Similarity Scoring ───────────────────────────────────────────
def _score_similarity(self, hash_a: str, hash_b: str) -> int:
"""Compute a similarity score (0-100) between two bytecode hashes.
Since we use SHA-256, exact hashes get 100; anything else is
compared via nibble-level similarity as a fallback heuristic.
"""
if hash_a == hash_b:
return 100
if not hash_a or not hash_b:
return 0
# Nibble-level comparison: count matching hex characters
min_len = min(len(hash_a), len(hash_b))
matches = sum(1 for i in range(min_len) if hash_a[i] == hash_b[i])
# Weight: 60% position-aware match + 40% character-set overlap
position_score = (matches / max(len(hash_a), len(hash_b))) * 60
set_a = set(hash_a)
set_b = set(hash_b)
if set_a and set_b:
overlap = len(set_a & set_b) / len(set_a | set_b)
set_score = overlap * 40
else:
set_score = 0
return min(99, int(position_score + set_score))
# ── Main Analysis ────────────────────────────────────────────────
async def analyze(self, bytecode_hex: str, chain: str) -> BytecodeSimilarityReport:
"""Hash *bytecode_hex* and compare against the scam DB.
The *bytecode_hex* may be a hex string of raw bytecode, or an
*address* (which will be resolved on-chain). If *chain* is
provided and *bytecode_hex* looks like an address (0x-prefixed
42-char string or base58), it is treated as an address to fetch.
Returns:
BytecodeSimilarityReport with similarity score and risk label.
"""
# Normalise input
chain_lower = chain.lower()
raw_hex: str | None = None
# Detect if input is an address rather than raw bytecode
if self._looks_like_address(bytecode_hex):
logger.info(f"Input looks like an address, fetching bytecode for {bytecode_hex}")
raw_hex = await self._fetch_bytecode(bytecode_hex, chain_lower)
if not raw_hex:
return BytecodeSimilarityReport(
sha256_hash="",
warnings=[f"Could not fetch bytecode for address {bytecode_hex} on {chain}"],
)
else:
raw_hex = bytecode_hex
# Hash the cleaned bytecode
bytecode_hash = hash_bytecode(raw_hex)
report = BytecodeSimilarityReport(
sha256_hash=bytecode_hash,
)
# Load DB
db = self._load_db()
if not db:
report.warnings.append("Scam bytecode DB is empty - no comparisons possible")
return report
# Check for exact match
if bytecode_hash in db:
entry = db[bytecode_hash]
report.exact_match = True
report.similarity_score = 100
report.matched_scam_type = entry.get("scam_type", "unknown")
report.matched_addresses = entry.get("addresses", [])
report.risk_label = "CRITICAL"
report.warnings.append(
f"Exact bytecode hash match! Recycled {report.matched_scam_type} scam "
f"found at {', '.join(report.matched_addresses[:3])}"
)
logger.warning(f"Exact scam match: hash {bytecode_hash[:12]}... type={report.matched_scam_type}")
return report
# Scan for partial matches via nibble similarity
best_score = 0
best_entry: dict[str, Any] | None = None
for db_hash, entry_data in db.items():
score = self._score_similarity(bytecode_hash, db_hash)
if score > best_score:
best_score = score
best_entry = entry_data
if best_entry and best_score > 0:
report.similarity_score = min(best_score, 99) # never 100 - that's exact
report.matched_scam_type = best_entry.get("scam_type", "unknown")
report.matched_addresses = best_entry.get("addresses", [])
if best_score >= 80:
report.risk_label = "HIGH"
report.warnings.append(
f"Bytecode is {best_score}% similar to known {report.matched_scam_type} "
f"scam at {report.matched_addresses[0] if report.matched_addresses else 'unknown'}"
)
elif best_score >= 50:
report.risk_label = "MEDIUM"
report.warnings.append(f"Bytecode is {best_score}% similar to known scam contract")
else:
report.risk_label = "LOW"
else:
report.similarity_score = 0
report.risk_label = "LOW"
report.warnings.append("No similar bytecode found in scam DB")
return report
# ── Internal Helpers ─────────────────────────────────────────────
@staticmethod
def _looks_like_address(input_str: str) -> bool:
"""Heuristic: is the input an address rather than raw bytecode?"""
s = input_str.strip()
# EVM address: 0x + 40 hex chars
if re.match(r"^0x[0-9a-fA-F]{40}$", s):
return True
# Solana address: base58, 32-44 chars
return bool(re.match(r"^[1-9A-HJ-NP-Za-km-z]{32,44}$", s))
# ── Resource Cleanup ─────────────────────────────────────────────
async def close(self):
"""No-op for interface compatibility with other SENTINEL scanners."""
pass