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
608 lines
22 KiB
Python
608 lines
22 KiB
Python
#!/usr/bin/env python3
|
|
"""Network change detection — continuous diff against baseline state.
|
|
|
|
Critical for BURN DETECTION. Alerts on new hosts, disappeared hosts,
|
|
new services, new open ports, security tool traffic, scan activity
|
|
targeting the implant subnet, and unusual auth patterns.
|
|
|
|
Publishes CHANGE_DETECTED events with severity levels.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import sqlite3
|
|
import threading
|
|
import time
|
|
from collections import defaultdict
|
|
from typing import Optional
|
|
|
|
from modules.base import BaseModule
|
|
from utils.credential_encryption import emit_credential_found
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS changes (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
timestamp REAL NOT NULL,
|
|
change_type TEXT NOT NULL,
|
|
severity TEXT NOT NULL DEFAULT 'info',
|
|
description TEXT NOT NULL,
|
|
old_value TEXT DEFAULT '',
|
|
new_value TEXT DEFAULT '',
|
|
target_ip TEXT DEFAULT '',
|
|
acknowledged INTEGER DEFAULT 0
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_changes_ts ON changes(timestamp);
|
|
CREATE INDEX IF NOT EXISTS idx_changes_sev ON changes(severity);
|
|
CREATE INDEX IF NOT EXISTS idx_changes_type ON changes(change_type);
|
|
|
|
CREATE TABLE IF NOT EXISTS baseline_hosts (
|
|
ip TEXT PRIMARY KEY,
|
|
hostname TEXT DEFAULT '',
|
|
mac TEXT DEFAULT '',
|
|
os_family TEXT DEFAULT '',
|
|
open_ports TEXT DEFAULT '[]',
|
|
services TEXT DEFAULT '[]',
|
|
first_seen REAL NOT NULL,
|
|
last_seen REAL NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS baseline_services (
|
|
ip TEXT NOT NULL,
|
|
port INTEGER NOT NULL,
|
|
protocol TEXT DEFAULT 'tcp',
|
|
service TEXT DEFAULT '',
|
|
banner TEXT DEFAULT '',
|
|
first_seen REAL NOT NULL,
|
|
PRIMARY KEY (ip, port, protocol)
|
|
);
|
|
"""
|
|
|
|
# Change types and their default severity
|
|
_CHANGE_SEVERITY = {
|
|
"new_host": "info",
|
|
"host_disappeared": "warning",
|
|
"new_service": "info",
|
|
"new_port": "info",
|
|
"service_disappeared": "info",
|
|
"security_tool_traffic": "critical",
|
|
"scan_detected": "critical",
|
|
"unusual_auth": "warning",
|
|
"mac_change": "warning",
|
|
"os_change": "warning",
|
|
"mass_port_scan": "critical",
|
|
"arp_anomaly": "critical",
|
|
}
|
|
|
|
# Ports/services that indicate security tool deployment
|
|
_SECURITY_TOOL_PORTS = {
|
|
8088: "splunk_hec",
|
|
8089: "splunk_mgmt",
|
|
9997: "splunk_forwarder",
|
|
1514: "wazuh_agent",
|
|
1515: "wazuh_enrollment",
|
|
55000: "wazuh_api",
|
|
514: "syslog",
|
|
6514: "syslog_tls",
|
|
443: None, # too generic, needs hostname corroboration
|
|
}
|
|
|
|
# DNS patterns suggesting security tool investigation
|
|
_SECURITY_DNS_PATTERNS = (
|
|
"nessus", "qualys", "rapid7", "tenable", "crowdstrike",
|
|
"sentinelone", "carbonblack", "cybereason", "sophos",
|
|
"defender", "wireshark", "zeek", "suricata", "snort",
|
|
)
|
|
|
|
# Scan detection: many ports from a single source in short time
|
|
_SCAN_THRESHOLD_PORTS = 50 # ports in window = scan
|
|
_SCAN_THRESHOLD_WINDOW = 60.0 # seconds
|
|
|
|
|
|
class ChangeDetector(BaseModule):
|
|
"""Detect network changes against baseline — critical for burn detection."""
|
|
|
|
name = "change_detector"
|
|
module_type = "intel"
|
|
priority = 50 # Critical priority
|
|
requires_root = False
|
|
|
|
def __init__(self, bus, state, config, engine=None):
|
|
super().__init__(bus, state, config, engine)
|
|
self._db_path = ""
|
|
self._conn: Optional[sqlite3.Connection] = None
|
|
self._lock = threading.Lock()
|
|
self._monitor_thread: Optional[threading.Thread] = None
|
|
self._baseline_built = False
|
|
self._change_count = 0
|
|
|
|
# Scan detection tracking: source_ip -> [(timestamp, dst_port)]
|
|
self._port_attempts: dict[str, list] = defaultdict(list)
|
|
|
|
# Our implant's IPs for self-protection monitoring
|
|
self._implant_ips: set = set()
|
|
|
|
# ------------------------------------------------------------------
|
|
# BaseModule interface
|
|
# ------------------------------------------------------------------
|
|
|
|
def start(self) -> None:
|
|
if self._running:
|
|
return
|
|
|
|
base_dir = self.config.get("data_dir", os.path.expanduser("~/.implant"))
|
|
self._db_path = os.path.join(base_dir, "change_detector.db")
|
|
os.makedirs(os.path.dirname(self._db_path), exist_ok=True)
|
|
|
|
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
|
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
self._conn.execute("PRAGMA synchronous=NORMAL")
|
|
self._conn.row_factory = sqlite3.Row
|
|
self._conn.executescript(_SCHEMA)
|
|
|
|
# Load implant IPs from config
|
|
self._implant_ips = set(self.config.get("implant_ips", []))
|
|
|
|
# Check if baseline exists
|
|
row = self._conn.execute("SELECT COUNT(*) FROM baseline_hosts").fetchone()
|
|
self._baseline_built = row[0] > 0
|
|
|
|
# Subscribe to events
|
|
self.bus.subscribe(self._on_host_discovered, "HOST_DISCOVERED")
|
|
self.bus.subscribe(self._on_credential_found, "CREDENTIAL_FOUND")
|
|
|
|
self._running = True
|
|
self._pid = os.getpid()
|
|
self._start_time = time.time()
|
|
|
|
# Monitor thread for periodic diffing
|
|
self._monitor_thread = threading.Thread(
|
|
target=self._monitor_loop, daemon=True,
|
|
name="sensor-change-detector",
|
|
)
|
|
self._monitor_thread.start()
|
|
|
|
self.state.set_module_status(self.name, "running", pid=self._pid)
|
|
logger.info(
|
|
"ChangeDetector started — baseline=%s, db=%s",
|
|
"loaded" if self._baseline_built else "empty", self._db_path,
|
|
)
|
|
|
|
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_credential_found, "CREDENTIAL_FOUND")
|
|
|
|
if self._conn:
|
|
self._conn.close()
|
|
self._conn = None
|
|
|
|
self.state.set_module_status(self.name, "stopped")
|
|
logger.info("ChangeDetector stopped — %d changes recorded", self._change_count)
|
|
|
|
def status(self) -> dict:
|
|
change_count = 0
|
|
critical_count = 0
|
|
if self._conn:
|
|
try:
|
|
with self._lock:
|
|
row = self._conn.execute(
|
|
"SELECT COUNT(*) FROM changes"
|
|
).fetchone()
|
|
change_count = row[0] if row else 0
|
|
row = self._conn.execute(
|
|
"SELECT COUNT(*) FROM changes WHERE severity='critical'"
|
|
).fetchone()
|
|
critical_count = row[0] if row else 0
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
"running": self._running,
|
|
"pid": self._pid,
|
|
"uptime": time.time() - self._start_time if self._start_time else 0,
|
|
"baseline_built": self._baseline_built,
|
|
"total_changes": change_count,
|
|
"critical_changes": critical_count,
|
|
}
|
|
|
|
def configure(self, config: dict) -> None:
|
|
self.config.update(config)
|
|
self._implant_ips = set(config.get("implant_ips", self._implant_ips))
|
|
|
|
# ------------------------------------------------------------------
|
|
# Baseline management
|
|
# ------------------------------------------------------------------
|
|
|
|
def build_baseline(self) -> int:
|
|
"""Snapshot current known hosts and services as the baseline.
|
|
|
|
Returns:
|
|
Number of hosts in the baseline.
|
|
"""
|
|
hosts_json = self.state.get("host_discovery", "discovered_hosts")
|
|
if not hosts_json:
|
|
logger.warning("No host discovery data available for baseline")
|
|
return 0
|
|
|
|
try:
|
|
hosts = json.loads(hosts_json)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return 0
|
|
|
|
with self._lock:
|
|
# Clear existing baseline
|
|
self._conn.execute("DELETE FROM baseline_hosts")
|
|
self._conn.execute("DELETE FROM baseline_services")
|
|
|
|
now = time.time()
|
|
for host in hosts:
|
|
ip = host.get("ip", "")
|
|
if not ip:
|
|
continue
|
|
|
|
ports = host.get("open_ports", [])
|
|
services = host.get("services", [])
|
|
|
|
self._conn.execute(
|
|
"""INSERT OR REPLACE INTO baseline_hosts
|
|
(ip, hostname, mac, os_family, open_ports, services,
|
|
first_seen, last_seen)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(ip, host.get("hostname", ""), host.get("mac", ""),
|
|
host.get("os_family", ""), json.dumps(ports),
|
|
json.dumps(services), now, now),
|
|
)
|
|
|
|
for port in ports:
|
|
self._conn.execute(
|
|
"""INSERT OR REPLACE INTO baseline_services
|
|
(ip, port, protocol, service, first_seen)
|
|
VALUES (?, ?, 'tcp', '', ?)""",
|
|
(ip, port, now),
|
|
)
|
|
|
|
self._conn.commit()
|
|
self._baseline_built = True
|
|
|
|
count = len(hosts)
|
|
logger.info("Baseline built with %d hosts", count)
|
|
return count
|
|
|
|
# ------------------------------------------------------------------
|
|
# Event handlers
|
|
# ------------------------------------------------------------------
|
|
|
|
def _on_host_discovered(self, event) -> None:
|
|
"""Check a newly discovered host against baseline."""
|
|
if not self._baseline_built:
|
|
return
|
|
|
|
p = event.payload
|
|
ip = p.get("ip", "")
|
|
if not ip:
|
|
return
|
|
|
|
with self._lock:
|
|
# Check if host is in baseline
|
|
row = self._conn.execute(
|
|
"SELECT * FROM baseline_hosts WHERE ip = ?", (ip,)
|
|
).fetchone()
|
|
|
|
if not row:
|
|
# NEW HOST
|
|
self._record_change(
|
|
change_type="new_host",
|
|
description=f"New host discovered: {ip} "
|
|
f"(hostname={p.get('hostname', '')}, "
|
|
f"mac={p.get('mac', '')})",
|
|
new_value=json.dumps(p),
|
|
target_ip=ip,
|
|
)
|
|
else:
|
|
# Existing host — check for changes
|
|
baseline_mac = row["mac"]
|
|
new_mac = p.get("mac", "")
|
|
if baseline_mac and new_mac and baseline_mac != new_mac:
|
|
self._record_change(
|
|
change_type="mac_change",
|
|
description=f"MAC changed on {ip}: "
|
|
f"{baseline_mac} -> {new_mac}",
|
|
old_value=baseline_mac,
|
|
new_value=new_mac,
|
|
target_ip=ip,
|
|
severity="warning",
|
|
)
|
|
|
|
baseline_os = row["os_family"]
|
|
new_os = p.get("os_family", "")
|
|
if baseline_os and new_os and baseline_os != new_os:
|
|
self._record_change(
|
|
change_type="os_change",
|
|
description=f"OS changed on {ip}: "
|
|
f"{baseline_os} -> {new_os}",
|
|
old_value=baseline_os,
|
|
new_value=new_os,
|
|
target_ip=ip,
|
|
)
|
|
|
|
# Check for new ports
|
|
try:
|
|
baseline_ports = set(json.loads(row["open_ports"]))
|
|
except (json.JSONDecodeError, TypeError):
|
|
baseline_ports = set()
|
|
|
|
new_ports = set(p.get("open_ports", []))
|
|
added_ports = new_ports - baseline_ports
|
|
|
|
for port in added_ports:
|
|
sev = "info"
|
|
desc = f"New port {port} on {ip}"
|
|
|
|
# Check if it's a security tool port
|
|
if port in _SECURITY_TOOL_PORTS:
|
|
tool = _SECURITY_TOOL_PORTS[port]
|
|
if tool:
|
|
sev = "critical"
|
|
desc = f"SECURITY TOOL: {tool} port {port} on {ip}"
|
|
|
|
self._record_change(
|
|
change_type="new_port",
|
|
description=desc,
|
|
new_value=str(port),
|
|
target_ip=ip,
|
|
severity=sev,
|
|
)
|
|
|
|
def _on_credential_found(self, event) -> None:
|
|
"""Monitor for unusual authentication patterns."""
|
|
p = event.payload
|
|
from utils.credential_encryption import decrypt_credential_payload
|
|
p = decrypt_credential_payload(p)
|
|
# Track rapid auth failures from unknown sources as investigation indicator
|
|
if p.get("cred_type") == "auth_failure":
|
|
src = p.get("source_ip", "")
|
|
target = p.get("target_ip", "")
|
|
self._record_change(
|
|
change_type="unusual_auth",
|
|
description=f"Auth failure: {src} -> {target} "
|
|
f"(user={p.get('username', '')})",
|
|
target_ip=target,
|
|
severity="warning",
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Connection / scan monitoring
|
|
# ------------------------------------------------------------------
|
|
|
|
def ingest_connection_attempt(self, src_ip: str, dst_ip: str,
|
|
dst_port: int, timestamp: float = None) -> None:
|
|
"""Track connection attempts for scan detection.
|
|
|
|
Called by passive modules watching network traffic.
|
|
"""
|
|
ts = timestamp or time.time()
|
|
|
|
# Check for scans targeting our implant
|
|
if dst_ip in self._implant_ips:
|
|
self._record_change(
|
|
change_type="scan_detected",
|
|
description=f"Connection to implant IP {dst_ip}:{dst_port} "
|
|
f"from {src_ip}",
|
|
target_ip=dst_ip,
|
|
severity="critical",
|
|
)
|
|
|
|
# Track port scan patterns
|
|
with self._lock:
|
|
attempts = self._port_attempts[src_ip]
|
|
attempts.append((ts, dst_port))
|
|
|
|
# Prune old entries
|
|
cutoff = ts - _SCAN_THRESHOLD_WINDOW
|
|
self._port_attempts[src_ip] = [
|
|
(t, p) for t, p in attempts if t > cutoff
|
|
]
|
|
|
|
# Check for scan pattern
|
|
recent = self._port_attempts[src_ip]
|
|
unique_ports = len(set(p for _, p in recent))
|
|
|
|
if unique_ports >= _SCAN_THRESHOLD_PORTS:
|
|
self._record_change(
|
|
change_type="mass_port_scan",
|
|
description=f"Port scan from {src_ip}: "
|
|
f"{unique_ports} unique ports in "
|
|
f"{_SCAN_THRESHOLD_WINDOW}s",
|
|
target_ip=src_ip,
|
|
severity="critical",
|
|
)
|
|
# Reset to avoid flood
|
|
self._port_attempts[src_ip] = []
|
|
|
|
def ingest_dns_query(self, query_name: str, client_ip: str = "") -> None:
|
|
"""Check DNS queries for security tool investigation indicators."""
|
|
query_lower = query_name.lower()
|
|
for pattern in _SECURITY_DNS_PATTERNS:
|
|
if pattern in query_lower:
|
|
self._record_change(
|
|
change_type="security_tool_traffic",
|
|
description=f"Security tool DNS query: {query_name} "
|
|
f"from {client_ip}",
|
|
new_value=query_name,
|
|
target_ip=client_ip,
|
|
severity="critical",
|
|
)
|
|
break
|
|
|
|
# ------------------------------------------------------------------
|
|
# Monitor loop
|
|
# ------------------------------------------------------------------
|
|
|
|
def _monitor_loop(self) -> None:
|
|
"""Periodic monitoring: check for disappeared hosts, etc."""
|
|
interval = self.config.get("change_check_interval", 300)
|
|
while self._running:
|
|
time.sleep(interval)
|
|
try:
|
|
if self._baseline_built:
|
|
self._check_disappeared_hosts()
|
|
self._prune_scan_tracking()
|
|
except Exception:
|
|
logger.exception("Change detector monitor cycle failed")
|
|
|
|
def _check_disappeared_hosts(self) -> None:
|
|
"""Check if any baseline hosts have disappeared."""
|
|
# Get current known hosts from host_discovery
|
|
current_json = self.state.get("host_discovery", "discovered_hosts")
|
|
if not current_json:
|
|
return
|
|
|
|
try:
|
|
current_hosts = json.loads(current_json)
|
|
current_ips = {h.get("ip") for h in current_hosts if h.get("ip")}
|
|
except (json.JSONDecodeError, TypeError):
|
|
return
|
|
|
|
with self._lock:
|
|
baseline_rows = self._conn.execute(
|
|
"SELECT ip, hostname FROM baseline_hosts"
|
|
).fetchall()
|
|
|
|
for row in baseline_rows:
|
|
ip = row["ip"]
|
|
if ip not in current_ips:
|
|
# Check if we already recorded this disappearance recently
|
|
recent = self._conn.execute(
|
|
"""SELECT id FROM changes
|
|
WHERE change_type='host_disappeared' AND target_ip=?
|
|
AND timestamp > ?""",
|
|
(ip, time.time() - 3600),
|
|
).fetchone()
|
|
|
|
if not recent:
|
|
hostname = row["hostname"]
|
|
self._record_change(
|
|
change_type="host_disappeared",
|
|
description=f"Baseline host disappeared: {ip} "
|
|
f"({hostname})",
|
|
old_value=ip,
|
|
target_ip=ip,
|
|
)
|
|
|
|
def _prune_scan_tracking(self) -> None:
|
|
"""Remove stale scan tracking data to limit memory usage."""
|
|
cutoff = time.time() - (_SCAN_THRESHOLD_WINDOW * 2)
|
|
with self._lock:
|
|
stale = [
|
|
ip for ip, attempts in self._port_attempts.items()
|
|
if not attempts or attempts[-1][0] < cutoff
|
|
]
|
|
for ip in stale:
|
|
del self._port_attempts[ip]
|
|
|
|
# ------------------------------------------------------------------
|
|
# Change recording
|
|
# ------------------------------------------------------------------
|
|
|
|
def _record_change(self, change_type: str, description: str,
|
|
old_value: str = "", new_value: str = "",
|
|
target_ip: str = "", severity: str = None) -> None:
|
|
"""Record a detected change and publish an event."""
|
|
if severity is None:
|
|
severity = _CHANGE_SEVERITY.get(change_type, "info")
|
|
|
|
now = time.time()
|
|
self._change_count += 1
|
|
|
|
try:
|
|
self._conn.execute(
|
|
"""INSERT INTO changes
|
|
(timestamp, change_type, severity, description,
|
|
old_value, new_value, target_ip)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
|
(now, change_type, severity, description,
|
|
old_value, new_value, target_ip),
|
|
)
|
|
self._conn.commit()
|
|
except Exception:
|
|
logger.exception("Failed to record change")
|
|
return
|
|
|
|
# Publish event
|
|
self.bus.emit("CHANGE_DETECTED", {
|
|
"change_type": change_type,
|
|
"severity": severity,
|
|
"description": description,
|
|
"target_ip": target_ip,
|
|
"old_value": old_value,
|
|
"new_value": new_value,
|
|
}, source_module=self.name)
|
|
|
|
if severity == "critical":
|
|
logger.critical("BURN RISK: %s", description)
|
|
elif severity == "warning":
|
|
logger.warning("Change: %s", description)
|
|
else:
|
|
logger.info("Change: %s", description)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Query interface
|
|
# ------------------------------------------------------------------
|
|
|
|
def get_changes(self, severity: str = None, change_type: str = None,
|
|
since: float = 0, limit: int = 100) -> list:
|
|
"""Query recorded changes with optional filters."""
|
|
clauses = ["timestamp > ?"]
|
|
params: list = [since]
|
|
|
|
if severity:
|
|
clauses.append("severity = ?")
|
|
params.append(severity)
|
|
if change_type:
|
|
clauses.append("change_type = ?")
|
|
params.append(change_type)
|
|
|
|
where = " AND ".join(clauses)
|
|
params.append(limit)
|
|
|
|
with self._lock:
|
|
rows = self._conn.execute(
|
|
f"""SELECT * FROM changes
|
|
WHERE {where}
|
|
ORDER BY timestamp DESC LIMIT ?""",
|
|
params,
|
|
).fetchall()
|
|
|
|
return [dict(r) for r in rows]
|
|
|
|
def get_critical_changes(self, hours: float = 24.0) -> list:
|
|
"""Get critical changes in the last N hours."""
|
|
since = time.time() - (hours * 3600)
|
|
return self.get_changes(severity="critical", since=since)
|
|
|
|
def get_summary(self) -> dict:
|
|
"""Return change detection summary."""
|
|
with self._lock:
|
|
rows = self._conn.execute(
|
|
"""SELECT severity, COUNT(*) FROM changes
|
|
GROUP BY severity"""
|
|
).fetchall()
|
|
by_type = self._conn.execute(
|
|
"""SELECT change_type, COUNT(*) FROM changes
|
|
GROUP BY change_type"""
|
|
).fetchall()
|
|
|
|
return {
|
|
"total": sum(c for _, c in rows),
|
|
"by_severity": {s: c for s, c in rows},
|
|
"by_type": {t: c for t, c in by_type},
|
|
"baseline_built": self._baseline_built,
|
|
}
|