merge: chore/cleanup-remove-bloat-and-secrets into main
This commit is contained in:
commit
bde2f3a97d
1173 changed files with 437609 additions and 0 deletions
65
app/domain/threat/brand_patterns.json
Normal file
65
app/domain/threat/brand_patterns.json
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
{
|
||||
"_comment": "T12 — CertStream brand patterns. Update as new phishing patterns emerge.",
|
||||
"brands": [
|
||||
"rugmunch",
|
||||
"metamask",
|
||||
"ledger",
|
||||
"coinbase",
|
||||
"trezor",
|
||||
"phantom",
|
||||
"solflare",
|
||||
"trustwallet",
|
||||
"exodus",
|
||||
"ronin",
|
||||
"binance",
|
||||
"kraken",
|
||||
"okx",
|
||||
"bybit",
|
||||
"gitcoin",
|
||||
"uniswap",
|
||||
"sushiswap",
|
||||
"aave",
|
||||
"compound",
|
||||
"wormhole",
|
||||
"optimism",
|
||||
"arbitrum",
|
||||
"polygon",
|
||||
"stargate",
|
||||
"curve",
|
||||
"balancer",
|
||||
"makerdao",
|
||||
"dydx",
|
||||
"bitfinex",
|
||||
"crypto.com",
|
||||
"kucoin",
|
||||
"huobi",
|
||||
"gate.io",
|
||||
"bitstamp",
|
||||
"gemini",
|
||||
"etoro",
|
||||
"robinhood",
|
||||
"swissborg",
|
||||
"blockfi",
|
||||
"celcius",
|
||||
"voyager",
|
||||
"ftx",
|
||||
"anchor",
|
||||
"lido",
|
||||
"rocketpool",
|
||||
"frax",
|
||||
"convex",
|
||||
"yearn",
|
||||
"synthetix",
|
||||
"ens",
|
||||
"opensea",
|
||||
"blur",
|
||||
"magic-eden",
|
||||
"pancakeswap",
|
||||
"1inch",
|
||||
"0x",
|
||||
"kyber",
|
||||
"paraswap",
|
||||
"rango",
|
||||
"lifi"
|
||||
]
|
||||
}
|
||||
347
app/domain/threat/certstream_listener.py
Normal file
347
app/domain/threat/certstream_listener.py
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
"""T12 — CertStream phishing domain monitor (G04 FIX adjacent).
|
||||
|
||||
Per MINIMAX_M3_TASKS.md T12. Watches Certificate Transparency logs in real
|
||||
time for domains that spoof crypto brands (MetaMask, Ledger, Coinbase,
|
||||
etc). When a match is found, fires a Telegram alert via the existing
|
||||
bot and persists the domain to Postgres `threat_domains`.
|
||||
|
||||
Run as a background task in lifespan.py. If CertStream is down, log and
|
||||
continue — never break startup.
|
||||
|
||||
Why this matters: phishing sites clone crypto brands and steal wallet
|
||||
seeds. CT logs show new domains BEFORE they go live, giving a 48h lead
|
||||
time to take them down.
|
||||
|
||||
Usage:
|
||||
from app.domain.threat.certstream_listener import start_listener
|
||||
asyncio.create_task(start_listener()) # in lifespan
|
||||
python3 -m app.domain.threat.certstream_listener --duration 300 # CLI
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
from contextlib import suppress
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Brand patterns (loaded once at import) ──────────────────────────
|
||||
_BRAND_PATTERNS_PATH = Path(__file__).parent / "brand_patterns.json"
|
||||
|
||||
|
||||
def load_brand_patterns() -> list[str]:
|
||||
"""Load brand pattern list. Returns lowercased list for matching."""
|
||||
try:
|
||||
with _BRAND_PATTERNS_PATH.open() as f:
|
||||
data = json.load(f)
|
||||
patterns = [str(p).lower() for p in data.get("brands", []) if p]
|
||||
logger.info("certstream_brands_loaded count=%d", len(patterns))
|
||||
return patterns
|
||||
except Exception as exc:
|
||||
logger.warning("certstream_brands_load_failed err=%s", exc)
|
||||
return []
|
||||
|
||||
|
||||
_BRAND_PATTERNS: list[str] = load_brand_patterns()
|
||||
|
||||
|
||||
# ── Domain matching ────────────────────────────────────────────────
|
||||
def match_brand(domain: str, brands: list[str] | None = None) -> str | None:
|
||||
"""Return the matched brand name if `domain` looks like a phishing
|
||||
clone of a known crypto brand. Returns None if no match.
|
||||
|
||||
Heuristic: brand substring appears in the domain's main label,
|
||||
but the domain is NOT the brand's official primary domain (we
|
||||
allow obvious legit variants like "metamask.io" but flag
|
||||
"metamask-secure-claim.com").
|
||||
"""
|
||||
if not domain:
|
||||
return None
|
||||
d = domain.lower().strip()
|
||||
# strip trailing dot
|
||||
if d.endswith("."):
|
||||
d = d[:-1]
|
||||
# take the main label (e.g. "metamask-secure" from "a.b.metamask-secure.com")
|
||||
parts = d.split(".")
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
main = parts[-2] if len(parts) >= 2 else parts[0]
|
||||
patterns = brands or _BRAND_PATTERNS
|
||||
for brand in patterns:
|
||||
# Strip non-alphanum for fuzzy match
|
||||
main_clean = "".join(c for c in main if c.isalnum())
|
||||
brand_clean = "".join(c for c in brand if c.isalnum())
|
||||
if not brand_clean:
|
||||
continue
|
||||
if brand_clean in main_clean and main_clean != brand_clean:
|
||||
# Phishing variant — not the official domain
|
||||
return brand
|
||||
return None
|
||||
|
||||
|
||||
# ── Telegram alert (best-effort, never blocks) ─────────────────────
|
||||
async def _telegram_alert(domain: str, brand: str, issuer: str) -> bool:
|
||||
"""Send a Telegram alert. Returns True on success. Never raises."""
|
||||
try:
|
||||
bot_token = os.getenv("TELEGRAM_BOT_TOKEN", "")
|
||||
chat_id = os.getenv("TELEGRAM_ALERT_CHAT_ID", "")
|
||||
if not bot_token or not chat_id:
|
||||
return False
|
||||
import httpx
|
||||
text = (
|
||||
f"⚠️ Phishing domain registered: {domain}\n"
|
||||
f"Brand: {brand}\n"
|
||||
f"Issuer: {issuer}\n"
|
||||
f"Issued: {datetime.now(UTC).isoformat()}\n"
|
||||
f"48h window to take down"
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.post(
|
||||
f"https://api.telegram.org/bot{bot_token}/sendMessage",
|
||||
json={"chat_id": chat_id, "text": text},
|
||||
)
|
||||
return r.status_code == 200
|
||||
except Exception as exc:
|
||||
logger.debug("telegram_alert_skipped err=%s", exc)
|
||||
return False
|
||||
|
||||
|
||||
# ── Postgres persistence (lazy import) ──────────────────────────────
|
||||
_THREAT_DOMAINS_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS threat_domains (
|
||||
domain TEXT PRIMARY KEY,
|
||||
brand_matched TEXT,
|
||||
issued_at TIMESTAMPTZ,
|
||||
issuer TEXT,
|
||||
first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
status TEXT NOT NULL DEFAULT 'new'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS threat_domains_first_seen_idx
|
||||
ON threat_domains (first_seen DESC);
|
||||
CREATE INDEX IF NOT EXISTS threat_domains_brand_idx
|
||||
ON threat_domains (brand_matched);
|
||||
"""
|
||||
|
||||
|
||||
async def _ensure_schema() -> bool:
|
||||
try:
|
||||
import asyncpg
|
||||
|
||||
from app.core.db_pool import PG_URL
|
||||
|
||||
conn = await asyncpg.connect(PG_URL)
|
||||
try:
|
||||
await conn.execute(_THREAT_DOMAINS_SCHEMA)
|
||||
finally:
|
||||
await conn.close()
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("threat_domains_schema_failed err=%s", exc)
|
||||
return False
|
||||
|
||||
|
||||
async def _persist_domain(domain: str, brand: str, issued_at: datetime | None, issuer: str) -> bool:
|
||||
try:
|
||||
import asyncpg
|
||||
|
||||
from app.core.db_pool import PG_URL
|
||||
|
||||
conn = await asyncpg.connect(PG_URL)
|
||||
try:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO threat_domains (domain, brand_matched, issued_at, issuer)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (domain) DO NOTHING
|
||||
""",
|
||||
domain,
|
||||
brand,
|
||||
issued_at,
|
||||
issuer,
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.debug("threat_domains_persist_failed domain=%s err=%s", domain, exc)
|
||||
return False
|
||||
|
||||
|
||||
# ── CertStream WebSocket listener ──────────────────────────────────
|
||||
# Public CertStream (CaliDog) — the canonical free CT feed.
|
||||
CERTSTREAM_URL = os.getenv(
|
||||
"CERTSTREAM_URL", "wss://certstream.calidog.io/"
|
||||
)
|
||||
|
||||
|
||||
async def _process_message(msg: dict) -> int:
|
||||
"""Process one CertStream message. Returns # of threats found (0 or 1+)."""
|
||||
data_type = msg.get("message_type") or msg.get("type")
|
||||
if data_type not in ("certificate_update", "heartbeat"):
|
||||
return 0
|
||||
if data_type == "heartbeat":
|
||||
return 0
|
||||
|
||||
leaf = msg.get("data", {}).get("leaf_cert", {}) or {}
|
||||
domains = set()
|
||||
# CertStream v2 puts all SANs here
|
||||
sans = leaf.get("all_domains") or []
|
||||
for d in sans:
|
||||
if d and isinstance(d, str) and not d.startswith("*"):
|
||||
domains.add(d.lower().strip())
|
||||
# Some payloads also have subject CN
|
||||
subject = (leaf.get("subject") or {}).get("CN", "")
|
||||
if subject and not subject.startswith("*"):
|
||||
domains.add(subject.lower().strip())
|
||||
|
||||
issuer = (
|
||||
msg.get("data", {}).get("chain", [{}])[0].get("subject", {}).get("O", "")
|
||||
or "unknown"
|
||||
)
|
||||
issued_at = None
|
||||
not_before = leaf.get("not_before")
|
||||
if not_before:
|
||||
try:
|
||||
issued_at = datetime.fromisoformat(not_before.replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
issued_at = datetime.now(UTC)
|
||||
if issued_at is None:
|
||||
issued_at = datetime.now(UTC)
|
||||
|
||||
threats = 0
|
||||
for d in domains:
|
||||
brand = match_brand(d)
|
||||
if brand:
|
||||
threats += 1
|
||||
logger.warning(
|
||||
"certstream_threat_detected domain=%s brand=%s issuer=%s",
|
||||
d, brand, issuer,
|
||||
)
|
||||
await _persist_domain(d, brand, issued_at, issuer)
|
||||
await _telegram_alert(d, brand, issuer)
|
||||
return threats
|
||||
|
||||
|
||||
async def _run_listener(stop_event: asyncio.Event) -> None:
|
||||
"""Connect to CertStream and consume messages until stop_event."""
|
||||
try:
|
||||
import websockets
|
||||
except ImportError:
|
||||
logger.warning("certstream_skipped err=websockets_not_installed")
|
||||
return
|
||||
|
||||
await _ensure_schema()
|
||||
backoff = 1.0
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
async with websockets.connect(CERTSTREAM_URL, ping_interval=20) as ws:
|
||||
logger.info("certstream_connected url=%s", CERTSTREAM_URL)
|
||||
backoff = 1.0
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
raw = await asyncio.wait_for(ws.recv(), timeout=60)
|
||||
except TimeoutError:
|
||||
# send a ping to keep the connection alive
|
||||
with suppress(Exception):
|
||||
await ws.send("ping")
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
await _process_message(msg)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("certstream_disconnected err=%s reconnecting_in=%.1fs", exc, backoff)
|
||||
with suppress(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(stop_event.wait(), timeout=backoff)
|
||||
backoff = min(backoff * 2, 60.0)
|
||||
|
||||
|
||||
# ── Public API ─────────────────────────────────────────────────────
|
||||
async def start_listener() -> asyncio.Task | None:
|
||||
"""Start the CertStream listener as a background task.
|
||||
|
||||
Returns the Task so caller can cancel on shutdown. Returns None if
|
||||
websockets is not installed or another instance is already running.
|
||||
"""
|
||||
if os.getenv("CERTSTREAM_ENABLED", "1") != "1":
|
||||
logger.info("certstream_disabled env=CERTSTREAM_ENABLED=0")
|
||||
return None
|
||||
try:
|
||||
import websockets # noqa: F401
|
||||
except ImportError:
|
||||
logger.info("certstream_skipped err=websockets_not_installed")
|
||||
return None
|
||||
stop_event = asyncio.Event()
|
||||
task = asyncio.create_task(
|
||||
_run_listener(stop_event),
|
||||
name="certstream-listener",
|
||||
)
|
||||
# Stash the stop event on the task so shutdown can set it
|
||||
task._rmi_stop_event = stop_event # type: ignore[attr-defined]
|
||||
return task
|
||||
|
||||
|
||||
async def stop_listener(task: asyncio.Task | None) -> None:
|
||||
"""Stop a running CertStream listener task."""
|
||||
if task is None or task.done():
|
||||
return
|
||||
stop_event = getattr(task, "_rmi_stop_event", None)
|
||||
if stop_event is not None:
|
||||
stop_event.set()
|
||||
task.cancel()
|
||||
with suppress(asyncio.CancelledError, Exception):
|
||||
await asyncio.wait_for(task, timeout=5.0)
|
||||
|
||||
|
||||
# ── CLI for ad-hoc testing ─────────────────────────────────────────
|
||||
def _cli() -> None:
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
parser = argparse.ArgumentParser(description="CertStream phishing monitor (CLI)")
|
||||
parser.add_argument("--duration", type=int, default=300, help="seconds to listen (0 = forever)")
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
|
||||
async def _main() -> None:
|
||||
stop = asyncio.Event()
|
||||
if args.duration > 0:
|
||||
async def _trigger() -> None:
|
||||
await asyncio.sleep(args.duration)
|
||||
stop.set()
|
||||
asyncio.create_task(_trigger())
|
||||
|
||||
await _run_listener(stop)
|
||||
|
||||
if args.duration == 0:
|
||||
# Forever mode: install SIGINT handler
|
||||
def _sigint(*_a: object) -> None:
|
||||
raise KeyboardInterrupt
|
||||
signal.signal(signal.SIGINT, _sigint)
|
||||
try:
|
||||
asyncio.run(_main())
|
||||
except KeyboardInterrupt:
|
||||
print("\n[cli] stopped", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_cli()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CERTSTREAM_URL",
|
||||
"load_brand_patterns",
|
||||
"match_brand",
|
||||
"start_listener",
|
||||
"stop_listener",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue