"""Exercise default discovery and new adapters with no target network traffic.""" import contextlib import io import json import tempfile import unittest from pathlib import Path from unittest.mock import patch from rpcscope.catalog import CHECKS, EXPOSURE_CHECKS, resolve_checks from rpcscope.checks import CheckResult from rpcscope.cli import main from rpcscope.reporting import coverage, minimal from rpcscope.models import HostEvidence, Report, RpcInterface from rpcscope.rules import evaluate from rpcscope.runner import ScanConfig, scan RPC_ONLY = "192.0.2.1" SMB_ONLY = "192.0.2.2" TOOLS = {"nxc": "nxc", "enum4linux-ng": "enum", "rpcdump": "rpcdump", "msfconsole": None} def discovered(addresses, on_result, **kwargs): states = {address: {135: "open" if address == RPC_ONLY else "closed", 445: "closed" if address == RPC_ONLY else "open"} for address in addresses} for address, ports in states.items(): on_result(address, ports) return states class DiscoveryIntegrationTests(unittest.TestCase): def test_rpc_only_hosts_survive_and_closed_services_are_not_scanned(self): calls = [] def run(command, timeout, log, password, **kwargs): calls.append(command) if command[0] == "rpcdump": self.assertEqual(command[-1], RPC_ONLY) return 0, f"UUID: 12345678-1234-abcd-ef00-0123456789ab v1.0\nBindings:\n ncacn_ip_tcp:{RPC_ONLY}[50000]\n" if "-M" in command: self.assertEqual(command[2], SMB_ONLY) return 0, f"MS17-010 {SMB_ONLY} 445 HOST {SMB_ONLY} does NOT appear vulnerable" self.assertNotIn(RPC_ONLY, command) return 0, f"SMB {SMB_ONLY} 445 HOST [*] Windows 11 (name:HOST) (domain:LAB)" with tempfile.TemporaryDirectory() as temporary: config = ScanConfig(["192.0.2.0/30"], out_dir=Path(temporary), tcp_discovery=True, use_enum4linux=False, checks=[CHECKS["ms17-010"]]) events = [] with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner.discover_services", side_effect=discovered), \ patch("rpcscope.runner._run", side_effect=run): hosts = scan(config, progress=events.append) self.assertEqual(set(hosts), {RPC_ONLY, SMB_ONLY}) self.assertEqual(hosts[RPC_ONLY].collection["rpcdump"], "success") self.assertEqual(hosts[SMB_ONLY].collection["rpcdump"], "skipped-service-unavailable") self.assertEqual(hosts[RPC_ONLY].checks["ms17-010"], "skipped-service-unavailable") self.assertEqual(hosts[SMB_ONLY].checks["ms17-010"], "not-vulnerable") self.assertEqual(events[-1].completed, events[-1].total) def test_bind_checks_pass_method_only_and_keep_all_evidence(self): commands = [] def run(command, timeout, log, password, **kwargs): commands.append(command) if "-M" in command: method = command[command.index("-o") + 1].partition("=")[2] return 0, f"COERCE_PLUS {SMB_ONLY} 445 HOST VULNERABLE, {method}" return 0, f"SMB {SMB_ONLY} 445 HOST [*] Windows 11 (name:HOST) (domain:LAB)" with tempfile.TemporaryDirectory() as temporary: config = ScanConfig([SMB_ONLY], out_dir=Path(temporary), checks=resolve_checks(["exposure"]), use_enum4linux=False, use_rpcdump=False) with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=run): hosts = scan(config) host = hosts[SMB_ONLY] self.assertEqual(len(host.check_attempts), len(EXPOSURE_CHECKS)) self.assertTrue(all(status == "exposed" for status in host.checks.values())) self.assertNotIn("LISTENER", str(commands)) self.assertNotIn("ALWAYS", str(commands)) report = Report("now", "test", [], None, None, [host], evaluate([host])) line = minimal(report) self.assertIn("INVENTORY ONLY", line) self.assertIn("EXPOSURE:", line) self.assertNotIn("VULN:", line) self.assertNotIn("NOT VULNERABLE", line) self.assertTrue(all(finding.severity == "info" for finding in report.findings)) def test_default_cli_discovers_ports_without_expanding_oxid_scope(self): with tempfile.TemporaryDirectory() as temporary: output = Path(temporary) / "out" captured = io.StringIO() def run(command, timeout, log, password, **kwargs): if "-M" in command: return 0, f"IOXIDRESOLVER {SMB_ONLY} 445 HOST Address: 10.99.0.99" return 0, f"SMB {SMB_ONLY} 445 HOST [*] Windows 11 (name:HOST) (domain:LAB)" def all_open(addresses, on_result, **kwargs): for address in addresses: on_result(address, {135: "open", 445: "open"}) with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner.discover_services", side_effect=all_open), \ patch("rpcscope.runner._run", side_effect=run), contextlib.redirect_stdout(captured), contextlib.redirect_stderr(io.StringIO()): code = main(["scan", SMB_ONLY, "--checks", "oxid", "--no-enum4linux", "--no-rpcdump", "--authorised", "--no-progress", "-o", str(output)]) self.assertEqual(code, 0) report = json.loads((output / "report.json").read_text(encoding="utf-8")) self.assertEqual([host["address"] for host in report["hosts"]], [SMB_ONLY]) self.assertIn("10.99.0.99", report["hosts"][0]["check_details"]["oxid"]) self.assertIn("INFO: [OXID-INTERFACES]", captured.getvalue()) def test_review_catalog_is_not_an_executable_check(self): captured = io.StringIO() with contextlib.redirect_stdout(captured): self.assertEqual(main(["catalog", "--advisories"]), 0) self.assertIn("CVE-2026-41089", captured.getvalue()) self.assertIn("review-only", captured.getvalue()) with self.assertRaises(ValueError): resolve_checks(["CVE-2026-41089"]) def test_netlogon_and_print_rpc_registrations_have_distinct_ids(self): host = HostEvidence(RPC_ONLY, rpc_interfaces=[ RpcInterface("12345678-1234-abcd-ef00-01234567cffb", "1.0"), RpcInterface("12345678-1234-abcd-ef00-0123456789ab", "1.0"), ]) findings = evaluate([host]) self.assertEqual(len({finding.rule_id for finding in findings}), 2) line = minimal(Report("now", "test", [], None, None, [host], findings)) self.assertIn("[MS-NRPC]", line) self.assertIn("[MS-RPRN]", line) self.assertNotIn(" VULN:", line) def test_dead_targets_stop_at_discovery_and_are_silent(self): with tempfile.TemporaryDirectory() as temporary: output = Path(temporary) / "out" diagnostics = io.StringIO() def timeout(addresses, on_result, **kwargs): for address in addresses: on_result(address, {135: "timeout", 445: "timeout"}) captured = io.StringIO() with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner.discover_services", side_effect=timeout), \ patch("rpcscope.runner._run") as run, contextlib.redirect_stdout(captured), contextlib.redirect_stderr(diagnostics): code = main(["scan", RPC_ONLY, "--checks", "recommended", "--authorised", "-o", str(output)]) self.assertEqual(code, 0) self.assertIn("SUMMARY scan complete", diagnostics.getvalue()) self.assertNotIn(RPC_ONLY, diagnostics.getvalue()) self.assertEqual(captured.getvalue(), "") report = json.loads((output / "report.json").read_text()) self.assertEqual(report["hosts"][0]["checks"], {}) self.assertEqual(report["hosts"][0]["check_attempts"], []) self.assertNotIn("enum4linux", report["hosts"][0]["collection"]) self.assertEqual((output / "report.txt").read_text(), "") self.assertNotIn(f"- `{RPC_ONLY}`", (output / "report.md").read_text()) run.assert_not_called() def test_only_live_host_pipelines_are_scheduled_and_uncertainty_completes(self): dead = "192.0.2.3" def probe(addresses, on_result, **kwargs): self.assertEqual(kwargs["jobs"], 256) for address in addresses: on_result(address, {135: "timeout", 445: "open" if address == SMB_ONLY else "closed"}) def run(command, timeout, log, password, **kwargs): self.assertNotIn(dead, command) if "-M" in command: return 0, f"SMBGHOST {SMB_ONLY} 445 HOST Potentially vulnerable to SMBGhost (CVE-2020-0796)" return 0, f"SMB {SMB_ONLY} 445 HOST [*] Windows 11 (domain:LAB)" with tempfile.TemporaryDirectory() as temporary: config = ScanConfig([SMB_ONLY, dead], out_dir=Path(temporary), tcp_discovery=True, use_enum4linux=False, use_rpcdump=False, checks=[CHECKS["smbghost"]]) events = [] with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner.discover_services", side_effect=probe), \ patch("rpcscope.runner._run", side_effect=run) as execute: hosts = scan(config, progress=events.append) self.assertEqual(execute.call_count, 2) self.assertEqual(hosts[dead].checks, {}) self.assertEqual(coverage(hosts[SMB_ONLY]), (1, 1, False)) self.assertEqual(events[-1].status, "complete") self.assertEqual(events[-1].completed, events[-1].total) self.assertFalse(any(event.host == dead for event in events)) def test_silent_module_is_unknown_not_execution_failure(self): def run(command, timeout, log, password, **kwargs): return (0, "") if "-M" in command else (0, f"SMB {SMB_ONLY} 445 HOST [*] Windows 11") with tempfile.TemporaryDirectory() as temporary: config = ScanConfig([SMB_ONLY], out_dir=Path(temporary), checks=[CHECKS["zerologon"]], use_enum4linux=False, use_rpcdump=False) events = [] with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=run): hosts = scan(config, progress=events.append) host = hosts[SMB_ONLY] self.assertEqual(host.checks["zerologon"], "inconclusive") self.assertEqual(coverage(host), (1, 1, False)) self.assertEqual(events[-1].status, "complete") text = minimal(Report("now", "test", [], None, None, [host], evaluate([host]))) self.assertIn("UNKNOWN:", text) self.assertIn("CHECKS: 1/1", text) self.assertNotIn("PARTIAL", text) self.assertNotIn("NOT VULNERABLE", text) def test_checker_process_failure_remains_partial(self): def run(command, timeout, log, password, **kwargs): return (124, "timeout") if "-M" in command else (0, f"SMB {SMB_ONLY} 445 HOST [*] Windows 11") with tempfile.TemporaryDirectory() as temporary: config = ScanConfig([SMB_ONLY], out_dir=Path(temporary), checks=[CHECKS["ms17-010"]], use_enum4linux=False, use_rpcdump=False) with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=run): hosts = scan(config) self.assertTrue(coverage(hosts[SMB_ONLY])[2]) self.assertEqual(hosts[SMB_ONLY].collection["check:ms17-010"], "failed") self.assertTrue(config.errors) def test_legacy_discovery_does_not_fabricate_dead_literal_hosts(self): with tempfile.TemporaryDirectory() as temporary: config = ScanConfig([SMB_ONLY], out_dir=Path(temporary), checks=[CHECKS["ms17-010"]]) with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", return_value=(0, "")) as execute: hosts = scan(config) self.assertEqual(hosts, {}) self.assertEqual(execute.call_count, 1) self.assertEqual(config.errors, []) def test_legacy_connection_error_line_is_not_an_alive_host(self): with tempfile.TemporaryDirectory() as temporary: config = ScanConfig([SMB_ONLY], out_dir=Path(temporary), checks=[CHECKS["ms17-010"]]) text = f"SMB {SMB_ONLY} 445 NONE [-] Error connecting to host: timed out" with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", return_value=(0, text)) as execute: self.assertEqual(scan(config), {}) self.assertEqual(execute.call_count, 1) def test_native_longlogon_runs_without_netexec_and_keeps_precondition_separate(self): def rpc_host(addresses, on_result, **kwargs): for address in addresses: on_result(address, {135: "open", 445: "closed"}) tools = {key: None for key in TOOLS} with tempfile.TemporaryDirectory() as temporary: config = ScanConfig([RPC_ONLY], out_dir=Path(temporary), tcp_discovery=True, use_enum4linux=False, use_rpcdump=False, checks=[CHECKS["longlogon"]]) with patch("rpcscope.runner.doctor", return_value=tools), patch("rpcscope.runner.discover_services", side_effect=rpc_host), \ patch("rpcscope.runner._run") as external, patch("rpcscope.runner.probe_longlogon", return_value=CheckResult( "precondition-met", "validated minimal legacy response; empirical estimate, not patch detection", )) as native: hosts = scan(config) native.assert_called_once() self.assertEqual(native.call_args.args, (RPC_ONLY,)) external.assert_not_called() host = hosts[RPC_ONLY] self.assertEqual(host.check_attempts[0].backend, "native") self.assertEqual(host.checks["longlogon"], "precondition-met") self.assertTrue(Path(host.check_attempts[0].source).is_file()) self.assertEqual(coverage(host), (1, 1, False)) line = minimal(Report("now", "test", [], None, None, [host], evaluate([host]))) self.assertIn("PRECONDITION: [LONGLOGON:MET]", line) self.assertNotIn("VULN:", line) self.assertNotIn("NOT VULNERABLE", line) self.assertTrue(all(finding.severity == "info" for finding in evaluate([host]))) self.assertEqual(config.errors, []) def test_native_longlogon_is_not_attempted_on_dead_targets(self): def dead(addresses, on_result, **kwargs): for address in addresses: on_result(address, {135: "closed", 445: "closed"}) with tempfile.TemporaryDirectory() as temporary: config = ScanConfig([RPC_ONLY], out_dir=Path(temporary), tcp_discovery=True, use_enum4linux=False, use_rpcdump=False, checks=[CHECKS["longlogon"]]) with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner.discover_services", side_effect=dead), \ patch("rpcscope.runner.probe_longlogon") as native: hosts = scan(config) native.assert_not_called() self.assertEqual(hosts[RPC_ONLY].checks, {}) def test_native_dry_run_records_plan_without_sending_packets(self): with tempfile.TemporaryDirectory() as temporary: config = ScanConfig([RPC_ONLY], out_dir=Path(temporary) / "absent", dry_run=True, use_enum4linux=False, use_rpcdump=False, checks=[CHECKS["longlogon"]]) with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner.probe_longlogon") as native: self.assertEqual(scan(config), {}) native.assert_not_called() self.assertFalse(config.out_dir.exists()) self.assertTrue(any(command[0] == "" for command in config.commands)) self.assertFalse(any("-M" in command for command in config.commands)) def test_discovery_deadline_does_not_treat_unprobed_ports_as_completed(self): def interrupted_scope(addresses, on_result, **kwargs): on_result(addresses[0], {135: "closed"}) with tempfile.TemporaryDirectory() as temporary: config = ScanConfig([RPC_ONLY], out_dir=Path(temporary), tcp_discovery=True, use_enum4linux=False, use_rpcdump=False, checks=[CHECKS["longlogon"]]) events = [] with patch("rpcscope.runner.doctor", return_value=TOOLS), \ patch("rpcscope.runner.discover_services", side_effect=interrupted_scope), \ patch("rpcscope.runner.probe_longlogon") as native: hosts = scan(config, progress=events.append) native.assert_not_called() self.assertEqual(hosts[RPC_ONLY].services["445"], "unprobed") self.assertEqual(hosts[RPC_ONLY].collection["tcp-discovery"], "incomplete") self.assertEqual(hosts[RPC_ONLY].checks, {}) self.assertEqual(events[-1].status, "incomplete") self.assertTrue(config.errors) if __name__ == "__main__": unittest.main()