Initial RPCScope release
This commit is contained in:
commit
9dc7289652
40 files changed
+6688
No files matched your search
@@ -0,0 +1,284 @@
|
||||
"""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()
|
||||
Reference in new issue
Block a user