Files
Shadowbroker/backend/services/fetchers/aircraft_database.py
T
BigBodyCobainandClaude Opus 4.7 71a9d9e144 [security] Close post-#227 control-surface and fetcher gaps
PR #227 hardened most Wormhole/Infonet control surfaces behind
require_local_operator and made the CrowdThreat fetcher opt-in. An
audit of the codebase against that PR's stated goals turned up four
classes of gap that the original change missed:

1. Two operator-only endpoints were left unprotected:
   - POST /api/wormhole/join: calls bootstrap_wormhole_identity() and
     flips the node into Tor mode, exactly the surface #227 hardened
     on /api/wormhole/identity/bootstrap.
   - POST /api/sigint/transmit: relays APRS-IS packets over radio
     using operator-supplied credentials. Anything that reached the
     API could transmit on the operator's authority.

   Both now require_local_operator. test_control_surface_auth.py
   extended with regression coverage for both.

2. Five third-party fetchers were still default-on, phoning home to
   politically/commercially sensitive upstreams on every poll cycle:
   - fimi.py            -> euvsdisinfo.eu        -> FIMI_ENABLED
   - prediction_markets -> Polymarket + Kalshi   -> PREDICTION_MARKETS_ENABLED
   - financial.py       -> Finnhub / yfinance    -> FINANCIAL_ENABLED or FINNHUB_API_KEY
   - nuforc_enrichment  -> huggingface.co        -> NUFORC_ENABLED
   - news.py            -> configured RSS feeds  -> NEWS_ENABLED (default on, kill switch)

   Same CrowdThreat-style pattern: explicit env-var opt-in, empty
   the data slot and mark_fresh when disabled. New regression test
   file test_third_party_fetchers_opt_in.py asserts each fetcher's
   network entry point is not called when its gate is off.

3. The outbound User-Agent leaked both the operator's personal email
   and a fork-specific GitHub URL on every fetcher request. Consolidated
   to a single DEFAULT_USER_AGENT in network_utils.py, project-generic
   by default (no contact info), overridable via SHADOWBROKER_USER_AGENT
   for operators who want to identify themselves (e.g. for Nominatim or
   weather.gov usage-policy compliance). Six call sites updated; the
   Nominatim-specific override is preserved.

4. The same generic UA now also flows through the peer prekey lookup
   in mesh_wormhole_prekey.py, so DM first-contact requests no longer
   identify the caller as a Shadowbroker fork to the peer being
   queried.

.env.example updated to document all new opt-in env vars.

Tests: backend/tests/test_control_surface_auth.py (extended),
       backend/tests/test_crowdthreat_opt_in.py (unchanged, still passes),
       backend/tests/test_third_party_fetchers_opt_in.py (new, 7 tests).
All 31 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 13:53:33 -06:00

174 lines
5.8 KiB
Python

