mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-08-01 17:51:49 +02:00
== Per-install operator handle for every third-party API call ==
Before this PR, every Shadowbroker install identified itself to
Wikipedia, Wikidata, Nominatim, GDELT, OpenMHz, Broadcastify,
weather.gov, NUFORC, Sentinel/Planetary Computer, TinyGS / CelesTrak,
Shodan, Finnhub, and others with a single project-wide User-Agent
("Shadowbroker/1.0" or "ShadowBroker-OSINT/1.0"). From the upstream's
perspective every install in the world looked like one giant scraper.
If one install misbehaved, the upstream's only recourse was to block
"Shadowbroker" as a whole.
PR #284 inadvertently doubled down on this in the frontend by
introducing a shared `WIKIMEDIA_API_USER_AGENT` constant. This PR
retrofits both backends to per-operator attribution.
New setting: OPERATOR_HANDLE (env var / settings UI / auto-gen)
New helper: network_utils.outbound_user_agent("purpose")
The handle is auto-generated as "operator-XXXXXX" on first call (the
"shadow-" prefix from earlier drafts was deliberately dropped — too
suspicious-looking for abuse-detection systems). Operators can
override via OPERATOR_HANDLE; the value is sanitized to lowercase
alphanumeric+dash+underscore and capped at 48 chars. Persisted to
backend/data/operator_handle.json so it survives container restarts.
Retrofitted call sites (every previously-MONSTER User-Agent):
- services/region_dossier.py (Wikipedia + Wikidata + Nominatim)
- services/geocode.py (Nominatim)
- services/sentinel_search.py (Microsoft Planetary Computer)
- services/feed_ingester.py (operator-curated RSS feeds)
- services/fetchers/earth_observation.py (weather.gov, NUFORC)
- services/fetchers/infrastructure.py
- services/fetchers/aircraft_database.py
- services/fetchers/route_database.py
- services/fetchers/trains.py
- services/fetchers/meshtastic_map.py
- services/shodan_connector.py
- services/unusual_whales_connector.py (Finnhub)
- services/tinygs_fetcher.py (CelesTrak + TinyGS)
- services/sar/sar_products_client.py
- services/geopolitics.py (GDELT)
- services/radio_intercept.py (Broadcastify + OpenMHz)
- routers/cctv.py + main.py (CCTV proxy)
- routers/ai_intel.py
- scripts/convert_power_plants.py (release-time data refresh)
Spoofed browser UAs removed (issues #289 / #290 / #291 — tg12 audit):
- cloudscraper-based Chrome impersonation against api.openmhz.com
-> replaced with honest requests + per-install UA
- Mozilla/5.0 spoofed UA on Broadcastify scrape
-> replaced with honest UA
- Mozilla/5.0 + fake first-party Referer on OpenMHz audio relay
-> replaced with honest UA
- cloudscraper dependency dropped from pyproject.toml + uv.lock
Frontend retrofit:
- new GET /api/settings/operator-handle endpoint (local-operator
gated) returns the install's handle
- frontend/src/lib/wikimediaClient.ts fetches the handle once on
first use, caches it for page lifetime, embeds it in the
Api-User-Agent for every Wikipedia / Wikidata browser-direct call
== GDELT GCS-direct fix ==
GDELT's data.gdeltproject.org is a CNAME to a Google Cloud Storage
bucket. GCS responds with the wildcard *.storage.googleapis.com cert
which legitimately does NOT cover the GDELT custom domain, so Python's
TLS verification correctly refuses the connection. Some networks
happen to route through a path where this works; many (notably Docker
Desktop's outbound NAT on local installs) do not. Verified on the
maintainer's local install: GDELT was unreachable; 1610 geopolitical
events / 48 export files were dropping silently.
Fix: services/geopolitics._gcs_direct_gdelt_url() rewrites any
data.gdeltproject.org URL to its GCS-direct equivalent
(storage.googleapis.com/data.gdeltproject.org/...) where the standard
GCS cert is genuinely valid. api.gdeltproject.org and every other host
are left untouched.
Confirmed live: backend log goes from
GDELT lastupdate failed: 500
to
Downloading 48 GDELT export files...
Downloaded 48/48 GDELT exports
GDELT parsed: 1610 conflict locations from 48 files
== Tests ==
backend/tests/test_per_operator_outbound_attribution.py (12 tests)
backend/tests/test_gdelt_gcs_direct_rewrite.py (6 tests)
backend/tests/test_region_dossier_wikimedia_ua.py (updated to
pin the helper + per-operator handle, not the old constant)
frontend/src/__tests__/utils/wikimediaClient.test.ts (rewritten
to mock /api/settings/operator-handle and assert per-operator UA)
Local: backend 114/114 security+audit+round7a suite green;
frontend 718/718 vitest suite green.
Credit: tg12 (external security audit, issues #289/#290/#291
relating to spoofed UAs); BigBodyCobain (operator-prefix call,
GDELT cloud-vs-local diagnosis).
319 lines
12 KiB
Python
319 lines
12 KiB
Python
"""
|
||
Finnhub API connector.
|
||
|
||
Provides defense stock quotes, congressional trading data, and insider
|
||
transactions — all from Finnhub's free tier (60 calls/min).
|
||
|
||
File kept at this path for git history; the module is referenced as
|
||
services.unusual_whales_connector in main.py imports but the public
|
||
surface is entirely Finnhub now.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import os
|
||
import threading
|
||
import time
|
||
from typing import Any
|
||
|
||
import math
|
||
import requests
|
||
from cachetools import TTLCache
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_FINNHUB_BASE = "https://finnhub.io/api/v1"
|
||
def _finnhub_user_agent():
|
||
from services.network_utils import outbound_user_agent
|
||
return outbound_user_agent("finnhub")
|
||
_REQUEST_TIMEOUT = 12
|
||
_MIN_INTERVAL_SECONDS = 0.35 # Stay well under 60 calls/min
|
||
|
||
# Tickers we poll for congress trades & insider activity
|
||
WATCHED_TICKERS = [
|
||
"NVDA", "AAPL", "TSLA", "MSFT", "GOOGL", "AMZN", "META",
|
||
"RTX", "LMT", "NOC", "GD", "BA", "PLTR",
|
||
]
|
||
|
||
# Defense + oil tickers for quotes (replaces yfinance)
|
||
QUOTE_TICKERS = [
|
||
("RTX", "RTX"), ("LMT", "LMT"), ("NOC", "NOC"),
|
||
("GD", "GD"), ("BA", "BA"), ("PLTR", "PLTR"),
|
||
]
|
||
CRYPTO_TICKERS = [
|
||
("BTC", "BINANCE:BTCUSDT"),
|
||
("ETH", "BINANCE:ETHUSDT"),
|
||
]
|
||
|
||
_quote_cache: TTLCache[str, dict[str, Any]] = TTLCache(maxsize=32, ttl=300)
|
||
_congress_cache: TTLCache[str, list[dict[str, Any]]] = TTLCache(maxsize=32, ttl=600)
|
||
_insider_cache: TTLCache[str, list[dict[str, Any]]] = TTLCache(maxsize=32, ttl=600)
|
||
|
||
_request_lock = threading.Lock()
|
||
_last_request_at = 0.0
|
||
|
||
|
||
class FinnhubConnectorError(Exception):
|
||
def __init__(self, detail: str, status_code: int = 400):
|
||
super().__init__(detail)
|
||
self.detail = detail
|
||
self.status_code = status_code
|
||
|
||
|
||
# Keep old name as alias for main.py imports
|
||
UWConnectorError = FinnhubConnectorError
|
||
|
||
|
||
def _get_api_key() -> str:
|
||
api_key = os.environ.get("FINNHUB_API_KEY", "").strip()
|
||
if not api_key:
|
||
raise FinnhubConnectorError(
|
||
"Finnhub API key not configured. Add FINNHUB_API_KEY in Settings > API Keys (free at finnhub.io).",
|
||
status_code=428,
|
||
)
|
||
return api_key
|
||
|
||
|
||
def _request(path: str, params: dict[str, Any] | None = None) -> Any:
|
||
"""Rate-limited GET to Finnhub. Returns parsed JSON."""
|
||
api_key = _get_api_key()
|
||
payload = dict(params or {})
|
||
payload["token"] = api_key
|
||
|
||
global _last_request_at
|
||
with _request_lock:
|
||
elapsed = time.monotonic() - _last_request_at
|
||
if elapsed < _MIN_INTERVAL_SECONDS:
|
||
time.sleep(_MIN_INTERVAL_SECONDS - elapsed)
|
||
try:
|
||
response = requests.get(
|
||
f"{_FINNHUB_BASE}{path}",
|
||
params=payload,
|
||
timeout=_REQUEST_TIMEOUT,
|
||
headers={"User-Agent": _finnhub_user_agent(), "Accept": "application/json"},
|
||
)
|
||
finally:
|
||
_last_request_at = time.monotonic()
|
||
|
||
if response.status_code == 401:
|
||
raise FinnhubConnectorError("Finnhub rejected the API key. Check FINNHUB_API_KEY.", 401)
|
||
if response.status_code == 403:
|
||
raise FinnhubConnectorError(
|
||
"Finnhub returned 403 — this endpoint may require a premium plan.", 403
|
||
)
|
||
if response.status_code == 429:
|
||
raise FinnhubConnectorError(
|
||
"Finnhub rate limit reached (60/min free). Try again shortly.", 429
|
||
)
|
||
if response.status_code >= 400:
|
||
detail = response.text.strip()[:240] or "Unexpected Finnhub API error."
|
||
raise FinnhubConnectorError(f"Finnhub request failed: {detail}", response.status_code)
|
||
|
||
try:
|
||
return response.json()
|
||
except ValueError as exc:
|
||
raise FinnhubConnectorError(f"Finnhub returned invalid JSON: {exc}", 502) from exc
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Status
|
||
# ---------------------------------------------------------------------------
|
||
def get_uw_status() -> dict[str, Any]:
|
||
"""Status check — kept as get_uw_status for route compatibility."""
|
||
has_key = bool(os.environ.get("FINNHUB_API_KEY", "").strip())
|
||
return {
|
||
"ok": True,
|
||
"configured": has_key,
|
||
"source": "Finnhub",
|
||
"attribution": "Data from Finnhub",
|
||
"mode": "free-tier market intelligence",
|
||
"local_only": True,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Stock Quotes
|
||
# ---------------------------------------------------------------------------
|
||
def fetch_defense_quotes() -> dict[str, dict[str, Any]]:
|
||
"""Fetch real-time quotes for defense tickers. Returns {ticker: {...}}."""
|
||
results: dict[str, dict[str, Any]] = {}
|
||
for label, symbol in QUOTE_TICKERS:
|
||
cache_key = f"quote:{symbol}"
|
||
if cache_key in _quote_cache:
|
||
results[label] = _quote_cache[cache_key]
|
||
continue
|
||
try:
|
||
raw = _request("/quote", {"symbol": symbol})
|
||
if raw and raw.get("c"):
|
||
entry = {
|
||
"price": round(float(raw["c"]), 2),
|
||
"change_percent": round(float(raw.get("dp") or 0), 2),
|
||
"up": float(raw.get("dp") or 0) >= 0,
|
||
}
|
||
results[label] = entry
|
||
_quote_cache[cache_key] = entry
|
||
except FinnhubConnectorError:
|
||
logger.warning(f"Finnhub quote failed for {symbol}")
|
||
except Exception as e:
|
||
logger.warning(f"Finnhub quote error for {symbol}: {e}")
|
||
# Crypto quotes via /crypto/candle
|
||
for label, symbol in CRYPTO_TICKERS:
|
||
cache_key = f"quote:{symbol}"
|
||
if cache_key in _quote_cache:
|
||
results[label] = _quote_cache[cache_key]
|
||
continue
|
||
try:
|
||
now = int(time.time())
|
||
raw = _request("/crypto/candle", {
|
||
"symbol": symbol,
|
||
"resolution": "D",
|
||
"from": now - 172800, # 2 days back
|
||
"to": now,
|
||
})
|
||
closes = raw.get("c") or []
|
||
if len(closes) >= 1:
|
||
current = float(closes[-1])
|
||
prev = float(closes[-2]) if len(closes) >= 2 else current
|
||
change_pct = ((current - prev) / prev * 100) if prev else 0
|
||
if math.isfinite(current) and math.isfinite(change_pct):
|
||
entry = {
|
||
"price": round(current, 2),
|
||
"change_percent": round(change_pct, 2),
|
||
"up": change_pct >= 0,
|
||
"crypto": True,
|
||
}
|
||
results[label] = entry
|
||
_quote_cache[cache_key] = entry
|
||
except FinnhubConnectorError:
|
||
logger.warning(f"Finnhub crypto quote failed for {symbol}")
|
||
except Exception as e:
|
||
logger.warning(f"Finnhub crypto quote error for {symbol}: {e}")
|
||
|
||
return results
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Congressional Trading
|
||
# ---------------------------------------------------------------------------
|
||
def _normalize_congress_trade(raw: dict[str, Any], symbol: str) -> dict[str, Any]:
|
||
amount_from = raw.get("amountFrom") or 0
|
||
amount_to = raw.get("amountTo") or 0
|
||
if amount_from and amount_to:
|
||
amount_range = f"${int(amount_from):,}–${int(amount_to):,}"
|
||
elif amount_to:
|
||
amount_range = f"Up to ${int(amount_to):,}"
|
||
else:
|
||
amount_range = ""
|
||
return {
|
||
"politician_name": str(raw.get("name") or "Unknown"),
|
||
"chamber": str(raw.get("position") or "unknown").lower(),
|
||
"filing_date": str(raw.get("filingDate") or raw.get("transactionDate") or ""),
|
||
"transaction_date": str(raw.get("transactionDate") or ""),
|
||
"ticker": symbol,
|
||
"asset_name": str(raw.get("assetName") or ""),
|
||
"transaction_type": str(raw.get("transactionType") or ""),
|
||
"amount_range": amount_range,
|
||
"owner_type": str(raw.get("ownerType") or ""),
|
||
}
|
||
|
||
|
||
def fetch_congress_trades() -> dict[str, Any]:
|
||
"""Fetch congressional trades across watched tickers."""
|
||
all_trades: list[dict[str, Any]] = []
|
||
for symbol in WATCHED_TICKERS:
|
||
cache_key = f"congress:{symbol}"
|
||
if cache_key in _congress_cache:
|
||
all_trades.extend(_congress_cache[cache_key])
|
||
continue
|
||
try:
|
||
raw = _request("/stock/congressional-trading", {"symbol": symbol})
|
||
data = raw.get("data") or []
|
||
if isinstance(data, list):
|
||
normalized = [_normalize_congress_trade(t, symbol) for t in data[:10] if isinstance(t, dict)]
|
||
_congress_cache[cache_key] = normalized
|
||
all_trades.extend(normalized)
|
||
except FinnhubConnectorError as e:
|
||
if e.status_code in (403, 402):
|
||
logger.info(f"Congressional trading endpoint not available on free tier for {symbol}")
|
||
# Cache empty to avoid re-hitting a premium endpoint
|
||
_congress_cache[cache_key] = []
|
||
else:
|
||
logger.warning(f"Finnhub congress fetch failed for {symbol}: {e.detail}")
|
||
except Exception as e:
|
||
logger.warning(f"Finnhub congress error for {symbol}: {e}")
|
||
|
||
# Sort by filing date descending
|
||
all_trades.sort(key=lambda t: t.get("filing_date", ""), reverse=True)
|
||
return {
|
||
"ok": True,
|
||
"source": "Finnhub",
|
||
"attribution": "Data from Finnhub",
|
||
"trades": all_trades[:50],
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Insider Transactions
|
||
# ---------------------------------------------------------------------------
|
||
def _normalize_insider(raw: dict[str, Any]) -> dict[str, Any]:
|
||
return {
|
||
"name": str(raw.get("name") or "Unknown"),
|
||
"ticker": str(raw.get("symbol") or ""),
|
||
"share": int(raw.get("share") or 0),
|
||
"change": int(raw.get("change") or 0),
|
||
"filing_date": str(raw.get("filingDate") or ""),
|
||
"transaction_date": str(raw.get("transactionDate") or ""),
|
||
"transaction_code": str(raw.get("transactionCode") or ""),
|
||
"transaction_price": float(raw.get("transactionPrice") or 0),
|
||
}
|
||
|
||
|
||
def fetch_insider_transactions() -> dict[str, Any]:
|
||
"""Fetch insider transactions across watched tickers."""
|
||
all_insiders: list[dict[str, Any]] = []
|
||
for symbol in WATCHED_TICKERS:
|
||
cache_key = f"insider:{symbol}"
|
||
if cache_key in _insider_cache:
|
||
all_insiders.extend(_insider_cache[cache_key])
|
||
continue
|
||
try:
|
||
raw = _request("/stock/insider-transactions", {"symbol": symbol})
|
||
data = raw.get("data") or []
|
||
if isinstance(data, list):
|
||
normalized = [_normalize_insider(t) for t in data[:8] if isinstance(t, dict)]
|
||
_insider_cache[cache_key] = normalized
|
||
all_insiders.extend(normalized)
|
||
except FinnhubConnectorError as e:
|
||
logger.warning(f"Finnhub insider fetch failed for {symbol}: {e.detail}")
|
||
except Exception as e:
|
||
logger.warning(f"Finnhub insider error for {symbol}: {e}")
|
||
|
||
all_insiders.sort(key=lambda t: t.get("filing_date", ""), reverse=True)
|
||
return {
|
||
"ok": True,
|
||
"source": "Finnhub",
|
||
"attribution": "Data from Finnhub",
|
||
"transactions": all_insiders[:50],
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Aliases for backward compatibility with main.py imports
|
||
# ---------------------------------------------------------------------------
|
||
def fetch_darkpool_recent() -> dict[str, Any]:
|
||
"""Replaced — now returns insider transactions instead of dark pool."""
|
||
return fetch_insider_transactions()
|
||
|
||
|
||
def fetch_flow_alerts() -> dict[str, Any]:
|
||
"""Replaced — now returns defense quotes formatted as alerts."""
|
||
quotes = fetch_defense_quotes()
|
||
return {
|
||
"ok": True,
|
||
"source": "Finnhub",
|
||
"attribution": "Data from Finnhub",
|
||
"alerts": [],
|
||
"quotes": quotes,
|
||
}
|