Files
rpcscope/tests/test_cli.py
T

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()