"""OpenSky aircraft metadata: ICAO24 hex -> ICAO type code + friendly model.
OpenSky's /states/all does not include aircraft type, so OpenSky-sourced
flights arrive with ``t`` field empty. This module bulk-loads the public
OpenSky aircraft database (one snapshot CSV per month, ~108 MB uncompressed,
~600k aircraft) once every 5 days and exposes a fast in-memory hex lookup.
The data is also useful when adsb.lol's live API is degraded: even the
adsb.lol /v2 feed sometimes returns aircraft with empty ``t`` for newly seen
transponders, and the lookup gracefully fills those in too.
"""
from __future__ import annotations
import csv
import logging
import threading
import time
import xml.etree.ElementTree as ET
from typing import Any
import requests
logger = logging.getLogger(__name__)
_BUCKET_LIST_URL = (
"https://s3.opensky-network.org/data-samples?prefix=metadata/&list-type=2"
)
_BUCKET_BASE = "https://s3.opensky-network.org/data-samples/"
_S3_NS = "{http://s3.amazonaws.com/doc/2006-03-01/}"
_REFRESH_INTERVAL_S = 5 * 24 * 3600
_LIST_TIMEOUT_S = 30
_DOWNLOAD_TIMEOUT_S = 600
from services.network_utils import DEFAULT_USER_AGENT as _USER_AGENT
_lock = threading.RLock()
_aircraft_by_hex: dict[str, dict[str, str]] = {}
_last_refresh = 0.0
_in_progress = False
def _latest_snapshot_key() -> str:
"""Discover the most recent aircraft-database-complete snapshot key."""
response = requests.get(
_BUCKET_LIST_URL,
timeout=_LIST_TIMEOUT_S,
headers={"User-Agent": _USER_AGENT},
)
response.raise_for_status()
root = ET.fromstring(response.text)
keys: list[str] = []
for content in root.iter(f"{_S3_NS}Contents"):
key_el = content.find(f"{_S3_NS}Key")
if key_el is None or not key_el.text:
continue
if "aircraft-database-complete-" in key_el.text and key_el.text.endswith(".csv"):
keys.append(key_el.text)
if not keys:
raise RuntimeError("no aircraft-database-complete snapshot found in bucket listing")
return sorted(keys)[-1]
def _stream_csv_index(url: str) -> dict[str, dict[str, str]]:
"""Stream-parse the OpenSky aircraft CSV into a hex-keyed index.
The CSV uses single-quote quoting, so csv.DictReader is configured with
``quotechar="'"``. Rows are processed line-by-line via iter_lines() to
keep memory bounded even though the file is ~108 MB.
"""
with requests.get(
url,
timeout=_DOWNLOAD_TIMEOUT_S,
stream=True,
headers={"User-Agent": _USER_AGENT},
) as response:
response.raise_for_status()
line_iter = (
line.decode("utf-8", errors="replace")
for line in response.iter_lines(decode_unicode=False)
if line
)
reader = csv.DictReader(line_iter, quotechar="'")
index: dict[str, dict[str, str]] = {}
for row in reader:
hex_code = (row.get("icao24") or "").strip().lower()
if not hex_code or hex_code == "000000":
continue
typecode = (row.get("typecode") or "").strip().upper()
model = (row.get("model") or "").strip()
mfr = (row.get("manufacturerName") or "").strip()
registration = (row.get("registration") or "").strip().upper()
operator = (row.get("operator") or "").strip()
if not (typecode or model):
continue
entry: dict[str, str] = {}
if typecode:
entry["typecode"] = typecode
if model:
entry["model"] = model
if mfr:
entry["manufacturer"] = mfr
if registration:
entry["registration"] = registration
if operator:
entry["operator"] = operator
index[hex_code] = entry
return index
def refresh_aircraft_database(force: bool = False) -> bool:
"""Download the latest OpenSky aircraft snapshot and rebuild the index.
Returns True if a refresh was performed (success or attempted), False if
skipped because the cache is still fresh or another refresh is in flight.
"""
global _last_refresh, _in_progress
now = time.time()
with _lock:
if _in_progress:
return False
if not force and (now - _last_refresh) < _REFRESH_INTERVAL_S and _aircraft_by_hex:
return False
_in_progress = True
try:
started = time.time()
key = _latest_snapshot_key()
index = _stream_csv_index(_BUCKET_BASE + key)
with _lock:
_aircraft_by_hex.clear()
_aircraft_by_hex.update(index)
_last_refresh = time.time()
logger.info(
"aircraft database refreshed in %.1fs from %s: %d aircraft",
time.time() - started,
key,
len(index),
)
return True
except (requests.RequestException, OSError, ValueError, ET.ParseError) as exc:
logger.warning("aircraft database refresh failed: %s", exc)
return True
finally:
with _lock:
_in_progress = False
def lookup_aircraft(icao24: str) -> dict[str, str] | None:
"""Return the metadata record for an ICAO24 hex code, or None."""
key = (icao24 or "").strip().lower()
if not key:
return None
with _lock:
entry = _aircraft_by_hex.get(key)
return dict(entry) if entry else None
def lookup_aircraft_type(icao24: str) -> str:
"""Return the ICAO type code (e.g. 'B738', 'GLF4') or '' if unknown."""
entry = lookup_aircraft(icao24)
if not entry:
return ""
return entry.get("typecode", "")
def aircraft_database_status() -> dict[str, Any]:
with _lock:
return {
"last_refresh": _last_refresh,
"aircraft": len(_aircraft_by_hex),
"in_progress": _in_progress,
}