osint-dashboard/app/masscan_scanner.py
Sirius DevOps 7ceba736dd
All checks were successful
build-and-deploy / build (push) Successful in 2m53s
cameras: map only working HTTP/MJPEG feeds
Masscan open-554 hosts almost never have a public picture. The map now
defaults to cameras with an http(s) snapshot_url (~132 directory cams).

Ingest probes unauthenticated still/MJPEG paths first and skips the rest,
so dead RTSP-only hosts never land on the map. Pass working=false to see
unverified port-554 hits.
2026-08-25 22:05:15 -04:00

226 lines
8.2 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, list[str]]:
"""Insert-or-refresh camera rows for open RTSP hosts that have a public feed.
A host only lands in the table (and therefore on the map) if an
unauthenticated HTTP still or MJPEG URL responds. Port-554-only hosts
are skipped. Returns (newly_inserted, hosts_with_working_feed).
"""
if not ips:
return 0, []
from camera_preview import probe_public_feed
unique = list(dict.fromkeys(ips))
sem = asyncio.Semaphore(20)
async def _probe(ip: str) -> tuple[str, str | None]:
async with sem:
return ip, await probe_public_feed(ip)
probed = await asyncio.gather(*(_probe(ip) for ip in unique))
live = [(ip, feed) for ip, feed in probed if feed]
if not live:
logger.info("masscan ingest: 0 working feeds of %d open-554 hosts",
len(unique))
return 0, []
now = datetime.now(timezone.utc)
coords = await geolocate_ips([ip for ip, _ in live])
new = 0
async with async_session() as session:
for ip, feed in live:
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=feed,
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,
"public_feed": feed},
))
new += 1
else:
await session.execute(cameras.update().where(
cameras.c.url_hash == h
).values(
last_seen=now,
snapshot_url=feed,
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 working feeds (%d probed, %d open-554)",
new, len(live), len(unique))
return new, [ip for ip, _ in live]
# ── 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, live = await ingest_open_hosts(ips)
published = await publish_new_hosts(live)
seen.clear()
return new, published