"""Mocked full scans: no sockets, external scanners or target traffic.""" import tempfile import unittest from pathlib import Path from unittest.mock import patch from rpcscope.catalog import EXPOSURE_CHECKS, resolve_checks from rpcscope.checks import CheckResult from rpcscope.netexec import NxcCapabilities from rpcscope.reporting import coverage from rpcscope.runner import ScanConfig, scan TOOLS = {"nxc": "fake-nxc", "enum4linux-ng": "fake-enum", "rpcdump": "fake-rpc", "msfconsole": None} CURRENT = NxcCapabilities.from_help("--shares --file-write-check --no-admin-check") def targets(command): return command[2:next(i for i, arg in enumerate(command) if arg.startswith("-u="))] def banner(address): return f"SMB {address} 445 HOST [*] Windows Server 2022 (name:HOST) (domain:LAB)" def alive(addresses, on_result, **kwargs): for address in addresses: on_result(address, {135: "open", 445: "open"}) class BatchExecutionTests(unittest.TestCase): def test_duplicate_api_checks_are_executed_and_counted_once(self): definition = resolve_checks(["ms17-010"])[0] with tempfile.TemporaryDirectory() as temporary: config = ScanConfig(["192.0.2.1"], out_dir=Path(temporary), batch_checks=True, use_enum4linux=False, use_rpcdump=False, checks=[definition, definition]) def execute(command, timeout, log, password, **kwargs): return (0, "MS17-010 192.0.2.1 445 HOST VULNERABLE") if "-M" in command else (0, banner("192.0.2.1")) events = [] with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=execute) as run: hosts = scan(config, progress=events.append) self.assertEqual(run.call_count, 2) self.assertEqual(len(hosts["192.0.2.1"].check_attempts), 1) self.assertEqual(events[-1].completed, events[-1].total) def test_full_enumeration_does_not_delay_dedicated_checks(self): order = [] def execute(command, timeout, log, password, **kwargs): if command[0] == "fake-enum": order.append("enum") prefix = command[command.index("-oJ") + 1] Path(prefix + ".json").write_text('{"target":{"host":"192.0.2.1"}}', encoding="utf-8") return 0, "" if "-M" in command: order.append("check") return 0, "MS17-010 192.0.2.1 445 HOST VULNERABLE" order.append("discovery") return 0, banner("192.0.2.1") def native(*args, **kwargs): order.append("native") return CheckResult("inconclusive", "no response") with tempfile.TemporaryDirectory() as temporary: config = ScanConfig(["192.0.2.1"], out_dir=Path(temporary), batch_checks=True, use_enum4linux=True, use_rpcdump=False, checks=resolve_checks(["ms17-010,longlogon"])) with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=execute), \ patch("rpcscope.runner.probe_longlogon", side_effect=native): hosts = scan(config) self.assertEqual(order, ["discovery", "check", "native", "enum"]) self.assertEqual(hosts["192.0.2.1"].collection["enum4linux"], "success") def test_local_discovery_failure_is_retained_as_failure_not_dead_scan_success(self): with tempfile.TemporaryDirectory() as temporary: config = ScanConfig(["192.0.2.1"], out_dir=Path(temporary), tcp_discovery=True, batch_checks=True, checks=resolve_checks(["recommended"])) events = [] with patch("rpcscope.runner.doctor", return_value=TOOLS), \ patch("rpcscope.runner.discover_services", side_effect=OSError("local socket resources exhausted")), \ patch("rpcscope.runner._run") as run: hosts = scan(config, progress=events.append) run.assert_not_called() self.assertFalse(config.cancelled) self.assertEqual(events[-1].status, "incomplete") self.assertEqual(hosts["192.0.2.1"].services["445"], "unprobed") self.assertEqual(hosts["192.0.2.1"].checks, {}) self.assertTrue(any("failed locally" in message for message in config.errors)) def test_recommended_32_host_scan_uses_28_processes_with_full_attribution(self): addresses = [f"192.0.2.{i}" for i in range(1, 33)] commands = [] def execute(command, timeout, log, password, **kwargs): commands.append(command) if "-M" not in command: return 0, "\n".join(banner(address) for address in targets(command)) self.assertLessEqual(len(targets(command)), 8) self.assertLessEqual(int(command[command.index("-t") + 1]), 8) self.assertIn("--no-admin-check", command) module = command[command.index("-M") + 1] lines = [] for address in targets(command): messages = { "ms17-010": [("MS17-010", f"{address} does NOT appear vulnerable")], "zerologon": [], # no recognized output remains UNKNOWN "printnightmare": [("PRINTNIGHT...", "Not vulnerable :'(")], "smbghost": [("SMBGHOST", "Potentially vulnerable to SMBGhost (CVE-2020-0796)")], "coerce_plus": [("COERCE_PLUS", "VULNERABLE, " + method) for method in ("PetitPotam", "DFSCoerce", "ShadowCoerce", "PrinterBug", "MSEven")], "spooler": [("SPOOLER", "Spooler service enabled")], "ioxidresolver": [("IOXIDRES...", "Address: 10.0.0.1")], }[module] lines.extend(f"{name} {address} 445 HOST {message}" for name, message in messages) self.assertEqual(timeout, 600 if module == "coerce_plus" else 120) return 0, "\n".join(lines) with tempfile.TemporaryDirectory() as temporary: config = ScanConfig(addresses, out_dir=Path(temporary), tcp_discovery=True, batch_checks=True, use_enum4linux=False, use_rpcdump=False, checks=resolve_checks(["recommended"])) events, snapshots = [], [] with patch("rpcscope.runner.doctor", return_value=TOOLS), \ patch("rpcscope.runner.inspect_capabilities", return_value=CURRENT), \ patch("rpcscope.runner.discover_services", side_effect=alive), \ patch("rpcscope.runner._run", side_effect=execute), \ patch("rpcscope.runner.probe_longlogon", return_value=CheckResult("precondition-not-met", "not patch detection")): hosts = scan(config, progress=events.append, checkpoint=lambda value: snapshots.append(len(value))) check_commands = [command for command in commands if "-M" in command] self.assertEqual(len(check_commands), 28) self.assertEqual(len(commands), 29) # one separate inventory invocation self.assertEqual(len(hosts), 32) self.assertGreater(len(snapshots), 1) for host in hosts.values(): self.assertEqual(len(host.check_attempts), 12) self.assertEqual(coverage(host), (12, 12, False)) self.assertEqual(host.checks["zerologon"], "inconclusive") self.assertEqual(host.checks["ms17-010"], "not-vulnerable") self.assertEqual(host.checks["smbghost"], "suspected") self.assertEqual(host.checks["oxid"], "observed") self.assertTrue(all(host.checks[key] == "exposed" for key in EXPOSURE_CHECKS)) self.assertEqual(config.errors, []) self.assertEqual(events[-1].status, "complete") self.assertEqual(events[-1].completed, events[-1].total) def test_zero_exit_host_exception_does_not_downgrade_other_hosts(self): addresses = ["192.0.2.1", "192.0.2.2"] def execute(command, timeout, log, password, **kwargs): if "-M" not in command: return 0, "\n".join(banner(address) for address in addresses) return 0, ("MS17-010 192.0.2.1 445 HOST VULNERABLE\n" "Exception while calling proto_flow() on target 192.0.2.2: failure\n" "Traceback (most recent call last):\nValueError: malformed response\n") with tempfile.TemporaryDirectory() as temporary: config = ScanConfig(addresses, out_dir=Path(temporary), batch_checks=True, use_enum4linux=False, use_rpcdump=False, checks=resolve_checks(["ms17-010"])) with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=execute): hosts = scan(config) self.assertEqual(hosts[addresses[0]].checks["ms17-010"], "vulnerable") self.assertFalse(coverage(hosts[addresses[0]])[2]) self.assertEqual(hosts[addresses[1]].checks["ms17-010"], "inconclusive") self.assertTrue(coverage(hosts[addresses[1]])[2]) self.assertTrue(config.errors) def test_failed_batch_is_not_retried_or_mistaken_for_negative(self): def execute(command, timeout, log, password, **kwargs): return (124, "MS17-010 192.0.2.1 445 HOST VULNERABLE") if "-M" in command else (0, banner("192.0.2.1")) with tempfile.TemporaryDirectory() as temporary: config = ScanConfig(["192.0.2.1"], out_dir=Path(temporary), batch_checks=True, use_enum4linux=False, use_rpcdump=False, checks=resolve_checks(["ms17-010"])) with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=execute) as run: hosts = scan(config) self.assertEqual(run.call_count, 2) self.assertEqual(hosts["192.0.2.1"].checks["ms17-010"], "inconclusive") self.assertTrue(coverage(hosts["192.0.2.1"])[2]) def test_batch_cancellation_retains_finished_evidence_and_stops_remaining_work(self): def execute(command, timeout, log, password, cancel, **kwargs): if "-M" in command: cancel.set() return 130, "" return 0, banner("192.0.2.1") with tempfile.TemporaryDirectory() as temporary: config = ScanConfig(["192.0.2.1"], out_dir=Path(temporary), batch_checks=True, checks=resolve_checks(["recommended"])) with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=execute) as run, \ patch("rpcscope.runner.probe_longlogon") as native: hosts = scan(config) native.assert_not_called() self.assertEqual(run.call_count, 2) self.assertTrue(config.cancelled) self.assertNotIn("pending", hosts["192.0.2.1"].checks.values()) self.assertEqual(hosts["192.0.2.1"].checks["longlogon"], "skipped-cancelled") def test_share_inventory_uses_only_verified_readonly_options(self): for caps, expected in ((CURRENT, ["--shares"]), (NxcCapabilities.from_help("--shares --no-write-check"), ["--shares", "--no-write-check"]), (NxcCapabilities(), [])): with self.subTest(flags=caps.flags), tempfile.TemporaryDirectory() as temporary: config = ScanConfig(["192.0.2.1"], out_dir=Path(temporary), shares=True, use_enum4linux=False, use_rpcdump=False) with patch("rpcscope.runner.doctor", return_value=TOOLS), \ patch("rpcscope.runner.inspect_capabilities", return_value=caps), \ patch("rpcscope.runner._run", return_value=(0, banner("192.0.2.1"))) as run: hosts = scan(config) command = run.call_args.args[0] self.assertEqual([arg for arg in command if arg in {"--shares", "--no-write-check"}], expected) self.assertNotIn("--file-write-check", command) self.assertEqual(bool(config.errors), not bool(expected)) if not expected: self.assertEqual(hosts["192.0.2.1"].collection["shares"], "skipped-unsupported") if __name__ == "__main__": unittest.main()