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,623 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Passive OS fingerprinting via p0f-style TCP analysis and protocol headers.
|
||||
|
||||
Analyzes:
|
||||
- TCP SYN/SYN-ACK: TTL, window size, DF flag, MSS, SACK, TCP timestamps
|
||||
- HTTP User-Agent headers
|
||||
- DHCP vendor class identifiers
|
||||
- SMB dialect negotiation
|
||||
- SSH version strings
|
||||
|
||||
Multiple signal sources produce confidence scoring per host. Results are
|
||||
merged with the hosts table from host_discovery.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import sqlite3
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# p0f-style TCP signature database (built-in fallback)
|
||||
# Format: (ttl_range, window_size, df, mss_range, sack, timestamps) -> (os_family, os_version)
|
||||
TCP_SIG_DB = [
|
||||
# Linux signatures
|
||||
{"ttl": (64, 64), "df": True, "window": (5720, 65535), "mss": (1360, 1460),
|
||||
"sack": True, "ts": True, "os_family": "Linux", "os_version": "2.6+"},
|
||||
# Windows signatures
|
||||
{"ttl": (128, 128), "df": True, "window": (8192, 65535), "mss": (1360, 1460),
|
||||
"sack": True, "ts": False, "os_family": "Windows", "os_version": "7/10/Server"},
|
||||
{"ttl": (128, 128), "df": True, "window": (8192, 8192), "mss": (1360, 1460),
|
||||
"sack": True, "ts": False, "os_family": "Windows", "os_version": "XP/2003"},
|
||||
# macOS / iOS
|
||||
{"ttl": (64, 64), "df": True, "window": (65535, 65535), "mss": (1360, 1460),
|
||||
"sack": True, "ts": True, "os_family": "macOS", "os_version": "10.x+"},
|
||||
# FreeBSD
|
||||
{"ttl": (64, 64), "df": True, "window": (65535, 65535), "mss": (1360, 1460),
|
||||
"sack": True, "ts": True, "os_family": "FreeBSD", "os_version": ""},
|
||||
# Cisco IOS
|
||||
{"ttl": (255, 255), "df": False, "window": (4128, 4128), "mss": (536, 536),
|
||||
"sack": False, "ts": False, "os_family": "Cisco", "os_version": "IOS"},
|
||||
# Solaris
|
||||
{"ttl": (255, 255), "df": False, "window": (49232, 49232), "mss": (1360, 1460),
|
||||
"sack": False, "ts": True, "os_family": "Solaris", "os_version": "10+"},
|
||||
]
|
||||
|
||||
# HTTP User-Agent patterns
|
||||
UA_PATTERNS = [
|
||||
(re.compile(r"Windows NT 10\.0"), "Windows", "10/11"),
|
||||
(re.compile(r"Windows NT 6\.3"), "Windows", "8.1"),
|
||||
(re.compile(r"Windows NT 6\.2"), "Windows", "8"),
|
||||
(re.compile(r"Windows NT 6\.1"), "Windows", "7"),
|
||||
(re.compile(r"Windows NT 5\.1"), "Windows", "XP"),
|
||||
(re.compile(r"Mac OS X (\d+[._]\d+)"), "macOS", ""),
|
||||
(re.compile(r"Linux"), "Linux", ""),
|
||||
(re.compile(r"Ubuntu"), "Linux", "Ubuntu"),
|
||||
(re.compile(r"Android (\d+)"), "Android", ""),
|
||||
(re.compile(r"iPhone OS (\d+)"), "iOS", ""),
|
||||
(re.compile(r"iPad.*OS (\d+)"), "iPadOS", ""),
|
||||
(re.compile(r"CrOS"), "ChromeOS", ""),
|
||||
]
|
||||
|
||||
|
||||
class OSFingerprint(BaseModule):
|
||||
"""Passive OS fingerprinting via TCP/protocol analysis."""
|
||||
|
||||
name = "os_fingerprint"
|
||||
module_type = "passive"
|
||||
priority = 150
|
||||
requires_root = True
|
||||
requires_capture_bus = True
|
||||
|
||||
BATCH_SIZE = 200
|
||||
FLUSH_INTERVAL = 120
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._capture_bus = None
|
||||
self._sub_queue = None
|
||||
self._reader_thread: Optional[threading.Thread] = None
|
||||
self._flusher_thread: Optional[threading.Thread] = None
|
||||
self._buffer = []
|
||||
self._buffer_lock = threading.Lock()
|
||||
self._db_path = ""
|
||||
self._db_conn: Optional[sqlite3.Connection] = None
|
||||
self._total_fingerprints = 0
|
||||
# Per-IP confidence tracker: ip -> {os_family: {method: confidence}}
|
||||
self._ip_os_scores = {}
|
||||
self._scores_lock = threading.Lock()
|
||||
# External signature database
|
||||
self._os_sigs = []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._capture_bus = self.config.get("capture_bus")
|
||||
if not self._capture_bus:
|
||||
logger.error("OSFingerprint requires capture_bus in config")
|
||||
return
|
||||
|
||||
base_dir = self.config.get("data_dir", os.path.expanduser("~/.implant"))
|
||||
self._db_path = os.path.join(base_dir, "os_fingerprints.db")
|
||||
Path(os.path.dirname(self._db_path)).mkdir(parents=True, exist_ok=True)
|
||||
self._init_db()
|
||||
|
||||
# Load external OS signatures
|
||||
sigs_path = self.config.get("os_sigs_db", "")
|
||||
if sigs_path and os.path.isfile(sigs_path):
|
||||
self._load_os_sigs(sigs_path)
|
||||
|
||||
# Subscribe to SYN packets + HTTP/SMB/SSH for protocol fingerprinting
|
||||
# Using a broad filter; the module parses selectively
|
||||
bpf = "tcp[tcpflags] & (tcp-syn) != 0"
|
||||
self._sub_queue = self._capture_bus.subscribe(
|
||||
name=self.name, bpf_filter=bpf, queue_depth=8000
|
||||
)
|
||||
|
||||
# Second subscription for application-layer fingerprints
|
||||
# We use a broader capture for HTTP/SMB/SSH — but since capture_bus
|
||||
# only supports one subscription per module name, we parse app-layer
|
||||
# from the same stream by also checking non-SYN packets that match
|
||||
# We subscribe with an empty filter to get everything and filter in code
|
||||
# Actually, let's keep SYN filter and add a second subscriber
|
||||
self._capture_bus.unsubscribe(self.name)
|
||||
self._sub_queue = self._capture_bus.subscribe(
|
||||
name=self.name,
|
||||
bpf_filter="tcp", # All TCP — we filter SYN and app-layer in code
|
||||
queue_depth=10000,
|
||||
)
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
self._reader_thread = threading.Thread(
|
||||
target=self._read_packets, daemon=True, name="sensor-osfp-reader"
|
||||
)
|
||||
self._reader_thread.start()
|
||||
|
||||
self._flusher_thread = threading.Thread(
|
||||
target=self._flush_loop, daemon=True, name="sensor-osfp-flusher"
|
||||
)
|
||||
self._flusher_thread.start()
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=os.getpid())
|
||||
logger.info("OSFingerprint started — passive TCP/protocol analysis")
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
|
||||
if self._capture_bus:
|
||||
self._capture_bus.unsubscribe(self.name)
|
||||
|
||||
self._flush_buffer()
|
||||
if self._db_conn:
|
||||
self._db_conn.close()
|
||||
self._db_conn = None
|
||||
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("OSFingerprint stopped — %d fingerprints collected", self._total_fingerprints)
|
||||
|
||||
def status(self) -> dict:
|
||||
with self._scores_lock:
|
||||
unique_hosts = len(self._ip_os_scores)
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"total_fingerprints": self._total_fingerprints,
|
||||
"unique_hosts": unique_hosts,
|
||||
"buffer_size": len(self._buffer),
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Database
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _init_db(self) -> None:
|
||||
self._db_conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._db_conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._db_conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._db_conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS os_fingerprints (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
signature TEXT,
|
||||
os_family TEXT,
|
||||
os_version TEXT,
|
||||
confidence REAL,
|
||||
timestamp REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_osfp_ip ON os_fingerprints(ip);
|
||||
CREATE INDEX IF NOT EXISTS idx_osfp_family ON os_fingerprints(os_family);
|
||||
""")
|
||||
self._db_conn.commit()
|
||||
|
||||
def _load_os_sigs(self, path: str) -> None:
|
||||
"""Load OS signature database from SQLite."""
|
||||
try:
|
||||
conn = sqlite3.connect(path)
|
||||
rows = conn.execute(
|
||||
"SELECT ttl, window, df, mss, sack, timestamps, os_family, os_version "
|
||||
"FROM signatures"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
self._os_sigs.append({
|
||||
"ttl": (row[0], row[0]),
|
||||
"window": (row[1], row[1]),
|
||||
"df": bool(row[2]),
|
||||
"mss": (row[3], row[3]),
|
||||
"sack": bool(row[4]),
|
||||
"ts": bool(row[5]),
|
||||
"os_family": row[6],
|
||||
"os_version": row[7] or "",
|
||||
})
|
||||
conn.close()
|
||||
logger.info("Loaded %d OS signatures from %s", len(self._os_sigs), path)
|
||||
except Exception:
|
||||
logger.warning("Failed to load OS signatures from %s", path)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Packet reader
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _read_packets(self) -> None:
|
||||
while self._running:
|
||||
result = self._sub_queue.get(timeout=1.0)
|
||||
if result is None:
|
||||
continue
|
||||
ts, raw_packet = result
|
||||
try:
|
||||
self._parse_packet(ts, raw_packet)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _parse_packet(self, ts: float, raw: bytes) -> None:
|
||||
if len(raw) < 14:
|
||||
return
|
||||
|
||||
eth_type = struct.unpack("!H", raw[12:14])[0]
|
||||
ip_offset = 14
|
||||
if eth_type == 0x8100:
|
||||
if len(raw) < 18:
|
||||
return
|
||||
eth_type = struct.unpack("!H", raw[16:18])[0]
|
||||
ip_offset = 18
|
||||
|
||||
if eth_type != 0x0800:
|
||||
return
|
||||
|
||||
if len(raw) < ip_offset + 20:
|
||||
return
|
||||
|
||||
ip_hdr = raw[ip_offset:]
|
||||
ihl = (ip_hdr[0] & 0x0F) * 4
|
||||
ip_proto = ip_hdr[9]
|
||||
if ip_proto != 6:
|
||||
return
|
||||
|
||||
src_ip = socket.inet_ntoa(ip_hdr[12:16])
|
||||
dst_ip = socket.inet_ntoa(ip_hdr[16:20])
|
||||
ip_ttl = ip_hdr[8]
|
||||
ip_flags = struct.unpack("!H", ip_hdr[6:8])[0]
|
||||
df_flag = bool(ip_flags & 0x4000)
|
||||
|
||||
tcp_offset = ip_offset + ihl
|
||||
if len(raw) < tcp_offset + 20:
|
||||
return
|
||||
|
||||
tcp_hdr = raw[tcp_offset:]
|
||||
src_port, dst_port = struct.unpack("!HH", tcp_hdr[:4])
|
||||
tcp_flags = tcp_hdr[13]
|
||||
window = struct.unpack("!H", tcp_hdr[14:16])[0]
|
||||
tcp_hdr_len = ((tcp_hdr[12] >> 4) & 0xF) * 4
|
||||
|
||||
syn = bool(tcp_flags & 0x02)
|
||||
ack = bool(tcp_flags & 0x10)
|
||||
|
||||
# SYN or SYN-ACK — do TCP fingerprinting
|
||||
if syn:
|
||||
self._fingerprint_tcp_syn(
|
||||
ts, src_ip, ip_ttl, df_flag, window,
|
||||
tcp_hdr[:tcp_hdr_len], syn_ack=ack,
|
||||
)
|
||||
|
||||
# Application-layer fingerprinting for non-SYN packets with payload
|
||||
if not syn:
|
||||
payload = raw[tcp_offset + tcp_hdr_len:]
|
||||
if payload:
|
||||
if src_port == 80 or dst_port == 80 or src_port == 8080 or dst_port == 8080:
|
||||
self._fingerprint_http(ts, src_ip, dst_ip, src_port, payload)
|
||||
elif src_port == 22 or dst_port == 22:
|
||||
self._fingerprint_ssh(ts, src_ip, src_port, payload)
|
||||
elif src_port == 445 or dst_port == 445:
|
||||
self._fingerprint_smb(ts, src_ip, src_port, payload)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# TCP SYN fingerprinting (p0f-style)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _fingerprint_tcp_syn(self, ts: float, ip: str, ttl: int,
|
||||
df: bool, window: int, tcp_header: bytes,
|
||||
syn_ack: bool = False) -> None:
|
||||
"""Analyze TCP SYN/SYN-ACK options for OS fingerprinting."""
|
||||
# Parse TCP options
|
||||
mss = 0
|
||||
has_sack = False
|
||||
has_timestamps = False
|
||||
wscale = 0
|
||||
|
||||
if len(tcp_header) > 20:
|
||||
opt_offset = 20
|
||||
while opt_offset < len(tcp_header):
|
||||
opt_kind = tcp_header[opt_offset]
|
||||
if opt_kind == 0: # End of options
|
||||
break
|
||||
if opt_kind == 1: # NOP
|
||||
opt_offset += 1
|
||||
continue
|
||||
if opt_offset + 1 >= len(tcp_header):
|
||||
break
|
||||
opt_len = tcp_header[opt_offset + 1]
|
||||
if opt_len < 2 or opt_offset + opt_len > len(tcp_header):
|
||||
break
|
||||
|
||||
if opt_kind == 2 and opt_len == 4: # MSS
|
||||
mss = struct.unpack("!H", tcp_header[opt_offset + 2:opt_offset + 4])[0]
|
||||
elif opt_kind == 3 and opt_len == 3: # Window Scale
|
||||
wscale = tcp_header[opt_offset + 2]
|
||||
elif opt_kind == 4: # SACK Permitted
|
||||
has_sack = True
|
||||
elif opt_kind == 8: # Timestamps
|
||||
has_timestamps = True
|
||||
|
||||
opt_offset += opt_len
|
||||
|
||||
# Normalize TTL to nearest power-of-2 boundary
|
||||
initial_ttl = self._normalize_ttl(ttl)
|
||||
|
||||
# Build signature string
|
||||
sig = f"ttl:{initial_ttl}:win:{window}:mss:{mss}:df:{int(df)}:sack:{int(has_sack)}:ts:{int(has_timestamps)}:wscale:{wscale}"
|
||||
|
||||
# Match against signature database
|
||||
os_family, os_version, confidence = self._match_tcp_signature(
|
||||
initial_ttl, window, df, mss, has_sack, has_timestamps,
|
||||
)
|
||||
|
||||
if os_family:
|
||||
method = "tcp_syn_ack" if syn_ack else "tcp_syn"
|
||||
self._record_fingerprint(ts, ip, method, sig, os_family, os_version, confidence)
|
||||
|
||||
def _match_tcp_signature(self, ttl: int, window: int, df: bool,
|
||||
mss: int, sack: bool, timestamps: bool) -> tuple:
|
||||
"""Match TCP parameters against signature database."""
|
||||
best_match = ("", "", 0.0)
|
||||
best_score = 0
|
||||
|
||||
sig_sources = self._os_sigs if self._os_sigs else TCP_SIG_DB
|
||||
|
||||
for sig in sig_sources:
|
||||
score = 0
|
||||
total = 6
|
||||
|
||||
ttl_lo, ttl_hi = sig["ttl"]
|
||||
if ttl_lo <= ttl <= ttl_hi:
|
||||
score += 2 # TTL is weighted higher
|
||||
|
||||
win_lo, win_hi = sig["window"]
|
||||
if win_lo <= window <= win_hi:
|
||||
score += 1
|
||||
|
||||
if sig["df"] == df:
|
||||
score += 1
|
||||
|
||||
mss_lo, mss_hi = sig["mss"]
|
||||
if mss_lo <= mss <= mss_hi:
|
||||
score += 1
|
||||
|
||||
if sig["sack"] == sack:
|
||||
score += 0.5
|
||||
|
||||
if sig["ts"] == timestamps:
|
||||
score += 0.5
|
||||
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
confidence = score / total
|
||||
best_match = (sig["os_family"], sig["os_version"], confidence)
|
||||
|
||||
return best_match
|
||||
|
||||
@staticmethod
|
||||
def _normalize_ttl(ttl: int) -> int:
|
||||
"""Estimate initial TTL from observed TTL."""
|
||||
if ttl <= 32:
|
||||
return 32
|
||||
elif ttl <= 64:
|
||||
return 64
|
||||
elif ttl <= 128:
|
||||
return 128
|
||||
else:
|
||||
return 255
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Application-layer fingerprinting
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _fingerprint_http(self, ts: float, src_ip: str, dst_ip: str,
|
||||
src_port: int, payload: bytes) -> None:
|
||||
"""Extract OS info from HTTP User-Agent headers."""
|
||||
try:
|
||||
text = payload[:4096].decode("utf-8", errors="ignore")
|
||||
except Exception:
|
||||
return
|
||||
|
||||
ua_match = re.search(r"User-Agent:\s*(.+?)(?:\r\n|\n)", text, re.IGNORECASE)
|
||||
if not ua_match:
|
||||
return
|
||||
|
||||
ua = ua_match.group(1).strip()
|
||||
# The User-Agent is from the client (request sender)
|
||||
# If src_port is ephemeral (>1024), this is a client
|
||||
if src_port > 1024:
|
||||
fp_ip = src_ip
|
||||
else:
|
||||
fp_ip = dst_ip
|
||||
|
||||
for pattern, os_family, os_version in UA_PATTERNS:
|
||||
m = pattern.search(ua)
|
||||
if m:
|
||||
version = os_version
|
||||
if not version and m.lastindex:
|
||||
version = m.group(1).replace("_", ".")
|
||||
self._record_fingerprint(
|
||||
ts, fp_ip, "http_ua", ua[:200],
|
||||
os_family, version, 0.7,
|
||||
)
|
||||
break
|
||||
|
||||
def _fingerprint_ssh(self, ts: float, ip: str, src_port: int,
|
||||
payload: bytes) -> None:
|
||||
"""Extract OS info from SSH version string."""
|
||||
try:
|
||||
text = payload[:256].decode("ascii", errors="ignore")
|
||||
except Exception:
|
||||
return
|
||||
|
||||
if not text.startswith("SSH-"):
|
||||
return
|
||||
|
||||
# SSH version string: SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.1
|
||||
version_str = text.strip()
|
||||
# The SSH server sends its banner — src_port should be 22
|
||||
fp_ip = ip if src_port == 22 else ip
|
||||
|
||||
os_family = ""
|
||||
os_version = ""
|
||||
|
||||
if "Ubuntu" in version_str:
|
||||
os_family = "Linux"
|
||||
os_version = "Ubuntu"
|
||||
elif "Debian" in version_str:
|
||||
os_family = "Linux"
|
||||
os_version = "Debian"
|
||||
elif "FreeBSD" in version_str:
|
||||
os_family = "FreeBSD"
|
||||
elif "OpenSSH" in version_str:
|
||||
# Generic OpenSSH — likely Linux or BSD
|
||||
os_family = "Linux/BSD"
|
||||
|
||||
if os_family:
|
||||
self._record_fingerprint(
|
||||
ts, fp_ip, "ssh_banner", version_str[:200],
|
||||
os_family, os_version, 0.8,
|
||||
)
|
||||
|
||||
def _fingerprint_smb(self, ts: float, ip: str, src_port: int,
|
||||
payload: bytes) -> None:
|
||||
"""Extract OS info from SMB negotiate response."""
|
||||
# SMB2 header: 0xFE 'S' 'M' 'B'
|
||||
if len(payload) < 68:
|
||||
return
|
||||
|
||||
# Look for SMB2 header
|
||||
smb2_offset = payload.find(b"\xfeSMB")
|
||||
if smb2_offset < 0:
|
||||
# Try SMB1
|
||||
smb1_offset = payload.find(b"\xffSMB")
|
||||
if smb1_offset >= 0 and src_port == 445:
|
||||
self._record_fingerprint(
|
||||
ts, ip, "smb_dialect", "SMB1",
|
||||
"Windows", "XP/2003 or Samba", 0.5,
|
||||
)
|
||||
return
|
||||
|
||||
# SMB2 negotiate response from server (src_port 445)
|
||||
if src_port != 445:
|
||||
return
|
||||
|
||||
smb2_data = payload[smb2_offset:]
|
||||
if len(smb2_data) < 68:
|
||||
return
|
||||
|
||||
# SMB2 header is 64 bytes, then negotiate response
|
||||
# Dialect at offset 4-5 of negotiate response (after 64-byte header)
|
||||
neg_response = smb2_data[64:]
|
||||
if len(neg_response) < 6:
|
||||
return
|
||||
|
||||
# struct_size(2) + security_mode(2) + dialect_revision(2)
|
||||
dialect = struct.unpack("<H", neg_response[4:6])[0]
|
||||
|
||||
dialect_map = {
|
||||
0x0202: ("Windows", "Vista/2008 or Samba"),
|
||||
0x0210: ("Windows", "7/2008R2"),
|
||||
0x0300: ("Windows", "8/2012"),
|
||||
0x0302: ("Windows", "8.1/2012R2"),
|
||||
0x0311: ("Windows", "10/2016+"),
|
||||
}
|
||||
|
||||
if dialect in dialect_map:
|
||||
os_family, os_version = dialect_map[dialect]
|
||||
self._record_fingerprint(
|
||||
ts, ip, "smb_dialect", f"SMB{dialect:#06x}",
|
||||
os_family, os_version, 0.75,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Record management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _record_fingerprint(self, ts: float, ip: str, method: str,
|
||||
signature: str, os_family: str,
|
||||
os_version: str, confidence: float) -> None:
|
||||
"""Record an OS fingerprint observation."""
|
||||
self._total_fingerprints += 1
|
||||
|
||||
# Update per-IP confidence scoring
|
||||
with self._scores_lock:
|
||||
if ip not in self._ip_os_scores:
|
||||
self._ip_os_scores[ip] = {}
|
||||
scores = self._ip_os_scores[ip]
|
||||
if os_family not in scores:
|
||||
scores[os_family] = {}
|
||||
scores[os_family][method] = confidence
|
||||
|
||||
record = (ip, method, signature[:500], os_family, os_version, confidence, ts)
|
||||
with self._buffer_lock:
|
||||
self._buffer.append(record)
|
||||
if len(self._buffer) >= self.BATCH_SIZE:
|
||||
self._flush_buffer()
|
||||
|
||||
def get_best_guess(self, ip: str) -> dict:
|
||||
"""Get the highest-confidence OS guess for an IP."""
|
||||
with self._scores_lock:
|
||||
scores = self._ip_os_scores.get(ip, {})
|
||||
|
||||
if not scores:
|
||||
return {"os_family": "", "confidence": 0.0}
|
||||
|
||||
# Aggregate confidence per OS family
|
||||
best_family = ""
|
||||
best_conf = 0.0
|
||||
for os_family, methods in scores.items():
|
||||
# Average confidence across methods, boosted by method count
|
||||
avg_conf = sum(methods.values()) / len(methods)
|
||||
method_bonus = min(len(methods) * 0.1, 0.3) # Up to 30% bonus
|
||||
total_conf = min(avg_conf + method_bonus, 1.0)
|
||||
if total_conf > best_conf:
|
||||
best_conf = total_conf
|
||||
best_family = os_family
|
||||
|
||||
return {"os_family": best_family, "confidence": round(best_conf, 2)}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Buffer flush
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _flush_loop(self) -> None:
|
||||
while self._running:
|
||||
time.sleep(self.FLUSH_INTERVAL)
|
||||
try:
|
||||
self._flush_buffer()
|
||||
except Exception:
|
||||
logger.exception("OS fingerprint flush error")
|
||||
|
||||
def _flush_buffer(self) -> None:
|
||||
with self._buffer_lock:
|
||||
batch = list(self._buffer)
|
||||
self._buffer.clear()
|
||||
|
||||
if not batch or not self._db_conn:
|
||||
return
|
||||
|
||||
try:
|
||||
self._db_conn.executemany(
|
||||
"INSERT INTO os_fingerprints "
|
||||
"(ip, method, signature, os_family, os_version, confidence, timestamp) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
batch,
|
||||
)
|
||||
self._db_conn.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to flush %d OS fingerprints", len(batch))
|
||||
Reference in New Issue
Block a user