ffd384f64b
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
481 lines
18 KiB
Python
481 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""Network intelligence — beacon detection, communication graph analysis,
|
|
existing C2/malware detection, and service dependency mapping.
|
|
|
|
Analyzes traffic patterns to identify beaconing connections, central/isolated
|
|
nodes, known-bad indicators, and service dependencies. Feeds anomaly
|
|
baselines to traffic_mimicry.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import math
|
|
import os
|
|
import sqlite3
|
|
import threading
|
|
import time
|
|
from collections import defaultdict
|
|
from typing import Optional
|
|
|
|
from modules.base import BaseModule
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Known malicious infrastructure patterns
|
|
_KNOWN_BAD_PORTS = {
|
|
4444, 5555, 1234, 31337, 8888, 6666, 6667, 6668, 6669, # common RAT/IRC
|
|
4443, 8443, 2222, # common C2 alt ports
|
|
}
|
|
|
|
# DNS tunneling detection thresholds
|
|
_DNS_TUNNEL_MIN_SUBDOMAIN_LEN = 30 # base64-encoded data tends to be long
|
|
_DNS_TUNNEL_QUERY_RATE = 50 # queries/min to a single domain = suspicious
|
|
|
|
# Beacon detection defaults
|
|
_BEACON_INTERVAL_RATIO = 0.15 # stddev/mean < 0.15 = likely beacon
|
|
_BEACON_MIN_CONNECTIONS = 10 # need enough samples to be meaningful
|
|
|
|
|
|
class NetIntel(BaseModule):
|
|
"""Analyze network traffic patterns for beacons, C2, anomalies, and dependencies."""
|
|
|
|
name = "net_intel"
|
|
module_type = "intel"
|
|
priority = 150
|
|
requires_root = False
|
|
|
|
def __init__(self, bus, state, config, engine=None):
|
|
super().__init__(bus, state, config, engine)
|
|
self._lock = threading.Lock()
|
|
self._analysis_thread: Optional[threading.Thread] = None
|
|
|
|
# Connection tracking: (src, dst, port) -> [timestamps]
|
|
self._conn_times: dict[tuple, list] = defaultdict(list)
|
|
|
|
# Communication graph: ip -> {peers: set, bytes_in, bytes_out}
|
|
self._comm_graph: dict[str, dict] = defaultdict(
|
|
lambda: {"peers": set(), "bytes_in": 0, "bytes_out": 0, "conn_count": 0}
|
|
)
|
|
|
|
# DNS query tracking: domain -> [timestamps]
|
|
self._dns_queries: dict[str, list] = defaultdict(list)
|
|
|
|
# Detected beacons
|
|
self._beacons: list[dict] = []
|
|
|
|
# Service dependency map: service_ip:port -> [client_ips]
|
|
self._service_deps: dict[str, set] = defaultdict(set)
|
|
|
|
# Anomalies / suspicious findings
|
|
self._findings: list[dict] = []
|
|
|
|
# ------------------------------------------------------------------
|
|
# BaseModule interface
|
|
# ------------------------------------------------------------------
|
|
|
|
def start(self) -> None:
|
|
if self._running:
|
|
return
|
|
|
|
self.bus.subscribe(self._on_host_discovered, "HOST_DISCOVERED")
|
|
self.bus.subscribe(self._on_beacon_detected, "BEACON_DETECTED")
|
|
|
|
self._running = True
|
|
self._pid = os.getpid()
|
|
self._start_time = time.time()
|
|
|
|
# Periodic analysis thread
|
|
self._analysis_thread = threading.Thread(
|
|
target=self._analysis_loop, daemon=True,
|
|
name="sensor-netintel-analysis",
|
|
)
|
|
self._analysis_thread.start()
|
|
|
|
self.state.set_module_status(self.name, "running", pid=self._pid)
|
|
logger.info("NetIntel started")
|
|
|
|
def stop(self) -> None:
|
|
if not self._running:
|
|
return
|
|
self._running = False
|
|
|
|
self.bus.unsubscribe(self._on_host_discovered, "HOST_DISCOVERED")
|
|
self.bus.unsubscribe(self._on_beacon_detected, "BEACON_DETECTED")
|
|
|
|
# Persist findings
|
|
self._save_findings()
|
|
|
|
self.state.set_module_status(self.name, "stopped")
|
|
logger.info(
|
|
"NetIntel stopped — %d beacons, %d findings",
|
|
len(self._beacons), len(self._findings),
|
|
)
|
|
|
|
def status(self) -> dict:
|
|
return {
|
|
"running": self._running,
|
|
"pid": self._pid,
|
|
"uptime": time.time() - self._start_time if self._start_time else 0,
|
|
"tracked_flows": len(self._conn_times),
|
|
"graph_nodes": len(self._comm_graph),
|
|
"beacons_detected": len(self._beacons),
|
|
"findings": len(self._findings),
|
|
}
|
|
|
|
def configure(self, config: dict) -> None:
|
|
self.config.update(config)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Data ingestion
|
|
# ------------------------------------------------------------------
|
|
|
|
def ingest_connection(self, src_ip: str, dst_ip: str, dst_port: int,
|
|
protocol: str = "tcp", bytes_sent: int = 0,
|
|
bytes_recv: int = 0, timestamp: float = None) -> None:
|
|
"""Ingest a connection record for analysis.
|
|
|
|
Called by other modules (dns_logger, packet analyzers) or directly.
|
|
"""
|
|
ts = timestamp or time.time()
|
|
|
|
with self._lock:
|
|
# Connection timing for beacon detection
|
|
flow_key = (src_ip, dst_ip, dst_port)
|
|
self._conn_times[flow_key].append(ts)
|
|
|
|
# Communication graph
|
|
self._comm_graph[src_ip]["peers"].add(dst_ip)
|
|
self._comm_graph[src_ip]["bytes_out"] += bytes_sent
|
|
self._comm_graph[src_ip]["conn_count"] += 1
|
|
|
|
self._comm_graph[dst_ip]["peers"].add(src_ip)
|
|
self._comm_graph[dst_ip]["bytes_in"] += bytes_recv
|
|
self._comm_graph[dst_ip]["conn_count"] += 1
|
|
|
|
# Service dependency
|
|
service_key = f"{dst_ip}:{dst_port}"
|
|
self._service_deps[service_key].add(src_ip)
|
|
|
|
# Known-bad port check
|
|
if dst_port in _KNOWN_BAD_PORTS:
|
|
self._add_finding(
|
|
"suspicious_port",
|
|
f"{src_ip} -> {dst_ip}:{dst_port} ({protocol})",
|
|
severity="warning",
|
|
details={"src": src_ip, "dst": dst_ip, "port": dst_port},
|
|
)
|
|
|
|
def ingest_dns_query(self, client_ip: str, query_name: str,
|
|
query_type: str = "A", timestamp: float = None) -> None:
|
|
"""Ingest a DNS query for tunneling and pattern analysis."""
|
|
ts = timestamp or time.time()
|
|
|
|
with self._lock:
|
|
self._dns_queries[query_name].append(ts)
|
|
|
|
# Check for DNS tunneling indicators
|
|
parts = query_name.split(".")
|
|
if len(parts) >= 3:
|
|
# Long subdomain labels suggest encoding
|
|
subdomain = ".".join(parts[:-2])
|
|
if len(subdomain) >= _DNS_TUNNEL_MIN_SUBDOMAIN_LEN:
|
|
base_domain = ".".join(parts[-2:])
|
|
self._add_finding(
|
|
"dns_tunnel_suspect",
|
|
f"Long subdomain to {base_domain} from {client_ip}: "
|
|
f"{len(subdomain)} chars",
|
|
severity="warning",
|
|
details={
|
|
"client": client_ip,
|
|
"base_domain": base_domain,
|
|
"subdomain_len": len(subdomain),
|
|
"query_type": query_type,
|
|
},
|
|
)
|
|
|
|
def _on_host_discovered(self, event) -> None:
|
|
"""Track new hosts for the communication graph."""
|
|
p = event.payload
|
|
ip = p.get("ip", "")
|
|
if ip and ip not in self._comm_graph:
|
|
with self._lock:
|
|
self._comm_graph[ip]["peers"] = set()
|
|
|
|
def _on_beacon_detected(self, event) -> None:
|
|
"""Record externally detected beacons."""
|
|
p = event.payload
|
|
with self._lock:
|
|
self._beacons.append({
|
|
"src": p.get("src_ip", ""),
|
|
"dst": p.get("dst_ip", ""),
|
|
"port": p.get("port", 0),
|
|
"interval": p.get("interval", 0),
|
|
"jitter": p.get("jitter", 0),
|
|
"detected_at": time.time(),
|
|
"source": event.source_module,
|
|
})
|
|
|
|
# ------------------------------------------------------------------
|
|
# Analysis engine
|
|
# ------------------------------------------------------------------
|
|
|
|
def _analysis_loop(self) -> None:
|
|
"""Periodic analysis of accumulated traffic data."""
|
|
interval = self.config.get("analysis_interval", 120)
|
|
while self._running:
|
|
time.sleep(interval)
|
|
try:
|
|
self._detect_beacons()
|
|
self._detect_dns_tunneling()
|
|
self._analyze_communication_graph()
|
|
self._save_findings()
|
|
except Exception:
|
|
logger.exception("NetIntel analysis cycle failed")
|
|
|
|
def _detect_beacons(self) -> None:
|
|
"""Detect beaconing connections by analyzing inter-connection intervals.
|
|
|
|
A beacon has a regular interval: stddev/mean ratio < threshold.
|
|
"""
|
|
threshold = self.config.get(
|
|
"beacon_ratio_threshold", _BEACON_INTERVAL_RATIO
|
|
)
|
|
min_samples = self.config.get(
|
|
"beacon_min_connections", _BEACON_MIN_CONNECTIONS
|
|
)
|
|
|
|
with self._lock:
|
|
for flow_key, timestamps in self._conn_times.items():
|
|
if len(timestamps) < min_samples:
|
|
continue
|
|
|
|
# Calculate inter-arrival intervals
|
|
sorted_ts = sorted(timestamps)
|
|
intervals = [
|
|
sorted_ts[i + 1] - sorted_ts[i]
|
|
for i in range(len(sorted_ts) - 1)
|
|
]
|
|
|
|
if not intervals:
|
|
continue
|
|
|
|
mean_interval = sum(intervals) / len(intervals)
|
|
if mean_interval <= 0:
|
|
continue
|
|
|
|
variance = sum((x - mean_interval) ** 2 for x in intervals) / len(intervals)
|
|
stddev = math.sqrt(variance)
|
|
ratio = stddev / mean_interval
|
|
|
|
if ratio < threshold:
|
|
src, dst, port = flow_key
|
|
beacon_info = {
|
|
"src": src,
|
|
"dst": dst,
|
|
"port": port,
|
|
"mean_interval": round(mean_interval, 2),
|
|
"stddev": round(stddev, 2),
|
|
"ratio": round(ratio, 4),
|
|
"sample_count": len(timestamps),
|
|
"detected_at": time.time(),
|
|
}
|
|
|
|
# Avoid duplicate beacon alerts
|
|
is_dup = any(
|
|
b["src"] == src and b["dst"] == dst and b["port"] == port
|
|
for b in self._beacons
|
|
)
|
|
if not is_dup:
|
|
self._beacons.append(beacon_info)
|
|
self.bus.emit("BEACON_DETECTED", {
|
|
"src_ip": src,
|
|
"dst_ip": dst,
|
|
"port": port,
|
|
"interval": mean_interval,
|
|
"jitter": stddev,
|
|
"ratio": ratio,
|
|
}, source_module=self.name)
|
|
|
|
logger.warning(
|
|
"BEACON: %s -> %s:%d every %.1fs "
|
|
"(ratio=%.4f, n=%d)",
|
|
src, dst, port, mean_interval, ratio,
|
|
len(timestamps),
|
|
)
|
|
|
|
def _detect_dns_tunneling(self) -> None:
|
|
"""Detect potential DNS tunneling by query rate to specific domains."""
|
|
now = time.time()
|
|
window = 60.0 # 1-minute window
|
|
|
|
with self._lock:
|
|
# Aggregate by base domain (last 2 labels)
|
|
domain_rates: dict[str, int] = defaultdict(int)
|
|
for query_name, timestamps in self._dns_queries.items():
|
|
parts = query_name.split(".")
|
|
if len(parts) >= 2:
|
|
base = ".".join(parts[-2:])
|
|
recent = sum(1 for t in timestamps if now - t < window)
|
|
domain_rates[base] += recent
|
|
|
|
for domain, rate in domain_rates.items():
|
|
if rate >= _DNS_TUNNEL_QUERY_RATE:
|
|
self._add_finding(
|
|
"dns_tunnel_rate",
|
|
f"High DNS query rate to {domain}: {rate}/min",
|
|
severity="critical",
|
|
details={"domain": domain, "rate_per_min": rate},
|
|
)
|
|
|
|
def _analyze_communication_graph(self) -> None:
|
|
"""Identify central nodes, isolated hosts, and unusual patterns."""
|
|
with self._lock:
|
|
if len(self._comm_graph) < 3:
|
|
return
|
|
|
|
# Find most-connected nodes (potential central servers / DCs)
|
|
by_peers = sorted(
|
|
self._comm_graph.items(),
|
|
key=lambda x: len(x[1]["peers"]),
|
|
reverse=True,
|
|
)
|
|
|
|
total_nodes = len(self._comm_graph)
|
|
central_threshold = max(total_nodes * 0.3, 5)
|
|
|
|
for ip, data in by_peers[:10]:
|
|
peer_count = len(data["peers"])
|
|
if peer_count >= central_threshold:
|
|
self.state.set(
|
|
self.name,
|
|
f"central_node_{ip}",
|
|
json.dumps({
|
|
"ip": ip,
|
|
"peer_count": peer_count,
|
|
"bytes_in": data["bytes_in"],
|
|
"bytes_out": data["bytes_out"],
|
|
}),
|
|
)
|
|
|
|
# Find isolated hosts (single or no peers — unusual)
|
|
for ip, data in self._comm_graph.items():
|
|
if len(data["peers"]) <= 1 and data["conn_count"] > 20:
|
|
self._add_finding(
|
|
"isolated_talker",
|
|
f"{ip} has {data['conn_count']} connections "
|
|
f"but only {len(data['peers'])} peer(s)",
|
|
severity="info",
|
|
details={"ip": ip, "peers": list(data["peers"])},
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Findings management
|
|
# ------------------------------------------------------------------
|
|
|
|
def _add_finding(self, finding_type: str, description: str,
|
|
severity: str = "info", details: dict = None) -> None:
|
|
"""Record a finding, deduplicating by type + description."""
|
|
# Simple dedup: don't re-add identical findings
|
|
for f in self._findings:
|
|
if f["type"] == finding_type and f["description"] == description:
|
|
f["last_seen"] = time.time()
|
|
f["count"] = f.get("count", 1) + 1
|
|
return
|
|
|
|
self._findings.append({
|
|
"type": finding_type,
|
|
"description": description,
|
|
"severity": severity,
|
|
"details": details or {},
|
|
"first_seen": time.time(),
|
|
"last_seen": time.time(),
|
|
"count": 1,
|
|
})
|
|
|
|
def _save_findings(self) -> None:
|
|
"""Persist findings and beacons to state."""
|
|
with self._lock:
|
|
beacons_ser = list(self._beacons)
|
|
findings_ser = list(self._findings)
|
|
|
|
# Serialize service deps
|
|
deps_ser = {k: list(v) for k, v in self._service_deps.items()}
|
|
|
|
self.state.set(self.name, "beacons", json.dumps(beacons_ser))
|
|
self.state.set(self.name, "findings", json.dumps(findings_ser))
|
|
self.state.set(self.name, "service_deps", json.dumps(deps_ser))
|
|
|
|
# ------------------------------------------------------------------
|
|
# Public query interface
|
|
# ------------------------------------------------------------------
|
|
|
|
def get_beacons(self) -> list:
|
|
"""Return all detected beacons."""
|
|
with self._lock:
|
|
return list(self._beacons)
|
|
|
|
def get_findings(self, severity: str = None) -> list:
|
|
"""Return findings, optionally filtered by severity."""
|
|
with self._lock:
|
|
if severity:
|
|
return [f for f in self._findings if f["severity"] == severity]
|
|
return list(self._findings)
|
|
|
|
def get_central_nodes(self, min_peers: int = 5) -> list:
|
|
"""Return the most-connected nodes in the communication graph."""
|
|
with self._lock:
|
|
results = []
|
|
for ip, data in self._comm_graph.items():
|
|
peer_count = len(data["peers"])
|
|
if peer_count >= min_peers:
|
|
results.append({
|
|
"ip": ip,
|
|
"peer_count": peer_count,
|
|
"bytes_in": data["bytes_in"],
|
|
"bytes_out": data["bytes_out"],
|
|
"conn_count": data["conn_count"],
|
|
})
|
|
results.sort(key=lambda x: x["peer_count"], reverse=True)
|
|
return results
|
|
|
|
def get_service_dependencies(self) -> dict:
|
|
"""Return service dependency map: service -> [dependent clients]."""
|
|
with self._lock:
|
|
return {k: list(v) for k, v in self._service_deps.items()}
|
|
|
|
def get_anomaly_baseline(self) -> dict:
|
|
"""Return traffic pattern baseline for traffic_mimicry module.
|
|
|
|
Provides:
|
|
- Typical connection intervals per flow
|
|
- Byte distribution per protocol
|
|
- Peak/quiet hour patterns
|
|
"""
|
|
with self._lock:
|
|
flow_intervals = {}
|
|
for flow_key, timestamps in self._conn_times.items():
|
|
if len(timestamps) < 3:
|
|
continue
|
|
sorted_ts = sorted(timestamps)
|
|
intervals = [sorted_ts[i + 1] - sorted_ts[i]
|
|
for i in range(len(sorted_ts) - 1)]
|
|
mean = sum(intervals) / len(intervals)
|
|
flow_intervals[f"{flow_key[0]}->{flow_key[1]}:{flow_key[2]}"] = {
|
|
"mean_interval": round(mean, 2),
|
|
"sample_count": len(timestamps),
|
|
}
|
|
|
|
# Hour-of-day connection distribution
|
|
hourly = defaultdict(int)
|
|
for timestamps in self._conn_times.values():
|
|
for ts in timestamps:
|
|
hour = time.localtime(ts).tm_hour
|
|
hourly[hour] += 1
|
|
|
|
return {
|
|
"flow_intervals": flow_intervals,
|
|
"hourly_distribution": dict(hourly),
|
|
"total_flows": len(self._conn_times),
|
|
"total_nodes": len(self._comm_graph),
|
|
}
|