134 lines
7.7 KiB
Python
134 lines
7.7 KiB
Python
import contextlib
|
|
import io
|
|
import json
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
from rpcscope.cli import main
|
|
from rpcscope.checks import CheckResult
|
|
|
|
|
|
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), 10 if isolated else 6)
|
|
self.assertTrue(any(command[0] == "<native:smb-compression>" for command in commands))
|
|
self.assertTrue(any(command[0] == "<native:longlogon>" 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], "<native:tcp-discovery>")
|
|
self.assertEqual(commands[1][2], "<TCP/445-listeners>")
|
|
self.assertIn("<version-specific-read-only-options>", 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", "--format", "full", "-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), \
|
|
patch("rpcscope.runner.probe_smb_compression", return_value=CheckResult("inconclusive", "no response")), \
|
|
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("1 hosts | 0 confirmed", output.getvalue())
|
|
self.assertNotIn("PARTIAL", output.getvalue())
|
|
self.assertNotIn("NOT VULNERABLE", output.getvalue())
|
|
self.assertIn("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()
|