Files
Bjorn/actions/scanning.py

709 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# scanning.py Network scanner (DB-first, no stubs)
# - Host discovery (nmap -sn -PR)
# - Resolve MAC/hostname (per-host threads) -> DB (hosts table)
# - Port scan (multi-threads) -> DB (merge ports by MAC)
# - Mark alive=0 for hosts not seen this run
# - Update stats (stats table)
# - Light logging (milestones) without flooding
# - WAL checkpoint(TRUNCATE) + PRAGMA optimize at end of scan
# - NEW: No DB insert without a real MAC. Unresolved IPs are kept in-memory for this run.
import os
import threading
import socket
import time
import logging
import subprocess
from datetime import datetime
import netifaces
from getmac import get_mac_address as gma
import ipaddress
import nmap
from logger import Logger
logger = Logger(name="scanning.py", level=logging.DEBUG)
b_class = "NetworkScanner"
b_module = "scanning"
b_status = "NetworkScanner"
b_port = None
b_parent = None
b_priority = 1
b_action = "global"
b_trigger = "on_interval:180"
b_requires = '{"max_concurrent": 1}'
class NetworkScanner:
"""
Network scanner that populates SQLite (hosts + stats). No CSV/JSON.
Keeps the original fast logic: nmap discovery, per-host threads, per-port threads.
NEW: no 'IP:<ip>' stubs are ever written to the DB; unresolved IPs are tracked in-memory.
"""
def __init__(self, shared_data):
self.shared_data = shared_data
self.logger = logger
self.blacklistcheck = shared_data.blacklistcheck
self.mac_scan_blacklist = set(shared_data.mac_scan_blacklist or [])
self.ip_scan_blacklist = set(shared_data.ip_scan_blacklist or [])
self.hostname_scan_blacklist = set(shared_data.hostname_scan_blacklist or [])
self.lock = threading.Lock()
self.nm = nmap.PortScanner()
self.running = False
self.scan_interface = None
# progress
self.total_hosts = 0
self.scanned_hosts = 0
self.total_ports = 0
self.scanned_ports = 0
# ---------- progress ----------
def update_progress(self, phase, increment=1):
with self.lock:
if phase == 'host':
self.scanned_hosts += increment
host_part = (self.scanned_hosts / self.total_hosts) * 50 if self.total_hosts else 0
total = host_part
elif phase == 'port':
self.scanned_ports += increment
port_part = (self.scanned_ports / self.total_ports) * 50 if self.total_ports else 0
total = 50 + port_part
else:
total = 0
total = min(max(total, 0), 100)
self.shared_data.bjorn_progress = f"{int(total)}%"
# ---------- network ----------
def get_network(self):
if self.shared_data.orchestrator_should_exit:
return None
try:
if self.shared_data.use_custom_network:
net = ipaddress.ip_network(self.shared_data.custom_network, strict=False)
self.logger.info(f"Using custom network: {net}")
return net
interface = self.shared_data.default_network_interface
if interface.startswith('bnep'):
for alt in ['wlan0', 'eth0']:
if alt in netifaces.interfaces():
interface = alt
self.logger.info(f"Switching from bnep* to {interface}")
break
addrs = netifaces.ifaddresses(interface)
ip_info = addrs.get(netifaces.AF_INET)
if not ip_info:
self.logger.error(f"No IPv4 address found for interface {interface}.")
return None
ip_address = ip_info[0]['addr']
netmask = ip_info[0]['netmask']
network = ipaddress.IPv4Network(f"{ip_address}/{netmask}", strict=False)
self.scan_interface = interface
self.logger.info(f"Using network: {network} via {interface}")
return network
except Exception as e:
self.logger.error(f"Error in get_network: {e}")
return None
# ---------- vendor / essid ----------
def load_mac_vendor_map(self):
vendor_map = {}
path = self.shared_data.nmap_prefixes_file
if not path or not os.path.exists(path):
self.logger.debug(f"nmap_prefixes not found at {path}")
return vendor_map
try:
with open(path, 'r') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
parts = line.split(None, 1)
if len(parts) == 2:
pref, vend = parts
vendor_map[pref.strip().upper()] = vend.strip()
except Exception as e:
self.logger.error(f"load_mac_vendor_map error: {e}")
return vendor_map
def mac_to_vendor(self, mac, vendor_map):
if not mac or len(mac.split(':')) < 3:
return ""
pref = ''.join(mac.split(':')[:3]).upper()
return vendor_map.get(pref, "")
def get_current_essid(self):
try:
essid = subprocess.check_output(['iwgetid', '-r'], stderr=subprocess.STDOUT, universal_newlines=True).strip()
return essid or ""
except Exception:
return ""
# ---------- hostname / mac ----------
def validate_hostname(self, ip, hostname):
if not hostname:
return ""
try:
infos = socket.getaddrinfo(hostname, None, family=socket.AF_INET)
ips = {ai[4][0] for ai in infos}
return hostname if ip in ips else ""
except Exception:
return ""
def get_mac_address(self, ip, hostname):
"""
Try multiple strategies to resolve a real MAC for the given IP.
RETURNS: normalized MAC like 'aa:bb:cc:dd:ee:ff' or None.
NEVER returns 'IP:<ip>'.
"""
if self.shared_data.orchestrator_should_exit:
return None
import re
MAC_RE = re.compile(r'([0-9A-Fa-f]{2})([-:])(?:[0-9A-Fa-f]{2}\2){4}[0-9A-Fa-f]{2}')
BAD_MACS = {"00:00:00:00:00:00", "ff:ff:ff:ff:ff:ff"}
def _normalize_mac(s: str | None) -> str | None:
if not s:
return None
m = MAC_RE.search(s)
if not m:
return None
return m.group(0).replace('-', ':').lower()
def _is_bad_mac(mac: str | None) -> bool:
if not mac:
return True
mac_l = mac.lower()
if mac_l in BAD_MACS:
return True
parts = mac_l.split(':')
if len(parts) == 6 and len(set(parts)) == 1:
return True
return False
try:
mac = None
# 1) getmac (retry a few times)
retries = 6
while not mac and retries > 0 and not self.shared_data.orchestrator_should_exit:
try:
from getmac import get_mac_address as gma
mac = _normalize_mac(gma(ip=ip))
except Exception:
mac = None
if not mac:
time.sleep(1.5)
retries -= 1
# 2) targeted arp-scan
if not mac:
try:
iface = self.scan_interface or self.shared_data.default_network_interface or "wlan0"
out = subprocess.check_output(
['sudo', 'arp-scan', '--interface', iface, '-q', ip],
universal_newlines=True, stderr=subprocess.STDOUT
)
for line in out.splitlines():
if line.strip().startswith(ip):
cand = _normalize_mac(line)
if cand:
mac = cand
break
if not mac:
cand = _normalize_mac(out)
if cand:
mac = cand
except Exception as e:
self.logger.debug(f"arp-scan fallback failed for {ip}: {e}")
# 3) ip neigh
if not mac:
try:
neigh = subprocess.check_output(['ip', 'neigh', 'show', ip],
universal_newlines=True, stderr=subprocess.STDOUT)
cand = _normalize_mac(neigh)
if cand:
mac = cand
except Exception:
pass
# 4) filter invalid/broadcast
if _is_bad_mac(mac):
mac = None
return mac
except Exception as e:
self.logger.error(f"Error in get_mac_address: {e}")
return None
# ---------- port scanning ----------
class PortScannerWorker:
def __init__(self, outer, target, open_ports, portstart, portend, extra_ports):
self.outer = outer
self.target = target
self.open_ports = open_ports
self.portstart = int(portstart)
self.portend = int(portend)
self.extra_ports = [int(p) for p in (extra_ports or [])]
def scan_one(self, port):
if self.outer.shared_data.orchestrator_should_exit:
return
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(2)
try:
s.connect((self.target, port))
with self.outer.lock:
self.open_ports.setdefault(self.target, []).append(port)
except Exception:
pass
finally:
try:
s.close()
except Exception:
pass
self.outer.update_progress('port', 1)
def run(self):
if self.outer.shared_data.orchestrator_should_exit:
return
threads = []
for port in range(self.portstart, self.portend):
if self.outer.shared_data.orchestrator_should_exit:
break
t = threading.Thread(target=self.scan_one, args=(port,))
t.start()
threads.append(t)
for port in self.extra_ports:
if self.outer.shared_data.orchestrator_should_exit:
break
t = threading.Thread(target=self.scan_one, args=(port,))
t.start()
threads.append(t)
for t in threads:
if self.outer.shared_data.orchestrator_should_exit:
break
t.join()
# ---------- main scan block ----------
class ScanPorts:
class IpData:
def __init__(self):
self.ip_list = []
self.hostname_list = []
self.mac_list = []
def __init__(self, outer, network, portstart, portend, extra_ports):
self.outer = outer
self.network = network
self.portstart = int(portstart)
self.portend = int(portend)
self.extra_ports = [int(p) for p in (extra_ports or [])]
self.ip_data = self.IpData()
self.ip_hostname_list = [] # tuples (ip, hostname, mac)
self.host_threads = []
self.open_ports = {}
self.all_ports = []
# NEW: per-run pending cache for unresolved IPs (no DB writes)
# ip -> {'hostnames': set(), 'ports': set(), 'first_seen': ts, 'essid': str}
self.pending = {}
def scan_network_and_collect(self):
if self.outer.shared_data.orchestrator_should_exit:
return
t0 = time.time()
self.outer.nm.scan(hosts=str(self.network), arguments='-sn -PR')
hosts = list(self.outer.nm.all_hosts())
if self.outer.blacklistcheck:
hosts = [ip for ip in hosts if ip not in self.outer.ip_scan_blacklist]
self.outer.total_hosts = len(hosts)
self.outer.scanned_hosts = 0
self.outer.update_progress('host', 0)
self.outer.logger.info(f"Host discovery: {len(hosts)} candidate(s) (took {time.time()-t0:.1f}s)")
# existing hosts (for quick merge)
existing_rows = self.outer.shared_data.db.get_all_hosts()
self.existing_map = {h['mac_address']: h for h in existing_rows}
self.seen_now = set()
# vendor/essid
self.vendor_map = self.outer.load_mac_vendor_map()
self.essid = self.outer.get_current_essid()
# per-host threads
for host in hosts:
if self.outer.shared_data.orchestrator_should_exit:
return
t = threading.Thread(target=self.scan_host, args=(host,))
t.start()
self.host_threads.append(t)
# wait
for t in self.host_threads:
if self.outer.shared_data.orchestrator_should_exit:
return
t.join()
self.outer.logger.info(
f"Host mapping completed: {self.outer.scanned_hosts}/{self.outer.total_hosts} processed, "
f"{len(self.ip_hostname_list)} MAC(s) found, {len(self.pending)} unresolved IP(s)"
)
# mark unseen as alive=0
existing_macs = set(self.existing_map.keys())
for mac in existing_macs - self.seen_now:
self.outer.shared_data.db.update_host(mac_address=mac, alive=0)
# feed ip_data
for ip, hostname, mac in self.ip_hostname_list:
self.ip_data.ip_list.append(ip)
self.ip_data.hostname_list.append(hostname)
self.ip_data.mac_list.append(mac)
def scan_host(self, ip):
if self.outer.shared_data.orchestrator_should_exit:
return
if self.outer.blacklistcheck and ip in self.outer.ip_scan_blacklist:
return
try:
# ARP ping to help populate neighbor cache
os.system(f"arping -c 2 -w 2 {ip} > /dev/null 2>&1")
# Hostname (validated)
hostname = ""
try:
hostname = self.outer.nm[ip].hostname()
except Exception:
pass
hostname = self.outer.validate_hostname(ip, hostname)
if self.outer.blacklistcheck and hostname and hostname in self.outer.hostname_scan_blacklist:
self.outer.update_progress('host', 1)
return
time.sleep(1.0) # let ARP breathe
mac = self.outer.get_mac_address(ip, hostname)
if mac:
mac = mac.lower()
if self.outer.blacklistcheck and mac in self.outer.mac_scan_blacklist:
self.outer.update_progress('host', 1)
return
if not mac:
# No MAC -> keep it in-memory only (no DB writes)
slot = self.pending.setdefault(
ip,
{'hostnames': set(), 'ports': set(), 'first_seen': int(time.time()), 'essid': self.essid}
)
if hostname:
slot['hostnames'].add(hostname)
self.outer.logger.debug(f"Pending (no MAC yet): {ip} hostname={hostname or '-'}")
else:
# MAC found -> write/update in DB
self.seen_now.add(mac)
vendor = self.outer.mac_to_vendor(mac, self.vendor_map)
prev = self.existing_map.get(mac)
ips_set, hosts_set, ports_set = set(), set(), set()
if prev:
if prev.get('ips'):
ips_set.update(p for p in prev['ips'].split(';') if p)
if prev.get('hostnames'):
hosts_set.update(h for h in prev['hostnames'].split(';') if h)
if prev.get('ports'):
ports_set.update(p for p in prev['ports'].split(';') if p)
if ip:
ips_set.add(ip)
# Update current hostname + track history
current_hn = ""
if hostname:
self.outer.shared_data.db.update_hostname(mac, hostname)
current_hn = hostname
else:
current_hn = (prev.get('hostnames') or "").split(';', 1)[0] if prev else ""
ips_sorted = ';'.join(sorted(
ips_set,
key=lambda x: tuple(map(int, x.split('.'))) if x.count('.') == 3 else (0, 0, 0, 0)
)) if ips_set else None
self.outer.shared_data.db.update_host(
mac_address=mac,
ips=ips_sorted,
hostnames=None,
alive=1,
ports=None,
vendor=vendor or (prev.get('vendor') if prev else ""),
essid=self.essid or (prev.get('essid') if prev else None)
)
# refresh local cache
self.existing_map[mac] = dict(
mac_address=mac,
ips=ips_sorted or (prev.get('ips') if prev else ""),
hostnames=current_hn or (prev.get('hostnames') if prev else ""),
alive=1,
ports=';'.join(sorted(ports_set)) if ports_set else (prev.get('ports') if prev else ""),
vendor=vendor or (prev.get('vendor') if prev else ""),
essid=self.essid or (prev.get('essid') if prev else "")
)
with self.outer.lock:
self.ip_hostname_list.append((ip, hostname or "", mac))
self.outer.logger.debug(f"MAC for {ip}: {mac} (hostname: {hostname or '-'})")
except Exception as e:
self.outer.logger.error(f"Error scanning host {ip}: {e}")
finally:
self.outer.update_progress('host', 1)
time.sleep(0.05)
def start(self):
if self.outer.shared_data.orchestrator_should_exit:
return
self.scan_network_and_collect()
if self.outer.shared_data.orchestrator_should_exit:
return
# init structures for ports
self.open_ports = {ip: [] for ip in self.ip_data.ip_list}
# port-scan summary
total_targets = len(self.ip_data.ip_list)
range_size = max(0, self.portend - self.portstart)
self.outer.total_ports = total_targets * (range_size + len(self.extra_ports))
self.outer.scanned_ports = 0
self.outer.update_progress('port', 0)
self.outer.logger.info(
f"Port scan: {total_targets} host(s), range {self.portstart}-{self.portend-1} "
f"(+{len(self.extra_ports)} extra)"
)
# per-IP port scan (threads per port, original logic)
for idx, ip in enumerate(self.ip_data.ip_list, 1):
if self.outer.shared_data.orchestrator_should_exit:
return
worker = self.outer.PortScannerWorker(self.outer, ip, self.open_ports, self.portstart, self.portend, self.extra_ports)
worker.run()
if idx % 10 == 0 or idx == total_targets:
found = sum(len(v) for v in self.open_ports.values())
self.outer.logger.info(
f"Port scan progress: {idx}/{total_targets} hosts, {found} open ports so far"
)
# unique list of open ports
self.all_ports = sorted(list({p for plist in self.open_ports.values() for p in plist}))
alive_macs = set(self.ip_data.mac_list)
total_open = sum(len(v) for v in self.open_ports.values())
self.outer.logger.info(f"Port scan done: {total_open} open ports across {total_targets} host(s)")
return self.ip_data, self.open_ports, self.all_ports, alive_macs
# ---------- orchestration ----------
def scan(self):
self.shared_data.orchestrator_should_exit = False
try:
if self.shared_data.orchestrator_should_exit:
self.logger.info("Orchestrator switched to manual mode. Stopping scanner.")
return
self.shared_data.bjorn_orch_status = "NetworkScanner"
self.logger.info("Starting Network Scanner")
# network
network = self.get_network() if not self.shared_data.use_custom_network \
else ipaddress.ip_network(self.shared_data.custom_network, strict=False)
if network is None:
self.logger.error("No network available. Aborting scan.")
return
self.shared_data.bjorn_status_text2 = str(network)
portstart = int(self.shared_data.portstart)
portend = int(self.shared_data.portend)
extra_ports = self.shared_data.portlist
scanner = self.ScanPorts(self, network, portstart, portend, extra_ports)
result = scanner.start()
if result is None:
self.logger.info("Scan interrupted (manual mode).")
return
ip_data, open_ports_by_ip, all_ports, alive_macs = result
if self.shared_data.orchestrator_should_exit:
self.logger.info("Scan canceled before DB finalization.")
return
# push ports -> DB (merge by MAC). Only for IPs with known MAC.
# map ip->mac
ip_to_mac = {ip: mac for ip, _, mac in zip(ip_data.ip_list, ip_data.hostname_list, ip_data.mac_list)}
# existing cache
existing_map = {h['mac_address']: h for h in self.shared_data.db.get_all_hosts()}
for ip, ports in open_ports_by_ip.items():
mac = ip_to_mac.get(ip)
if not mac:
# store to pending (no DB write)
slot = scanner.pending.setdefault(
ip,
{'hostnames': set(), 'ports': set(), 'first_seen': int(time.time()), 'essid': scanner.essid}
)
slot['ports'].update(ports or [])
continue
prev = existing_map.get(mac)
ports_set = set()
if prev and prev.get('ports'):
try:
ports_set.update([p for p in prev['ports'].split(';') if p])
except Exception:
pass
ports_set.update(str(p) for p in (ports or []))
self.shared_data.db.update_host(
mac_address=mac,
ports=';'.join(sorted(ports_set, key=lambda x: int(x))),
alive=1
)
# Late resolution pass: try to resolve pending IPs before stats
unresolved_before = len(scanner.pending)
for ip, data in list(scanner.pending.items()):
if self.shared_data.orchestrator_should_exit:
break
try:
guess_hostname = next(iter(data['hostnames']), "")
except Exception:
guess_hostname = ""
mac = self.get_mac_address(ip, guess_hostname)
if not mac:
continue # still unresolved for this run
mac = mac.lower()
vendor = self.mac_to_vendor(mac, scanner.vendor_map)
# create/update host now
self.shared_data.db.update_host(
mac_address=mac,
ips=ip,
hostnames=';'.join(data['hostnames']) or None,
vendor=vendor,
essid=data.get('essid'),
alive=1
)
if data['ports']:
self.shared_data.db.update_host(
mac_address=mac,
ports=';'.join(str(p) for p in sorted(data['ports'], key=int)),
alive=1
)
del scanner.pending[ip]
if scanner.pending:
self.logger.info(
f"Unresolved IPs (kept in-memory only this run): {len(scanner.pending)} "
f"(resolved during late pass: {unresolved_before - len(scanner.pending)})"
)
# stats (alive, total ports, distinct vulnerabilities on alive)
rows = self.shared_data.db.get_all_hosts()
alive_hosts = [r for r in rows if int(r.get('alive') or 0) == 1]
all_known = len(rows)
total_open_ports = 0
for r in alive_hosts:
ports_txt = r.get('ports') or ""
if ports_txt:
try:
total_open_ports += len([p for p in ports_txt.split(';') if p])
except Exception:
pass
try:
vulnerabilities_count = self.shared_data.db.count_distinct_vulnerabilities(alive_only=True)
except Exception:
vulnerabilities_count = 0
self.shared_data.db.set_stats(
total_open_ports=total_open_ports,
alive_hosts_count=len(alive_hosts),
all_known_hosts_count=all_known,
vulnerabilities_count=int(vulnerabilities_count)
)
# WAL checkpoint + optimize
try:
if hasattr(self.shared_data, "db") and hasattr(self.shared_data.db, "execute"):
self.shared_data.db.execute("PRAGMA wal_checkpoint(TRUNCATE);")
self.shared_data.db.execute("PRAGMA optimize;")
self.logger.debug("WAL checkpoint TRUNCATE + PRAGMA optimize executed.")
except Exception as e:
self.logger.debug(f"Checkpoint/optimize skipped or failed: {e}")
self.shared_data.bjorn_progress = ""
self.logger.info("Network scan complete (DB updated).")
except Exception as e:
if self.shared_data.orchestrator_should_exit:
self.logger.info("Orchestrator switched to manual mode. Gracefully stopping the network scanner.")
else:
self.logger.error(f"Error in scan: {e}")
finally:
with self.lock:
self.shared_data.bjorn_progress = ""
# ---------- thread wrapper ----------
def start(self):
if not self.running:
self.running = True
self.thread = threading.Thread(target=self.scan_wrapper, daemon=True)
self.thread.start()
logger.info("NetworkScanner started.")
def scan_wrapper(self):
try:
self.scan()
finally:
with self.lock:
self.shared_data.bjorn_progress = ""
logger.debug("bjorn_progress reset to empty string")
def stop(self):
if self.running:
self.running = False
self.shared_data.orchestrator_should_exit = True
try:
if hasattr(self, "thread") and self.thread.is_alive():
self.thread.join()
except Exception:
pass
logger.info("NetworkScanner stopped.")
if __name__ == "__main__":
# SharedData must provide .db (BjornDatabase) and fields:
# default_network_interface, use_custom_network, custom_network,
# portstart, portend, portlist, blacklistcheck, mac/ip/hostname blacklists,
# bjorn_progress, bjorn_orch_status, bjorn_status_text2, orchestrator_should_exit.
from shared import SharedData
sd = SharedData()
scanner = NetworkScanner(sd)
scanner.scan()