4 Commits
Author SHA1 Message Date
olivier c020bd318a Fix scan timeouts and privileged shutdown 2026-08-23 10:44:02 +02:00
olivier ff18511ff2 Merge branch 'master' 2026-08-23 09:59:05 +02:00
olivier bcb8f9532f first commit 2026-08-21 15:21:29 +02:00
olivier e8c6f98182 first commit 2026-08-21 14:57:20 +02:00
9 changed files with 116 additions and 37 deletions
Binary file not shown.
+3
View File
@@ -19,3 +19,6 @@ package-dir = {"" = "src"}
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
[tool.pytest.ini_options]
pythonpath = ["src"]
+6 -3
View File
@@ -135,7 +135,8 @@ def command_for(operation: str, args: list[str]) -> list[str]:
target = validate_target(args[0]) target = validate_target(args[0])
return [ return [
nmap(), "-sS", "-sV", "-O", "--osscan-guess", "--version-light", nmap(), "-sS", "-sV", "-O", "--osscan-guess", "--version-light",
"--open", "-T4", "--top-ports", "100", "-oX", "-", target, "--open", "-T4", "--max-retries", "1", "--host-timeout", "60s",
"--top-ports", "100", "-oX", "-", target,
] ]
if operation == "nmap-deep-hosts": if operation == "nmap-deep-hosts":
@@ -145,7 +146,8 @@ def command_for(operation: str, args: list[str]) -> list[str]:
hosts = validate_host_list(args) hosts = validate_host_list(args)
return [ return [
nmap(), "-Pn", "-n", "-sS", "-sV", "-O", "--osscan-guess", nmap(), "-Pn", "-n", "-sS", "-sV", "-O", "--osscan-guess",
"--version-light", "--open", "-T4", "--top-ports", "1000", "--version-light", "--open", "-T4", "--max-retries", "1",
"--host-timeout", "60s", "--top-ports", "1000",
"-oX", "-", *hosts, "-oX", "-", *hosts,
] ]
@@ -155,7 +157,8 @@ def command_for(operation: str, args: list[str]) -> list[str]:
host = validate_target(args[0], network_allowed=False) host = validate_target(args[0], network_allowed=False)
return [ return [
nmap(), "-Pn", "-n", "-sS", "-sV", "-O", "--osscan-guess", "--version-light", nmap(), "-Pn", "-n", "-sS", "-sV", "-O", "--osscan-guess", "--version-light",
"--open", "-T4", "--top-ports", "1000", "-oX", "-", host, "--open", "-T4", "--max-retries", "1", "--host-timeout", "60s",
"--top-ports", "1000", "-oX", "-", host,
] ]
raise ValueError("Opération privilégiée non autorisée") raise ValueError("Opération privilégiée non autorisée")
+20 -6
View File
@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import os import os
import shutil
from dataclasses import dataclass from dataclasses import dataclass
@@ -19,14 +18,29 @@ class PrivilegeDiagnostic:
def find_pkexec() -> str | None: def find_pkexec() -> str | None:
return shutil.which("pkexec") or ("/usr/bin/pkexec" if os.path.isfile("/usr/bin/pkexec") else None) path = "/usr/bin/pkexec"
try:
info = os.stat(path)
except OSError:
return None
if os.path.isfile(path) and os.access(path, os.X_OK) and info.st_uid == 0 and not (info.st_mode & 0o022):
return path
return None
def find_helper() -> str | None: def find_helper() -> str | None:
override = os.environ.get("LIBRENET_PRIVILEGED_HELPER", "").strip() # Le chemin du helper est une frontière de privilèges : ne pas permettre à
if override and os.path.isfile(override) and os.access(override, os.X_OK): # l'environnement de l'utilisateur de le remplacer par un exécutable arbitraire.
return override try:
if os.path.isfile(HELPER_PATH) and os.access(HELPER_PATH, os.X_OK): info = os.stat(HELPER_PATH)
except OSError:
return None
if (
os.path.isfile(HELPER_PATH)
and os.access(HELPER_PATH, os.X_OK)
and info.st_uid == 0
and not (info.st_mode & 0o022)
):
return HELPER_PATH return HELPER_PATH
return None return None
+49 -25
View File
@@ -34,6 +34,9 @@ NAABU_BATCH_TIMEOUT_SECONDS = 10.0
NAABU_CONNECT_TIMEOUT = "800ms" NAABU_CONNECT_TIMEOUT = "800ms"
NAABU_RATE = "2500" NAABU_RATE = "2500"
NAABU_CONCURRENCY = "100" NAABU_CONCURRENCY = "100"
RAPID_DISCOVERY_MAX_SECONDS = 45.0
DEEP_SCAN_MAX_SECONDS = 180.0
HOST_SCAN_MAX_SECONDS = 60.0
COMMON_PORTS = ( COMMON_PORTS = (
"21,22,23,25,53,80,110,135,139,143,389,443,445,465,515,587,631,636,993,995," "21,22,23,25,53,80,110,135,139,143,389,443,445,465,515,587,631,636,993,995,"
@@ -589,7 +592,7 @@ class ScanWorker(QThread):
if not ips: if not ips:
return [] return []
prefix = "Base Approfondi" if self.request.profile == "Approfondi" else "Standard" prefix = "Base Approfondi" if self.request.profile == "Approfondi" else "Standard"
suffix = " — REPLI Naabu" if fallback else " — moteur adaptatif" suffix = " — REPLI Nmap" if fallback else " — moteur adaptatif"
if self.request.privileged: if self.request.privileged:
args = privileged_command("nmap-standard", *ips) args = privileged_command("nmap-standard", *ips)
label = f"{prefix} — ports Nmap SYN (Admin){suffix}" label = f"{prefix} — ports Nmap SYN (Admin){suffix}"
@@ -753,14 +756,13 @@ class ScanWorker(QThread):
def _discover_hosts(self, *, start_percent: int = 4, end_percent: int = 52) -> set[str]: def _discover_hosts(self, *, start_percent: int = 4, end_percent: int = 52) -> set[str]:
"""Découverte robuste et additive des hôtes. """Découverte robuste et additive des hôtes.
IMPORTANT : le mode administrateur ne remplace plus la découverte normale. IMPORTANT : le mode administrateur ne remplace pas la découverte normale.
Il ajoute ARP/Nmap privilégiés aux résultats non privilégiés. Ainsi activer Il ajoute les informations ARP privilégiées aux résultats Nmap utilisateur,
les privilèges ne peut pas réduire le nombre d'hôtes détectés. sans relancer une seconde découverte complète.
""" """
span = max(20, end_percent - start_percent) span = max(20, end_percent - start_percent)
arp_end = start_percent + round(span * 0.20) arp_end = start_percent + round(span * 0.20)
normal_end = start_percent + round(span * 0.62) normal_end = start_percent + round(span * 0.62)
admin_end = start_percent + round(span * 0.84)
neighbor_percent = start_percent + round(span * 0.94) neighbor_percent = start_percent + round(span * 0.94)
local_hosts = self._emit_local_host() local_hosts = self._emit_local_host()
@@ -777,34 +779,24 @@ class ScanWorker(QThread):
"Découverte Nmap utilisateur échouée", "Découverte Nmap utilisateur échouée",
start_percent=arp_end, start_percent=arp_end,
end_percent=normal_end, end_percent=normal_end,
timeout_seconds=min(
RAPID_DISCOVERY_MAX_SECONDS,
max(12.0, 8.0 + len(target_ipv4_hosts(self.request.target)) / 32.0),
),
) )
if self.isInterruptionRequested(): if self.isInterruptionRequested():
return union_host_ips(local_hosts, arp_hosts, normal_hosts) return union_host_ips(local_hosts, arp_hosts, normal_hosts)
privileged_hosts: list[Host] = [] # Le mode Admin enrichit la découverte locale via ARP, mais ne relance pas
if self.request.privileged and not self.isInterruptionRequested(): # une seconde découverte Nmap identique. Les privilèges sont réservés aux
try: # opérations qui en ont réellement besoin (SYN, OS et ARP).
cmd = privileged_command("nmap-discover", self.request.target) known_ips = union_host_ips(arp_hosts, normal_hosts)
except RuntimeError as exc:
self.warning.emit(str(exc))
else:
privileged_hosts = self._nmap_optional(
cmd,
"Découverte des hôtes — Nmap (Admin complémentaire)…",
"Découverte Nmap Admin complémentaire échouée",
start_percent=normal_end,
end_percent=admin_end,
)
known_ips = union_host_ips(arp_hosts, normal_hosts, privileged_hosts)
known_ips.update(union_host_ips(local_hosts)) known_ips.update(union_host_ips(local_hosts))
if self.isInterruptionRequested(): if self.isInterruptionRequested():
return known_ips return known_ips
self._neighbor_hosts(known_ips, percent=neighbor_percent) self._neighbor_hosts(known_ips, percent=neighbor_percent)
details = f"local {len(local_hosts)} · ARP {len(arp_hosts)} · Nmap {len(normal_hosts)}" details = f"local {len(local_hosts)} · ARP {len(arp_hosts)} · Nmap {len(normal_hosts)}"
if self.request.privileged:
details += f" · admin {len(privileged_hosts)}"
self._set_progress(end_percent, f"Découverte : {len(known_ips)} hôte(s) unique(s) — {details}") self._set_progress(end_percent, f"Découverte : {len(known_ips)} hôte(s) unique(s) — {details}")
return known_ips return known_ips
@@ -838,12 +830,18 @@ class ScanWorker(QThread):
else: else:
deep_args = [ deep_args = [
"nmap", "-Pn", "-n", "-sT", "-sV", "--version-light", "nmap", "-Pn", "-n", "-sT", "-sV", "--version-light",
"--open", "-T4", "--top-ports", "1000", "--open", "-T4", "--max-retries", "1", "--host-timeout", "60s",
"--top-ports", "1000",
"-oX", "-", *ips, "-oX", "-", *ips,
] ]
deep_label = "Enrichissement approfondi — Nmap TCP et services…" deep_label = "Enrichissement approfondi — Nmap TCP et services…"
deep_timeout = min(
DEEP_SCAN_MAX_SECONDS,
max(90.0, 30.0 + len(ips) * 1.5),
)
deep_hosts = self._nmap( deep_hosts = self._nmap(
deep_args, deep_label, start_percent=62, end_percent=96 deep_args, deep_label, start_percent=62, end_percent=96,
timeout_seconds=deep_timeout,
) )
if deep_hosts: if deep_hosts:
self.hosts_found.emit(deep_hosts) self.hosts_found.emit(deep_hosts)
@@ -927,6 +925,7 @@ class HostScanWorker(QThread):
else: else:
args = [ args = [
"nmap", "-Pn", "-n", "-sT", "-sV", "--version-light", "--open", "-T4", "nmap", "-Pn", "-n", "-sT", "-sV", "--version-light", "--open", "-T4",
"--max-retries", "1", "--host-timeout", "60s",
"--top-ports", "1000", "-oX", "-", self.ip, "--top-ports", "1000", "-oX", "-", self.ip,
] ]
try: try:
@@ -961,10 +960,16 @@ class HostScanWorker(QThread):
for reader in readers: for reader in readers:
reader.start() reader.start()
stop_sent = False stop_sent = False
started = time.monotonic()
timed_out = False
while self._proc.poll() is None: while self._proc.poll() is None:
if self.isInterruptionRequested() and not stop_sent: if self.isInterruptionRequested() and not stop_sent:
self.stop() self.stop()
stop_sent = True stop_sent = True
elif not stop_sent and time.monotonic() - started >= HOST_SCAN_MAX_SECONDS:
timed_out = True
self.stop()
stop_sent = True
try: try:
self._proc.wait(timeout=0.10) self._proc.wait(timeout=0.10)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
@@ -973,7 +978,26 @@ class HostScanWorker(QThread):
reader.join(timeout=1.0) reader.join(timeout=1.0)
stdout, stderr = "".join(stdout_parts), "".join(stderr_parts) stdout, stderr = "".join(stdout_parts), "".join(stderr_parts)
else: else:
timed_out = False
try:
stdout, stderr = self._proc.communicate(timeout=HOST_SCAN_MAX_SECONDS)
except subprocess.TimeoutExpired:
timed_out = True
try:
os.killpg(self._proc.pid, signal.SIGTERM)
except (ProcessLookupError, PermissionError, OSError):
pass
try:
stdout, stderr = self._proc.communicate(timeout=1.5)
except subprocess.TimeoutExpired:
try:
os.killpg(self._proc.pid, signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError):
pass
stdout, stderr = self._proc.communicate() stdout, stderr = self._proc.communicate()
if timed_out:
self.failed.emit(f"Délai maximal dépassé ({HOST_SCAN_MAX_SECONDS:.0f} s).")
return
if self.isInterruptionRequested(): if self.isInterruptionRequested():
return return
if self._proc.returncode != 0: if self._proc.returncode != 0:
+11 -1
View File
@@ -136,8 +136,9 @@ class PrivilegeAuthWorker(QThread):
encoding="utf-8", encoding="utf-8",
errors="replace", errors="replace",
check=False, check=False,
timeout=30.0,
) )
except (OSError, RuntimeError) as exc: except (OSError, RuntimeError, subprocess.TimeoutExpired) as exc:
self.result.emit(False, str(exc)) self.result.emit(False, str(exc))
return return
if proc.returncode == 0: if proc.returncode == 0:
@@ -1039,6 +1040,15 @@ class MainWindow(QMainWindow):
self.statusBar().showMessage("Disposition réinitialisée", 3000) self.statusBar().showMessage("Disposition réinitialisée", 3000)
def closeEvent(self, event) -> None: def closeEvent(self, event) -> None:
# Une fenêtre Qt ne doit pas détruire un QThread encore actif. Les workers
# arrêtent aussi leur groupe de processus, y compris le helper pkexec.
self.stop_scan()
workers = [self.worker, self.host_worker, self.auth_worker, self.vendor_lookup_worker]
for worker in workers:
if worker and worker.isRunning() and not worker.wait(3500):
self.statusBar().showMessage("Arrêt du scan encore en cours…", 5000)
event.ignore()
return
self._save_ui_layout() self._save_ui_layout()
super().closeEvent(event) super().closeEvent(event)
+1 -1
View File
@@ -37,7 +37,7 @@ class EngineStatus0419Tests(unittest.TestCase):
self.assertIn("découverte rapide Nmap/ARP", source) self.assertIn("découverte rapide Nmap/ARP", source)
self.assertIn("ports Naabu SYN (Admin)", source) self.assertIn("ports Naabu SYN (Admin)", source)
self.assertIn("ports Nmap SYN (Admin)", source) self.assertIn("ports Nmap SYN (Admin)", source)
self.assertIn("REPLI Naabu", source) self.assertIn("REPLI Nmap", source)
if __name__ == "__main__": if __name__ == "__main__":
+1
View File
@@ -112,6 +112,7 @@ class StandardBehavior0421Tests(unittest.TestCase):
args = worker._nmap.call_args.args[0] args = worker._nmap.call_args.args[0]
self.assertIn("-sV", args) self.assertIn("-sV", args)
self.assertIn("1000", args) self.assertIn("1000", args)
self.assertGreaterEqual(worker._nmap.call_args.kwargs["timeout_seconds"], 90.0)
class Diagnostics0421Tests(unittest.TestCase): class Diagnostics0421Tests(unittest.TestCase):
+24
View File
@@ -138,6 +138,30 @@ class PerformancePipeline0423Tests(unittest.TestCase):
self.assertEqual(cmd[:6], ["nmap", "-sn", "-n", "-T4", "--max-retries", "1"]) self.assertEqual(cmd[:6], ["nmap", "-sn", "-n", "-T4", "--max-retries", "1"])
self.assertIsNotNone(worker._nmap.call_args.kwargs["timeout_seconds"]) self.assertIsNotNone(worker._nmap.call_args.kwargs["timeout_seconds"])
def test_rapid_discovery_has_a_wall_clock_timeout(self):
worker = self.worker()
worker.request.profile = "Rapide"
worker._emit_local_host = Mock(return_value=[])
worker._emit_arp = Mock(return_value=[])
worker._neighbor_hosts = Mock(return_value=[])
worker._set_progress = Mock()
worker._nmap_optional = Mock(return_value=[])
worker._discover_hosts(start_percent=4, end_percent=96)
timeout = worker._nmap_optional.call_args.kwargs["timeout_seconds"]
self.assertIsNotNone(timeout)
self.assertLessEqual(timeout, scanner.RAPID_DISCOVERY_MAX_SECONDS)
def test_admin_rapid_discovery_does_not_repeat_nmap(self):
worker = self.worker(privileged=True)
worker.request.profile = "Rapide"
worker._emit_local_host = Mock(return_value=[])
worker._emit_arp = Mock(return_value=[])
worker._neighbor_hosts = Mock(return_value=[])
worker._set_progress = Mock()
worker._nmap_optional = Mock(return_value=[])
worker._discover_hosts(start_percent=4, end_percent=96)
worker._nmap_optional.assert_called_once()
def test_naabu_failure_does_not_rescan_dead_addresses_with_nmap(self): def test_naabu_failure_does_not_rescan_dead_addresses_with_nmap(self):
worker = self.worker() worker = self.worker()
live = {f"192.168.10.{i}" for i in range(1, scanner.NAABU_ACTIVE_HOST_THRESHOLD + 1)} live = {f"192.168.10.{i}" for i in range(1, scanner.NAABU_ACTIVE_HOST_THRESHOLD + 1)}