Files
rpcscope/tests/test_cli.py
T
2026-10-15 12:34:26 -04:00

131 lines
7.4 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
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] == "<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", "-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()