admin / Synapse-Sonar
publicAttack Surface Simulation
Synapse-Sonar / synapse-sonar / public / agents / sensor / sonar-sensor.py
5402 B · main
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 | #!/usr/bin/env python3 """ Synapse Sonar passive sensor — agentless flow monitoring. For devices that cannot run an agent. Deploy this on a host connected to a switch SPAN / mirror port (or an inline tap / gateway) so it can observe the traffic to and from those devices. It sniffs IPv4 TCP/UDP packets, aggregates them into flows over a window, and pushes them to the Synapse Sonar ingestion endpoint. Linux-only (uses AF_PACKET). Requires CAP_NET_RAW (+ CAP_NET_ADMIN for promiscuous mode). Pure Python 3 standard library — no pip dependencies. Config (see sonar-sensor.env.example): SONAR_URL e.g. https://sonar.corp.internal:3000 SONAR_INGEST_KEY the "Sensor ingest key" from Integrations -> NetscanXi SONAR_IFACE interface to sniff, e.g. eth1 (default: all) SONAR_FLUSH seconds per aggregation window (default 30) SONAR_VERIFY_TLS "false" to skip TLS verification (default true) """ import json import os import socket import ssl import struct import sys import time import urllib.error import urllib.request SONAR_URL = os.environ.get("SONAR_URL", "").rstrip("/") INGEST_KEY = os.environ.get("SONAR_INGEST_KEY", "") IFACE = os.environ.get("SONAR_IFACE", "").strip() FLUSH = int(os.environ.get("SONAR_FLUSH", "30")) VERIFY_TLS = os.environ.get("SONAR_VERIFY_TLS", "true").lower() != "false" ETH_P_ALL = 0x0003 SENSOR_HOST = socket.gethostname() def log(msg: str) -> None: print(f"[sonar-sensor] {msg}", flush=True) def resolve_sonar_ip(): """Resolve the Sonar host IP so we can exclude our own push traffic.""" try: netloc = SONAR_URL.split("://", 1)[-1].split("/", 1)[0] host = netloc.split("@")[-1].rsplit(":", 1)[0].strip("[]") return socket.gethostbyname(host) except Exception: return None def push(flows): if not flows: return # Send the heaviest flows first, capped to the endpoint's batch limit. flows.sort(key=lambda f: f["bytes"], reverse=True) body = json.dumps({"flows": flows[:1000]}).encode() req = urllib.request.Request( f"{SONAR_URL}/api/ingest/flow-logs", data=body, method="POST", headers={ "Content-Type": "application/json", "Authorization": f"Bearer {INGEST_KEY}", }, ) ctx = None if SONAR_URL.startswith("https") and not VERIFY_TLS: ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE try: with urllib.request.urlopen(req, timeout=15, context=ctx) as resp: log(f"pushed {len(flows[:1000])} flows -> HTTP {resp.status}") except urllib.error.HTTPError as e: log(f"push failed: HTTP {e.code} {e.read().decode(errors='ignore')[:200]}") except Exception as e: log(f"push error: {e}") def main(): if not SONAR_URL or not INGEST_KEY: log("SONAR_URL and SONAR_INGEST_KEY must be set. Exiting.") sys.exit(1) if not hasattr(socket, "AF_PACKET"): log("AF_PACKET unavailable — this sensor runs on Linux only. Exiting.") sys.exit(1) sonar_ip = resolve_sonar_ip() s = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(ETH_P_ALL)) if IFACE: s.bind((IFACE, 0)) s.settimeout(1.0) log(f"sniffing iface={IFACE or 'all'} window={FLUSH}s target={SONAR_URL}") flows = {} # (src,dst,proto,port) -> bytes last_flush = time.monotonic() while True: try: frame = s.recv(65535) except socket.timeout: frame = None except Exception as e: log(f"recv error: {e}") frame = None if frame and len(frame) >= 34: eth_proto = struct.unpack("!H", frame[12:14])[0] if eth_proto == 0x0800: # IPv4 ihl = (frame[14] & 0x0F) * 4 proto_num = frame[23] total_len = struct.unpack("!H", frame[16:18])[0] src = socket.inet_ntoa(frame[26:30]) dst = socket.inet_ntoa(frame[30:34]) if proto_num in (6, 17): # TCP / UDP l4 = 14 + ihl if len(frame) >= l4 + 4: sport = struct.unpack("!H", frame[l4:l4 + 2])[0] dport = struct.unpack("!H", frame[l4 + 2:l4 + 4])[0] proto = "tcp" if proto_num == 6 else "udp" # Skip our own push traffic to avoid a feedback loop. if sonar_ip and (src == sonar_ip or dst == sonar_ip): pass else: port = min(sport, dport) # service port heuristic key = (src, dst, proto, port) flows[key] = flows.get(key, 0) + total_len now = time.monotonic() if now - last_flush >= FLUSH: batch = [ { "sourceIp": k[0], "destIp": k[1], "protocol": k[2], "port": k[3], "bytes": v, "metadata": {"collector": "sensor", "sensor": SENSOR_HOST}, } for k, v in flows.items() ] push(batch) flows.clear() last_flush = now if __name__ == "__main__": main() |