Files
Bjorn/resources/default_config/actions/nmap_vuln_scanner.py
infinition b0584a1a8e feat: Add login page with dynamic RGB effects and password toggle functionality
feat: Implement package management utilities with JSON endpoints for listing and uninstalling packages

feat: Create plugin management utilities with endpoints for listing, configuring, and installing plugins

feat: Develop schedule and trigger management utilities with CRUD operations for schedules and triggers
2026-03-19 00:40:04 +01:00

418 lines
16 KiB
Python

"""nmap_vuln_scanner.py - CPE + CVE vulnerability scanning via nmap/vulners."""
import nmap
import json
import logging
from typing import Dict, List, Set, Any, Optional
from datetime import datetime, timedelta
from shared import SharedData
from logger import Logger
logger = Logger(name="NmapVulnScanner.py", level=logging.DEBUG)
# Scheduler parameters
b_class = "NmapVulnScanner"
b_module = "nmap_vuln_scanner"
b_status = "NmapVulnScanner"
b_port = None
b_parent = None
b_action = "normal"
b_service = []
b_trigger = "on_port_change"
b_requires = '{"action":"NetworkScanner","status":"success","scope":"global"}'
b_priority = 11
b_cooldown = 0
b_enabled = 0
b_rate_limit = None
class NmapVulnScanner:
"""Vulnerability scanner via nmap (fast CPE/CVE mode)."""
def __init__(self, shared_data: SharedData):
self.shared_data = shared_data
self.nm = nmap.PortScanner()
logger.info("NmapVulnScanner initialized")
# ---------------------------- Public API ---------------------------- #
def execute(self, ip: str, port: str, row: Dict, status_key: str) -> str:
try:
logger.info(f"Starting vulnerability scan for {ip}")
self.shared_data.bjorn_orch_status = "NmapVulnScanner"
# 1) metadata from the queue
meta = {}
try:
meta = json.loads(row.get('metadata') or '{}')
except Exception:
pass
# 2) resolve ports (order: row -> metadata -> DB by MAC -> DB by IP)
ports_str = (
row.get("Ports") or row.get("ports") or
meta.get("ports_snapshot") or ""
)
mac = (
row.get("MAC Address") or row.get("mac_address") or
""
)
if not ports_str and mac:
r = self.shared_data.db.query(
"SELECT ports FROM hosts WHERE mac_address=? LIMIT 1", (mac,)
)
if r and r[0].get('ports'):
ports_str = r[0]['ports']
if not ports_str and ip:
r = self.shared_data.db.query(
"SELECT mac_address, ports FROM hosts WHERE ips LIKE ? LIMIT 1",
(f"%{ip}%",)
)
if r:
mac = mac or r[0].get('mac_address') or mac
ports_str = r[0].get('ports') or ports_str
if not ports_str:
logger.warning(f"No ports to scan for {ip}")
return 'failed'
ports = [p.strip() for p in ports_str.split(';') if p.strip()]
mac = mac or row.get("MAC Address") or ""
# Skip already-scanned ports (unless TTL expired)
ports = self._filter_ports_already_scanned(mac, ports)
if not ports:
logger.info(f"No new/changed ports to scan for {ip}")
# Still touch statuses to deactivate stale entries
self.save_vulnerabilities(mac, ip, [])
return 'success'
# Scan (fast mode by default)
findings = self.scan_vulnerabilities(ip, ports)
# Persistence (split CVE/CPE)
self.save_vulnerabilities(mac, ip, findings)
logger.success(f"Vuln scan done on {ip}: {len(findings)} entries")
return 'success'
except Exception as e:
logger.error(f"NmapVulnScanner failed for {ip}: {e}")
return 'failed'
def _filter_ports_already_scanned(self, mac: str, ports: List[str]) -> List[str]:
"""
Return ports to scan, excluding recently scanned ones.
Config:
vuln_rescan_on_change_only (bool, default True)
vuln_rescan_ttl_seconds (int, 0 = disabled)
"""
if not ports:
return []
if not bool(self.shared_data.config.get('vuln_rescan_on_change_only', True)):
return ports # no filtering
# Ports already covered by detected_software (is_active=1)
rows = self.shared_data.db.query("""
SELECT port, last_seen
FROM detected_software
WHERE mac_address=? AND is_active=1 AND port IS NOT NULL
""", (mac,))
seen = {}
for r in rows:
try:
p = str(r['port'])
ls = r.get('last_seen')
seen[p] = ls
except Exception:
pass
ttl = int(self.shared_data.config.get('vuln_rescan_ttl_seconds', 0) or 0)
if ttl > 0:
cutoff = datetime.utcnow() - timedelta(seconds=ttl)
def fresh(port: str) -> bool:
ls = seen.get(port)
if not ls:
return False
try:
dt = datetime.fromisoformat(ls.replace('Z',''))
return dt >= cutoff
except Exception:
return True # if in doubt, consider it fresh
return [p for p in ports if (p not in seen) or (not fresh(p))]
else:
# No TTL: if already scanned/active => skip
return [p for p in ports if p not in seen]
# ---------------------------- Scanning ------------------------------ #
def scan_vulnerabilities(self, ip: str, ports: List[str]) -> List[Dict]:
"""
Fast mode (default):
- nmap -sV --version-light on a reduced port set
- CPE extracted directly from service detection
- (optional) --script=vulners to extract CVE (if script installed)
Fallback (vuln_fast=False): legacy mode with 'vuln' scripts, etc.
"""
fast = bool(self.shared_data.config.get('vuln_fast', True))
use_vulners = bool(self.shared_data.config.get('nse_vulners', False))
max_ports = int(self.shared_data.config.get('vuln_max_ports', 10 if fast else 20))
p_list = [str(p).split('/')[0] for p in ports if str(p).strip()]
port_list = ','.join(p_list[:max_ports]) if p_list else ''
if not port_list:
logger.warning("No valid ports for scan")
return []
if fast:
return self._scan_fast_cpe_cve(ip, port_list, use_vulners)
else:
return self._scan_heavy(ip, port_list)
def _scan_fast_cpe_cve(self, ip: str, port_list: str, use_vulners: bool) -> List[Dict]:
"""Fast scan to extract CPE and (optionally) CVE via vulners."""
vulns: List[Dict] = []
args = "-sV --version-light -T4 --max-retries 1 --host-timeout 30s --script-timeout 10s"
if use_vulners:
args += " --script vulners --script-args mincvss=0.0"
logger.info(f"[FAST] nmap {ip} -p {port_list} ({args})")
try:
self.nm.scan(hosts=ip, ports=port_list, arguments=args)
except Exception as e:
logger.error(f"Fast scan failed to start: {e}")
return vulns
if ip not in self.nm.all_hosts():
return vulns
host = self.nm[ip]
for proto in host.all_protocols():
for port in host[proto].keys():
port_info = host[proto][port]
service = port_info.get('name', '') or ''
# 1) CPE from -sV
cpe_values = self._extract_cpe_values(port_info)
for cpe in cpe_values:
vulns.append({
'port': port,
'service': service,
'vuln_id': f"CPE:{cpe}",
'script': 'service-detect',
'details': f"CPE detected: {cpe}"[:500]
})
# 2) CVE via 'vulners' script (if enabled)
try:
script_out = (port_info.get('script') or {}).get('vulners')
if script_out:
for cve in self.extract_cves(script_out):
vulns.append({
'port': port,
'service': service,
'vuln_id': cve,
'script': 'vulners',
'details': str(script_out)[:500]
})
except Exception:
pass
return vulns
def _scan_heavy(self, ip: str, port_list: str) -> List[Dict]:
"""Legacy strategy (slower) with vuln category scripts, etc."""
vulnerabilities: List[Dict] = []
vuln_scripts = [
'vuln','exploit','http-vuln-*','smb-vuln-*',
'ssl-*','ssh-*','ftp-vuln-*','mysql-vuln-*',
]
script_arg = ','.join(vuln_scripts)
args = f"-sV --script={script_arg} -T3 --script-timeout 20s"
logger.info(f"[HEAVY] nmap {ip} -p {port_list} ({args})")
try:
self.nm.scan(hosts=ip, ports=port_list, arguments=args)
except Exception as e:
logger.error(f"Heavy scan failed to start: {e}")
return vulnerabilities
if ip in self.nm.all_hosts():
host = self.nm[ip]
discovered_ports: Set[str] = set()
for proto in host.all_protocols():
for port in host[proto].keys():
discovered_ports.add(str(port))
port_info = host[proto][port]
service = port_info.get('name', '') or ''
if 'script' in port_info:
for script_name, output in (port_info.get('script') or {}).items():
for cve in self.extract_cves(str(output)):
vulnerabilities.append({
'port': port,
'service': service,
'vuln_id': cve,
'script': script_name,
'details': str(output)[:500]
})
if 'vuln' in (script_name or '') and not self.extract_cves(str(output)):
# Skip findings without CVE IDs
pass
if bool(self.shared_data.config.get('scan_cpe', False)):
ports_for_cpe = list(discovered_ports) if discovered_ports else port_list.split(',')
cpes = self.scan_cpe(ip, ports_for_cpe[:10])
vulnerabilities.extend(cpes)
return vulnerabilities
# ---------------------------- Helpers -------------------------------- #
def _extract_cpe_values(self, port_info: Dict[str, Any]) -> List[str]:
"""Normalize all CPE formats returned by python-nmap."""
cpe = port_info.get('cpe')
if not cpe:
return []
if isinstance(cpe, str):
parts = [x.strip() for x in cpe.splitlines() if x.strip()]
return parts or [cpe]
if isinstance(cpe, (list, tuple, set)):
return [str(x).strip() for x in cpe if str(x).strip()]
try:
return [str(cpe).strip()] if str(cpe).strip() else []
except Exception:
return []
def extract_cves(self, text: str) -> List[str]:
"""Extract CVE identifiers from text."""
import re
if not text:
return []
cve_pattern = r'CVE-\d{4}-\d{4,7}'
return re.findall(cve_pattern, str(text), re.IGNORECASE)
def scan_cpe(self, ip: str, ports: List[str]) -> List[Dict]:
"""(Heavy fallback) Detailed CPE scan if requested."""
cpe_vulns: List[Dict] = []
try:
port_list = ','.join([str(p) for p in ports if str(p).strip()])
if not port_list:
return cpe_vulns
args = "-sV --version-all -T3 --max-retries 2 --host-timeout 45s"
logger.info(f"[CPE] nmap {ip} -p {port_list} ({args})")
self.nm.scan(hosts=ip, ports=port_list, arguments=args)
if ip in self.nm.all_hosts():
host = self.nm[ip]
for proto in host.all_protocols():
for port in host[proto].keys():
port_info = host[proto][port]
service = port_info.get('name', '') or ''
for cpe in self._extract_cpe_values(port_info):
cpe_vulns.append({
'port': port,
'service': service,
'vuln_id': f"CPE:{cpe}",
'script': 'version-scan',
'details': f"CPE detected: {cpe}"[:500]
})
except Exception as e:
logger.error(f"CPE scan error: {e}")
return cpe_vulns
# ---------------------------- Persistence ---------------------------- #
def save_vulnerabilities(self, mac: str, ip: str, findings: List[Dict]):
"""Split CPE/CVE, update statuses, and persist new findings with full info."""
# Fetch hostname from DB
hostname = None
try:
host_row = self.shared_data.db.query_one(
"SELECT hostnames FROM hosts WHERE mac_address=? LIMIT 1",
(mac,)
)
if host_row and host_row.get('hostnames'):
hostname = host_row['hostnames'].split(';')[0]
except Exception as e:
logger.debug(f"Could not fetch hostname: {e}")
# Group by port with full info
findings_by_port = {}
for f in findings:
port = int(f.get('port', 0) or 0)
if port not in findings_by_port:
findings_by_port[port] = {
'cves': set(),
'cpes': set(),
'findings': []
}
findings_by_port[port]['findings'].append(f)
vid = str(f.get('vuln_id', ''))
if vid.upper().startswith('CVE-'):
findings_by_port[port]['cves'].add(vid)
elif vid.upper().startswith('CPE:'):
findings_by_port[port]['cpes'].add(vid.split(':', 1)[1])
elif vid.lower().startswith('cpe:'):
findings_by_port[port]['cpes'].add(vid)
# 1) Process CVEs by port
for port, data in findings_by_port.items():
if data['cves']:
for cve in data['cves']:
try:
# Check if already exists
existing = self.shared_data.db.query_one(
"SELECT id FROM vulnerabilities WHERE mac_address=? AND vuln_id=? AND port=? LIMIT 1",
(mac, cve, port)
)
if existing:
# Update with IP and hostname
self.shared_data.db.execute("""
UPDATE vulnerabilities
SET ip=?, hostname=?, last_seen=CURRENT_TIMESTAMP, is_active=1
WHERE mac_address=? AND vuln_id=? AND port=?
""", (ip, hostname, mac, cve, port))
else:
# New entry with full info
self.shared_data.db.execute("""
INSERT INTO vulnerabilities(mac_address, ip, hostname, port, vuln_id, is_active)
VALUES(?,?,?,?,?,1)
""", (mac, ip, hostname, port, cve))
logger.debug(f"Saved CVE {cve} for {ip}:{port}")
except Exception as e:
logger.error(f"Failed to save CVE {cve}: {e}")
# 2) Process CPEs
for port, data in findings_by_port.items():
for cpe in data['cpes']:
try:
self.shared_data.db.add_detected_software(
mac_address=mac,
cpe=cpe,
ip=ip,
hostname=hostname,
port=port
)
except Exception as e:
logger.error(f"Failed to save CPE {cpe}: {e}")
logger.info(f"Saved vulnerabilities for {ip} ({mac}): {len(findings_by_port)} ports processed")