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

285 lines
13 KiB
Python

"""Local subprocess and mocked orchestration tests; collectors never contact hosts."""
import json
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
from unittest.mock import patch
from rpcscope.catalog import CHECKS
from rpcscope.runner import ScanConfig, _run, scan
HOST = "10.20.30.41"
TOOLS = {
"nxc": "fake-nxc",
"enum4linux-ng": "fake-enum4linux",
"rpcdump": "fake-rpcdump",
"msfconsole": "fake-msfconsole",
}
RPC_UUID = "12345678-1234-abcd-ef00-0123456789ab"
def discovery_output(addresses):
return "".join(
f"SMB {address} 445 HOST{number} [*] Windows 7 "
f"(name:HOST{number}) (domain:LAB) (signing:True) (SMBv1:False)\n"
for number, address in enumerate(addresses, 1)
)
class ExecutionTests(unittest.TestCase):
def test_output_limit_stops_a_running_noisy_collector(self):
with tempfile.TemporaryDirectory() as directory:
started = time.monotonic()
code, output = _run([sys.executable, "-B", "-c",
"import sys,time;sys.stdout.write('x'*(17*1024*1024));sys.stdout.flush();time.sleep(10)"],
8, Path(directory) / "noisy.log", "")
self.assertEqual(code, 125)
self.assertIn("output limit", output)
self.assertLess(len(output), 16 * 1024 * 1024 + 200)
self.assertLess(time.monotonic() - started, 7)
def test_timeout_retains_partial_output_and_redacts_saved_log(self):
secret = "timeout-secret-9281"
with tempfile.TemporaryDirectory() as directory:
log = Path(directory) / "collector.log"
code, text = _run([
sys.executable, "-B", "-c",
"import time; print('partial output timeout-secret-9281', flush=True); time.sleep(10)",
], 0.8, log, secret)
self.assertEqual(code, 124)
self.assertIn("partial output " + secret, text)
self.assertIn("RPCScope timeout", text)
saved = log.read_text(encoding="utf-8")
self.assertNotIn(secret, saved)
self.assertEqual(saved, text.replace(secret, "<redacted>"))
def test_passwords_matching_host_digits_or_markers_do_not_corrupt_scanning(self):
for password in ("1", "VULNERABLE"):
with self.subTest(password=password), tempfile.TemporaryDirectory() as directory:
def local_collector(command, timeout, log, supplied_password, **kwargs):
text = (
f"MS17-010 {HOST} 445 HOST [+] VULNERABLE\n"
if "-M" in command else discovery_output([HOST])
)
return _run([
sys.executable, "-B", "-c", f"print({text!r}, end='')",
], timeout, log, supplied_password, **kwargs)
config = ScanConfig(
targets=["example.test"], username="auditor", password=password,
out_dir=Path(directory), use_enum4linux=False, use_rpcdump=False,
checks=[CHECKS["ms17-010"]],
)
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch(
"rpcscope.runner._run", side_effect=local_collector,
):
hosts = scan(config)
self.assertEqual(set(hosts), {HOST})
self.assertEqual(hosts[HOST].checks["ms17-010"], "vulnerable")
if password == "VULNERABLE":
self.assertEqual(hosts[HOST].check_details["ms17-010"], "<redacted>")
for path in (Path(directory) / "raw").iterdir():
self.assertNotIn(password, path.read_text(encoding="utf-8"))
def test_cancellation_stops_local_process(self):
with tempfile.TemporaryDirectory() as directory:
cancel = threading.Event()
cancel.set()
code, text = _run([
sys.executable, "-B", "-c", "import time; time.sleep(10)",
], 10, Path(directory) / "cancelled.log", "", cancel=cancel)
self.assertEqual(code, 130)
self.assertIn("cancelled", text)
def test_dry_run_never_creates_output_or_launches_collectors(self):
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "never-created"
config = ScanConfig(
targets=[HOST], username="auditor", password="private-password",
domain="LAB", dry_run=True, out_dir=output, use_msf=True,
checks=[CHECKS["ms17-010"]],
)
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run") as run:
self.assertEqual(scan(config), {})
run.assert_not_called()
self.assertFalse(output.exists())
self.assertEqual(list(Path(directory).iterdir()), [])
commands = json.dumps(config.commands)
self.assertNotIn(config.password, commands)
self.assertIn("fake-enum4linux", commands)
self.assertIn("fake-rpcdump", commands)
self.assertIn("fake-msfconsole", commands)
def test_nonempty_output_is_refused_before_any_collector_runs(self):
with tempfile.TemporaryDirectory() as directory:
output = Path(directory)
stale = output / "enum4linux-old.json"
stale.write_text('{"target": {"host": "192.0.2.5"}}', encoding="utf-8")
config = ScanConfig(targets=[HOST], out_dir=output)
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run") as run:
with self.assertRaisesRegex(ValueError, "empty directory"):
scan(config)
run.assert_not_called()
self.assertTrue(stale.exists())
def test_host_check_concurrency_is_bounded_by_jobs(self):
addresses = ["10.20.30.41", "10.20.30.42", "10.20.30.43", "10.20.30.44"]
barrier = threading.Barrier(2, timeout=3)
lock = threading.Lock()
active = 0
peak = 0
checked = []
def fake_run(command, timeout, log, password, **kwargs):
nonlocal active, peak
if "-M" not in command:
return 0, discovery_output(addresses)
address = command[2]
with lock:
active += 1
peak = max(peak, active)
checked.append(address)
try:
barrier.wait()
return 0, f"MS17-010 {address} 445 HOST [+] VULNERABLE\n"
finally:
with lock:
active -= 1
with tempfile.TemporaryDirectory() as directory:
config = ScanConfig(
targets=addresses, out_dir=Path(directory), jobs=2,
use_enum4linux=False, use_rpcdump=False, checks=[CHECKS["ms17-010"]],
)
events = []
snapshots = []
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=fake_run):
hosts = scan(config, progress=events.append, checkpoint=lambda value: snapshots.append(len(value)))
self.assertEqual(peak, 2)
self.assertCountEqual(checked, addresses)
self.assertTrue(all(host.checks["ms17-010"] == "vulnerable" for host in hosts.values()))
self.assertEqual(config.errors, [])
self.assertEqual(events[-1].kind, "summary")
finishes = [event.completed for event in events if event.kind == "finish"]
self.assertEqual(finishes, sorted(finishes))
self.assertEqual(finishes[-1], 5)
self.assertEqual(len(snapshots), 5)
def test_malformed_enum_export_does_not_block_rpcdump(self):
calls = []
def fake_run(command, timeout, log, password, **kwargs):
calls.append(command[0])
if command[0] == TOOLS["nxc"]:
return 0, discovery_output([HOST])
if command[0] == TOOLS["enum4linux-ng"]:
prefix = command[command.index("-oJ") + 1]
Path(prefix + ".json").write_text("{malformed", encoding="utf-8")
return 0, ""
if command[0] == TOOLS["rpcdump"]:
return 0, f"Protocol: TEST\nProvider: test.dll\nUUID: {RPC_UUID} v1.0\nBindings:\n ncacn_ip_tcp:{HOST}[49670]\n"
raise AssertionError(f"unexpected collector {command[0]}")
with tempfile.TemporaryDirectory() as directory:
config = ScanConfig(targets=[HOST], out_dir=Path(directory))
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=fake_run):
hosts = scan(config)
host = hosts[HOST]
self.assertIn(TOOLS["rpcdump"], calls)
self.assertEqual(host.collection["enum4linux"], "failed")
self.assertEqual(host.collection["rpcdump"], "success")
self.assertEqual(host.rpc_interfaces[0].uuid, RPC_UUID)
self.assertTrue(any("enum4linux" in error for error in config.errors))
def test_enum_export_scrubs_escaped_password_and_passes_domain(self):
password = 'spaces "quotes" and \\slashes'
def fake_run(command, timeout, log, supplied_password, **kwargs):
if command[0] == TOOLS["nxc"]:
return 0, discovery_output([HOST])
self.assertEqual(command[command.index("-w") + 1], "LAB")
prefix = command[command.index("-oJ") + 1]
data = {
"target": {"host": HOST},
"credentials": {"password": password, "user": "auditor"},
"notes": ["echo: " + password],
}
Path(prefix + ".json").write_text(json.dumps(data), encoding="utf-8")
return 0, ""
with tempfile.TemporaryDirectory() as directory:
output = Path(directory)
config = ScanConfig(
targets=[HOST], username="auditor", password=password, domain="LAB",
out_dir=output, use_rpcdump=False,
)
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=fake_run):
hosts = scan(config)
exported = json.loads((output / "raw" / f"enum4linux-{HOST}.json").read_text(encoding="utf-8"))
self.assertEqual(exported["credentials"]["password"], "<redacted>")
self.assertEqual(exported["notes"], ["echo: <redacted>"])
self.assertEqual(hosts[HOST].collection["enum4linux"], "success")
self.assertFalse(any(path.name.startswith(".enum-") for path in (output / "raw").iterdir()))
for command in config.commands:
for argument in command:
self.assertNotIn(password, argument)
def test_enum_password_digit_preserves_target_identity_and_raw_facts(self):
def fake_run(command, timeout, log, supplied_password, **kwargs):
if command[0] == TOOLS["nxc"]:
return 0, f"SMB {HOST} 445 HOST [*] (name:HOST) (domain:LAB)\n"
prefix = command[command.index("-oJ") + 1]
data = {
"target": {"host": HOST},
"credentials": {"password": "1", "user": "auditor"},
"os_info": {"Native OS": "Windows 10"},
}
Path(prefix + ".json").write_text(json.dumps(data), encoding="utf-8")
return 0, ""
with tempfile.TemporaryDirectory() as directory:
output = Path(directory)
config = ScanConfig(
targets=[HOST], username="auditor", password="1", out_dir=output,
use_rpcdump=False,
)
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=fake_run):
hosts = scan(config)
self.assertEqual(hosts[HOST].os, "Windows 10")
exported = json.loads((output / "raw" / f"enum4linux-{HOST}.json").read_text(encoding="utf-8"))
self.assertEqual(exported["target"]["host"], HOST)
self.assertEqual(exported["credentials"]["password"], "<redacted>")
def test_later_msf_inconclusive_or_negative_preserves_nxc_positive(self):
cases = [
(0, "[*] scanner completed without a result\n", False),
(0, f"[-] {HOST}:445 - Host does NOT appear vulnerable.\n", True),
]
for msf_code, msf_text, conflict in cases:
with self.subTest(conflict=conflict), tempfile.TemporaryDirectory() as directory:
def fake_run(command, timeout, log, password, **kwargs):
if command[0] == TOOLS["msfconsole"]:
return msf_code, msf_text
if "-M" in command:
return 0, f"MS17-010 {HOST} 445 HOST [+] VULNERABLE\n"
return 0, discovery_output([HOST])
config = ScanConfig(
targets=[HOST], out_dir=Path(directory), use_enum4linux=False,
use_rpcdump=False, use_msf=True, checks=[CHECKS["ms17-010"]],
)
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=fake_run):
host = scan(config)[HOST]
self.assertEqual(host.checks["ms17-010"], "vulnerable")
self.assertEqual(host.check_details["ms17-010"], "VULNERABLE")
self.assertEqual(any("Conflicting ms17-010" in note for note in host.notes), conflict)
if __name__ == "__main__":
unittest.main()