Files
bigbrother/modules/passive/network_mapper.py
T
Cobra ffd384f64b Strip OPSEC tool identity fingerprints
Replace all sensor.* logger namespaces with __name__ (generic module
identifiers instead of discoverable 'sensor.*' prefixes).

Change hardcoded 'bb' API user to 'admin' in config and code defaults.

Change hardcoded relay_user 'bb' to 'operator' — prevents network
profiling from exposing tool identity via SSH config.

Fixes #457, #458, #459
2026-04-08 22:18:35 -04:00

382 lines
14 KiB
Python

#!/usr/bin/env python3
"""Network relationship mapper — passive communication graph builder.
Builds a graph of all observed host-to-host communication with protocol,
byte count, and packet count metadata. Identifies roles (servers, clients,
admin workstations, printers) and generates Graphviz DOT output. Periodic
snapshots for change_detector consumption.
"""
import logging
import os
import socket
import sqlite3
import struct
import threading
import time
from collections import defaultdict
from pathlib import Path
from typing import Optional
from modules.base import BaseModule
logger = logging.getLogger(__name__)
TCP_PROTO = 6
UDP_PROTO = 17
# Role detection thresholds
SERVER_INBOUND_THRESHOLD = 5 # Unique source IPs connecting in
CLIENT_OUTBOUND_THRESHOLD = 10 # Unique dest IPs connected to
ADMIN_SSH_RDP_THRESHOLD = 3 # Unique SSH/RDP destinations
PRINTER_PORT = 9100
_DB_INIT = """
CREATE TABLE IF NOT EXISTS connections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
src_ip TEXT NOT NULL,
dst_ip TEXT NOT NULL,
protocol TEXT NOT NULL,
port INTEGER NOT NULL,
bytes_total INTEGER DEFAULT 0,
packets INTEGER DEFAULT 0,
first_seen REAL NOT NULL,
last_seen REAL NOT NULL,
relationship_type TEXT DEFAULT '',
UNIQUE(src_ip, dst_ip, protocol, port)
);
CREATE INDEX IF NOT EXISTS idx_conn_src ON connections(src_ip);
CREATE INDEX IF NOT EXISTS idx_conn_dst ON connections(dst_ip);
"""
FLUSH_INTERVAL = 45
SNAPSHOT_INTERVAL = 300 # 5 minutes
class NetworkMapper(BaseModule):
"""Build communication graph from all observed traffic."""
name = "network_mapper"
module_type = "passive"
priority = 150
requires_root = True
requires_capture_bus = True
def __init__(self, bus, state, config, engine=None):
super().__init__(bus, state, config, engine)
self._capture_bus = None
self._sub_queue = None
self._read_thread: Optional[threading.Thread] = None
self._flush_thread: Optional[threading.Thread] = None
self._snapshot_thread: Optional[threading.Thread] = None
self._db_path = self._resolve_db_path()
self._db_conn: Optional[sqlite3.Connection] = None
self._lock = threading.Lock()
# In-memory graph: (src_ip, dst_ip, proto, port) -> {bytes, packets, first, last}
self._graph: dict[tuple, dict] = {}
# Role tracking: ip -> set of connected IPs per direction
self._inbound: dict[str, set] = defaultdict(set) # dst -> set(src)
self._outbound: dict[str, set] = defaultdict(set) # src -> set(dst)
self._ssh_rdp_dests: dict[str, set] = defaultdict(set) # src -> set(dst) for SSH/RDP
self._printer_servers: set = set()
self._stats = {
"packets_processed": 0,
"unique_connections": 0,
"hosts_seen": 0,
"snapshots_taken": 0,
}
# ------------------------------------------------------------------
# BaseModule interface
# ------------------------------------------------------------------
def start(self) -> None:
if self._running:
return
self._init_db()
self._capture_bus = self.config.get("_capture_bus")
if self._capture_bus is None:
logger.error("NetworkMapper requires _capture_bus in config")
return
# Subscribe to all traffic
self._sub_queue = self._capture_bus.subscribe(
name=self.name, bpf_filter="", queue_depth=5000
)
self._running = True
self._start_time = time.time()
self._pid = os.getpid()
self._read_thread = threading.Thread(
target=self._read_loop, daemon=True, name="sensor-netmap-read"
)
self._read_thread.start()
self._flush_thread = threading.Thread(
target=self._flush_loop, daemon=True, name="sensor-netmap-flush"
)
self._flush_thread.start()
self._snapshot_thread = threading.Thread(
target=self._snapshot_loop, daemon=True, name="sensor-netmap-snapshot"
)
self._snapshot_thread.start()
self.state.set_module_status(self.name, "running", pid=self._pid)
self.bus.emit("MODULE_STARTED", {"module": self.name}, source_module=self.name)
logger.info("NetworkMapper started")
def stop(self) -> None:
if not self._running:
return
self._running = False
if self._capture_bus:
self._capture_bus.unsubscribe(self.name)
for t in (self._read_thread, self._flush_thread, self._snapshot_thread):
if t and t.is_alive():
t.join(timeout=5.0)
self._flush_to_db()
if self._db_conn:
self._db_conn.close()
self._db_conn = None
self.state.set_module_status(self.name, "stopped")
self.bus.emit("MODULE_STOPPED", {"module": self.name}, source_module=self.name)
logger.info("NetworkMapper stopped — stats: %s", self._stats)
def status(self) -> dict:
with self._lock:
hosts = set()
for (src, dst, _, _) in self._graph:
hosts.add(src)
hosts.add(dst)
self._stats["hosts_seen"] = len(hosts)
self._stats["unique_connections"] = len(self._graph)
return {
"running": self._running,
"pid": self._pid,
"uptime": time.time() - self._start_time if self._start_time else 0,
**self._stats,
}
def configure(self, config: dict) -> None:
self.config.update(config)
# ------------------------------------------------------------------
# Packet processing
# ------------------------------------------------------------------
def _read_loop(self) -> None:
while self._running:
result = self._sub_queue.get(timeout=1.0)
if result is None:
continue
ts, pkt = result
self._stats["packets_processed"] += 1
try:
self._process_packet(pkt, ts)
except Exception:
pass
def _process_packet(self, pkt: bytes, ts: float) -> None:
"""Extract IP flow tuple and update graph."""
if len(pkt) < 34:
return
ethertype = struct.unpack("!H", pkt[12:14])[0]
# Handle 802.1Q
eth_offset = 14
if ethertype == 0x8100:
if len(pkt) < 38:
return
ethertype = struct.unpack("!H", pkt[16:18])[0]
eth_offset = 18
if ethertype != 0x0800: # IPv4 only
return
if len(pkt) < eth_offset + 20:
return
ip_header = pkt[eth_offset:]
ihl = (ip_header[0] & 0x0F) * 4
total_len = struct.unpack("!H", ip_header[2:4])[0]
ip_proto = ip_header[9]
src_ip = socket.inet_ntoa(ip_header[12:16])
dst_ip = socket.inet_ntoa(ip_header[16:20])
port = 0
proto_name = "other"
if ip_proto == TCP_PROTO and len(ip_header) >= ihl + 4:
_, dst_port = struct.unpack("!HH", ip_header[ihl:ihl + 4])
port = dst_port
proto_name = "tcp"
elif ip_proto == UDP_PROTO and len(ip_header) >= ihl + 4:
_, dst_port = struct.unpack("!HH", ip_header[ihl:ihl + 4])
port = dst_port
proto_name = "udp"
elif ip_proto == 1:
proto_name = "icmp"
pkt_bytes = total_len
key = (src_ip, dst_ip, proto_name, port)
with self._lock:
if key not in self._graph:
self._graph[key] = {
"bytes": 0, "packets": 0,
"first_seen": ts, "last_seen": ts,
}
entry = self._graph[key]
entry["bytes"] += pkt_bytes
entry["packets"] += 1
entry["last_seen"] = ts
# Track roles
self._inbound[dst_ip].add(src_ip)
self._outbound[src_ip].add(dst_ip)
if port in (22, 3389):
self._ssh_rdp_dests[src_ip].add(dst_ip)
if port == PRINTER_PORT:
self._printer_servers.add(dst_ip)
# ------------------------------------------------------------------
# Role classification
# ------------------------------------------------------------------
def _classify_role(self, ip: str) -> str:
"""Classify a host's role based on observed traffic patterns."""
roles = []
if len(self._inbound.get(ip, set())) >= SERVER_INBOUND_THRESHOLD:
roles.append("server")
if len(self._ssh_rdp_dests.get(ip, set())) >= ADMIN_SSH_RDP_THRESHOLD:
roles.append("admin_workstation")
if ip in self._printer_servers:
roles.append("printer")
if len(self._outbound.get(ip, set())) >= CLIENT_OUTBOUND_THRESHOLD:
roles.append("client")
return ",".join(roles) if roles else "unknown"
# ------------------------------------------------------------------
# Graphviz output
# ------------------------------------------------------------------
def generate_dot(self) -> str:
"""Generate Graphviz DOT format of the communication graph."""
lines = ['digraph network {', ' rankdir=LR;', ' node [shape=box];']
with self._lock:
hosts = set()
for (src, dst, proto, port) in self._graph:
hosts.add(src)
hosts.add(dst)
# Node declarations with role colors
role_colors = {
"server": "lightblue",
"admin_workstation": "orange",
"printer": "lightgreen",
"client": "lightyellow",
}
for ip in sorted(hosts):
role = self._classify_role(ip)
color = "white"
for r, c in role_colors.items():
if r in role:
color = c
break
label = f"{ip}\\n[{role}]"
lines.append(f' "{ip}" [label="{label}", style=filled, fillcolor={color}];')
# Edges
for (src, dst, proto, port), data in self._graph.items():
label = f"{proto}/{port}\\n{data['packets']}pkts"
lines.append(f' "{src}" -> "{dst}" [label="{label}"];')
lines.append('}')
return '\n'.join(lines)
# ------------------------------------------------------------------
# Database
# ------------------------------------------------------------------
def _resolve_db_path(self) -> str:
base = self.config.get("db_dir", os.path.join(
os.path.expanduser("~"), ".implant"
))
return os.path.join(base, "network_mapper.db")
def _init_db(self) -> None:
Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
self._db_conn = sqlite3.connect(self._db_path, check_same_thread=False)
self._db_conn.execute("PRAGMA journal_mode=WAL")
self._db_conn.execute("PRAGMA synchronous=NORMAL")
self._db_conn.executescript(_DB_INIT)
self._db_conn.commit()
def _flush_to_db(self) -> None:
"""Flush in-memory graph to SQLite."""
with self._lock:
snapshot = dict(self._graph)
if not snapshot or not self._db_conn:
return
try:
with self._db_conn:
for (src, dst, proto, port), data in snapshot.items():
rel = self._classify_role(dst)
self._db_conn.execute(
"""INSERT INTO connections
(src_ip, dst_ip, protocol, port, bytes_total, packets,
first_seen, last_seen, relationship_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(src_ip, dst_ip, protocol, port)
DO UPDATE SET
bytes_total = bytes_total + excluded.bytes_total,
packets = packets + excluded.packets,
last_seen = MAX(last_seen, excluded.last_seen)""",
(src, dst, proto, port, data["bytes"], data["packets"],
data["first_seen"], data["last_seen"], rel),
)
except Exception:
logger.exception("Failed to flush connections to DB")
def _flush_loop(self) -> None:
while self._running:
time.sleep(FLUSH_INTERVAL)
try:
self._flush_to_db()
except Exception:
logger.exception("Flush loop error")
def _snapshot_loop(self) -> None:
"""Periodic snapshots for change_detector consumption."""
while self._running:
time.sleep(SNAPSHOT_INTERVAL)
try:
dot_output = self.generate_dot()
snapshot_dir = os.path.join(
os.path.dirname(self._db_path), "snapshots"
)
Path(snapshot_dir).mkdir(parents=True, exist_ok=True)
filename = f"netmap_{int(time.time())}.dot"
filepath = os.path.join(snapshot_dir, filename)
with open(filepath, "w") as f:
f.write(dot_output)
self._stats["snapshots_taken"] += 1
# Publish snapshot event for change_detector
self.bus.emit("CHANGE_DETECTED", {
"module": self.name,
"snapshot_path": filepath,
"hosts_count": self._stats["hosts_seen"],
"connections_count": self._stats["unique_connections"],
}, source_module=self.name)
except Exception:
logger.exception("Snapshot loop error")