Add RLUtils class for managing RL/AI dashboard endpoints

- Implemented methods for fetching AI stats, training history, and recent experiences.
- Added functionality to set operation mode (MANUAL, AUTO, AI) with appropriate handling.
- Included helper methods for querying the database and sending JSON responses.
- Integrated model metadata extraction for visualization purposes.
This commit is contained in:
Fabien POLLY
2026-02-18 22:36:10 +01:00
parent b8a13cc698
commit eb20b168a6
684 changed files with 53278 additions and 27977 deletions

View File

@@ -2,13 +2,16 @@
Vulnerability Scanner Action
Scanne ultra-rapidement CPE (+ CVE via vulners si dispo),
avec fallback "lourd" optionnel.
Affiche une progression en % dans Bjorn.
"""
import re
import time
import nmap
import json
import logging
from typing import Dict, List, Set, Any, Optional
from datetime import datetime, timedelta
from typing import Dict, List, Any
from shared import SharedData
from logger import Logger
@@ -22,41 +25,47 @@ b_port = None
b_parent = None
b_action = "normal"
b_service = []
b_trigger = "on_port_change"
b_trigger = "on_port_change"
b_requires = '{"action":"NetworkScanner","status":"success","scope":"global"}'
b_priority = 11
b_cooldown = 0
b_cooldown = 0
b_enabled = 1
b_rate_limit = None
# Regex compilé une seule fois (gain CPU sur Pi Zero)
CVE_RE = re.compile(r'CVE-\d{4}-\d{4,7}', re.IGNORECASE)
class NmapVulnScanner:
"""Scanner de vulnérabilités via nmap (mode rapide CPE/CVE)."""
"""Scanner de vulnérabilités via nmap (mode rapide CPE/CVE) avec progression."""
def __init__(self, shared_data: SharedData):
self.shared_data = shared_data
self.nm = nmap.PortScanner()
# Pas de self.nm partagé : on instancie dans chaque méthode de scan
# pour éviter les corruptions d'état entre batches.
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}")
logger.info(f"Starting vulnerability scan for {ip}")
self.shared_data.bjorn_orch_status = "NmapVulnScanner"
self.shared_data.bjorn_progress = "0%"
# 1) metadata depuis la queue
if self.shared_data.orchestrator_should_exit:
return 'failed'
# 1) Metadata
meta = {}
try:
meta = json.loads(row.get('metadata') or '{}')
except Exception:
pass
# 2) récupérer MAC et TOUS les ports de l'hôte
# 2) Récupérer MAC et TOUS les ports
mac = row.get("MAC Address") or row.get("mac_address") or ""
# ✅ FORCER la récupération de TOUS les ports depuis la DB
ports_str = ""
if mac:
r = self.shared_data.db.query(
@@ -64,8 +73,7 @@ class NmapVulnScanner:
)
if r and r[0].get('ports'):
ports_str = r[0]['ports']
# Fallback sur les métadonnées si besoin
if not ports_str:
ports_str = (
row.get("Ports") or row.get("ports") or
@@ -73,143 +81,240 @@ class NmapVulnScanner:
)
if not ports_str:
logger.warning(f"⚠️ No ports to scan for {ip}")
logger.warning(f"No ports to scan for {ip}")
self.shared_data.bjorn_progress = ""
return 'failed'
ports = [p.strip() for p in ports_str.split(';') if p.strip()]
logger.debug(f"📋 Found {len(ports)} ports for {ip}: {ports[:5]}...")
# ✅ FIX : Ne filtrer QUE si config activée ET déjà scanné
# Nettoyage des ports (garder juste le numéro si format 80/tcp)
ports = [p.split('/')[0] for p in ports]
self.shared_data.comment_params = {"ip": ip, "ports": str(len(ports))}
logger.debug(f"Found {len(ports)} ports for {ip}: {ports[:5]}...")
# 3) Filtrage "Rescan Only"
if self.shared_data.config.get('vuln_rescan_on_change_only', False):
if self._has_been_scanned(mac):
original_count = len(ports)
ports = self._filter_ports_already_scanned(mac, ports)
logger.debug(f"🔄 Filtered {original_count - len(ports)} already-scanned ports")
logger.debug(f"Filtered {original_count - len(ports)} already-scanned ports")
if not ports:
logger.info(f"No new/changed ports to scan for {ip}")
logger.info(f"No new/changed ports to scan for {ip}")
self.shared_data.bjorn_progress = "100%"
return 'success'
# Scanner (mode rapide par défaut)
logger.info(f"🚀 Starting nmap scan on {len(ports)} ports for {ip}")
# 4) SCAN AVEC PROGRESSION
if self.shared_data.orchestrator_should_exit:
return 'failed'
logger.info(f"Starting nmap scan on {len(ports)} ports for {ip}")
findings = self.scan_vulnerabilities(ip, ports)
# Persistance (split CVE/CPE)
if self.shared_data.orchestrator_should_exit:
logger.info("Scan interrupted by user")
return 'failed'
# 5) Déduplication en mémoire avant persistance
findings = self._deduplicate_findings(findings)
# 6) Persistance
self.save_vulnerabilities(mac, ip, findings)
logger.success(f"✅ Vuln scan done on {ip}: {len(findings)} entries")
# Finalisation UI
self.shared_data.bjorn_progress = "100%"
self.shared_data.comment_params = {"ip": ip, "vulns_found": str(len(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}")
logger.error(f"NmapVulnScanner failed for {ip}: {e}")
self.shared_data.bjorn_progress = "Error"
return 'failed'
def _has_been_scanned(self, mac: str) -> bool:
"""Vérifie si l'hôte a déjà été scanné au moins une fois."""
rows = self.shared_data.db.query("""
SELECT 1 FROM action_queue
WHERE mac_address=? AND action_name='NmapVulnScanner'
WHERE mac_address=? AND action_name='NmapVulnScanner'
AND status IN ('success', 'failed')
LIMIT 1
""", (mac,))
return bool(rows)
def _filter_ports_already_scanned(self, mac: str, ports: List[str]) -> List[str]:
"""
Retourne la liste des ports à scanner en excluant ceux déjà scannés récemment.
"""
if not ports:
return []
# Ports déjà couverts par 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
seen[str(r['port'])] = r.get('last_seen')
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
return [p for p in ports if (p not in seen) or (not fresh(p))]
final_ports = []
for p in ports:
if p not in seen:
final_ports.append(p)
else:
try:
dt = datetime.fromisoformat(seen[p].replace('Z', ''))
if dt < cutoff:
final_ports.append(p)
except Exception:
pass
return final_ports
else:
# Sans TTL: si déjà scanné/présent actif => on skip
return [p for p in ports if p not in seen]
# ---------------------------- Scanning ------------------------------ #
# ---------------------------- Helpers -------------------------------- #
def _deduplicate_findings(self, findings: List[Dict]) -> List[Dict]:
"""Supprime les doublons (même port + vuln_id) pour éviter des inserts inutiles."""
seen: set = set()
deduped = []
for f in findings:
key = (str(f.get('port', '')), str(f.get('vuln_id', '')))
if key not in seen:
seen.add(key)
deduped.append(f)
return deduped
def _extract_cpe_values(self, port_info: Dict[str, Any]) -> List[str]:
cpe = port_info.get('cpe')
if not cpe:
return []
if isinstance(cpe, str):
return [x.strip() for x in cpe.splitlines() if x.strip()]
if isinstance(cpe, (list, tuple, set)):
return [str(x).strip() for x in cpe if str(x).strip()]
return [str(cpe).strip()]
def extract_cves(self, text: str) -> List[str]:
"""Extrait les CVE via regex pré-compilé (pas de recompilation à chaque appel)."""
if not text:
return []
return CVE_RE.findall(str(text))
# ---------------------------- Scanning (Batch Mode) ------------------------------ #
def scan_vulnerabilities(self, ip: str, ports: List[str]) -> List[Dict]:
"""Mode rapide CPE/CVE ou fallback lourd."""
fast = bool(self.shared_data.config.get('vuln_fast', True))
"""
Orchestre le scan en lots (batches) pour permettre la mise à jour
de la barre de progression.
"""
all_findings = []
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))
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 ''
# Pause entre batches important sur Pi Zero pour laisser respirer le CPU
batch_pause = float(self.shared_data.config.get('vuln_batch_pause', 0.5))
if not port_list:
logger.warning("No valid ports for scan")
# Taille de lot réduite par défaut (2 sur Pi Zero, configurable)
batch_size = int(self.shared_data.config.get('vuln_batch_size', 2))
target_ports = ports[:max_ports]
total = len(target_ports)
if total == 0:
return []
if fast:
return self._scan_fast_cpe_cve(ip, port_list, use_vulners)
else:
return self._scan_heavy(ip, port_list)
batches = [target_ports[i:i + batch_size] for i in range(0, total, batch_size)]
processed_count = 0
for batch in batches:
if self.shared_data.orchestrator_should_exit:
break
port_str = ','.join(batch)
# Mise à jour UI avant le scan du lot
pct = int((processed_count / total) * 100)
self.shared_data.bjorn_progress = f"{pct}%"
self.shared_data.comment_params = {
"ip": ip,
"progress": f"{processed_count}/{total} ports",
"current_batch": port_str
}
t0 = time.time()
# Scan du lot (instanciation locale pour éviter la corruption d'état)
if fast:
batch_findings = self._scan_fast_cpe_cve(ip, port_str, use_vulners)
else:
batch_findings = self._scan_heavy(ip, port_str)
elapsed = time.time() - t0
logger.debug(f"Batch [{port_str}] scanned in {elapsed:.1f}s {len(batch_findings)} finding(s)")
all_findings.extend(batch_findings)
processed_count += len(batch)
# Mise à jour post-lot
pct = int((processed_count / total) * 100)
self.shared_data.bjorn_progress = f"{pct}%"
# Pause CPU entre batches (vital sur Pi Zero)
if batch_pause > 0 and processed_count < total:
time.sleep(batch_pause)
return all_findings
def _scan_fast_cpe_cve(self, ip: str, port_list: str, use_vulners: bool) -> List[Dict]:
"""Scan rapide pour récupérer CPE et (option) CVE via vulners."""
vulns: List[Dict] = []
nm = nmap.PortScanner() # Instance locale pas de partage d'état
args = "-sV --version-light -T4 --max-retries 1 --host-timeout 30s --script-timeout 10s"
# --version-light au lieu de --version-all : bien plus rapide sur Pi Zero
# --min-rate/--max-rate : évite de saturer CPU et réseau
args = (
"-sV --version-light -T4 "
"--max-retries 1 --host-timeout 60s --script-timeout 20s "
"--min-rate 50 --max-rate 100"
)
if use_vulners:
args += " --script vulners --script-args mincvss=0.0"
logger.info(f"[FAST] nmap {ip} -p {port_list} ({args})")
logger.debug(f"[FAST] nmap {ip} -p {port_list}")
try:
self.nm.scan(hosts=ip, ports=port_list, arguments=args)
nm.scan(hosts=ip, ports=port_list, arguments=args)
except Exception as e:
logger.error(f"Fast scan failed to start: {e}")
logger.error(f"Fast batch scan failed for {ip} [{port_list}]: {e}")
return vulns
if ip not in self.nm.all_hosts():
if ip not in nm.all_hosts():
return vulns
host = self.nm[ip]
host = 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 depuis -sV
cpe_values = self._extract_cpe_values(port_info)
for cpe in cpe_values:
# CPE
for cpe in self._extract_cpe_values(port_info):
vulns.append({
'port': port,
'service': service,
'vuln_id': f"CPE:{cpe}",
'script': 'service-detect',
'details': f"CPE detected: {cpe}"[:500]
'details': f"CPE: {cpe}"
})
# 2) CVE via script 'vulners' (si actif)
try:
# CVE via vulners
if use_vulners:
script_out = (port_info.get('script') or {}).get('vulners')
if script_out:
for cve in self.extract_cves(script_out):
@@ -218,97 +323,73 @@ class NmapVulnScanner:
'service': service,
'vuln_id': cve,
'script': 'vulners',
'details': str(script_out)[:500]
'details': str(script_out)[:200]
})
except Exception:
pass
return vulns
def _scan_heavy(self, ip: str, port_list: str) -> List[Dict]:
"""Ancienne stratégie (plus lente) avec catégorie vuln, etc."""
vulnerabilities: List[Dict] = []
nm = nmap.PortScanner() # Instance locale
vuln_scripts = [
'vuln','exploit','http-vuln-*','smb-vuln-*',
'ssl-*','ssh-*','ftp-vuln-*','mysql-vuln-*',
'vuln', 'exploit', 'http-vuln-*', 'smb-vuln-*',
'ssl-*', 'ssh-*', 'ftp-vuln-*', 'mysql-vuln-*',
]
script_arg = ','.join(vuln_scripts)
# --min-rate/--max-rate pour ne pas saturer le Pi
args = (
f"-sV --script={script_arg} -T3 "
"--script-timeout 30s --min-rate 50 --max-rate 100"
)
args = f"-sV --script={script_arg} -T3 --script-timeout 20s"
logger.info(f"[HEAVY] nmap {ip} -p {port_list} ({args})")
logger.debug(f"[HEAVY] nmap {ip} -p {port_list}")
try:
self.nm.scan(hosts=ip, ports=port_list, arguments=args)
nm.scan(hosts=ip, ports=port_list, arguments=args)
except Exception as e:
logger.error(f"Heavy scan failed to start: {e}")
logger.error(f"Heavy batch scan failed for {ip} [{port_list}]: {e}")
return vulnerabilities
if ip in self.nm.all_hosts():
host = self.nm[ip]
discovered_ports: Set[str] = set()
if ip not in nm.all_hosts():
return vulnerabilities
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 ''
host = nm[ip]
discovered_ports_in_batch: set = set()
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]
})
for proto in host.all_protocols():
for port in host[proto].keys():
discovered_ports_in_batch.add(str(port))
port_info = host[proto][port]
service = port_info.get('name', '') or ''
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)
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)[:200]
})
# CPE Scan optionnel (sur ce batch)
if bool(self.shared_data.config.get('scan_cpe', False)):
ports_for_cpe = list(discovered_ports_in_batch)
if ports_for_cpe:
vulnerabilities.extend(self.scan_cpe(ip, ports_for_cpe))
return vulnerabilities
# ---------------------------- Helpers -------------------------------- #
def _extract_cpe_values(self, port_info: Dict[str, Any]) -> List[str]:
"""Normalise tous les formats possibles de CPE renvoyés par 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]:
"""Extrait les identifiants CVE d'un texte."""
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]:
"""(Fallback lourd) Scan CPE détaillé si demandé."""
cpe_vulns: List[Dict] = []
cpe_vulns = []
nm = nmap.PortScanner() # Instance locale
try:
port_list = ','.join([str(p) for p in ports if str(p).strip()])
if not port_list:
return cpe_vulns
port_list = ','.join([str(p) for p in ports])
# --version-light à la place de --version-all (bien plus rapide)
args = "-sV --version-light -T4 --max-retries 1 --host-timeout 45s"
nm.scan(hosts=ip, ports=port_list, arguments=args)
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]
if ip in nm.all_hosts():
host = nm[ip]
for proto in host.all_protocols():
for port in host[proto].keys():
port_info = host[proto][port]
@@ -319,90 +400,61 @@ class NmapVulnScanner:
'service': service,
'vuln_id': f"CPE:{cpe}",
'script': 'version-scan',
'details': f"CPE detected: {cpe}"[:500]
'details': f"CPE: {cpe}"
})
except Exception as e:
logger.error(f"CPE scan error: {e}")
logger.error(f"scan_cpe failed for {ip}: {e}")
return cpe_vulns
# ---------------------------- Persistence ---------------------------- #
def save_vulnerabilities(self, mac: str, ip: str, findings: List[Dict]):
"""Sépare CPE et CVE, met à jour les statuts + enregistre les nouveautés."""
# Récupérer le hostname depuis la DB
hostname = None
try:
host_row = self.shared_data.db.query_one(
"SELECT hostnames FROM hosts WHERE mac_address=? LIMIT 1",
(mac,)
"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}")
# Grouper par port avec les infos complètes
findings_by_port = {}
except Exception:
pass
findings_by_port: Dict[int, Dict] = {}
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)
findings_by_port[port] = {'cves': set(), 'cpes': set()}
vid = str(f.get('vuln_id', ''))
if vid.upper().startswith('CVE-'):
vid_upper = vid.upper()
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)
elif vid_upper.startswith('CPE:'):
# On stocke sans le préfixe "CPE:"
findings_by_port[port]['cpes'].add(vid[4:])
# 1) Traiter les CVE par port
# 1) CVEs
for port, data in findings_by_port.items():
if data['cves']:
for cve in data['cves']:
try:
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:
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:
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}")
for cve in data['cves']:
try:
self.shared_data.db.execute("""
INSERT INTO vulnerabilities(mac_address, ip, hostname, port, vuln_id, is_active, last_seen)
VALUES(?,?,?,?,?,1,CURRENT_TIMESTAMP)
ON CONFLICT(mac_address, vuln_id, port) DO UPDATE SET
is_active=1, last_seen=CURRENT_TIMESTAMP, ip=excluded.ip
""", (mac, ip, hostname, port, cve))
except Exception as e:
logger.error(f"Save CVE err: {e}")
# 2) Traiter les CPE
# 2) 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
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.error(f"Save CPE err: {e}")
logger.info(f"Saved vulnerabilities for {ip} ({mac}): {len(findings_by_port)} ports processed")
logger.info(f"Saved vulnerabilities for {ip}: {len(findings)} findings")