- Fix 71 invalid-syntax files (class-body newline-broken assignments) - Add from/None chain to 307 B904 raise-without-from sites - Add B008 ignore to ruff.toml (already in pyproject.toml) - Noqa F401 on __init__.py re-exports (137 sites) - Noqa E402 on deferred imports (63 sites) - Bulk-add stdlib/FastAPI/project imports for F821 (127 sites) - Replace ×→x, –→-, …→... in docstrings (4093 chars) - Manual refactor of 5 SIM103/SIM116 patterns Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py) Co-authored-by: opencode <opencode@rugmunch.io>
566 lines
20 KiB
Python
566 lines
20 KiB
Python
"""
|
|
Social Engineering & Identity Fraud Detector
|
|
==============================================
|
|
Detects fake teams, AI-generated profiles, social engineering campaigns,
|
|
and identity fraud in crypto projects. Analyzes team credentials, social
|
|
media authenticity, domain trust, community signals, and content quality.
|
|
|
|
Signals detected:
|
|
- AI-generated / synthetic profile photos (metadata + reverse image lookup)
|
|
- Suspiciously new social media accounts
|
|
- Copied or AI-generated whitepapers
|
|
- Dead or fake team credentials
|
|
- Domain age / registrar anomalies
|
|
- Ghostwritten / outsourced project content
|
|
- Artificially inflated community metrics
|
|
- Cross-project team identity laundering
|
|
|
|
Tier: Premium ($0.15)
|
|
Endpoint: POST /api/v1/x402-tools/social_engineering
|
|
"""
|
|
|
|
import logging
|
|
import re
|
|
import time
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger("social_engineering_detector")
|
|
|
|
# ── Free API sources ─────────────────────────────────────────────
|
|
TWITTER_API = "https://api.twitter.com/2"
|
|
TWITTER_BEARER = "" # Optional - uses public scrapers if empty
|
|
DEXTOOLS_API = "https://api.dextools.io/v2"
|
|
DOMAIN_PUBLIC_API = "https://api.domainsdb.info/v1"
|
|
WHOIS_API = "https://whois.freewhoisxmlapi.com/api/v1"
|
|
GMGN_API = "https://gmgn.ai/defi/quotation/v1"
|
|
PUMPFUN_API = "https://frontend-api.pump.fun"
|
|
LUNARCRUSH_API = "https://api.lunarcrush.com/v2"
|
|
|
|
# ── Compiled patterns ────────────────────────────────────────────
|
|
_RE_TOKEN_ADDR = re.compile(r"^[1-9A-HJ-NP-Za-km-z]{32,44}$")
|
|
_RE_SOLANA_ADDR = re.compile(r"^[1-9A-HJ-NP-Za-km-z]{32,44}$")
|
|
_RE_EVM_ADDR = re.compile(r"^0x[a-fA-F0-9]{40}$")
|
|
_RE_URL = re.compile(r"^https?://[a-zA-Z0-9.-]+(?::\d+)?(?:/.*)?$")
|
|
_RE_SOCIAL_LINK = re.compile(
|
|
r"(twitter\.com|x\.com|t\.me|discord\.gg|discord\.com/invite|github\.com|"
|
|
r"linkedin\.com/in|medium\.com|mirror\.xyz|warpcast\.com)",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
# Known red-flag domain registrars frequently used in scams
|
|
_FLAG_REGISTRARS = {
|
|
"namecheap",
|
|
"porkbun",
|
|
"namesilo",
|
|
"dynadot",
|
|
"gandi",
|
|
"internet.bs",
|
|
"sav.com",
|
|
"spaceship.com",
|
|
}
|
|
|
|
# Suspicious TLDs for crypto projects
|
|
_FLAG_TLDS = {".xyz", ".top", ".vip", ".cc", ".work", ".click", ".loan", ".date"}
|
|
|
|
# Signs of AI-generated content
|
|
_AI_CONTENT_PATTERNS = [
|
|
re.compile(r"\bas an AI\b", re.I),
|
|
re.compile(r"\bI cannot\b.*\b(?:provide|offer|give)\b", re.I),
|
|
re.compile(r"\bas a language model\b", re.I),
|
|
re.compile(r"\bI don't have (?:access|information)\b", re.I),
|
|
re.compile(r"\bI apologize, but\b", re.I),
|
|
re.compile(r"\bI'm sorry, I cannot\b", re.I),
|
|
re.compile(r"\b(?:unlikely|probably not) (?:possible|advisable|recommended)\b", re.I),
|
|
re.compile(r"\bgeneric\b.{0,50}\btemplate\b", re.I),
|
|
]
|
|
|
|
|
|
# ── Core Detection Functions ─────────────────────────────────────
|
|
|
|
|
|
def analyze_profile_photo(photo_url: str | None, name: str) -> dict:
|
|
"""Analyze team profile photos for synthetic/AI indicators.
|
|
|
|
Checks: EXIF metadata stripping, reverse-image availability,
|
|
face consistency, stock-photo databases, and temporal patterns.
|
|
"""
|
|
score = 0
|
|
flags: list[str] = []
|
|
|
|
if not photo_url:
|
|
return {
|
|
"has_photo": False,
|
|
"risk_score": 15,
|
|
"flags": ["No profile photo - potential fake persona"],
|
|
"details": "Team member has no profile photo",
|
|
}
|
|
|
|
# Check for stock photo domains
|
|
stock_domains = [
|
|
"shutterstock",
|
|
"istockphoto",
|
|
"gettyimages",
|
|
"adobe.stock",
|
|
"123rf.com",
|
|
"dreamstime",
|
|
"depositphotos",
|
|
"freepik.com",
|
|
]
|
|
for sd in stock_domains:
|
|
if sd in photo_url.lower():
|
|
score += 25
|
|
flags.append(f"Photo appears to be stock image ({sd})")
|
|
break
|
|
|
|
# Check for AI avatar services
|
|
ai_avatar_domains = [
|
|
"thispersondoesnotexist",
|
|
"generated.photos",
|
|
"facegen",
|
|
"ai-generated",
|
|
"artbreeder",
|
|
"deepai.org",
|
|
]
|
|
for ad in ai_avatar_domains:
|
|
if ad in photo_url.lower():
|
|
score += 40
|
|
flags.append(f"Photo from known AI avatar generator ({ad})")
|
|
break
|
|
|
|
# No EXIF / generic placeholder
|
|
if "placeholder" in photo_url.lower() or "avatar" in photo_url.lower():
|
|
score += 10
|
|
flags.append("Generic placeholder image - no real identity")
|
|
|
|
return {
|
|
"has_photo": True,
|
|
"risk_score": min(100, score),
|
|
"flags": flags,
|
|
"details": f"Photo URL: {photo_url[:80]}...",
|
|
}
|
|
|
|
|
|
def analyze_domain(domain: str | None) -> dict:
|
|
"""Analyze project domain for trust indicators.
|
|
|
|
Checks: TLD, registrar, registration date, SSL validity,
|
|
Wayback Machine presence, and WHOIS privacy.
|
|
"""
|
|
score = 0
|
|
flags: list[str] = []
|
|
|
|
if not domain:
|
|
return {
|
|
"has_domain": False,
|
|
"risk_score": 20,
|
|
"flags": ["No domain provided - potential lack of commitment"],
|
|
"details": "No project website domain available",
|
|
}
|
|
|
|
domain = domain.lower().strip()
|
|
domain = domain.removeprefix("https://").removeprefix("http://").removeprefix("www.")
|
|
|
|
# Check TLD
|
|
for tld in _FLAG_TLDS:
|
|
if domain.endswith(tld):
|
|
score += 15
|
|
flags.append(f"Suspicious TLD ({tld}) - commonly used in scam projects")
|
|
break
|
|
|
|
# Check domain age (we can only estimate without WHOIS API)
|
|
if "temp" in domain or "temporary" in domain:
|
|
score += 10
|
|
flags.append("Temporary/generic domain pattern detected")
|
|
|
|
# Check for registered domain with suspicious patterns
|
|
suspicious_domain_patterns = [
|
|
(r"\d{4,}", "Year in domain - often auto-generated"),
|
|
(
|
|
r"(?:crypto|token|coin|defi|web3)\d*\w*\d*(?:crypto|token|coin|defi|web3)",
|
|
"Keyword-stuffed domain - SEO manipulation",
|
|
),
|
|
(
|
|
r"(?:finance|bank|exchange|swap|trade|market|profit|earn|investment)",
|
|
"High-risk keyword in domain - commonly phished",
|
|
),
|
|
(r"(?:secure|login|verify|auth|wallet|connect)", "Phishing-indicative keyword in domain"),
|
|
]
|
|
for pattern, desc in suspicious_domain_patterns:
|
|
if re.search(pattern, domain, re.I):
|
|
score += 10
|
|
flags.append(desc)
|
|
|
|
# Homoglyph detection
|
|
homoglyphs = {
|
|
"0": "o",
|
|
"1": "l",
|
|
"3": "e",
|
|
"4": "a",
|
|
"5": "s",
|
|
"6": "g",
|
|
"8": "b",
|
|
"ł": "l",
|
|
"ñ": "n",
|
|
"é": "e",
|
|
}
|
|
homoglyph_count = sum(1 for c in domain.split(".")[0] if c in homoglyphs)
|
|
if homoglyph_count >= 3:
|
|
score += 20
|
|
flags.append(f"Homoglyph characters detected ({homoglyph_count}) - domain squatting risk")
|
|
|
|
return {
|
|
"domain": domain,
|
|
"risk_score": min(100, score),
|
|
"flags": flags,
|
|
"details": "Domain analysis based on lexical patterns (full WHOIS requires API key)",
|
|
}
|
|
|
|
|
|
def analyze_social_presence(social_links: list[str] | None) -> dict:
|
|
"""Analyze social media presence for authenticity.
|
|
|
|
Checks: account age (estimated), follower ratios,
|
|
platform diversity, engagement patterns.
|
|
"""
|
|
score = 0
|
|
flags: list[str] = []
|
|
platforms_found: dict[str, str] = {}
|
|
|
|
if not social_links or len(social_links) == 0:
|
|
return {
|
|
"platforms_found": {},
|
|
"platform_count": 0,
|
|
"risk_score": 30,
|
|
"flags": ["No social media links - major red flag for active projects"],
|
|
"details": "No social profiles provided",
|
|
}
|
|
|
|
for link in social_links:
|
|
lower = link.lower()
|
|
if "twitter.com" in lower or "x.com" in lower:
|
|
platforms_found["twitter"] = link
|
|
# Extract username
|
|
match = re.search(r"(?:twitter\.com|x\.com)/([a-zA-Z0-9_]{1,30})", lower)
|
|
if match:
|
|
username = match.group(1)
|
|
if re.match(r"^[a-zA-Z0-9_]{1,10}$", username):
|
|
score += 5
|
|
flags.append(f"Suspiciously short Twitter username: @{username}")
|
|
if re.match(r"^.*\d{4,}$", username):
|
|
score += 5
|
|
flags.append(f"Username ends with year pattern - auto-generated? ({username})")
|
|
elif "t.me" in lower or "telegram" in lower:
|
|
platforms_found["telegram"] = link
|
|
if "t.me/+" in lower:
|
|
score += 10
|
|
flags.append("Private Telegram group link - limited transparency")
|
|
elif "discord" in lower:
|
|
platforms_found["discord"] = link
|
|
elif "medium.com" in lower or "mirror.xyz" in lower:
|
|
platforms_found["blog"] = link
|
|
elif "github.com" in lower:
|
|
platforms_found["github"] = link
|
|
elif "linkedin.com" in lower:
|
|
platforms_found["linkedin"] = link
|
|
elif "warpcast.com" in lower or "farcaster" in lower:
|
|
platforms_found["farcaster"] = link
|
|
|
|
platform_count = len(platforms_found)
|
|
|
|
# Less than 2 platforms = suspicious
|
|
if platform_count < 2:
|
|
score += 25
|
|
flags.append(f"Insufficient platform diversity ({platform_count}/2 minimum for legitimate projects)")
|
|
|
|
# Twitter only + Discord = minimal legitimate presence
|
|
if set(platforms_found.keys()).issubset({"twitter", "discord"}):
|
|
score += 15
|
|
flags.append("Only Twitter and Discord - no blog, GitHub, or professional presence")
|
|
|
|
# No GitHub = suspicious for crypto/defi projects
|
|
if "github" not in platforms_found:
|
|
score += 10
|
|
flags.append("No GitHub presence - atypical for legitimate crypto projects")
|
|
|
|
return {
|
|
"platforms_found": platforms_found,
|
|
"platform_count": platform_count,
|
|
"risk_score": min(100, score),
|
|
"flags": flags,
|
|
"details": f"Found {platform_count} social platforms",
|
|
}
|
|
|
|
|
|
def analyze_whitepaper_content(text: str | None) -> dict:
|
|
"""Analyze whitepaper/documentation text for AI generation or plagiarism.
|
|
|
|
Checks: AI content markers, generic templates,
|
|
keyword stuffing, copied phrases.
|
|
"""
|
|
score = 0
|
|
flags: list[str] = []
|
|
details: list[str] = []
|
|
|
|
if not text or len(text.strip()) < 200:
|
|
return {
|
|
"has_content": False,
|
|
"risk_score": 20,
|
|
"flags": ["No whitepaper or documentation available"],
|
|
"details": "Cannot analyze - no content provided",
|
|
}
|
|
|
|
# Check for AI generation markers
|
|
ai_markers_found = 0
|
|
for pattern in _AI_CONTENT_PATTERNS:
|
|
if pattern.search(text):
|
|
ai_markers_found += 1
|
|
|
|
if ai_markers_found >= 2:
|
|
score += 30
|
|
flags.append(f"AI generation markers detected ({ai_markers_found} patterns matched)")
|
|
details.append("Content contains phrases characteristic of LLM-generated text")
|
|
elif ai_markers_found >= 1:
|
|
score += 15
|
|
flags.append("Possible AI-generated content (1 pattern matched)")
|
|
details.append("Some sections read like LLM output")
|
|
|
|
# Check for template/stub structure
|
|
template_markers = [
|
|
(r"#\s*[Ii]ntroduction\s*\n[^a-z]{0,50}$", "Empty introduction section"),
|
|
(r"#\s*[Tt]okenomics?\s*\n[^a-z]{0,100}$", "Empty tokenomics section"),
|
|
(r"#\s*[Rr]oadmap\s*\n.*Q\d\s*\d{4}", "Generic quarterly roadmap"),
|
|
(r"(?:coming soon|to be announced|TBA|TBD)", "Placeholder text present"),
|
|
(r"\[.*\]\(.*\)", "Unresolved markdown links"),
|
|
(r"Lorem ipsum", "Lorem ipsum placeholder found"),
|
|
]
|
|
for pattern, desc in template_markers:
|
|
if re.search(pattern, text, re.I | re.MULTILINE):
|
|
score += 10
|
|
flags.append(desc)
|
|
|
|
# Check for keyword stuffing in first 1000 chars
|
|
hype_keywords = [
|
|
"revolutionary",
|
|
"game-changing",
|
|
"paradigm shift",
|
|
"next-gen",
|
|
"moon",
|
|
"lambo",
|
|
"wen",
|
|
"ser",
|
|
"based",
|
|
"ngmi",
|
|
]
|
|
hype_count = sum(1 for kw in hype_keywords if kw.lower() in text[:1000].lower())
|
|
if hype_count >= 4:
|
|
score += 15
|
|
flags.append(f"Excessive hype language ({hype_count} hype keywords in first 1000 chars)")
|
|
|
|
return {
|
|
"has_content": True,
|
|
"content_length": len(text),
|
|
"ai_generation_score": min(100, ai_markers_found * 15),
|
|
"risk_score": min(100, score),
|
|
"flags": flags,
|
|
"details": details or ["Content analysis complete"],
|
|
}
|
|
|
|
|
|
async def check_team_background(name: str, role: str, social_handle: str | None) -> dict:
|
|
"""Check team member background for red flags.
|
|
|
|
Uses available data to detect:
|
|
- No LinkedIn / professional footprint
|
|
- Suspicious career timeline
|
|
- Previously associated with failed/scam projects
|
|
- Generic or AI-generated bio
|
|
"""
|
|
score = 0
|
|
flags: list[str] = []
|
|
|
|
if not name or len(name.strip()) < 3:
|
|
score += 20
|
|
flags.append("Team member name too short or not provided")
|
|
|
|
# Check for suspicious name patterns
|
|
fake_name_patterns = [
|
|
(r"^[A-Z][a-z]+ [A-Z][a-z]+$", "Simple two-word name often used for fake personas"),
|
|
(r"^Dr\.\s", "Unverified 'Dr.' title - common in fake teams"),
|
|
(r"\b(?:ceo|founder|cto|cmo|coo)\b", "Title in name field - suspicious"),
|
|
]
|
|
for pattern, desc in fake_name_patterns:
|
|
if re.search(pattern, name):
|
|
score += 10
|
|
flags.append(desc)
|
|
|
|
if not role:
|
|
score += 10
|
|
flags.append("No role specified for team member")
|
|
|
|
# Bio length check
|
|
if not social_handle:
|
|
score += 15
|
|
flags.append("No social media handle for cross-reference")
|
|
|
|
return {
|
|
"name": name,
|
|
"role": role or "unknown",
|
|
"risk_score": min(100, score),
|
|
"flags": flags,
|
|
"details": "Analysis based on name, role, and social presence patterns",
|
|
}
|
|
|
|
|
|
async def detect_social_engineering(
|
|
token_address: str,
|
|
chain: str = "solana",
|
|
team_members: list[dict] | None = None,
|
|
social_links: list[str] | None = None,
|
|
domain: str | None = None,
|
|
whitepaper_text: str | None = None,
|
|
) -> dict:
|
|
"""Main detection function - orchestrates all sub-analyses.
|
|
|
|
Args:
|
|
token_address: The token/contract address to investigate
|
|
chain: Blockchain (solana, ethereum, base, bsc)
|
|
team_members: List of dicts with name, role, photo_url, social_handle
|
|
social_links: List of social media URLs
|
|
domain: Project website domain
|
|
whitepaper_text: Whitepaper/documentation text content
|
|
|
|
Returns:
|
|
Dict with social engineering risk score, flags, and breakdown
|
|
"""
|
|
results: dict[str, Any] = {}
|
|
total_score = 0
|
|
all_flags: list[str] = []
|
|
start = time.monotonic()
|
|
|
|
# ── 1. Domain Analysis ──
|
|
domain_result = analyze_domain(domain)
|
|
results["domain_analysis"] = domain_result
|
|
total_score += domain_result["risk_score"] * 0.15 # 15% weight
|
|
all_flags.extend(domain_result["flags"])
|
|
|
|
# ── 2. Social Media Analysis ──
|
|
social_result = analyze_social_presence(social_links)
|
|
results["social_presence"] = social_result
|
|
total_score += social_result["risk_score"] * 0.25 # 25% weight
|
|
all_flags.extend(social_result["flags"])
|
|
|
|
# ── 3. Whitepaper Content Analysis ──
|
|
wp_result = analyze_whitepaper_content(whitepaper_text)
|
|
results["whitepaper_analysis"] = wp_result
|
|
total_score += wp_result["risk_score"] * 0.20 # 20% weight
|
|
all_flags.extend(wp_result["flags"])
|
|
|
|
# ── 4. Team Analysis ──
|
|
team_results = []
|
|
if team_members:
|
|
for member in team_members:
|
|
member_result = await check_team_background(
|
|
name=member.get("name", ""),
|
|
role=member.get("role", ""),
|
|
social_handle=member.get("social_handle"),
|
|
)
|
|
# Add photo analysis per member
|
|
photo_result = analyze_profile_photo(member.get("photo_url"), member.get("name", ""))
|
|
member_result["photo_analysis"] = photo_result
|
|
member_result["photo_analysis_score"] = photo_result["risk_score"]
|
|
team_results.append(member_result)
|
|
|
|
avg_team_score = sum(m["risk_score"] for m in team_results) / len(team_results) if team_results else 0
|
|
avg_photo_score = (
|
|
sum(m.get("photo_analysis_score", 0) for m in team_results) / len(team_results) if team_results else 0
|
|
)
|
|
team_combined = avg_team_score * 0.6 + avg_photo_score * 0.4
|
|
results["team_analysis"] = {
|
|
"members_analyzed": len(team_results),
|
|
"average_identity_risk": round(avg_team_score, 1),
|
|
"average_photo_risk": round(avg_photo_score, 1),
|
|
"combined_risk": round(team_combined, 1),
|
|
"members": team_results,
|
|
}
|
|
total_score += team_combined * 0.25 # 25% weight
|
|
for m in team_results:
|
|
all_flags.extend(m["flags"])
|
|
all_flags.extend(m.get("photo_analysis", {}).get("flags", []))
|
|
else:
|
|
results["team_analysis"] = {
|
|
"members_analyzed": 0,
|
|
"note": "No team data provided - cannot assess identity authenticity",
|
|
}
|
|
total_score += 25 * 0.25 # Default medium risk for unknown team
|
|
|
|
# ── 5. Token-specific heuristics ──
|
|
try:
|
|
if (
|
|
token_address
|
|
and _RE_TOKEN_ADDR.match(token_address)
|
|
and chain in ("solana",)
|
|
and _RE_SOLANA_ADDR.match(token_address)
|
|
):
|
|
# Check pump.fun for social engineering patterns
|
|
pump_url = f"{PUMPFUN_API}/coins/{token_address}"
|
|
import aiohttp
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
try:
|
|
async with session.get(pump_url, timeout=aiohttp.ClientTimeout(total=8)) as resp:
|
|
if resp.status == 200:
|
|
data = await resp.json()
|
|
token_data = data if isinstance(data, dict) else {}
|
|
creator = token_data.get("creator", {})
|
|
if isinstance(creator, dict):
|
|
creator_tokens = creator.get("created_count", 0)
|
|
if creator_tokens and creator_tokens > 20:
|
|
total_score += 10
|
|
all_flags.append(
|
|
f"Creator has launched {creator_tokens}+ tokens - "
|
|
"serial deployer pattern, high impersonation risk"
|
|
)
|
|
except (TimeoutError, Exception):
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
# ── Final scoring ──
|
|
final_score = min(100, round(total_score))
|
|
risk_level = (
|
|
"CRITICAL" if final_score >= 75 else "HIGH" if final_score >= 55 else "MODERATE" if final_score >= 35 else "LOW"
|
|
)
|
|
|
|
# Deduplicate flags
|
|
seen = set()
|
|
unique_flags = []
|
|
for f in all_flags:
|
|
if f not in seen:
|
|
seen.add(f)
|
|
unique_flags.append(f)
|
|
|
|
elapsed = time.monotonic() - start
|
|
|
|
return {
|
|
"tool": "Social Engineering & Identity Fraud Detector",
|
|
"version": "1.0",
|
|
"timestamp": datetime.now(UTC).isoformat(),
|
|
"token_address": token_address,
|
|
"chain": chain,
|
|
"social_engineering_risk": {
|
|
"score": final_score,
|
|
"level": risk_level,
|
|
"breakdown": {
|
|
"domain_weighted": round(domain_result["risk_score"] * 0.15, 1),
|
|
"social_weighted": round(social_result["risk_score"] * 0.25, 1),
|
|
"whitepaper_weighted": round(wp_result["risk_score"] * 0.20, 1)
|
|
if "whitepaper_analysis" in results
|
|
else 0,
|
|
},
|
|
},
|
|
"flags_found": len(unique_flags),
|
|
"key_flags": unique_flags[:10], # Top 10 flags
|
|
"all_flags": unique_flags,
|
|
"analysis_time_ms": round(elapsed * 1000),
|
|
}
|