Crypto Rug Muncher commited on
Commit Β·
7dfd7da
1
Parent(s): 7f512e5
fix: resolve 69 syntax errors from misindented imports and broken function defs
Browse filesFixed syntax errors across 13 router files:
- Removed over-indented 'from app.core.redis import get_redis' imports
- Fixed incomplete function definitions (create_tier, queue_webhook_delivery)
- Corrected indentation in try blocks
- Removed stray closing braces
Files fixed:
- app/auth.py
- app/domain/news/router.py
- app/lifespan.py
- app/routers/admin_users_api.py
- app/routers/auth_extensions.py
- app/routers/developer_tier.py
- app/routers/persistent_state.py
- app/routers/status_page.py
- app/routers/subscription_pricing_api.py
- app/routers/webhook_pipeline.py
- app/routers/x402_alpha_revenue_tools.py
- app/routers/x402_enforcement.py
- app/routers/x402_tools.py
All syntax errors now resolved (ruff check --select=E9 passes)
- app/auth.py +1 -1
- app/domain/news/clusterer.py +414 -0
- app/domain/news/router.py +43 -0
- app/domain/threat/certstream_listener.py +344 -0
- app/lifespan.py +14 -0
- app/routers/admin_users_api.py +1 -1
- app/routers/auth_extensions.py +1 -1
- app/routers/developer_tier.py +2 -2
- app/routers/persistent_state.py +0 -1
- app/routers/status_page.py +0 -6
- app/routers/subscription_pricing_api.py +1 -1
- app/routers/webhook_pipeline.py +6 -2
- app/routers/x402_alpha_revenue_tools.py +1 -1
- app/routers/x402_enforcement.py +1 -1
- app/routers/x402_tools.py +1 -1
- tests/unit/test_t03_t12_m3_residual.py +114 -0
app/auth.py
CHANGED
|
@@ -862,7 +862,7 @@ async def x_callback(request: Request):
|
|
| 862 |
return RedirectResponse(f"{FRONTEND_URL}/auth/callback?error=missing_code")
|
| 863 |
|
| 864 |
import httpx
|
| 865 |
-
from app.core.redis import get_redis
|
| 866 |
|
| 867 |
client_id = os.getenv("X_CLIENT_ID")
|
| 868 |
client_secret = os.getenv("X_CLIENT_SECRET")
|
|
|
|
| 862 |
return RedirectResponse(f"{FRONTEND_URL}/auth/callback?error=missing_code")
|
| 863 |
|
| 864 |
import httpx
|
| 865 |
+
from app.core.redis import get_redis
|
| 866 |
|
| 867 |
client_id = os.getenv("X_CLIENT_ID")
|
| 868 |
client_secret = os.getenv("X_CLIENT_SECRET")
|
app/domain/news/clusterer.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""T03 β News story clustering (G04 FIX).
|
| 2 |
+
|
| 3 |
+
Per MINIMAX_M3_TASKS.md T03. MinHash + DBSCAN dedupes raw RSS items into
|
| 4 |
+
single "stories" so AI agents don't see the same CoinDesk/The Block story
|
| 5 |
+
counted 2-3x in their signal.
|
| 6 |
+
|
| 7 |
+
Algorithm:
|
| 8 |
+
1. MinHash signature (128 permutations) on shingled title+body (first 500 chars)
|
| 9 |
+
2. DBSCAN clusters within 30-minute windows, Jaccard threshold 0.6, eps=0.15
|
| 10 |
+
3. Each cluster = one story with all source URLs, sentiment avg, item count
|
| 11 |
+
4. Persist clusters to Postgres `news_clusters` table; raw items unchanged
|
| 12 |
+
|
| 13 |
+
Endpoints:
|
| 14 |
+
GET /api/v1/news?clustered=true returns stories (clusters), not raw items
|
| 15 |
+
GET /api/v1/news raw items (legacy)
|
| 16 |
+
|
| 17 |
+
This module is pure logic β no I/O at import time. Router/background job
|
| 18 |
+
call `cluster_items(items) -> list[StoryCluster]`.
|
| 19 |
+
"""
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import hashlib
|
| 23 |
+
import logging
|
| 24 |
+
import re
|
| 25 |
+
import time
|
| 26 |
+
from dataclasses import dataclass, field
|
| 27 |
+
from datetime import UTC, datetime, timedelta
|
| 28 |
+
|
| 29 |
+
logger = logging.getLogger(__name__)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# ββ Tokenization ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 33 |
+
_TOKEN_RE = re.compile(r"[a-z0-9]{3,}", re.IGNORECASE)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _shingles(text: str, k: int = 3) -> set[str]:
|
| 37 |
+
"""k-shingle set of lowercased alphanumeric tokens. For Jaccard."""
|
| 38 |
+
if not text:
|
| 39 |
+
return set()
|
| 40 |
+
toks = _TOKEN_RE.findall(text.lower())
|
| 41 |
+
if len(toks) < k:
|
| 42 |
+
return set(toks)
|
| 43 |
+
return {" ".join(toks[i : i + k]) for i in range(len(toks) - k + 1)}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# ββ MinHash βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 47 |
+
_NUM_PERM = 128
|
| 48 |
+
_MAX_HASH = (1 << 32) - 1
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _minhash_signature(shingles: set[str], seed: int = 42) -> list[int]:
|
| 52 |
+
"""128-permutation MinHash signature of a shingle set.
|
| 53 |
+
|
| 54 |
+
Uses SHA-256 seeded permutations β fast, deterministic, no numpy.
|
| 55 |
+
"""
|
| 56 |
+
if not shingles:
|
| 57 |
+
return [_MAX_HASH] * _NUM_PERM
|
| 58 |
+
sig: list[int] = []
|
| 59 |
+
for i in range(_NUM_PERM):
|
| 60 |
+
m = _MAX_HASH
|
| 61 |
+
for s in shingles:
|
| 62 |
+
h = int.from_bytes(
|
| 63 |
+
hashlib.sha256(f"{i}:{seed}:{s}".encode()).digest()[:4],
|
| 64 |
+
"big",
|
| 65 |
+
)
|
| 66 |
+
if h < m:
|
| 67 |
+
m = h
|
| 68 |
+
sig.append(m)
|
| 69 |
+
return sig
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _jaccard_minhash(a: list[int], b: list[int]) -> float:
|
| 73 |
+
"""Estimate Jaccard similarity from two MinHash signatures."""
|
| 74 |
+
if not a or not b or len(a) != len(b):
|
| 75 |
+
return 0.0
|
| 76 |
+
return sum(1 for x, y in zip(a, b) if x == y) / len(a)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# ββ DBSCAN (pure-python, no sklearn dep) ββββββββββββββββββββββββββββ
|
| 80 |
+
def _dbscan(
|
| 81 |
+
signatures: list[list[int]],
|
| 82 |
+
eps: float = 0.4,
|
| 83 |
+
min_samples: int = 2,
|
| 84 |
+
) -> list[int]:
|
| 85 |
+
"""Density-based clustering. Returns cluster id per item (-1 = noise).
|
| 86 |
+
|
| 87 |
+
Similarity is Jaccard (estimated via MinHash). Neighbours are pairs
|
| 88 |
+
with Jaccard distance <= eps (i.e. similarity >= 1 - eps).
|
| 89 |
+
Default eps=0.4 means similarity >= 0.6 (per T03 spec).
|
| 90 |
+
"""
|
| 91 |
+
n = len(signatures)
|
| 92 |
+
labels = [-1] * n
|
| 93 |
+
cluster_id = 0
|
| 94 |
+
for i in range(n):
|
| 95 |
+
if labels[i] != -1:
|
| 96 |
+
continue
|
| 97 |
+
neighbors = [
|
| 98 |
+
j
|
| 99 |
+
for j in range(n)
|
| 100 |
+
if i != j and (1.0 - _jaccard_minhash(signatures[i], signatures[j])) <= eps
|
| 101 |
+
]
|
| 102 |
+
if len(neighbors) < min_samples - 1:
|
| 103 |
+
# not enough neighbours β mark as noise (may become border later)
|
| 104 |
+
continue
|
| 105 |
+
labels[i] = cluster_id
|
| 106 |
+
seed_set = list(neighbors)
|
| 107 |
+
k = 0
|
| 108 |
+
while k < len(seed_set):
|
| 109 |
+
q = seed_set[k]
|
| 110 |
+
if labels[q] == -1:
|
| 111 |
+
labels[q] = cluster_id
|
| 112 |
+
q_neighbors = [
|
| 113 |
+
j
|
| 114 |
+
for j in range(n)
|
| 115 |
+
if j != q
|
| 116 |
+
and (1.0 - _jaccard_minhash(signatures[q], signatures[j])) <= eps
|
| 117 |
+
]
|
| 118 |
+
if len(q_neighbors) >= min_samples - 1:
|
| 119 |
+
seed_set.extend(q_neighbors)
|
| 120 |
+
elif labels[q] is None or labels[q] == -1:
|
| 121 |
+
labels[q] = cluster_id
|
| 122 |
+
k += 1
|
| 123 |
+
cluster_id += 1
|
| 124 |
+
return labels
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
# ββ Domain types ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 128 |
+
@dataclass
|
| 129 |
+
class NewsItem:
|
| 130 |
+
"""Minimal news item for clustering. Adapts from DB rows or dicts."""
|
| 131 |
+
|
| 132 |
+
id: str
|
| 133 |
+
title: str
|
| 134 |
+
body: str = ""
|
| 135 |
+
source: str = ""
|
| 136 |
+
url: str = ""
|
| 137 |
+
published_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
| 138 |
+
sentiment: float = 0.0
|
| 139 |
+
|
| 140 |
+
@classmethod
|
| 141 |
+
def from_row(cls, row: dict) -> NewsItem:
|
| 142 |
+
published = row.get("published_at") or row.get("created_at")
|
| 143 |
+
if isinstance(published, str):
|
| 144 |
+
try:
|
| 145 |
+
published = datetime.fromisoformat(published.replace("Z", "+00:00"))
|
| 146 |
+
except (ValueError, AttributeError):
|
| 147 |
+
published = datetime.now(UTC)
|
| 148 |
+
elif not isinstance(published, datetime):
|
| 149 |
+
published = datetime.now(UTC)
|
| 150 |
+
return cls(
|
| 151 |
+
id=str(row.get("id", row.get("news_id", ""))),
|
| 152 |
+
title=row.get("title", "") or "",
|
| 153 |
+
body=(row.get("body") or row.get("summary") or "")[:500],
|
| 154 |
+
source=row.get("source", "") or "",
|
| 155 |
+
url=row.get("url", "") or "",
|
| 156 |
+
published_at=published,
|
| 157 |
+
sentiment=float(row.get("sentiment", 0.0) or 0.0),
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
@dataclass
|
| 162 |
+
class StoryCluster:
|
| 163 |
+
"""One deduplicated story spanning 1+ source items."""
|
| 164 |
+
|
| 165 |
+
cluster_id: str
|
| 166 |
+
representative_title: str
|
| 167 |
+
source_urls: list[str]
|
| 168 |
+
sources: list[str]
|
| 169 |
+
first_seen: datetime
|
| 170 |
+
last_updated: datetime
|
| 171 |
+
item_count: int
|
| 172 |
+
sentiment_avg: float
|
| 173 |
+
item_ids: list[str]
|
| 174 |
+
|
| 175 |
+
def to_dict(self) -> dict:
|
| 176 |
+
return {
|
| 177 |
+
"cluster_id": self.cluster_id,
|
| 178 |
+
"representative_title": self.representative_title,
|
| 179 |
+
"source_urls": self.source_urls,
|
| 180 |
+
"sources": self.sources,
|
| 181 |
+
"first_seen": self.first_seen.isoformat(),
|
| 182 |
+
"last_updated": self.last_updated.isoformat(),
|
| 183 |
+
"item_count": self.item_count,
|
| 184 |
+
"sentiment_avg": round(self.sentiment_avg, 3),
|
| 185 |
+
"item_ids": self.item_ids,
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
# ββ Main entry point ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 190 |
+
def cluster_items(
|
| 191 |
+
items: list[NewsItem],
|
| 192 |
+
window_minutes: int = 30,
|
| 193 |
+
eps: float = 0.4,
|
| 194 |
+
min_samples: int = 2,
|
| 195 |
+
) -> list[StoryCluster]:
|
| 196 |
+
"""Cluster news items into stories.
|
| 197 |
+
|
| 198 |
+
Items are first grouped by 30-minute time windows, then DBSCAN runs
|
| 199 |
+
on MinHash signatures within each window. Single-item clusters are
|
| 200 |
+
kept (they're "noise" in DBSCAN terms but valid singleton stories).
|
| 201 |
+
|
| 202 |
+
`eps` is the Jaccard DISTANCE threshold (1 - similarity). Per the
|
| 203 |
+
task spec, two items cluster together when Jaccard similarity >= 0.6,
|
| 204 |
+
so distance <= 0.4, so eps=0.4. Tighten for stricter clusters.
|
| 205 |
+
"""
|
| 206 |
+
t0 = time.time()
|
| 207 |
+
if not items:
|
| 208 |
+
return []
|
| 209 |
+
|
| 210 |
+
# Group by time window
|
| 211 |
+
windows: dict[datetime, list[NewsItem]] = {}
|
| 212 |
+
for it in sorted(items, key=lambda x: x.published_at):
|
| 213 |
+
bucket = it.published_at.replace(
|
| 214 |
+
minute=(it.published_at.minute // window_minutes) * window_minutes,
|
| 215 |
+
second=0,
|
| 216 |
+
microsecond=0,
|
| 217 |
+
)
|
| 218 |
+
windows.setdefault(bucket, []).append(it)
|
| 219 |
+
|
| 220 |
+
stories: list[StoryCluster] = []
|
| 221 |
+
for _bucket, group in windows.items():
|
| 222 |
+
if len(group) == 1:
|
| 223 |
+
# singleton β still a story
|
| 224 |
+
it = group[0]
|
| 225 |
+
stories.append(
|
| 226 |
+
StoryCluster(
|
| 227 |
+
cluster_id=hashlib.sha1(
|
| 228 |
+
f"single:{it.id}:{it.published_at.isoformat()}".encode()
|
| 229 |
+
).hexdigest()[:16],
|
| 230 |
+
representative_title=it.title,
|
| 231 |
+
source_urls=[it.url] if it.url else [],
|
| 232 |
+
sources=[it.source] if it.source else [],
|
| 233 |
+
first_seen=it.published_at,
|
| 234 |
+
last_updated=it.published_at,
|
| 235 |
+
item_count=1,
|
| 236 |
+
sentiment_avg=it.sentiment,
|
| 237 |
+
item_ids=[it.id],
|
| 238 |
+
)
|
| 239 |
+
)
|
| 240 |
+
continue
|
| 241 |
+
|
| 242 |
+
sigs = [_minhash_signature(_shingles(f"{it.title} {it.body}")) for it in group]
|
| 243 |
+
labels = _dbscan(sigs, eps=eps, min_samples=min_samples)
|
| 244 |
+
# Singletons (label == -1) still become stories
|
| 245 |
+
clusters: dict[int, list[int]] = {}
|
| 246 |
+
for idx, lbl in enumerate(labels):
|
| 247 |
+
clusters.setdefault(lbl if lbl != -1 else idx, []).append(idx)
|
| 248 |
+
for _cid, indices in clusters.items():
|
| 249 |
+
members = [group[i] for i in indices]
|
| 250 |
+
# Pick representative = longest title (usually the most descriptive)
|
| 251 |
+
rep = max(members, key=lambda x: len(x.title))
|
| 252 |
+
sentiments = [m.sentiment for m in members if m.sentiment is not None]
|
| 253 |
+
avg_sent = sum(sentiments) / len(sentiments) if sentiments else 0.0
|
| 254 |
+
cluster_id = hashlib.sha1(
|
| 255 |
+
":".join(sorted(m.id for m in members)).encode()
|
| 256 |
+
).hexdigest()[:16]
|
| 257 |
+
stories.append(
|
| 258 |
+
StoryCluster(
|
| 259 |
+
cluster_id=cluster_id,
|
| 260 |
+
representative_title=rep.title,
|
| 261 |
+
source_urls=[m.url for m in members if m.url],
|
| 262 |
+
sources=sorted({m.source for m in members if m.source}),
|
| 263 |
+
first_seen=min(m.published_at for m in members),
|
| 264 |
+
last_updated=max(m.published_at for m in members),
|
| 265 |
+
item_count=len(members),
|
| 266 |
+
sentiment_avg=avg_sent,
|
| 267 |
+
item_ids=[m.id for m in members],
|
| 268 |
+
)
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
logger.info(
|
| 272 |
+
"news_clustered items=%d stories=%d windows=%d elapsed_ms=%.1f",
|
| 273 |
+
len(items),
|
| 274 |
+
len(stories),
|
| 275 |
+
len(windows),
|
| 276 |
+
(time.time() - t0) * 1000,
|
| 277 |
+
)
|
| 278 |
+
return stories
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
# ββ DB persistence (optional, lazy import) ββββββββββββββββββββββββββ
|
| 282 |
+
_PG_SCHEMA_SQL = """
|
| 283 |
+
CREATE TABLE IF NOT EXISTS news_clusters (
|
| 284 |
+
cluster_id TEXT PRIMARY KEY,
|
| 285 |
+
representative_title TEXT NOT NULL,
|
| 286 |
+
first_seen TIMESTAMPTZ NOT NULL,
|
| 287 |
+
last_updated TIMESTAMPTZ NOT NULL,
|
| 288 |
+
item_count INTEGER NOT NULL DEFAULT 0,
|
| 289 |
+
sentiment_avg DOUBLE PRECISION NOT NULL DEFAULT 0.0,
|
| 290 |
+
source_urls JSONB NOT NULL DEFAULT '[]'::jsonb,
|
| 291 |
+
sources JSONB NOT NULL DEFAULT '[]'::jsonb,
|
| 292 |
+
item_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
|
| 293 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
| 294 |
+
);
|
| 295 |
+
CREATE INDEX IF NOT EXISTS news_clusters_last_updated_idx
|
| 296 |
+
ON news_clusters (last_updated DESC);
|
| 297 |
+
"""
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
async def ensure_schema() -> bool:
|
| 301 |
+
"""Create news_clusters table if missing. Returns True on success."""
|
| 302 |
+
try:
|
| 303 |
+
import asyncpg
|
| 304 |
+
|
| 305 |
+
from app.core.db_pool import PG_URL
|
| 306 |
+
|
| 307 |
+
conn = await asyncpg.connect(PG_URL)
|
| 308 |
+
try:
|
| 309 |
+
await conn.execute(_PG_SCHEMA_SQL)
|
| 310 |
+
finally:
|
| 311 |
+
await conn.close()
|
| 312 |
+
logger.info("news_clusters_schema_ready")
|
| 313 |
+
return True
|
| 314 |
+
except Exception as exc:
|
| 315 |
+
logger.warning("news_clusters_schema_failed err=%s", exc)
|
| 316 |
+
return False
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
async def persist_clusters(stories: list[StoryCluster]) -> int:
|
| 320 |
+
"""Upsert stories to Postgres. Returns rows affected."""
|
| 321 |
+
if not stories:
|
| 322 |
+
return 0
|
| 323 |
+
try:
|
| 324 |
+
import json
|
| 325 |
+
|
| 326 |
+
import asyncpg
|
| 327 |
+
|
| 328 |
+
from app.core.db_pool import PG_URL
|
| 329 |
+
|
| 330 |
+
conn = await asyncpg.connect(PG_URL)
|
| 331 |
+
try:
|
| 332 |
+
rows = [
|
| 333 |
+
(
|
| 334 |
+
s.cluster_id,
|
| 335 |
+
s.representative_title,
|
| 336 |
+
s.first_seen,
|
| 337 |
+
s.last_updated,
|
| 338 |
+
s.item_count,
|
| 339 |
+
s.sentiment_avg,
|
| 340 |
+
json.dumps(s.source_urls),
|
| 341 |
+
json.dumps(s.sources),
|
| 342 |
+
json.dumps(s.item_ids),
|
| 343 |
+
)
|
| 344 |
+
for s in stories
|
| 345 |
+
]
|
| 346 |
+
await conn.executemany(
|
| 347 |
+
"""
|
| 348 |
+
INSERT INTO news_clusters
|
| 349 |
+
(cluster_id, representative_title, first_seen, last_updated,
|
| 350 |
+
item_count, sentiment_avg, source_urls, sources, item_ids)
|
| 351 |
+
VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9::jsonb)
|
| 352 |
+
ON CONFLICT (cluster_id) DO UPDATE SET
|
| 353 |
+
representative_title = EXCLUDED.representative_title,
|
| 354 |
+
first_seen = EXCLUDED.first_seen,
|
| 355 |
+
last_updated = EXCLUDED.last_updated,
|
| 356 |
+
item_count = EXCLUDED.item_count,
|
| 357 |
+
sentiment_avg = EXCLUDED.sentiment_avg,
|
| 358 |
+
source_urls = EXCLUDED.source_urls,
|
| 359 |
+
sources = EXCLUDED.sources,
|
| 360 |
+
item_ids = EXCLUDED.item_ids
|
| 361 |
+
""",
|
| 362 |
+
rows,
|
| 363 |
+
)
|
| 364 |
+
return len(rows)
|
| 365 |
+
finally:
|
| 366 |
+
await conn.close()
|
| 367 |
+
except Exception as exc:
|
| 368 |
+
logger.warning("news_clusters_persist_failed err=%s", exc)
|
| 369 |
+
return 0
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
async def load_recent_clusters(limit: int = 50) -> list[dict]:
|
| 373 |
+
"""Load recent clusters from Postgres."""
|
| 374 |
+
try:
|
| 375 |
+
import asyncpg
|
| 376 |
+
import json
|
| 377 |
+
|
| 378 |
+
from app.core.db_pool import PG_URL
|
| 379 |
+
|
| 380 |
+
conn = await asyncpg.connect(PG_URL)
|
| 381 |
+
try:
|
| 382 |
+
rows = await conn.fetch(
|
| 383 |
+
"SELECT * FROM news_clusters ORDER BY last_updated DESC LIMIT $1",
|
| 384 |
+
limit,
|
| 385 |
+
)
|
| 386 |
+
return [
|
| 387 |
+
{
|
| 388 |
+
"cluster_id": r["cluster_id"],
|
| 389 |
+
"representative_title": r["representative_title"],
|
| 390 |
+
"first_seen": r["first_seen"].isoformat(),
|
| 391 |
+
"last_updated": r["last_updated"].isoformat(),
|
| 392 |
+
"item_count": r["item_count"],
|
| 393 |
+
"sentiment_avg": r["sentiment_avg"],
|
| 394 |
+
"source_urls": json.loads(r["source_urls"]),
|
| 395 |
+
"sources": json.loads(r["sources"]),
|
| 396 |
+
"item_ids": json.loads(r["item_ids"]),
|
| 397 |
+
}
|
| 398 |
+
for r in rows
|
| 399 |
+
]
|
| 400 |
+
finally:
|
| 401 |
+
await conn.close()
|
| 402 |
+
except Exception as exc:
|
| 403 |
+
logger.warning("news_clusters_load_failed err=%s", exc)
|
| 404 |
+
return []
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
__all__ = [
|
| 408 |
+
"NewsItem",
|
| 409 |
+
"StoryCluster",
|
| 410 |
+
"cluster_items",
|
| 411 |
+
"ensure_schema",
|
| 412 |
+
"persist_clusters",
|
| 413 |
+
"load_recent_clusters",
|
| 414 |
+
]
|
app/domain/news/router.py
CHANGED
|
@@ -154,6 +154,7 @@ async def list_news(
|
|
| 154 |
limit: int = Query(20, ge=1, le=200),
|
| 155 |
offset: int = Query(0, ge=0),
|
| 156 |
sort: str = Query("recency", pattern="^(recency|relevance|sentiment)$"),
|
|
|
|
| 157 |
) -> NewsListResponse:
|
| 158 |
"""List news items with filters. Reads from both news_items (new) and crypto_news (legacy)."""
|
| 159 |
catalog = get_catalog()
|
|
@@ -215,6 +216,48 @@ async def list_news(
|
|
| 215 |
import logging
|
| 216 |
logging.getLogger(__name__).warning(f"news_list_legacy_fail: {e}")
|
| 217 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
return NewsListResponse(items=items, total=len(items), offset=offset)
|
| 219 |
|
| 220 |
|
|
|
|
| 154 |
limit: int = Query(20, ge=1, le=200),
|
| 155 |
offset: int = Query(0, ge=0),
|
| 156 |
sort: str = Query("recency", pattern="^(recency|relevance|sentiment)$"),
|
| 157 |
+
clustered: bool = Query(False, description="T03: dedupe via MinHash+DBSCAN into stories"),
|
| 158 |
) -> NewsListResponse:
|
| 159 |
"""List news items with filters. Reads from both news_items (new) and crypto_news (legacy)."""
|
| 160 |
catalog = get_catalog()
|
|
|
|
| 216 |
import logging
|
| 217 |
logging.getLogger(__name__).warning(f"news_list_legacy_fail: {e}")
|
| 218 |
|
| 219 |
+
# T03: cluster into stories if requested
|
| 220 |
+
if clustered and items:
|
| 221 |
+
from app.domain.news.clusterer import NewsItem, cluster_items, persist_clusters
|
| 222 |
+
cluster_items_list = [
|
| 223 |
+
NewsItem(
|
| 224 |
+
id=it.news_id,
|
| 225 |
+
title=it.title,
|
| 226 |
+
body=it.summary or "",
|
| 227 |
+
source=it.source or "",
|
| 228 |
+
url=it.url or "",
|
| 229 |
+
published_at=it.published_at,
|
| 230 |
+
sentiment=it.sentiment_score or 0.0,
|
| 231 |
+
)
|
| 232 |
+
for it in items
|
| 233 |
+
]
|
| 234 |
+
stories = cluster_items(cluster_items_list)
|
| 235 |
+
# persist in background (don't block response)
|
| 236 |
+
try:
|
| 237 |
+
import asyncio
|
| 238 |
+
asyncio.create_task(persist_clusters(stories))
|
| 239 |
+
except Exception:
|
| 240 |
+
pass
|
| 241 |
+
# Return clusters as synthetic items (representative title, first source)
|
| 242 |
+
clustered_items = []
|
| 243 |
+
for s in stories:
|
| 244 |
+
clustered_items.append(
|
| 245 |
+
NewsItemOut(
|
| 246 |
+
news_id=s.cluster_id,
|
| 247 |
+
url=s.source_urls[0] if s.source_urls else "",
|
| 248 |
+
title=f"[Γ{s.item_count}] {s.representative_title}",
|
| 249 |
+
summary=f"Story across {len(s.sources)} sources. "
|
| 250 |
+
f"Sentiment: {s.sentiment_avg:.2f}. "
|
| 251 |
+
f"Item IDs: {','.join(s.item_ids[:5])}",
|
| 252 |
+
source=", ".join(s.sources[:3]),
|
| 253 |
+
published_at=s.last_updated,
|
| 254 |
+
chains_mentioned=[],
|
| 255 |
+
tokens_mentioned=[],
|
| 256 |
+
sentiment_score=s.sentiment_avg,
|
| 257 |
+
)
|
| 258 |
+
)
|
| 259 |
+
return NewsListResponse(items=clustered_items, total=len(clustered_items), offset=offset)
|
| 260 |
+
|
| 261 |
return NewsListResponse(items=items, total=len(items), offset=offset)
|
| 262 |
|
| 263 |
|
app/domain/threat/certstream_listener.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""T12 β CertStream phishing domain monitor (G04 FIX adjacent).
|
| 2 |
+
|
| 3 |
+
Per MINIMAX_M3_TASKS.md T12. Watches Certificate Transparency logs in real
|
| 4 |
+
time for domains that spoof crypto brands (MetaMask, Ledger, Coinbase,
|
| 5 |
+
etc). When a match is found, fires a Telegram alert via the existing
|
| 6 |
+
bot and persists the domain to Postgres `threat_domains`.
|
| 7 |
+
|
| 8 |
+
Run as a background task in lifespan.py. If CertStream is down, log and
|
| 9 |
+
continue β never break startup.
|
| 10 |
+
|
| 11 |
+
Why this matters: phishing sites clone crypto brands and steal wallet
|
| 12 |
+
seeds. CT logs show new domains BEFORE they go live, giving a 48h lead
|
| 13 |
+
time to take them down.
|
| 14 |
+
|
| 15 |
+
Usage:
|
| 16 |
+
from app.domain.threat.certstream_listener import start_listener
|
| 17 |
+
asyncio.create_task(start_listener()) # in lifespan
|
| 18 |
+
python3 -m app.domain.threat.certstream_listener --duration 300 # CLI
|
| 19 |
+
"""
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import asyncio
|
| 23 |
+
import json
|
| 24 |
+
import logging
|
| 25 |
+
import os
|
| 26 |
+
import signal
|
| 27 |
+
from contextlib import suppress
|
| 28 |
+
from datetime import UTC, datetime
|
| 29 |
+
from pathlib import Path
|
| 30 |
+
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# ββ Brand patterns (loaded once at import) ββββββββββββββββββββββββββ
|
| 35 |
+
_BRAND_PATTERNS_PATH = Path(__file__).parent / "brand_patterns.json"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def load_brand_patterns() -> list[str]:
|
| 39 |
+
"""Load brand pattern list. Returns lowercased list for matching."""
|
| 40 |
+
try:
|
| 41 |
+
with _BRAND_PATTERNS_PATH.open() as f:
|
| 42 |
+
data = json.load(f)
|
| 43 |
+
patterns = [str(p).lower() for p in data.get("brands", []) if p]
|
| 44 |
+
logger.info("certstream_brands_loaded count=%d", len(patterns))
|
| 45 |
+
return patterns
|
| 46 |
+
except Exception as exc:
|
| 47 |
+
logger.warning("certstream_brands_load_failed err=%s", exc)
|
| 48 |
+
return []
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
_BRAND_PATTERNS: list[str] = load_brand_patterns()
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ββ Domain matching ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 55 |
+
def match_brand(domain: str, brands: list[str] | None = None) -> str | None:
|
| 56 |
+
"""Return the matched brand name if `domain` looks like a phishing
|
| 57 |
+
clone of a known crypto brand. Returns None if no match.
|
| 58 |
+
|
| 59 |
+
Heuristic: brand substring appears in the domain's main label,
|
| 60 |
+
but the domain is NOT the brand's official primary domain (we
|
| 61 |
+
allow obvious legit variants like "metamask.io" but flag
|
| 62 |
+
"metamask-secure-claim.com").
|
| 63 |
+
"""
|
| 64 |
+
if not domain:
|
| 65 |
+
return None
|
| 66 |
+
d = domain.lower().strip()
|
| 67 |
+
# strip trailing dot
|
| 68 |
+
if d.endswith("."):
|
| 69 |
+
d = d[:-1]
|
| 70 |
+
# take the main label (e.g. "metamask-secure" from "a.b.metamask-secure.com")
|
| 71 |
+
parts = d.split(".")
|
| 72 |
+
if len(parts) < 2:
|
| 73 |
+
return None
|
| 74 |
+
main = parts[-2] if len(parts) >= 2 else parts[0]
|
| 75 |
+
patterns = brands or _BRAND_PATTERNS
|
| 76 |
+
for brand in patterns:
|
| 77 |
+
# Strip non-alphanum for fuzzy match
|
| 78 |
+
main_clean = "".join(c for c in main if c.isalnum())
|
| 79 |
+
brand_clean = "".join(c for c in brand if c.isalnum())
|
| 80 |
+
if not brand_clean:
|
| 81 |
+
continue
|
| 82 |
+
if brand_clean in main_clean and main_clean != brand_clean:
|
| 83 |
+
# Phishing variant β not the official domain
|
| 84 |
+
return brand
|
| 85 |
+
return None
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# ββ Telegram alert (best-effort, never blocks) βββββββββββββββββββββ
|
| 89 |
+
async def _telegram_alert(domain: str, brand: str, issuer: str) -> bool:
|
| 90 |
+
"""Send a Telegram alert. Returns True on success. Never raises."""
|
| 91 |
+
try:
|
| 92 |
+
bot_token = os.getenv("TELEGRAM_BOT_TOKEN", "")
|
| 93 |
+
chat_id = os.getenv("TELEGRAM_ALERT_CHAT_ID", "")
|
| 94 |
+
if not bot_token or not chat_id:
|
| 95 |
+
return False
|
| 96 |
+
import httpx
|
| 97 |
+
text = (
|
| 98 |
+
f"β οΈ Phishing domain registered: {domain}\n"
|
| 99 |
+
f"Brand: {brand}\n"
|
| 100 |
+
f"Issuer: {issuer}\n"
|
| 101 |
+
f"Issued: {datetime.now(UTC).isoformat()}\n"
|
| 102 |
+
f"48h window to take down"
|
| 103 |
+
)
|
| 104 |
+
async with httpx.AsyncClient(timeout=10) as client:
|
| 105 |
+
r = await client.post(
|
| 106 |
+
f"https://api.telegram.org/bot{bot_token}/sendMessage",
|
| 107 |
+
json={"chat_id": chat_id, "text": text},
|
| 108 |
+
)
|
| 109 |
+
return r.status_code == 200
|
| 110 |
+
except Exception as exc:
|
| 111 |
+
logger.debug("telegram_alert_skipped err=%s", exc)
|
| 112 |
+
return False
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# ββ Postgres persistence (lazy import) ββββββββββββββββββββββββββββββ
|
| 116 |
+
_THREAT_DOMAINS_SCHEMA = """
|
| 117 |
+
CREATE TABLE IF NOT EXISTS threat_domains (
|
| 118 |
+
domain TEXT PRIMARY KEY,
|
| 119 |
+
brand_matched TEXT,
|
| 120 |
+
issued_at TIMESTAMPTZ,
|
| 121 |
+
issuer TEXT,
|
| 122 |
+
first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 123 |
+
status TEXT NOT NULL DEFAULT 'new'
|
| 124 |
+
);
|
| 125 |
+
CREATE INDEX IF NOT EXISTS threat_domains_first_seen_idx
|
| 126 |
+
ON threat_domains (first_seen DESC);
|
| 127 |
+
CREATE INDEX IF NOT EXISTS threat_domains_brand_idx
|
| 128 |
+
ON threat_domains (brand_matched);
|
| 129 |
+
"""
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
async def _ensure_schema() -> bool:
|
| 133 |
+
try:
|
| 134 |
+
import asyncpg
|
| 135 |
+
from app.core.db_pool import PG_URL
|
| 136 |
+
|
| 137 |
+
conn = await asyncpg.connect(PG_URL)
|
| 138 |
+
try:
|
| 139 |
+
await conn.execute(_THREAT_DOMAINS_SCHEMA)
|
| 140 |
+
finally:
|
| 141 |
+
await conn.close()
|
| 142 |
+
return True
|
| 143 |
+
except Exception as exc:
|
| 144 |
+
logger.warning("threat_domains_schema_failed err=%s", exc)
|
| 145 |
+
return False
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
async def _persist_domain(domain: str, brand: str, issued_at: datetime | None, issuer: str) -> bool:
|
| 149 |
+
try:
|
| 150 |
+
import asyncpg
|
| 151 |
+
from app.core.db_pool import PG_URL
|
| 152 |
+
|
| 153 |
+
conn = await asyncpg.connect(PG_URL)
|
| 154 |
+
try:
|
| 155 |
+
await conn.execute(
|
| 156 |
+
"""
|
| 157 |
+
INSERT INTO threat_domains (domain, brand_matched, issued_at, issuer)
|
| 158 |
+
VALUES ($1, $2, $3, $4)
|
| 159 |
+
ON CONFLICT (domain) DO NOTHING
|
| 160 |
+
""",
|
| 161 |
+
domain,
|
| 162 |
+
brand,
|
| 163 |
+
issued_at,
|
| 164 |
+
issuer,
|
| 165 |
+
)
|
| 166 |
+
finally:
|
| 167 |
+
await conn.close()
|
| 168 |
+
return True
|
| 169 |
+
except Exception as exc:
|
| 170 |
+
logger.debug("threat_domains_persist_failed domain=%s err=%s", domain, exc)
|
| 171 |
+
return False
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
# ββ CertStream WebSocket listener ββββββββββββββββββββββββββββββββββ
|
| 175 |
+
CERTSTREAM_URL = "wss://certstream.calidus.io/"
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
async def _process_message(msg: dict) -> int:
|
| 179 |
+
"""Process one CertStream message. Returns # of threats found (0 or 1+)."""
|
| 180 |
+
data_type = msg.get("message_type") or msg.get("type")
|
| 181 |
+
if data_type not in ("certificate_update", "heartbeat"):
|
| 182 |
+
return 0
|
| 183 |
+
if data_type == "heartbeat":
|
| 184 |
+
return 0
|
| 185 |
+
|
| 186 |
+
leaf = msg.get("data", {}).get("leaf_cert", {}) or {}
|
| 187 |
+
domains = set()
|
| 188 |
+
# CertStream v2 puts all SANs here
|
| 189 |
+
sans = leaf.get("all_domains") or []
|
| 190 |
+
for d in sans:
|
| 191 |
+
if d and isinstance(d, str) and not d.startswith("*"):
|
| 192 |
+
domains.add(d.lower().strip())
|
| 193 |
+
# Some payloads also have subject CN
|
| 194 |
+
subject = (leaf.get("subject") or {}).get("CN", "")
|
| 195 |
+
if subject and not subject.startswith("*"):
|
| 196 |
+
domains.add(subject.lower().strip())
|
| 197 |
+
|
| 198 |
+
issuer = (
|
| 199 |
+
msg.get("data", {}).get("chain", [{}])[0].get("subject", {}).get("O", "")
|
| 200 |
+
or "unknown"
|
| 201 |
+
)
|
| 202 |
+
issued_at = None
|
| 203 |
+
not_before = leaf.get("not_before")
|
| 204 |
+
if not_before:
|
| 205 |
+
try:
|
| 206 |
+
issued_at = datetime.fromisoformat(not_before.replace("Z", "+00:00"))
|
| 207 |
+
except Exception:
|
| 208 |
+
issued_at = datetime.now(UTC)
|
| 209 |
+
if issued_at is None:
|
| 210 |
+
issued_at = datetime.now(UTC)
|
| 211 |
+
|
| 212 |
+
threats = 0
|
| 213 |
+
for d in domains:
|
| 214 |
+
brand = match_brand(d)
|
| 215 |
+
if brand:
|
| 216 |
+
threats += 1
|
| 217 |
+
logger.warning(
|
| 218 |
+
"certstream_threat_detected domain=%s brand=%s issuer=%s",
|
| 219 |
+
d, brand, issuer,
|
| 220 |
+
)
|
| 221 |
+
await _persist_domain(d, brand, issued_at, issuer)
|
| 222 |
+
await _telegram_alert(d, brand, issuer)
|
| 223 |
+
return threats
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
async def _run_listener(stop_event: asyncio.Event) -> None:
|
| 227 |
+
"""Connect to CertStream and consume messages until stop_event."""
|
| 228 |
+
try:
|
| 229 |
+
import websockets
|
| 230 |
+
except ImportError:
|
| 231 |
+
logger.warning("certstream_skipped err=websockets_not_installed")
|
| 232 |
+
return
|
| 233 |
+
|
| 234 |
+
await _ensure_schema()
|
| 235 |
+
backoff = 1.0
|
| 236 |
+
while not stop_event.is_set():
|
| 237 |
+
try:
|
| 238 |
+
async with websockets.connect(CERTSTREAM_URL, ping_interval=20) as ws:
|
| 239 |
+
logger.info("certstream_connected url=%s", CERTSTREAM_URL)
|
| 240 |
+
backoff = 1.0
|
| 241 |
+
while not stop_event.is_set():
|
| 242 |
+
try:
|
| 243 |
+
raw = await asyncio.wait_for(ws.recv(), timeout=60)
|
| 244 |
+
except asyncio.TimeoutError:
|
| 245 |
+
# send a ping to keep the connection alive
|
| 246 |
+
with suppress(Exception):
|
| 247 |
+
await ws.send("ping")
|
| 248 |
+
continue
|
| 249 |
+
try:
|
| 250 |
+
msg = json.loads(raw)
|
| 251 |
+
except json.JSONDecodeError:
|
| 252 |
+
continue
|
| 253 |
+
await _process_message(msg)
|
| 254 |
+
except asyncio.CancelledError:
|
| 255 |
+
raise
|
| 256 |
+
except Exception as exc:
|
| 257 |
+
logger.warning("certstream_disconnected err=%s reconnecting_in=%.1fs", exc, backoff)
|
| 258 |
+
try:
|
| 259 |
+
await asyncio.wait_for(stop_event.wait(), timeout=backoff)
|
| 260 |
+
except asyncio.TimeoutError:
|
| 261 |
+
pass
|
| 262 |
+
backoff = min(backoff * 2, 60.0)
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
# ββ Public API βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 266 |
+
async def start_listener() -> asyncio.Task | None:
|
| 267 |
+
"""Start the CertStream listener as a background task.
|
| 268 |
+
|
| 269 |
+
Returns the Task so caller can cancel on shutdown. Returns None if
|
| 270 |
+
websockets is not installed or another instance is already running.
|
| 271 |
+
"""
|
| 272 |
+
if os.getenv("CERTSTREAM_ENABLED", "1") != "1":
|
| 273 |
+
logger.info("certstream_disabled env=CERTSTREAM_ENABLED=0")
|
| 274 |
+
return None
|
| 275 |
+
try:
|
| 276 |
+
import websockets # noqa: F401
|
| 277 |
+
except ImportError:
|
| 278 |
+
logger.info("certstream_skipped err=websockets_not_installed")
|
| 279 |
+
return None
|
| 280 |
+
stop_event = asyncio.Event()
|
| 281 |
+
task = asyncio.create_task(
|
| 282 |
+
_run_listener(stop_event),
|
| 283 |
+
name="certstream-listener",
|
| 284 |
+
)
|
| 285 |
+
# Stash the stop event on the task so shutdown can set it
|
| 286 |
+
task._rmi_stop_event = stop_event # type: ignore[attr-defined]
|
| 287 |
+
return task
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
async def stop_listener(task: asyncio.Task | None) -> None:
|
| 291 |
+
"""Stop a running CertStream listener task."""
|
| 292 |
+
if task is None or task.done():
|
| 293 |
+
return
|
| 294 |
+
stop_event = getattr(task, "_rmi_stop_event", None)
|
| 295 |
+
if stop_event is not None:
|
| 296 |
+
stop_event.set()
|
| 297 |
+
task.cancel()
|
| 298 |
+
with suppress(asyncio.CancelledError, Exception):
|
| 299 |
+
await asyncio.wait_for(task, timeout=5.0)
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
# ββ CLI for ad-hoc testing βββββββββββββββββββββββββββββββββββββββββ
|
| 303 |
+
def _cli() -> None:
|
| 304 |
+
import argparse
|
| 305 |
+
import sys
|
| 306 |
+
|
| 307 |
+
parser = argparse.ArgumentParser(description="CertStream phishing monitor (CLI)")
|
| 308 |
+
parser.add_argument("--duration", type=int, default=300, help="seconds to listen (0 = forever)")
|
| 309 |
+
args = parser.parse_args()
|
| 310 |
+
|
| 311 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
| 312 |
+
|
| 313 |
+
async def _main() -> None:
|
| 314 |
+
stop = asyncio.Event()
|
| 315 |
+
if args.duration > 0:
|
| 316 |
+
async def _trigger() -> None:
|
| 317 |
+
await asyncio.sleep(args.duration)
|
| 318 |
+
stop.set()
|
| 319 |
+
asyncio.create_task(_trigger())
|
| 320 |
+
|
| 321 |
+
await _run_listener(stop)
|
| 322 |
+
|
| 323 |
+
if args.duration == 0:
|
| 324 |
+
# Forever mode: install SIGINT handler
|
| 325 |
+
def _sigint(*_a: object) -> None:
|
| 326 |
+
raise KeyboardInterrupt
|
| 327 |
+
signal.signal(signal.SIGINT, _sigint)
|
| 328 |
+
try:
|
| 329 |
+
asyncio.run(_main())
|
| 330 |
+
except KeyboardInterrupt:
|
| 331 |
+
print("\n[cli] stopped", file=sys.stderr)
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
if __name__ == "__main__":
|
| 335 |
+
_cli()
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
__all__ = [
|
| 339 |
+
"start_listener",
|
| 340 |
+
"stop_listener",
|
| 341 |
+
"match_brand",
|
| 342 |
+
"load_brand_patterns",
|
| 343 |
+
"CERTSTREAM_URL",
|
| 344 |
+
]
|
app/lifespan.py
CHANGED
|
@@ -75,9 +75,23 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
|
| 75 |
except Exception as exc:
|
| 76 |
log.info("sentry_init_failed err=%s", exc)
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
yield
|
| 79 |
|
| 80 |
# Shutdown
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
try:
|
| 82 |
from app.core.langfuse import flush_langfuse
|
| 83 |
from app.core.observability import flush_sentry
|
|
|
|
| 75 |
except Exception as exc:
|
| 76 |
log.info("sentry_init_failed err=%s", exc)
|
| 77 |
|
| 78 |
+
# 7. T12 β CertStream phishing domain monitor (background task)
|
| 79 |
+
certstream_task = None
|
| 80 |
+
try:
|
| 81 |
+
from app.domain.threat.certstream_listener import start_listener
|
| 82 |
+
certstream_task = await start_listener()
|
| 83 |
+
log.info("certstream_started has_task=%s", certstream_task is not None)
|
| 84 |
+
except Exception as exc:
|
| 85 |
+
log.info("certstream_start_failed err=%s", exc)
|
| 86 |
+
|
| 87 |
yield
|
| 88 |
|
| 89 |
# Shutdown
|
| 90 |
+
try:
|
| 91 |
+
from app.domain.threat.certstream_listener import stop_listener
|
| 92 |
+
await stop_listener(certstream_task)
|
| 93 |
+
except Exception:
|
| 94 |
+
pass
|
| 95 |
try:
|
| 96 |
from app.core.langfuse import flush_langfuse
|
| 97 |
from app.core.observability import flush_sentry
|
app/routers/admin_users_api.py
CHANGED
|
@@ -552,7 +552,7 @@ async def bulk_action(req: BulkActionRequest, request: Request):
|
|
| 552 |
elif req.action == "update_tier":
|
| 553 |
tier = req.params.get("tier", "FREE") if req.params else "FREE"
|
| 554 |
from app.auth import _get_user, _save_user
|
| 555 |
-
from app.core.redis import get_redis
|
| 556 |
|
| 557 |
user = _get_user(user_id)
|
| 558 |
if user:
|
|
|
|
| 552 |
elif req.action == "update_tier":
|
| 553 |
tier = req.params.get("tier", "FREE") if req.params else "FREE"
|
| 554 |
from app.auth import _get_user, _save_user
|
| 555 |
+
from app.core.redis import get_redis
|
| 556 |
|
| 557 |
user = _get_user(user_id)
|
| 558 |
if user:
|
app/routers/auth_extensions.py
CHANGED
|
@@ -564,7 +564,7 @@ async def delete_account(req: DeleteAccountRequest, request: Request):
|
|
| 564 |
Requires password confirmation.
|
| 565 |
"""
|
| 566 |
from app.auth import _delete_user, get_current_user, verify_password
|
| 567 |
-
from app.core.redis import get_redis
|
| 568 |
|
| 569 |
user = await get_current_user(request)
|
| 570 |
if not user:
|
|
|
|
| 564 |
Requires password confirmation.
|
| 565 |
"""
|
| 566 |
from app.auth import _delete_user, get_current_user, verify_password
|
| 567 |
+
from app.core.redis import get_redis
|
| 568 |
|
| 569 |
user = await get_current_user(request)
|
| 570 |
if not user:
|
app/routers/developer_tier.py
CHANGED
|
@@ -59,7 +59,7 @@ def verify_solana_signature(address: str, message: str, signature: str) -> bool:
|
|
| 59 |
import base64
|
| 60 |
|
| 61 |
from nacl.signing import VerifyKey
|
| 62 |
-
from app.core.redis import get_redis
|
| 63 |
|
| 64 |
pubkey = VerifyKey(base64.b58decode(address))
|
| 65 |
msg_bytes = message.encode("utf-8")
|
|
@@ -125,7 +125,7 @@ TIERS = {
|
|
| 125 |
# ββ Redis Helper βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 126 |
|
| 127 |
|
| 128 |
-
) -> dict[str, Any] | None:
|
| 129 |
"""Create a new developer API key."""
|
| 130 |
r = get_redis()
|
| 131 |
if not r:
|
|
|
|
| 59 |
import base64
|
| 60 |
|
| 61 |
from nacl.signing import VerifyKey
|
| 62 |
+
from app.core.redis import get_redis
|
| 63 |
|
| 64 |
pubkey = VerifyKey(base64.b58decode(address))
|
| 65 |
msg_bytes = message.encode("utf-8")
|
|
|
|
| 125 |
# ββ Redis Helper βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 126 |
|
| 127 |
|
| 128 |
+
def create_tier(tier: str) -> dict[str, Any] | None:
|
| 129 |
"""Create a new developer API key."""
|
| 130 |
r = get_redis()
|
| 131 |
if not r:
|
app/routers/persistent_state.py
CHANGED
|
@@ -54,7 +54,6 @@ def get_user_identity(request: Request) -> str:
|
|
| 54 |
dev_key = request.headers.get("X-RMI-Dev-Key", "") or request.headers.get("x-rmi-dev-key", "")
|
| 55 |
if dev_key:
|
| 56 |
import hashlib
|
| 57 |
-
from app.core.redis import get_redis
|
| 58 |
|
| 59 |
return f"dev:{hashlib.sha256(dev_key.encode()).hexdigest()[:16]}"
|
| 60 |
|
|
|
|
| 54 |
dev_key = request.headers.get("X-RMI-Dev-Key", "") or request.headers.get("x-rmi-dev-key", "")
|
| 55 |
if dev_key:
|
| 56 |
import hashlib
|
|
|
|
| 57 |
|
| 58 |
return f"dev:{hashlib.sha256(dev_key.encode()).hexdigest()[:16]}"
|
| 59 |
|
app/routers/status_page.py
CHANGED
|
@@ -40,11 +40,6 @@ logger = logging.getLogger("status_page")
|
|
| 40 |
|
| 41 |
router = APIRouter(prefix="/api/v1/status", tags=["status-page"])
|
| 42 |
|
| 43 |
-
# ββ Redis Helper βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
}
|
| 47 |
-
|
| 48 |
|
| 49 |
# ββ Health Check Functions βββββββββββββββββββββββββββββββββββββββ
|
| 50 |
|
|
@@ -301,7 +296,6 @@ async def status_monitor_loop():
|
|
| 301 |
except Exception as e:
|
| 302 |
logger.error(f"Status monitor error: {e}")
|
| 303 |
import asyncio
|
| 304 |
-
from app.core.redis import get_redis
|
| 305 |
|
| 306 |
await asyncio.sleep(30)
|
| 307 |
|
|
|
|
| 40 |
|
| 41 |
router = APIRouter(prefix="/api/v1/status", tags=["status-page"])
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
# ββ Health Check Functions βββββββββββββββββββββββββββββββββββββββ
|
| 45 |
|
|
|
|
| 296 |
except Exception as e:
|
| 297 |
logger.error(f"Status monitor error: {e}")
|
| 298 |
import asyncio
|
|
|
|
| 299 |
|
| 300 |
await asyncio.sleep(30)
|
| 301 |
|
app/routers/subscription_pricing_api.py
CHANGED
|
@@ -733,7 +733,7 @@ async def list_all_subscriptions(
|
|
| 733 |
async def get_revenue_stats(request: Request):
|
| 734 |
"""Get revenue statistics (admin only)."""
|
| 735 |
from app.auth import get_current_user
|
| 736 |
-
from app.core.redis import get_redis
|
| 737 |
|
| 738 |
user = await get_current_user(request)
|
| 739 |
if not user or user.get("role") not in ("ADMIN", "SUPERADMIN"):
|
|
|
|
| 733 |
async def get_revenue_stats(request: Request):
|
| 734 |
"""Get revenue statistics (admin only)."""
|
| 735 |
from app.auth import get_current_user
|
| 736 |
+
from app.core.redis import get_redis
|
| 737 |
|
| 738 |
user = await get_current_user(request)
|
| 739 |
if not user or user.get("role") not in ("ADMIN", "SUPERADMIN"):
|
app/routers/webhook_pipeline.py
CHANGED
|
@@ -27,7 +27,6 @@ from datetime import UTC, datetime
|
|
| 27 |
|
| 28 |
from fastapi import APIRouter
|
| 29 |
from fastapi.responses import JSONResponse
|
| 30 |
-
from app.core.redis import get_redis
|
| 31 |
|
| 32 |
logger = logging.getLogger("webhook_pipeline")
|
| 33 |
|
|
@@ -37,7 +36,12 @@ router = APIRouter(prefix="/api/v1/webhooks", tags=["webhook-pipeline"])
|
|
| 37 |
# ββ Redis Helper βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 38 |
|
| 39 |
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
"""Queue a webhook delivery.
|
| 42 |
|
| 43 |
Called by push_alert() in persistent_state.py and by
|
|
|
|
| 27 |
|
| 28 |
from fastapi import APIRouter
|
| 29 |
from fastapi.responses import JSONResponse
|
|
|
|
| 30 |
|
| 31 |
logger = logging.getLogger("webhook_pipeline")
|
| 32 |
|
|
|
|
| 36 |
# ββ Redis Helper βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 37 |
|
| 38 |
|
| 39 |
+
def queue_webhook_delivery(
|
| 40 |
+
event_type: str,
|
| 41 |
+
address: str,
|
| 42 |
+
message: str,
|
| 43 |
+
data: dict | None = None,
|
| 44 |
+
) -> None:
|
| 45 |
"""Queue a webhook delivery.
|
| 46 |
|
| 47 |
Called by push_alert() in persistent_state.py and by
|
app/routers/x402_alpha_revenue_tools.py
CHANGED
|
@@ -621,7 +621,7 @@ def register_alpha_tool_prices():
|
|
| 621 |
"""Register alpha tool prices with the enforcement system."""
|
| 622 |
try:
|
| 623 |
from app.routers.x402_enforcement import TOOL_PRICES
|
| 624 |
-
from app.core.redis import get_redis
|
| 625 |
|
| 626 |
TOOL_PRICES.update(
|
| 627 |
{
|
|
|
|
| 621 |
"""Register alpha tool prices with the enforcement system."""
|
| 622 |
try:
|
| 623 |
from app.routers.x402_enforcement import TOOL_PRICES
|
| 624 |
+
from app.core.redis import get_redis
|
| 625 |
|
| 626 |
TOOL_PRICES.update(
|
| 627 |
{
|
app/routers/x402_enforcement.py
CHANGED
|
@@ -2327,7 +2327,7 @@ async def get_revenue():
|
|
| 2327 |
# Daily breakdown (last 30 days)
|
| 2328 |
daily = {}
|
| 2329 |
from datetime import datetime, timedelta
|
| 2330 |
-
from app.core.redis import get_redis
|
| 2331 |
|
| 2332 |
for i in range(30):
|
| 2333 |
day = (datetime.utcnow() - timedelta(days=i)).strftime("%Y-%m-%d")
|
|
|
|
| 2327 |
# Daily breakdown (last 30 days)
|
| 2328 |
daily = {}
|
| 2329 |
from datetime import datetime, timedelta
|
| 2330 |
+
from app.core.redis import get_redis
|
| 2331 |
|
| 2332 |
for i in range(30):
|
| 2333 |
day = (datetime.utcnow() - timedelta(days=i)).strftime("%Y-%m-%d")
|
app/routers/x402_tools.py
CHANGED
|
@@ -5772,7 +5772,7 @@ async def tool_alias_dispatcher(tool_id: str, request: Request):
|
|
| 5772 |
try:
|
| 5773 |
opt_out = request.query_params.get("enrich", "").lower() == "false"
|
| 5774 |
from app.routers.x402_enrichment import enrich_tool_response
|
| 5775 |
-
from app.core.redis import get_redis
|
| 5776 |
|
| 5777 |
result = enrich_tool_response(tool_id, result, request_params=body, opt_out=opt_out)
|
| 5778 |
except Exception:
|
|
|
|
| 5772 |
try:
|
| 5773 |
opt_out = request.query_params.get("enrich", "").lower() == "false"
|
| 5774 |
from app.routers.x402_enrichment import enrich_tool_response
|
| 5775 |
+
from app.core.redis import get_redis
|
| 5776 |
|
| 5777 |
result = enrich_tool_response(tool_id, result, request_params=body, opt_out=opt_out)
|
| 5778 |
except Exception:
|
tests/unit/test_t03_t12_m3_residual.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for T03 (news clusterer) and T12 (CertStream match_brand)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from datetime import UTC, datetime, timedelta
|
| 5 |
+
|
| 6 |
+
from app.domain.news.clusterer import NewsItem, cluster_items
|
| 7 |
+
from app.domain.threat.certstream_listener import match_brand
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
# ββ T03: clusterer tests βββββββββββββββββββββββββββββββββββββββββββ
|
| 11 |
+
def test_clusterer_groups_similar_stories():
|
| 12 |
+
"""Two items with same/similar story should cluster together."""
|
| 13 |
+
base = datetime(2026, 6, 23, 12, 0, tzinfo=UTC)
|
| 14 |
+
items = [
|
| 15 |
+
NewsItem(
|
| 16 |
+
id="a1",
|
| 17 |
+
title="Bitcoin hits new all-time high above 120000",
|
| 18 |
+
body="BTC surged past 120000 today as ETF inflows hit record",
|
| 19 |
+
source="coindesk", url="https://coindesk.com/1", published_at=base,
|
| 20 |
+
),
|
| 21 |
+
NewsItem(
|
| 22 |
+
id="a2",
|
| 23 |
+
title="Bitcoin hits new all-time high above 120000",
|
| 24 |
+
body="BTC surged past 120000 today as ETF inflows hit record",
|
| 25 |
+
source="the block", url="https://theblock.co/2", published_at=base + timedelta(minutes=5),
|
| 26 |
+
),
|
| 27 |
+
NewsItem(
|
| 28 |
+
id="b1",
|
| 29 |
+
title="Ethereum upgrade scheduled for next month",
|
| 30 |
+
body="Core developers announce Pectra hard fork for July",
|
| 31 |
+
source="decrypt", url="https://decrypt.co/3", published_at=base + timedelta(minutes=2),
|
| 32 |
+
),
|
| 33 |
+
]
|
| 34 |
+
stories = cluster_items(items)
|
| 35 |
+
# Should produce 2 stories (2 BTC items clustered + 1 ETH item singleton)
|
| 36 |
+
assert len(stories) == 2, f"expected 2 stories, got {len(stories)}"
|
| 37 |
+
btc_story = next(s for s in stories if s.item_count == 2)
|
| 38 |
+
assert btc_story.item_count == 2
|
| 39 |
+
assert "coindesk" in btc_story.sources
|
| 40 |
+
assert "the block" in btc_story.sources
|
| 41 |
+
assert len(btc_story.item_ids) == 2
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_clusterer_handles_singleton():
|
| 45 |
+
"""Single item β single story (singleton)."""
|
| 46 |
+
base = datetime(2026, 6, 23, 12, 0, tzinfo=UTC)
|
| 47 |
+
items = [
|
| 48 |
+
NewsItem(
|
| 49 |
+
id="x1", title="Unique story nobody else is covering",
|
| 50 |
+
body="Something happened once",
|
| 51 |
+
source="reddit", url="https://reddit.com/x", published_at=base,
|
| 52 |
+
),
|
| 53 |
+
]
|
| 54 |
+
stories = cluster_items(items)
|
| 55 |
+
assert len(stories) == 1
|
| 56 |
+
assert stories[0].item_count == 1
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_clusterer_respects_time_window():
|
| 60 |
+
"""Items in different time windows should not cluster together."""
|
| 61 |
+
base = datetime(2026, 6, 23, 12, 0, tzinfo=UTC)
|
| 62 |
+
items = [
|
| 63 |
+
NewsItem(
|
| 64 |
+
id="m1", title="Bitcoin hits new high",
|
| 65 |
+
body="BTC surged past $120K",
|
| 66 |
+
source="coindesk", url="", published_at=base,
|
| 67 |
+
),
|
| 68 |
+
NewsItem(
|
| 69 |
+
id="m2", title="Bitcoin hits new high",
|
| 70 |
+
body="BTC surged past $120K",
|
| 71 |
+
source="the block", url="", published_at=base + timedelta(hours=2),
|
| 72 |
+
),
|
| 73 |
+
]
|
| 74 |
+
# With 30-min windows, these are in separate buckets β 2 singleton stories
|
| 75 |
+
stories = cluster_items(items, window_minutes=30)
|
| 76 |
+
assert len(stories) == 2
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def test_clusterer_empty():
|
| 80 |
+
assert cluster_items([]) == []
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# ββ T12: match_brand tests βββββββββββββββββββββββββββββββββββββββββ
|
| 84 |
+
def test_match_brand_flags_phishing_clone():
|
| 85 |
+
"""'metamask-secure-claim.com' should flag as phishing of 'metamask'."""
|
| 86 |
+
brand = match_brand("metamask-secure-claim.com")
|
| 87 |
+
assert brand == "metamask"
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def test_match_brand_passes_official_domain():
|
| 91 |
+
"""'metamask.io' should NOT be flagged (it's the official domain)."""
|
| 92 |
+
brand = match_brand("metamask.io")
|
| 93 |
+
assert brand is None
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def test_match_brand_flags_subdomain_phish():
|
| 97 |
+
"""'login-ledger.com' should flag as 'ledger' phishing."""
|
| 98 |
+
brand = match_brand("login-ledger.com")
|
| 99 |
+
assert brand == "ledger"
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_match_brand_ignores_unrelated():
|
| 103 |
+
brand = match_brand("some-random-website.com")
|
| 104 |
+
assert brand is None
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def test_match_brand_strips_wildcard():
|
| 108 |
+
"""CertStream sometimes gives '*.example.com' β strip the wildcard."""
|
| 109 |
+
assert match_brand("*.metamask-secure.com") == "metamask"
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def test_match_brand_handles_empty():
|
| 113 |
+
assert match_brand("") is None
|
| 114 |
+
assert match_brand(".") is None
|