Files
Shadowbroker/backend/services/unusual_whales_connector.py
ShadowbrokerandGitHub 76750caa92 Round 7a: per-operator outbound attribution + GDELT GCS-direct fix (#292)
== 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).
2026-05-21 15:11:28 -06:00

319 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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,
}