import contextlib import io import json import tempfile import unittest from pathlib import Path from unittest.mock import patch from rpcscope.cli import main TOOLS = {"nxc": "nxc", "enum4linux-ng": None, "rpcdump": None, "msfconsole": None} class CliTests(unittest.TestCase): def test_fast_default_deep_collection_and_isolated_fallback_plans(self): for extra, deep, isolated in (([], False, False), (["--deep"], True, False), (["--isolated-checks"], False, True)): with self.subTest(extra=extra), tempfile.TemporaryDirectory() as temporary: captured = io.StringIO() destination = Path(temporary) / "absent" with patch("rpcscope.runner.doctor", return_value=TOOLS), contextlib.redirect_stdout(captured): code = main(["scan", "192.0.2.1", "--checks", "recommended", "--dry-run", "-o", str(destination), *extra]) commands = json.loads(captured.getvalue()) self.assertEqual(code, 0) self.assertFalse(destination.exists()) self.assertEqual(any(command[0] == "enum4linux-ng" for command in commands), deep) self.assertEqual(sum("-M" in command for command in commands), 11 if isolated else 7) self.assertTrue(any(command[0] == "" for command in commands)) def test_tcp_share_dry_run_shows_gating_without_guessing_live_capabilities(self): captured = io.StringIO() with patch("rpcscope.runner.doctor", return_value=TOOLS), \ patch("rpcscope.runner.inspect_capabilities") as inspect, \ contextlib.redirect_stdout(captured): self.assertEqual(main(["scan", "192.0.2.0/24", "--shares", "--dry-run"]), 0) inspect.assert_not_called() commands = json.loads(captured.getvalue()) self.assertEqual(commands[0][0], "") self.assertEqual(commands[1][2], "") self.assertIn("", commands[1]) self.assertNotIn("--no-write-check", commands[1]) def test_invalid_limits_are_usage_errors(self): for argument in ("--jobs", "--discovery-workers", "--timeout", "--discovery-timeout"): with self.subTest(argument=argument), contextlib.redirect_stderr(io.StringIO()): with self.assertRaises(SystemExit) as raised: main(["scan", "192.0.2.1", argument, "0", "--dry-run"]) self.assertEqual(raised.exception.code, 2) def test_progress_and_final_reports_use_attributed_check_results(self): with tempfile.TemporaryDirectory() as temporary: destination = Path(temporary) / "scan" captured, diagnostics = io.StringIO(), io.StringIO() def execute(command, timeout, log, password, **kwargs): if "-M" not in command: return 0, "\n".join( f"SMB 192.0.2.{i} 445 HOST{i} [*] Windows 11 (name:HOST{i}) (domain:LAB) (signing:True) (SMBv1:False)" for i in (1, 2) ) lines = [] for host in command[2:command.index("-u=")]: lines.append(f"MS17-010 {host} 445 HOST1 {host} is likely VULNERABLE to MS17-010! (Windows 7)" if host.endswith(".1") else f"MS17-010 {host} 445 HOST2 {host} does NOT appear vulnerable") return 0, "\n".join(lines) with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=execute), \ contextlib.redirect_stdout(captured), contextlib.redirect_stderr(diagnostics): code = main(["scan", "192.0.2.0/30", "--checks", "ms17-010", "--authorised", "--discovery", "smb", "--no-enum4linux", "--no-rpcdump", "--progress", "plain", "-o", str(destination)]) self.assertEqual(code, 0) self.assertIn("[3/3] SUMMARY scan complete", diagnostics.getvalue()) self.assertIn("VULN: [MS17-010]", captured.getvalue()) self.assertIn("NOT VULNERABLE (SELECTED CHECKS)", captured.getvalue()) report = json.loads((destination / "report.json").read_text()) self.assertEqual(report["mode"], "live-scan") self.assertEqual(len(report["hosts"]), 2) self.assertEqual((destination / "report.txt").read_text(), captured.getvalue()) self.assertIn("Host summary", (destination / "report.md").read_text()) def test_discovery_failure_returns_nonzero_but_retains_reports(self): with tempfile.TemporaryDirectory() as temporary: destination = Path(temporary) / "scan" with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", return_value=(124, "")), \ contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): code = main(["scan", "192.0.2.0/30", "--authorised", "--discovery", "smb", "--no-progress", "-o", str(destination)]) self.assertEqual(code, 1) report = json.loads((destination / "report.json").read_text()) self.assertTrue(report["errors"]) self.assertEqual(report["hosts"], []) def test_unknown_check_is_completed_with_zero_exit_not_a_negative(self): with tempfile.TemporaryDirectory() as temporary: output = io.StringIO() diagnostics = io.StringIO() def execute(command, timeout, log, password, **kwargs): return (0, "") if "-M" in command else (0, "SMB 192.0.2.1 445 HOST [*] Windows 11 (name:HOST)") with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=execute), \ contextlib.redirect_stdout(output), contextlib.redirect_stderr(diagnostics): code = main(["scan", "192.0.2.1", "--checks", "smbghost", "--discovery", "smb", "--authorised", "--no-enum4linux", "--no-rpcdump", "-o", str(Path(temporary) / "out")]) self.assertEqual(code, 0) self.assertIn("UNKNOWN:", output.getvalue()) self.assertIn("CHECKS: 1/1", output.getvalue()) self.assertNotIn("PARTIAL", output.getvalue()) self.assertNotIn("NOT VULNERABLE", output.getvalue()) self.assertIn("SUMMARY scan complete", diagnostics.getvalue()) def test_dry_run_has_no_files_and_keeps_json_on_stdout(self): with tempfile.TemporaryDirectory() as temporary: destination = Path(temporary) / "scan" output = io.StringIO() with patch("rpcscope.runner.doctor", return_value=TOOLS), contextlib.redirect_stdout(output): code = main(["scan", "192.0.2.1", "--dry-run", "-o", str(destination)]) self.assertEqual(code, 0) self.assertFalse(destination.exists()) self.assertIsInstance(json.loads(output.getvalue()), list) def test_missing_offline_input_is_reported_without_traceback(self): with tempfile.TemporaryDirectory() as temporary: errors = io.StringIO() with contextlib.redirect_stderr(errors): code = main(["analyze", "--nxc", str(Path(temporary) / "missing.txt")]) self.assertEqual(code, 1) self.assertIn("error:", errors.getvalue()) if __name__ == "__main__": unittest.main()