Compare commits

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]
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])
return [
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":
@@ -145,7 +146,8 @@ def command_for(operation: str, args: list[str]) -> list[str]:
hosts = validate_host_list(args)
return [
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,
]
@@ -155,7 +157,8 @@ def command_for(operation: str, args: list[str]) -> list[str]:
host = validate_target(args[0], network_allowed=False)
return [
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")
+20 -6
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import os
import shutil
from dataclasses import dataclass
@@ -19,14 +18,29 @@ class PrivilegeDiagnostic:
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:
override = os.environ.get("LIBRENET_PRIVILEGED_HELPER", "").strip()
if override and os.path.isfile(override) and os.access(override, os.X_OK):
return override
if os.path.isfile(HELPER_PATH) and os.access(HELPER_PATH, os.X_OK):
# Le chemin du helper est une frontière de privilèges : ne pas permettre à
# l'environnement de l'utilisateur de le remplacer par un exécutable arbitraire.
try:
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 None
+49 -25
View File
@@ -34,6 +34,9 @@ NAABU_BATCH_TIMEOUT_SECONDS = 10.0
NAABU_CONNECT_TIMEOUT = "800ms"
NAABU_RATE = "2500"
NAABU_CONCURRENCY = "100"
RAPID_DISCOVERY_MAX_SECONDS = 45.0
DEEP_SCAN_MAX_SECONDS = 180.0
HOST_SCAN_MAX_SECONDS = 60.0
COMMON_PORTS = (
"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:
return []
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:
args = privileged_command("nmap-standard", *ips)
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]:
"""Découverte robuste et additive des hôtes.
IMPORTANT : le mode administrateur ne remplace plus la découverte normale.
Il ajoute ARP/Nmap privilégiés aux résultats non privilégiés. Ainsi activer
les privilèges ne peut pas réduire le nombre d'hôtes détectés.
IMPORTANT : le mode administrateur ne remplace pas la découverte normale.
Il ajoute les informations ARP privilégiées aux résultats Nmap utilisateur,
sans relancer une seconde découverte complète.
"""
span = max(20, end_percent - start_percent)
arp_end = start_percent + round(span * 0.20)
normal_end = start_percent + round(span * 0.62)
admin_end = start_percent + round(span * 0.84)
neighbor_percent = start_percent + round(span * 0.94)
local_hosts = self._emit_local_host()
@@ -777,34 +779,24 @@ class ScanWorker(QThread):
"Découverte Nmap utilisateur échouée",
start_percent=arp_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():
return union_host_ips(local_hosts, arp_hosts, normal_hosts)
privileged_hosts: list[Host] = []
if self.request.privileged and not self.isInterruptionRequested():
try:
cmd = privileged_command("nmap-discover", self.request.target)
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)
# Le mode Admin enrichit la découverte locale via ARP, mais ne relance pas
# une seconde découverte Nmap identique. Les privilèges sont réservés aux
# opérations qui en ont réellement besoin (SYN, OS et ARP).
known_ips = union_host_ips(arp_hosts, normal_hosts)
known_ips.update(union_host_ips(local_hosts))
if self.isInterruptionRequested():
return known_ips
self._neighbor_hosts(known_ips, percent=neighbor_percent)
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}")
return known_ips
@@ -838,12 +830,18 @@ class ScanWorker(QThread):
else:
deep_args = [
"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,
]
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_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:
self.hosts_found.emit(deep_hosts)
@@ -927,6 +925,7 @@ class HostScanWorker(QThread):
else:
args = [
"nmap", "-Pn", "-n", "-sT", "-sV", "--version-light", "--open", "-T4",
"--max-retries", "1", "--host-timeout", "60s",
"--top-ports", "1000", "-oX", "-", self.ip,
]
try:
@@ -961,10 +960,16 @@ class HostScanWorker(QThread):
for reader in readers:
reader.start()
stop_sent = False
started = time.monotonic()
timed_out = False
while self._proc.poll() is None:
if self.isInterruptionRequested() and not stop_sent:
self.stop()
stop_sent = True
elif not stop_sent and time.monotonic() - started >= HOST_SCAN_MAX_SECONDS:
timed_out = True
self.stop()
stop_sent = True
try:
self._proc.wait(timeout=0.10)
except subprocess.TimeoutExpired:
@@ -973,7 +978,26 @@ class HostScanWorker(QThread):
reader.join(timeout=1.0)
stdout, stderr = "".join(stdout_parts), "".join(stderr_parts)
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()
if timed_out:
self.failed.emit(f"Délai maximal dépassé ({HOST_SCAN_MAX_SECONDS:.0f} s).")
return
if self.isInterruptionRequested():
return
if self._proc.returncode != 0:
+11 -1
View File
@@ -136,8 +136,9 @@ class PrivilegeAuthWorker(QThread):
encoding="utf-8",
errors="replace",
check=False,
timeout=30.0,
)
except (OSError, RuntimeError) as exc:
except (OSError, RuntimeError, subprocess.TimeoutExpired) as exc:
self.result.emit(False, str(exc))
return
if proc.returncode == 0:
@@ -1039,6 +1040,15 @@ class MainWindow(QMainWindow):
self.statusBar().showMessage("Disposition réinitialisée", 3000)
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()
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("ports Naabu SYN (Admin)", source)
self.assertIn("ports Nmap SYN (Admin)", source)
self.assertIn("REPLI Naabu", source)
self.assertIn("REPLI Nmap", source)
if __name__ == "__main__":
+1
View File
@@ -112,6 +112,7 @@ class StandardBehavior0421Tests(unittest.TestCase):
args = worker._nmap.call_args.args[0]
self.assertIn("-sV", args)
self.assertIn("1000", args)
self.assertGreaterEqual(worker._nmap.call_args.kwargs["timeout_seconds"], 90.0)
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.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):
worker = self.worker()
live = {f"192.168.10.{i}" for i in range(1, scanner.NAABU_ACTIVE_HOST_THRESHOLD + 1)}