diff --git a/dist/librenet-scanner_0.1.0_all.deb b/dist/librenet-scanner_0.1.0_all.deb deleted file mode 100644 index e7c124c..0000000 Binary files a/dist/librenet-scanner_0.1.0_all.deb and /dev/null differ diff --git a/dist/librenet-scanner_1.0.0_amd64.deb b/dist/librenet-scanner_1.0.0_amd64.deb new file mode 100644 index 0000000..ac490e3 Binary files /dev/null and b/dist/librenet-scanner_1.0.0_amd64.deb differ diff --git a/pyproject.toml b/pyproject.toml index 7d88c88..021046f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,3 +19,6 @@ package-dir = {"" = "src"} [tool.setuptools.packages.find] where = ["src"] + +[tool.pytest.ini_options] +pythonpath = ["src"] diff --git a/src/librenet_scanner/privileged_helper.py b/src/librenet_scanner/privileged_helper.py index ccaecaf..fb1e845 100644 --- a/src/librenet_scanner/privileged_helper.py +++ b/src/librenet_scanner/privileged_helper.py @@ -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") diff --git a/src/librenet_scanner/privileges.py b/src/librenet_scanner/privileges.py index 572ffb9..9603eac 100644 --- a/src/librenet_scanner/privileges.py +++ b/src/librenet_scanner/privileges.py @@ -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 diff --git a/src/librenet_scanner/scanner.py b/src/librenet_scanner/scanner.py index 640a8c8..67c8cb6 100644 --- a/src/librenet_scanner/scanner.py +++ b/src/librenet_scanner/scanner.py @@ -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: - stdout, stderr = self._proc.communicate() + 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: diff --git a/src/librenet_scanner/ui.py b/src/librenet_scanner/ui.py index d931bf3..54f65a5 100644 --- a/src/librenet_scanner/ui.py +++ b/src/librenet_scanner/ui.py @@ -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) diff --git a/tests/test_v0419.py b/tests/test_v0419.py index ed72036..8f9f310 100644 --- a/tests/test_v0419.py +++ b/tests/test_v0419.py @@ -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__": diff --git a/tests/test_v0421.py b/tests/test_v0421.py index d3ee6bb..a063947 100644 --- a/tests/test_v0421.py +++ b/tests/test_v0421.py @@ -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): diff --git a/tests/test_v0423.py b/tests/test_v0423.py index ed050b0..0a692c5 100644 --- a/tests/test_v0423.py +++ b/tests/test_v0423.py @@ -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)}