208 lines
7.6 KiB
Python
208 lines
7.6 KiB
Python
|
|
"""masscan result parsing + ingestion for the OSINT dashboard.
|
||
|
|
|
||
|
|
Turns a stream of masscan JSON-lines (open port 554 hosts) into rows in the
|
||
|
|
`cameras` table with discovery_source='masscan', deduped by URL hash against
|
||
|
|
whatever the passive scraper already found. Newly discovered hosts are also
|
||
|
|
published to NATS (`events.camera`) so the shared ingester pipeline persists
|
||
|
|
them exactly like scraper finds.
|
||
|
|
|
||
|
|
Scope: detection of OPEN hosts only. No credentials, no banners, no feed
|
||
|
|
access. Private/reserved ranges never enter masscan (see excludefile).
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
import json
|
||
|
|
import logging
|
||
|
|
from datetime import datetime, timezone
|
||
|
|
|
||
|
|
from camera_models import cameras
|
||
|
|
from camera_scraper import url_hash, geolocate_ips
|
||
|
|
from database import async_session
|
||
|
|
|
||
|
|
from masscan_config import (
|
||
|
|
MASSCAN_NATS_SUBJECT, MASSCAN_DISCOVERY_SOURCE,
|
||
|
|
)
|
||
|
|
|
||
|
|
logger = logging.getLogger("osint.masscan_scanner")
|
||
|
|
|
||
|
|
|
||
|
|
# ── URL building ──────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def build_rtsp_url(ip: str) -> str:
|
||
|
|
"""Canonical URL for an open-RTSP host. Used as the dedupe key."""
|
||
|
|
return f"rtsp://{ip}/"
|
||
|
|
|
||
|
|
|
||
|
|
# ── masscan JSON parsing ──────────────────────────────────────────────────
|
||
|
|
# masscan --output-format=json --output-file=- emits line-delimited JSON on a
|
||
|
|
# pipe (a bare object per open host), not the array form used for seekable
|
||
|
|
# files. We parse per-line and tolerate an accidental leading '['.
|
||
|
|
|
||
|
|
def parse_masscan_line(line: str) -> list[dict]:
|
||
|
|
"""Parse one masscan stdout line into a list of host records.
|
||
|
|
|
||
|
|
A line may contain one JSON object or, defensively, be wrapped in an
|
||
|
|
array. Returns [] on anything unparseable (harmless — the sweep repeats).
|
||
|
|
"""
|
||
|
|
s = line.strip()
|
||
|
|
if not s:
|
||
|
|
return []
|
||
|
|
s = s.lstrip("[").rstrip("]").strip()
|
||
|
|
if not s:
|
||
|
|
return []
|
||
|
|
# Multiple records may share a line separated by '},{'.
|
||
|
|
if s.endswith(","):
|
||
|
|
s = s[:-1].rstrip()
|
||
|
|
out: list[dict] = []
|
||
|
|
for cand in _split_records(s):
|
||
|
|
try:
|
||
|
|
obj = json.loads(cand)
|
||
|
|
except (json.JSONDecodeError, ValueError):
|
||
|
|
continue
|
||
|
|
if isinstance(obj, dict) and obj.get("ip"):
|
||
|
|
out.append(obj)
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def _split_records(s: str) -> list[str]:
|
||
|
|
"""Split a buffer into individual JSON object strings, honoring nesting."""
|
||
|
|
records, depth, start = [], 0, 0
|
||
|
|
for i, ch in enumerate(s):
|
||
|
|
if ch == "{":
|
||
|
|
if depth == 0:
|
||
|
|
start = i
|
||
|
|
depth += 1
|
||
|
|
elif ch == "}":
|
||
|
|
depth -= 1
|
||
|
|
if depth == 0:
|
||
|
|
records.append(s[start:i + 1])
|
||
|
|
return records
|
||
|
|
|
||
|
|
|
||
|
|
def extract_open_ips(records: list[dict], port: int) -> list[str]:
|
||
|
|
"""Return the list of IPs from records that have `port` open."""
|
||
|
|
ips: list[str] = []
|
||
|
|
for rec in records:
|
||
|
|
for p in rec.get("ports", []):
|
||
|
|
if p.get("port") == port and p.get("status") == "open":
|
||
|
|
ips.append(rec["ip"])
|
||
|
|
break
|
||
|
|
return ips
|
||
|
|
|
||
|
|
|
||
|
|
# ── Persistence ───────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
async def ingest_open_hosts(ips: list[str]) -> tuple[int, int]:
|
||
|
|
"""Insert-or-refresh camera rows for open RTSP hosts.
|
||
|
|
|
||
|
|
Returns (newly_inserted, total_hosts_seen_this_batch). Geolocates each
|
||
|
|
host via the shared ip-api batch resolver. Already-known hosts (matching
|
||
|
|
url_hash) have last_seen/coords refreshed and are NOT counted as new.
|
||
|
|
"""
|
||
|
|
if not ips:
|
||
|
|
return 0, 0
|
||
|
|
now = datetime.now(timezone.utc)
|
||
|
|
coords = await geolocate_ips(list(dict.fromkeys(ips)))
|
||
|
|
new = 0
|
||
|
|
async with async_session() as session:
|
||
|
|
for ip in dict.fromkeys(ips):
|
||
|
|
url = build_rtsp_url(ip)
|
||
|
|
h = url_hash(url)
|
||
|
|
lat, lon = coords.get(ip, (None, None))
|
||
|
|
existing = (await session.execute(
|
||
|
|
cameras.select().where(cameras.c.url_hash == h)
|
||
|
|
)).one_or_none()
|
||
|
|
if existing is None:
|
||
|
|
await session.execute(cameras.insert().values(
|
||
|
|
url_hash=h,
|
||
|
|
source_url=url,
|
||
|
|
snapshot_url=None, # RTSP-only; no HTTP snapshot
|
||
|
|
discovery_source=MASSCAN_DISCOVERY_SOURCE,
|
||
|
|
location_lat=lat,
|
||
|
|
location_lon=lon,
|
||
|
|
location_name=f"{ip} (IP-geo)" if lat is not None else None,
|
||
|
|
vendor=None,
|
||
|
|
device_type="rtsp",
|
||
|
|
first_seen=now,
|
||
|
|
last_seen=now,
|
||
|
|
raw={"discovered_via": "masscan", "port": 554},
|
||
|
|
))
|
||
|
|
new += 1
|
||
|
|
else:
|
||
|
|
await session.execute(cameras.update().where(
|
||
|
|
cameras.c.url_hash == h
|
||
|
|
).values(
|
||
|
|
last_seen=now,
|
||
|
|
location_lat=lat,
|
||
|
|
location_lon=lon,
|
||
|
|
location_name=f"{ip} (IP-geo)" if lat is not None else None,
|
||
|
|
))
|
||
|
|
await session.commit()
|
||
|
|
logger.info("masscan ingest: %d new, %d refreshed", new, len(set(ips)))
|
||
|
|
return new, len(set(ips))
|
||
|
|
|
||
|
|
|
||
|
|
# ── NATS publish ──────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
async def publish_new_hosts(ips: list[str]) -> int:
|
||
|
|
"""Publish newly-found open hosts to NATS for the shared ingester.
|
||
|
|
|
||
|
|
Returns the number of messages published (0 if NATS is down).
|
||
|
|
"""
|
||
|
|
import json as _json
|
||
|
|
import nats
|
||
|
|
from config import NATS_URL
|
||
|
|
|
||
|
|
if not ips:
|
||
|
|
return 0
|
||
|
|
try:
|
||
|
|
nc = await nats.connect(NATS_URL)
|
||
|
|
except Exception: # noqa: BLE001
|
||
|
|
logger.warning("NATS unavailable — skipping publish pass")
|
||
|
|
return 0
|
||
|
|
published = 0
|
||
|
|
try:
|
||
|
|
js = nc.jetstream()
|
||
|
|
for ip in dict.fromkeys(ips):
|
||
|
|
url = build_rtsp_url(ip)
|
||
|
|
msg = {
|
||
|
|
"source_type": "camera",
|
||
|
|
"title": f"Open RTSP camera ({ip})",
|
||
|
|
"url": url,
|
||
|
|
"location_lat": None,
|
||
|
|
"location_lon": None,
|
||
|
|
"location_name": None,
|
||
|
|
"tags": ["osint", "camera", MASSCAN_DISCOVERY_SOURCE],
|
||
|
|
"raw": {
|
||
|
|
"url_hash": url_hash(url),
|
||
|
|
"source_url": url,
|
||
|
|
"snapshot_url": None,
|
||
|
|
"vendor": None,
|
||
|
|
"device_type": "rtsp",
|
||
|
|
"discovered_via": "masscan",
|
||
|
|
"port": 554,
|
||
|
|
},
|
||
|
|
"source_timestamp": datetime.now(timezone.utc).isoformat(),
|
||
|
|
}
|
||
|
|
await js.publish(MASSCAN_NATS_SUBJECT, _json.dumps(msg).encode())
|
||
|
|
published += 1
|
||
|
|
finally:
|
||
|
|
await nc.close()
|
||
|
|
logger.info("published %d masscan finds to %s", published, MASSCAN_NATS_SUBJECT)
|
||
|
|
return published
|
||
|
|
|
||
|
|
|
||
|
|
# ── Batch drain helper used by the runner ─────────────────────────────────
|
||
|
|
|
||
|
|
async def flush(seen: set[str], new_accum: int) -> tuple[int, int]:
|
||
|
|
"""Ingest + publish the accumulated host set; return (new, published)."""
|
||
|
|
if not seen:
|
||
|
|
return 0, 0
|
||
|
|
ips = list(seen)
|
||
|
|
new, _ = await ingest_open_hosts(ips)
|
||
|
|
published = await publish_new_hosts(ips)
|
||
|
|
seen.clear()
|
||
|
|
return new, published
|