Initial RPCScope release

This commit is contained in:
0x000 committed 2026-10-15 12:34:26 -04:00
commit 9dc7289652
40 files changed
+6688

No files matched your search

+147
View File
@@ -0,0 +1,147 @@
"""Advisory refresh is metadata-only, bounded, and atomic; no network needed."""
import copy
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from rpcscope.advisories import ADVISORIES, _download, sync_advisories
from rpcscope.catalog import CHECKS
MICROSOFT = "f38d906d-7342-40ea-92c1-6c4a2c6478c8"
def record_for(advisory):
return {
"dataType": "CVE_RECORD",
"cveMetadata": {
"cveId": advisory.key, "state": "PUBLISHED", "assignerOrgId": MICROSOFT,
"datePublished": "2026-05-12T16:58:50.091Z", "dateUpdated": "2026-08-10T15:12:59.779Z",
},
"containers": {"cna": {
"title": "Vendor advisory title",
"providerMetadata": {"orgId": MICROSOFT, "shortName": "microsoft"},
"references": [
{"url": advisory.reference}, {"url": "https://example.com/poc.py"},
{"url": "https://msrc.microsoft.com.evil.example/metadata"},
],
"affected": [{"vendor": "Microsoft", "product": "Windows Example",
"platforms": ["x64-based Systems"],
"versions": [{"version": "10.0.0", "lessThan": "10.0.1",
"versionType": "custom", "status": "affected"}]}],
"untrusted_command": "do not carry executable fields into cache",
}},
}
class AdvisoryTests(unittest.TestCase):
def test_registry_remains_separate_from_executable_checks(self):
self.assertEqual(len(ADVISORIES), 7)
self.assertTrue(all(item.status == "review-only" for item in ADVISORIES.values()))
executable_cves = {cve for check in CHECKS.values() if check.kind == "vulnerability" for cve in check.cves}
self.assertFalse(executable_cves.intersection(ADVISORIES))
self.assertEqual(CHECKS["longlogon"].kind, "precondition")
self.assertEqual(CHECKS["longlogon"].positive_status, "precondition-met")
self.assertIn("/2020/1xxx/", ADVISORIES["CVE-2020-1206"].cna_url)
self.assertIn("/2026/41xxx/", ADVISORIES["CVE-2026-41089"].cna_url)
def test_sync_copies_selected_metadata_and_never_fetches_references(self):
fetched = []
by_url = {item.cna_url: item for item in ADVISORIES.values()}
def fetch(url):
fetched.append(url)
return json.dumps(record_for(by_url[url])).encode()
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / "nested" / "advisories.json"
result = sync_advisories(path, fetch=fetch)
dataset = json.loads(path.read_text(encoding="utf-8"))
self.assertEqual(fetched, list(by_url))
self.assertEqual(dataset["advisories"], result)
self.assertFalse(dataset["scanner_coverage"])
self.assertEqual(result[0]["title"], "Vendor advisory title")
self.assertEqual(result[0]["vendorReferences"], [result[0]["reference"]])
self.assertEqual(result[0]["affected"][0]["versions"][0]["lessThan"], "10.0.1")
self.assertNotIn("untrusted_command", json.dumps(result))
self.assertTrue(all(item["status"] == "review-only" for item in result))
def test_invalid_later_record_preserves_existing_destination(self):
advisories = list(ADVISORIES.values())
index = 0
def fetch(url):
nonlocal index
record = record_for(advisories[index])
index += 1
if index == 2:
record["cveMetadata"]["cveId"] = "CVE-2000-0001"
return json.dumps(record).encode()
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / "advisories.json"
path.write_text("existing valid cache", encoding="utf-8")
with self.assertRaisesRegex(ValueError, "CVE ID mismatch"):
sync_advisories(path, fetch=fetch)
self.assertEqual(path.read_text(encoding="utf-8"), "existing valid cache")
def test_unpublished_untrusted_and_malformed_records_fail_closed(self):
advisory = next(iter(ADVISORIES.values()))
original = record_for(advisory)
cases = []
for mutate in (
lambda record: record["cveMetadata"].update(state="REJECTED"),
lambda record: record["cveMetadata"].update(assignerOrgId="other"),
lambda record: record["containers"]["cna"]["providerMetadata"].update(orgId="other"),
lambda record: record["containers"]["cna"]["providerMetadata"].update(shortName="other"),
lambda record: record["containers"]["cna"].update(title="bad\x1b[31m"),
lambda record: record["cveMetadata"].update(dateUpdated="yesterday"),
lambda record: record["containers"]["cna"].update(references=[]),
lambda record: record["containers"]["cna"].update(affected=[{}] * 513),
):
record = copy.deepcopy(original)
mutate(record)
cases.append(json.dumps(record).encode())
cases.extend([b"not JSON", b"{}", b"x" * (2 * 1024 * 1024 + 1)])
with tempfile.TemporaryDirectory() as temporary:
for payload in cases:
with self.subTest(payload_size=len(payload)):
path = Path(temporary) / "advisories.json"
with self.assertRaises(ValueError):
sync_advisories(path, fetch=lambda url: payload)
self.assertFalse(path.exists())
def test_absent_cna_title_has_curated_fallback(self):
by_url = {item.cna_url: item for item in ADVISORIES.values()}
def fetch(url):
record = record_for(by_url[url])
del record["containers"]["cna"]["title"]
return json.dumps(record).encode()
with tempfile.TemporaryDirectory() as temporary:
snapshots = sync_advisories(Path(temporary) / "metadata.json", fetch=fetch)
self.assertEqual(snapshots[0]["title"], next(iter(ADVISORIES.values())).title)
def test_network_failure_preserves_existing_destination(self):
def fetch(url):
raise OSError("network unavailable")
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / "metadata.json"
path.write_text("old cache", encoding="utf-8")
with self.assertRaisesRegex(ValueError, "network unavailable"):
sync_advisories(path, fetch=fetch)
self.assertEqual(path.read_text(encoding="utf-8"), "old cache")
def test_downloader_bounds_read_and_timeout_and_rejects_redirect(self):
url = next(iter(ADVISORIES.values())).cna_url
with patch("rpcscope.advisories.urlopen") as opening:
response = opening.return_value.__enter__.return_value
response.geturl.return_value = url
response.read.return_value = b"{}"
self.assertEqual(_download(url), b"{}")
self.assertEqual(opening.call_args.kwargs["timeout"], 15)
response.read.assert_called_once_with(2 * 1024 * 1024 + 1)
response.geturl.return_value = "https://example.com/redirect"
with self.assertRaisesRegex(ValueError, "redirected"):
_download(url)
if __name__ == "__main__":
unittest.main()
+209
View File
@@ -0,0 +1,209 @@
"""Mocked full scans: no sockets, external scanners or target traffic."""
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from rpcscope.catalog import EXPOSURE_CHECKS, resolve_checks
from rpcscope.checks import CheckResult
from rpcscope.netexec import NxcCapabilities
from rpcscope.reporting import coverage
from rpcscope.runner import ScanConfig, scan
TOOLS = {"nxc": "fake-nxc", "enum4linux-ng": "fake-enum", "rpcdump": "fake-rpc", "msfconsole": None}
CURRENT = NxcCapabilities.from_help("--shares --file-write-check --no-admin-check")
def targets(command):
return command[2:next(i for i, arg in enumerate(command) if arg.startswith("-u="))]
def banner(address):
return f"SMB {address} 445 HOST [*] Windows Server 2022 (name:HOST) (domain:LAB)"
def alive(addresses, on_result, **kwargs):
for address in addresses:
on_result(address, {135: "open", 445: "open"})
class BatchExecutionTests(unittest.TestCase):
def test_duplicate_api_checks_are_executed_and_counted_once(self):
definition = resolve_checks(["ms17-010"])[0]
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig(["192.0.2.1"], out_dir=Path(temporary), batch_checks=True,
use_enum4linux=False, use_rpcdump=False, checks=[definition, definition])
def execute(command, timeout, log, password, **kwargs):
return (0, "MS17-010 192.0.2.1 445 HOST VULNERABLE") if "-M" in command else (0, banner("192.0.2.1"))
events = []
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=execute) as run:
hosts = scan(config, progress=events.append)
self.assertEqual(run.call_count, 2)
self.assertEqual(len(hosts["192.0.2.1"].check_attempts), 1)
self.assertEqual(events[-1].completed, events[-1].total)
def test_full_enumeration_does_not_delay_dedicated_checks(self):
order = []
def execute(command, timeout, log, password, **kwargs):
if command[0] == "fake-enum":
order.append("enum")
prefix = command[command.index("-oJ") + 1]
Path(prefix + ".json").write_text('{"target":{"host":"192.0.2.1"}}', encoding="utf-8")
return 0, ""
if "-M" in command:
order.append("check")
return 0, "MS17-010 192.0.2.1 445 HOST VULNERABLE"
order.append("discovery")
return 0, banner("192.0.2.1")
def native(*args, **kwargs):
order.append("native")
return CheckResult("inconclusive", "no response")
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig(["192.0.2.1"], out_dir=Path(temporary), batch_checks=True,
use_enum4linux=True, use_rpcdump=False, checks=resolve_checks(["ms17-010,longlogon"]))
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=execute), \
patch("rpcscope.runner.probe_longlogon", side_effect=native):
hosts = scan(config)
self.assertEqual(order, ["discovery", "check", "native", "enum"])
self.assertEqual(hosts["192.0.2.1"].collection["enum4linux"], "success")
def test_local_discovery_failure_is_retained_as_failure_not_dead_scan_success(self):
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig(["192.0.2.1"], out_dir=Path(temporary), tcp_discovery=True, batch_checks=True,
checks=resolve_checks(["recommended"]))
events = []
with patch("rpcscope.runner.doctor", return_value=TOOLS), \
patch("rpcscope.runner.discover_services", side_effect=OSError("local socket resources exhausted")), \
patch("rpcscope.runner._run") as run:
hosts = scan(config, progress=events.append)
run.assert_not_called()
self.assertFalse(config.cancelled)
self.assertEqual(events[-1].status, "incomplete")
self.assertEqual(hosts["192.0.2.1"].services["445"], "unprobed")
self.assertEqual(hosts["192.0.2.1"].checks, {})
self.assertTrue(any("failed locally" in message for message in config.errors))
def test_recommended_32_host_scan_uses_28_processes_with_full_attribution(self):
addresses = [f"192.0.2.{i}" for i in range(1, 33)]
commands = []
def execute(command, timeout, log, password, **kwargs):
commands.append(command)
if "-M" not in command:
return 0, "\n".join(banner(address) for address in targets(command))
self.assertLessEqual(len(targets(command)), 8)
self.assertLessEqual(int(command[command.index("-t") + 1]), 8)
self.assertIn("--no-admin-check", command)
module = command[command.index("-M") + 1]
lines = []
for address in targets(command):
messages = {
"ms17-010": [("MS17-010", f"{address} does NOT appear vulnerable")],
"zerologon": [], # no recognized output remains UNKNOWN
"printnightmare": [("PRINTNIGHT...", "Not vulnerable :'(")],
"smbghost": [("SMBGHOST", "Potentially vulnerable to SMBGhost (CVE-2020-0796)")],
"coerce_plus": [("COERCE_PLUS", "VULNERABLE, " + method) for method in
("PetitPotam", "DFSCoerce", "ShadowCoerce", "PrinterBug", "MSEven")],
"spooler": [("SPOOLER", "Spooler service enabled")],
"ioxidresolver": [("IOXIDRES...", "Address: 10.0.0.1")],
}[module]
lines.extend(f"{name} {address} 445 HOST {message}" for name, message in messages)
self.assertEqual(timeout, 600 if module == "coerce_plus" else 120)
return 0, "\n".join(lines)
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig(addresses, out_dir=Path(temporary), tcp_discovery=True, batch_checks=True,
use_enum4linux=False, use_rpcdump=False, checks=resolve_checks(["recommended"]))
events, snapshots = [], []
with patch("rpcscope.runner.doctor", return_value=TOOLS), \
patch("rpcscope.runner.inspect_capabilities", return_value=CURRENT), \
patch("rpcscope.runner.discover_services", side_effect=alive), \
patch("rpcscope.runner._run", side_effect=execute), \
patch("rpcscope.runner.probe_longlogon", return_value=CheckResult("precondition-not-met", "not patch detection")):
hosts = scan(config, progress=events.append, checkpoint=lambda value: snapshots.append(len(value)))
check_commands = [command for command in commands if "-M" in command]
self.assertEqual(len(check_commands), 28)
self.assertEqual(len(commands), 29) # one separate inventory invocation
self.assertEqual(len(hosts), 32)
self.assertGreater(len(snapshots), 1)
for host in hosts.values():
self.assertEqual(len(host.check_attempts), 12)
self.assertEqual(coverage(host), (12, 12, False))
self.assertEqual(host.checks["zerologon"], "inconclusive")
self.assertEqual(host.checks["ms17-010"], "not-vulnerable")
self.assertEqual(host.checks["smbghost"], "suspected")
self.assertEqual(host.checks["oxid"], "observed")
self.assertTrue(all(host.checks[key] == "exposed" for key in EXPOSURE_CHECKS))
self.assertEqual(config.errors, [])
self.assertEqual(events[-1].status, "complete")
self.assertEqual(events[-1].completed, events[-1].total)
def test_zero_exit_host_exception_does_not_downgrade_other_hosts(self):
addresses = ["192.0.2.1", "192.0.2.2"]
def execute(command, timeout, log, password, **kwargs):
if "-M" not in command:
return 0, "\n".join(banner(address) for address in addresses)
return 0, ("MS17-010 192.0.2.1 445 HOST VULNERABLE\n"
"Exception while calling proto_flow() on target 192.0.2.2: failure\n"
"Traceback (most recent call last):\nValueError: malformed response\n")
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig(addresses, out_dir=Path(temporary), batch_checks=True,
use_enum4linux=False, use_rpcdump=False, checks=resolve_checks(["ms17-010"]))
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=execute):
hosts = scan(config)
self.assertEqual(hosts[addresses[0]].checks["ms17-010"], "vulnerable")
self.assertFalse(coverage(hosts[addresses[0]])[2])
self.assertEqual(hosts[addresses[1]].checks["ms17-010"], "inconclusive")
self.assertTrue(coverage(hosts[addresses[1]])[2])
self.assertTrue(config.errors)
def test_failed_batch_is_not_retried_or_mistaken_for_negative(self):
def execute(command, timeout, log, password, **kwargs):
return (124, "MS17-010 192.0.2.1 445 HOST VULNERABLE") if "-M" in command else (0, banner("192.0.2.1"))
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig(["192.0.2.1"], out_dir=Path(temporary), batch_checks=True,
use_enum4linux=False, use_rpcdump=False, checks=resolve_checks(["ms17-010"]))
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=execute) as run:
hosts = scan(config)
self.assertEqual(run.call_count, 2)
self.assertEqual(hosts["192.0.2.1"].checks["ms17-010"], "inconclusive")
self.assertTrue(coverage(hosts["192.0.2.1"])[2])
def test_batch_cancellation_retains_finished_evidence_and_stops_remaining_work(self):
def execute(command, timeout, log, password, cancel, **kwargs):
if "-M" in command:
cancel.set()
return 130, ""
return 0, banner("192.0.2.1")
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig(["192.0.2.1"], out_dir=Path(temporary), batch_checks=True,
checks=resolve_checks(["recommended"]))
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=execute) as run, \
patch("rpcscope.runner.probe_longlogon") as native:
hosts = scan(config)
native.assert_not_called()
self.assertEqual(run.call_count, 2)
self.assertTrue(config.cancelled)
self.assertNotIn("pending", hosts["192.0.2.1"].checks.values())
self.assertEqual(hosts["192.0.2.1"].checks["longlogon"], "skipped-cancelled")
def test_share_inventory_uses_only_verified_readonly_options(self):
for caps, expected in ((CURRENT, ["--shares"]),
(NxcCapabilities.from_help("--shares --no-write-check"), ["--shares", "--no-write-check"]),
(NxcCapabilities(), [])):
with self.subTest(flags=caps.flags), tempfile.TemporaryDirectory() as temporary:
config = ScanConfig(["192.0.2.1"], out_dir=Path(temporary), shares=True,
use_enum4linux=False, use_rpcdump=False)
with patch("rpcscope.runner.doctor", return_value=TOOLS), \
patch("rpcscope.runner.inspect_capabilities", return_value=caps), \
patch("rpcscope.runner._run", return_value=(0, banner("192.0.2.1"))) as run:
hosts = scan(config)
command = run.call_args.args[0]
self.assertEqual([arg for arg in command if arg in {"--shares", "--no-write-check"}], expected)
self.assertNotIn("--file-write-check", command)
self.assertEqual(bool(config.errors), not bool(expected))
if not expected:
self.assertEqual(hosts["192.0.2.1"].collection["shares"], "skipped-unsupported")
if __name__ == "__main__":
unittest.main()
+111
View File
@@ -0,0 +1,111 @@
"""Regression cases based on upstream checker output, without network access."""
import unittest
from rpcscope.checks import classify_msf, classify_nxc
HOST = "10.20.30.41"
OTHER_HOST = "10.20.30.42"
def nxc_line(module, message, host=HOST):
return f"{module:16} {host} 445 HOST1 {message}\n"
class CheckerOutputTests(unittest.TestCase):
def test_ms17_indeterminate_message_is_never_positive(self):
result = classify_nxc(
"ms17-010", HOST,
nxc_line("MS17-010", f"[-] {HOST} Unable to detect if this host is vulnerable"),
)
self.assertEqual(result.status, "inconclusive")
def test_ms17_upstream_positive_and_negative_markers(self):
cases = [
(f"[+] {HOST} is likely VULNERABLE to MS17-010! (Windows 7)", "vulnerable"),
("[+] VULNERABLE", "vulnerable"),
(f"[-] {HOST} does NOT appear vulnerable", "not-vulnerable"),
]
for message, expected in cases:
with self.subTest(message=message):
self.assertEqual(
classify_nxc("ms17-010", HOST, nxc_line("MS17-010", message)).status,
expected,
)
def test_markers_must_match_target_and_module(self):
text = (
nxc_line("MS17-010", "[+] VULNERABLE", OTHER_HOST)
+ nxc_line("ZEROLOGON", "[+] VULNERABLE")
+ nxc_line("SMB", r"[+] LAB\auditor:VULNERABLE")
+ "description: target may be VULNERABLE\n"
)
self.assertEqual(classify_nxc("ms17-010", HOST, text).status, "inconclusive")
def test_ansi_and_truncated_module_name_are_attributed(self):
text = "\x1b[32m" + nxc_line(
"PRINTNIGH...",
"[+] Vulnerable, next step https://github.com/ly4k/PrintNightmare",
) + "\x1b[0m"
self.assertEqual(classify_nxc("printnightmare", HOST, text).status, "suspected")
def test_smbghost_capability_indicator_is_only_suspected(self):
text = nxc_line(
"SMBGHOST", "[+] Potentially vulnerable to SMBGhost (CVE-2020-0796)",
)
self.assertEqual(classify_nxc("smbghost", HOST, text).status, "suspected")
def test_nonzero_exit_fails_closed_even_with_result_markers(self):
for message in ("[+] VULNERABLE", f"[-] {HOST} does NOT appear vulnerable"):
with self.subTest(message=message):
result = classify_nxc(
"ms17-010", HOST, nxc_line("MS17-010", message), returncode=124,
)
self.assertEqual(result.status, "inconclusive")
self.assertIn("124", result.evidence)
self.assertEqual(classify_msf(
HOST, f"[+] {HOST}:445 - Host is likely VULNERABLE to MS17-010!\n", 1,
).status, "inconclusive")
def test_conflicting_nxc_markers_require_review(self):
text = (
nxc_line("MS17-010", "[+] VULNERABLE")
+ nxc_line("MS17-010", f"[-] {HOST} does NOT appear vulnerable")
)
result = classify_nxc("ms17-010", HOST, text)
self.assertEqual(result.status, "inconclusive")
self.assertIn("conflicting", result.evidence)
def test_silence_and_upstream_errors_remain_inconclusive(self):
cases = [
("ms17-010", "MS17-010", ""),
("zerologon", "ZEROLOGON", "[+] Attack failed. Target is probably patched."),
("printnightmare", "PRINTNIGHTMARE", "[-] Failed to bind: access denied"),
("smbghost", "SMBGHOST", "[-] Error while connecting to host: timed out"),
]
for key, module, message in cases:
with self.subTest(key=key):
text = nxc_line(module, message) if message else ""
self.assertEqual(classify_nxc(key, HOST, text).status, "inconclusive")
def test_removed_module_is_explicitly_unsupported(self):
text = nxc_line(
"NTLM_REFLECTION",
"[-] [REMOVED] This module has been integrated into the enum_cve module.",
)
self.assertEqual(classify_nxc("ntlm-reflection", HOST, text).status, "skipped-unsupported")
def test_msf_results_require_target_and_conflicts_are_inconclusive(self):
positive = f"[+] {HOST}:445 - Host is likely VULNERABLE to MS17-010! (Windows 7)\n"
negative = f"[-] {HOST}:445 - Host does NOT appear vulnerable.\n"
self.assertEqual(classify_msf(HOST, positive).status, "vulnerable")
self.assertEqual(classify_msf(HOST, negative).status, "not-vulnerable")
self.assertEqual(classify_msf(HOST, positive.replace(HOST, OTHER_HOST)).status, "inconclusive")
result = classify_msf(HOST, positive + negative)
self.assertEqual(result.status, "inconclusive")
self.assertIn("conflicting", result.evidence)
if __name__ == "__main__":
unittest.main()
+130
View File
@@ -0,0 +1,130 @@
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()
+295
View File
@@ -0,0 +1,295 @@
"""Exercise default discovery and new adapters with no target network traffic."""
import contextlib
import io
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from rpcscope.catalog import CHECKS, EXPOSURE_CHECKS, resolve_checks
from rpcscope.checks import CheckResult
from rpcscope.cli import main
from rpcscope.reporting import coverage, minimal
from rpcscope.models import HostEvidence, Report, RpcInterface
from rpcscope.rules import evaluate
from rpcscope.runner import ScanConfig, scan
RPC_ONLY = "192.0.2.1"
SMB_ONLY = "192.0.2.2"
TOOLS = {"nxc": "nxc", "enum4linux-ng": "enum", "rpcdump": "rpcdump", "msfconsole": None}
def discovered(addresses, on_result, **kwargs):
states = {address: {135: "open" if address == RPC_ONLY else "closed",
445: "closed" if address == RPC_ONLY else "open"} for address in addresses}
for address, ports in states.items():
on_result(address, ports)
return states
class DiscoveryIntegrationTests(unittest.TestCase):
def test_rpc_only_hosts_survive_and_closed_services_are_not_scanned(self):
calls = []
def run(command, timeout, log, password, **kwargs):
calls.append(command)
if command[0] == "rpcdump":
self.assertEqual(command[-1], RPC_ONLY)
return 0, f"UUID: 12345678-1234-abcd-ef00-0123456789ab v1.0\nBindings:\n ncacn_ip_tcp:{RPC_ONLY}[50000]\n"
if "-M" in command:
self.assertEqual(command[2], SMB_ONLY)
return 0, f"MS17-010 {SMB_ONLY} 445 HOST {SMB_ONLY} does NOT appear vulnerable"
self.assertNotIn(RPC_ONLY, command)
return 0, f"SMB {SMB_ONLY} 445 HOST [*] Windows 11 (name:HOST) (domain:LAB)"
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig(["192.0.2.0/30"], out_dir=Path(temporary), tcp_discovery=True,
use_enum4linux=False, checks=[CHECKS["ms17-010"]])
events = []
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner.discover_services", side_effect=discovered), \
patch("rpcscope.runner._run", side_effect=run):
hosts = scan(config, progress=events.append)
self.assertEqual(set(hosts), {RPC_ONLY, SMB_ONLY})
self.assertEqual(hosts[RPC_ONLY].collection["rpcdump"], "success")
self.assertEqual(hosts[SMB_ONLY].collection["rpcdump"], "skipped-service-unavailable")
self.assertEqual(hosts[RPC_ONLY].checks["ms17-010"], "skipped-service-unavailable")
self.assertEqual(hosts[SMB_ONLY].checks["ms17-010"], "not-vulnerable")
self.assertEqual(events[-1].completed, events[-1].total)
def test_bind_checks_pass_method_only_and_keep_all_evidence(self):
commands = []
def run(command, timeout, log, password, **kwargs):
commands.append(command)
if "-M" in command:
method = command[command.index("-o") + 1].partition("=")[2]
return 0, f"COERCE_PLUS {SMB_ONLY} 445 HOST VULNERABLE, {method}"
return 0, f"SMB {SMB_ONLY} 445 HOST [*] Windows 11 (name:HOST) (domain:LAB)"
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig([SMB_ONLY], out_dir=Path(temporary), checks=resolve_checks(["exposure"]),
use_enum4linux=False, use_rpcdump=False)
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=run):
hosts = scan(config)
host = hosts[SMB_ONLY]
self.assertEqual(len(host.check_attempts), len(EXPOSURE_CHECKS))
self.assertTrue(all(status == "exposed" for status in host.checks.values()))
self.assertNotIn("LISTENER", str(commands))
self.assertNotIn("ALWAYS", str(commands))
report = Report("now", "test", [], None, None, [host], evaluate([host]))
line = minimal(report)
self.assertIn("INVENTORY ONLY", line)
self.assertIn("EXPOSURE:", line)
self.assertNotIn("VULN:", line)
self.assertNotIn("NOT VULNERABLE", line)
self.assertTrue(all(finding.severity == "info" for finding in report.findings))
def test_default_cli_discovers_ports_without_expanding_oxid_scope(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "out"
captured = io.StringIO()
def run(command, timeout, log, password, **kwargs):
if "-M" in command:
return 0, f"IOXIDRESOLVER {SMB_ONLY} 445 HOST Address: 10.99.0.99"
return 0, f"SMB {SMB_ONLY} 445 HOST [*] Windows 11 (name:HOST) (domain:LAB)"
def all_open(addresses, on_result, **kwargs):
for address in addresses:
on_result(address, {135: "open", 445: "open"})
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner.discover_services", side_effect=all_open), \
patch("rpcscope.runner._run", side_effect=run), contextlib.redirect_stdout(captured), contextlib.redirect_stderr(io.StringIO()):
code = main(["scan", SMB_ONLY, "--checks", "oxid", "--no-enum4linux", "--no-rpcdump",
"--authorised", "--no-progress", "-o", str(output)])
self.assertEqual(code, 0)
report = json.loads((output / "report.json").read_text(encoding="utf-8"))
self.assertEqual([host["address"] for host in report["hosts"]], [SMB_ONLY])
self.assertIn("10.99.0.99", report["hosts"][0]["check_details"]["oxid"])
self.assertIn("INFO: [OXID-INTERFACES]", captured.getvalue())
def test_review_catalog_is_not_an_executable_check(self):
captured = io.StringIO()
with contextlib.redirect_stdout(captured):
self.assertEqual(main(["catalog", "--advisories"]), 0)
self.assertIn("CVE-2026-41089", captured.getvalue())
self.assertIn("review-only", captured.getvalue())
with self.assertRaises(ValueError):
resolve_checks(["CVE-2026-41089"])
def test_netlogon_and_print_rpc_registrations_have_distinct_ids(self):
host = HostEvidence(RPC_ONLY, rpc_interfaces=[
RpcInterface("12345678-1234-abcd-ef00-01234567cffb", "1.0"),
RpcInterface("12345678-1234-abcd-ef00-0123456789ab", "1.0"),
])
findings = evaluate([host])
self.assertEqual(len({finding.rule_id for finding in findings}), 2)
line = minimal(Report("now", "test", [], None, None, [host], findings))
self.assertIn("[MS-NRPC]", line)
self.assertIn("[MS-RPRN]", line)
self.assertNotIn(" VULN:", line)
def test_dead_targets_stop_at_discovery_and_are_silent(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "out"
diagnostics = io.StringIO()
def timeout(addresses, on_result, **kwargs):
for address in addresses:
on_result(address, {135: "timeout", 445: "timeout"})
captured = io.StringIO()
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner.discover_services", side_effect=timeout), \
patch("rpcscope.runner._run") as run, contextlib.redirect_stdout(captured), contextlib.redirect_stderr(diagnostics):
code = main(["scan", RPC_ONLY, "--checks", "recommended", "--authorised", "-o", str(output)])
self.assertEqual(code, 0)
self.assertIn("SUMMARY scan complete", diagnostics.getvalue())
self.assertNotIn(RPC_ONLY, diagnostics.getvalue())
self.assertEqual(captured.getvalue(), "")
report = json.loads((output / "report.json").read_text())
self.assertEqual(report["hosts"][0]["checks"], {})
self.assertEqual(report["hosts"][0]["check_attempts"], [])
self.assertNotIn("enum4linux", report["hosts"][0]["collection"])
self.assertEqual((output / "report.txt").read_text(), "")
self.assertNotIn(f"- `{RPC_ONLY}`", (output / "report.md").read_text())
run.assert_not_called()
def test_only_live_host_pipelines_are_scheduled_and_uncertainty_completes(self):
dead = "192.0.2.3"
def probe(addresses, on_result, **kwargs):
self.assertEqual(kwargs["jobs"], 256)
for address in addresses:
on_result(address, {135: "timeout", 445: "open" if address == SMB_ONLY else "closed"})
def run(command, timeout, log, password, **kwargs):
self.assertNotIn(dead, command)
if "-M" in command:
return 0, f"SMBGHOST {SMB_ONLY} 445 HOST Potentially vulnerable to SMBGhost (CVE-2020-0796)"
return 0, f"SMB {SMB_ONLY} 445 HOST [*] Windows 11 (domain:LAB)"
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig([SMB_ONLY, dead], out_dir=Path(temporary), tcp_discovery=True,
use_enum4linux=False, use_rpcdump=False, checks=[CHECKS["smbghost"]])
events = []
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner.discover_services", side_effect=probe), \
patch("rpcscope.runner._run", side_effect=run) as execute:
hosts = scan(config, progress=events.append)
self.assertEqual(execute.call_count, 2)
self.assertEqual(hosts[dead].checks, {})
self.assertEqual(coverage(hosts[SMB_ONLY]), (1, 1, False))
self.assertEqual(events[-1].status, "complete")
self.assertEqual(events[-1].completed, events[-1].total)
self.assertFalse(any(event.host == dead for event in events))
def test_silent_module_is_unknown_not_execution_failure(self):
def run(command, timeout, log, password, **kwargs):
return (0, "") if "-M" in command else (0, f"SMB {SMB_ONLY} 445 HOST [*] Windows 11")
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig([SMB_ONLY], out_dir=Path(temporary), checks=[CHECKS["zerologon"]],
use_enum4linux=False, use_rpcdump=False)
events = []
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=run):
hosts = scan(config, progress=events.append)
host = hosts[SMB_ONLY]
self.assertEqual(host.checks["zerologon"], "inconclusive")
self.assertEqual(coverage(host), (1, 1, False))
self.assertEqual(events[-1].status, "complete")
text = minimal(Report("now", "test", [], None, None, [host], evaluate([host])))
self.assertIn("UNKNOWN:", text)
self.assertIn("CHECKS: 1/1", text)
self.assertNotIn("PARTIAL", text)
self.assertNotIn("NOT VULNERABLE", text)
def test_checker_process_failure_remains_partial(self):
def run(command, timeout, log, password, **kwargs):
return (124, "timeout") if "-M" in command else (0, f"SMB {SMB_ONLY} 445 HOST [*] Windows 11")
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig([SMB_ONLY], out_dir=Path(temporary), checks=[CHECKS["ms17-010"]],
use_enum4linux=False, use_rpcdump=False)
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=run):
hosts = scan(config)
self.assertTrue(coverage(hosts[SMB_ONLY])[2])
self.assertEqual(hosts[SMB_ONLY].collection["check:ms17-010"], "failed")
self.assertTrue(config.errors)
def test_legacy_discovery_does_not_fabricate_dead_literal_hosts(self):
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig([SMB_ONLY], out_dir=Path(temporary), checks=[CHECKS["ms17-010"]])
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", return_value=(0, "")) as execute:
hosts = scan(config)
self.assertEqual(hosts, {})
self.assertEqual(execute.call_count, 1)
self.assertEqual(config.errors, [])
def test_legacy_connection_error_line_is_not_an_alive_host(self):
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig([SMB_ONLY], out_dir=Path(temporary), checks=[CHECKS["ms17-010"]])
text = f"SMB {SMB_ONLY} 445 NONE [-] Error connecting to host: timed out"
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", return_value=(0, text)) as execute:
self.assertEqual(scan(config), {})
self.assertEqual(execute.call_count, 1)
def test_native_longlogon_runs_without_netexec_and_keeps_precondition_separate(self):
def rpc_host(addresses, on_result, **kwargs):
for address in addresses:
on_result(address, {135: "open", 445: "closed"})
tools = {key: None for key in TOOLS}
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig([RPC_ONLY], out_dir=Path(temporary), tcp_discovery=True,
use_enum4linux=False, use_rpcdump=False, checks=[CHECKS["longlogon"]])
with patch("rpcscope.runner.doctor", return_value=tools), patch("rpcscope.runner.discover_services", side_effect=rpc_host), \
patch("rpcscope.runner._run") as external, patch("rpcscope.runner.probe_longlogon", return_value=CheckResult(
"precondition-met", "validated minimal legacy response; empirical estimate, not patch detection",
)) as native:
hosts = scan(config)
native.assert_called_once()
self.assertEqual(native.call_args.args, (RPC_ONLY,))
external.assert_not_called()
host = hosts[RPC_ONLY]
self.assertEqual(host.check_attempts[0].backend, "native")
self.assertEqual(host.checks["longlogon"], "precondition-met")
self.assertTrue(Path(host.check_attempts[0].source).is_file())
self.assertEqual(coverage(host), (1, 1, False))
line = minimal(Report("now", "test", [], None, None, [host], evaluate([host])))
self.assertIn("PRECONDITION: [LONGLOGON:MET]", line)
self.assertNotIn("VULN:", line)
self.assertNotIn("NOT VULNERABLE", line)
self.assertTrue(all(finding.severity == "info" for finding in evaluate([host])))
self.assertEqual(config.errors, [])
def test_native_longlogon_is_not_attempted_on_dead_targets(self):
def dead(addresses, on_result, **kwargs):
for address in addresses:
on_result(address, {135: "closed", 445: "closed"})
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig([RPC_ONLY], out_dir=Path(temporary), tcp_discovery=True,
use_enum4linux=False, use_rpcdump=False, checks=[CHECKS["longlogon"]])
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner.discover_services", side_effect=dead), \
patch("rpcscope.runner.probe_longlogon") as native:
hosts = scan(config)
native.assert_not_called()
self.assertEqual(hosts[RPC_ONLY].checks, {})
def test_native_dry_run_records_plan_without_sending_packets(self):
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig([RPC_ONLY], out_dir=Path(temporary) / "absent", dry_run=True,
use_enum4linux=False, use_rpcdump=False, checks=[CHECKS["longlogon"]])
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner.probe_longlogon") as native:
self.assertEqual(scan(config), {})
native.assert_not_called()
self.assertFalse(config.out_dir.exists())
self.assertTrue(any(command[0] == "<native:longlogon>" for command in config.commands))
self.assertFalse(any("-M" in command for command in config.commands))
def test_discovery_deadline_does_not_treat_unprobed_ports_as_completed(self):
def interrupted_scope(addresses, on_result, **kwargs):
on_result(addresses[0], {135: "closed"})
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig([RPC_ONLY], out_dir=Path(temporary), tcp_discovery=True,
use_enum4linux=False, use_rpcdump=False, checks=[CHECKS["longlogon"]])
events = []
with patch("rpcscope.runner.doctor", return_value=TOOLS), \
patch("rpcscope.runner.discover_services", side_effect=interrupted_scope), \
patch("rpcscope.runner.probe_longlogon") as native:
hosts = scan(config, progress=events.append)
native.assert_not_called()
self.assertEqual(hosts[RPC_ONLY].services["445"], "unprobed")
self.assertEqual(hosts[RPC_ONLY].collection["tcp-discovery"], "incomplete")
self.assertEqual(hosts[RPC_ONLY].checks, {})
self.assertEqual(events[-1].status, "incomplete")
self.assertTrue(config.errors)
if __name__ == "__main__":
unittest.main()
+284
View File
@@ -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()
+262
View File
@@ -0,0 +1,262 @@
"""Synthetic protocol fixtures only: these tests never open a network socket."""
import socket
import struct
import threading
import unittest
from unittest.mock import patch
from rpcscope import longlogon
ADDRESS = "192.0.2.10"
GUID = bytes(range(16))
def tlv(tag, value):
size = len(value)
length = bytes([size]) if size < 128 else b"\x81" + bytes([size]) if size < 256 else b"\x82" + size.to_bytes(2, "big")
return bytes([tag]) + length + value
def dns_name(name):
return b"".join(bytes([len(label.encode())]) + label.encode() for label in name.split(".") if label) + b"\0"
def identity(domain="lab.example.test", forest="example.test", host=None, nb_domain="LAB", nb_host="DC1", guid=GUID):
return longlogon._Identity(forest, domain, host or "dc1." + domain, nb_domain.casefold(), nb_host.casefold(), guid)
def ex_blob(value=None):
value = value or identity()
names = (value.forest, value.domain, value.host, value.netbios_domain,
value.netbios_host, "x", "Default-First-Site-Name", "")
return (struct.pack("<HHI", 25, 0, 0x18) + value.guid
+ b"".join(dns_name(name) for name in names)
+ struct.pack("<IHH", 5, 0xFFFF, 0xFFFF))
def legacy_blob(value=None):
value = value or identity()
strings = (value.netbios_host, "x", value.netbios_domain)
return (struct.pack("<H", 21)
+ b"".join(name.encode("utf-16-le") + b"\0\0" for name in strings)
+ value.guid + bytes(16)
+ b"".join(dns_name(name) for name in (value.forest, value.domain, value.host))
+ socket.inet_aton(ADDRESS) + struct.pack("<IIHH", 0x10, 3, 0xFFFF, 0xFFFF))
def ldap(blob, message_id, *, done=True):
attribute = tlv(0x30, tlv(4, b"Netlogon") + tlv(0x31, tlv(4, blob)))
entry = tlv(0x30, tlv(2, bytes([message_id])) + tlv(0x64, tlv(4, b"") + tlv(0x30, attribute)))
if done:
entry += tlv(0x30, tlv(2, bytes([message_id])) + tlv(0x65, tlv(10, b"\0") + tlv(4, b"") + tlv(4, b"")))
return entry
def request_id(request):
outer = longlogon._Reader(request)
inner = longlogon._Reader(outer.take(0x30))
return int.from_bytes(inner.take(2), "big")
class FakeSocket:
def __init__(self, responses):
self.responses = list(responses)
self.sent = []
self.peer = None
self.closed = False
def __enter__(self):
return self
def __exit__(self, *args):
self.closed = True
def connect(self, peer):
self.peer = peer
def settimeout(self, timeout):
self.timeout = timeout
def send(self, request):
self.sent.append(request)
return len(request)
def recv(self, maximum):
if not self.responses:
raise OSError("no UDP reply")
response = self.responses.pop(0)
if isinstance(response, Exception):
raise response
if callable(response):
return response(self.sent[-1])
return response
def response(blob):
return lambda request: ldap(blob, request_id(request))
class LongLogonTests(unittest.TestCase):
def probe(self, responses, **kwargs):
fake = FakeSocket(responses)
with patch.object(longlogon.socket, "socket", return_value=fake) as factory:
result = longlogon.probe_longlogon(ADDRESS, **kwargs)
self.assertTrue(fake.closed)
self.assertEqual(fake.peer, (ADDRESS, 389))
self.assertEqual(factory.call_args.args, (socket.AF_INET, socket.SOCK_DGRAM))
return result, fake
def test_valid_pair_is_precondition_only_and_requests_are_fixed_small(self):
result, fake = self.probe([response(ex_blob()), response(legacy_blob())])
self.assertEqual(result.status, "precondition-not-met")
self.assertIn("patch state is not determined", result.evidence)
self.assertIn("projected full-user length=", result.evidence)
self.assertEqual(len(fake.sent), 2)
self.assertNotEqual(request_id(fake.sent[0]), request_id(fake.sent[1]))
self.assertLess(max(map(len, fake.sent)), 256)
self.assertNotIn(b"DnsDomain", fake.sent[0])
self.assertIn(b"DnsDomain", fake.sent[1])
self.assertIn(b"lab.example.test", fake.sent[1])
for request, version in zip(fake.sent, (6, 2)):
self.assertIn(tlv(4, b"User") + tlv(4, b"x"), request)
self.assertIn(tlv(4, b"NtVer") + tlv(4, struct.pack("<I", version)), request)
self.assertNotIn(b"x" * 2, request)
def test_long_identity_can_produce_positive_precondition_without_long_user(self):
value = identity(domain="a" * 60 + ".example.test")
result, fake = self.probe([response(ex_blob(value)), response(legacy_blob(value))])
self.assertEqual(result.status, "precondition-met")
self.assertIn("empirical", result.evidence)
self.assertTrue(all(b"x" * 2 not in request for request in fake.sent))
def test_exact_empirical_thresholds(self):
for estimate, expected in ((527, "precondition-not-met"), (528, "precondition-borderline"),
(535, "precondition-borderline"), (536, "precondition-met")):
with self.subTest(estimate=estimate):
result = longlogon._result("lab.test", 180, estimate - 258, 400)
self.assertEqual(result.status, expected)
self.assertIn(f"length={estimate} B", result.evidence)
def test_known_domain_is_normalized_and_must_match_response(self):
result, fake = self.probe([response(ex_blob()), response(legacy_blob())], domain="LAB.EXAMPLE.TEST.")
self.assertEqual(result.status, "precondition-not-met")
self.assertIn(b"lab.example.test", fake.sent[0])
result, fake = self.probe([response(ex_blob())], domain="other.example.test")
self.assertEqual(result.status, "inconclusive")
self.assertIn("does not match", result.evidence)
self.assertEqual(len(fake.sent), 1)
def test_unsafe_baseline_withholds_legacy_query(self):
domain = ".".join(("a" * 60, "b" * 60, "c" * 60, "test"))
value = identity(domain=domain, forest=domain)
result, fake = self.probe([response(ex_blob(value))])
self.assertEqual(result.status, "inconclusive")
self.assertIn("legacy query withheld", result.evidence)
self.assertIn("upper bound=", result.evidence)
self.assertEqual(len(fake.sent), 1)
def test_no_control_response_and_ex_only_are_unknown_not_negative(self):
result, fake = self.probe([OSError("connection refused")])
self.assertEqual(result.status, "inconclusive")
self.assertEqual(len(fake.sent), 1)
result, fake = self.probe([response(ex_blob()), OSError("no UDP reply")])
self.assertEqual(result.status, "inconclusive")
self.assertIn("EX=", result.evidence)
self.assertIn("legacy=no valid response", result.evidence)
self.assertEqual(len(fake.sent), 2)
def test_socket_timeouts_are_bounded_and_unknown(self):
fake = FakeSocket([socket.timeout()])
with patch.object(longlogon.socket, "socket", return_value=fake), \
patch.object(longlogon.time, "monotonic", side_effect=(0.0, 0.01, 1.0)):
result = longlogon.probe_longlogon(ADDRESS, timeout=0.5)
self.assertEqual(result.status, "inconclusive")
self.assertIn("timed out", result.evidence)
self.assertEqual(len(fake.sent), 1)
def test_invalid_control_packets_never_send_legacy(self):
cases = [b"", b"x" * 8193, b"\x30\x80", b"\x30\x82\xff\xff", b"\x30\x04\x02\x01\x01\x64"]
for raw in cases:
with self.subTest(packet=raw[:8]):
result, fake = self.probe([raw])
self.assertEqual(result.status, "inconclusive")
self.assertEqual(len(fake.sent), 1)
def test_wrong_transaction_id_and_error_only_response_are_unknown(self):
bad_id = lambda request: ldap(ex_blob(), request_id(request) % 127 + 1)
error = lambda request: tlv(0x30, tlv(2, bytes([request_id(request)])) + tlv(0x65, tlv(10, b"\x20") + tlv(4, b"") + tlv(4, b"no object")))
for response_value in (bad_id, error):
result, fake = self.probe([response_value])
self.assertEqual(result.status, "inconclusive")
self.assertEqual(len(fake.sent), 1)
def test_unsupported_control_version_and_opcode_are_unknown(self):
original = ex_blob()
cases = [struct.pack("<H", 24) + original[2:], original[:-8] + struct.pack("<IHH", 3, 0xFFFF, 0xFFFF),
original[:2] + b"\x01\0" + original[4:], original[:-1] + b"\0"]
for blob in cases:
result, fake = self.probe([response(blob)])
self.assertEqual(result.status, "inconclusive")
self.assertEqual(len(fake.sent), 1)
def test_legacy_identity_and_variant_must_match_control(self):
other = identity(guid=b"1" * 16)
old = legacy_blob()
for blob in (legacy_blob(other), old[:-8] + struct.pack("<IHH", 1, 0xFFFF, 0xFFFF), ex_blob()):
result, fake = self.probe([response(ex_blob()), response(blob)])
self.assertEqual(result.status, "inconclusive")
self.assertEqual(len(fake.sent), 2)
def test_ldap_requires_single_netlogon_value_and_success_when_done_present(self):
original = ldap(ex_blob(), 1)
for data in (original.replace(b"Netlogon", b"Anything"), original + original,
original[:-7] + b"\x01" + original[-6:]):
with self.assertRaises(longlogon._Invalid):
longlogon._ldap_netlogon(data, 1)
self.assertEqual(longlogon._ldap_netlogon(ldap(ex_blob(), 1, done=False), 1), ex_blob())
def test_compressed_names_and_cycle_detection(self):
data = b"\x03lab\x04test\0\x02dc\xc0\0"
self.assertEqual(longlogon._name(data, 10, len(data)), ("dc.lab.test", len(data)))
for data, offset in ((b"\xc0\0", 0), (b"\xc0\x02\0", 0), (b"\x40x\0", 0), (b"\x03ab", 0)):
with self.assertRaises(longlogon._Invalid):
longlogon._name(data, offset, len(data))
def test_invalid_target_domain_or_timeout_opens_no_socket(self):
for address, kwargs in (("dc.example.test", {}), (ADDRESS, {"domain": "bad)(*"}),
(ADDRESS, {"timeout": float("inf")}), (ADDRESS, {"timeout": 0})):
with patch.object(longlogon.socket, "socket") as factory:
self.assertEqual(longlogon.probe_longlogon(address, **kwargs).status, "inconclusive")
factory.assert_not_called()
def test_cancel_before_start_sends_nothing(self):
cancel = threading.Event()
cancel.set()
with patch.object(longlogon.socket, "socket") as factory:
result = longlogon.probe_longlogon(ADDRESS, cancel=cancel)
self.assertEqual(result.status, "skipped-cancelled")
factory.assert_not_called()
def test_cancel_after_control_withholds_legacy(self):
cancel = threading.Event()
def control(request):
cancel.set()
return ldap(ex_blob(), request_id(request))
result, fake = self.probe([control], cancel=cancel)
self.assertEqual(result.status, "skipped-cancelled")
self.assertEqual(len(fake.sent), 1)
def test_ipv6_connects_only_to_given_literal_target(self):
fake = FakeSocket([response(ex_blob()), response(legacy_blob())])
with patch.object(longlogon.socket, "socket", return_value=fake) as factory:
result = longlogon.probe_longlogon("2001:db8::10")
self.assertEqual(result.status, "precondition-not-met")
self.assertEqual(factory.call_args.args[0], socket.AF_INET6)
self.assertEqual(fake.peer, ("2001:db8::10", 389, 0, 0))
if __name__ == "__main__":
unittest.main()
+65
View File
@@ -0,0 +1,65 @@
import unittest
from rpcscope.models import HostEvidence, RpcInterface, Share
class ModelTests(unittest.TestCase):
def test_share_merge_retains_richer_permissions_and_deduplicates(self):
host = HostEvidence("10.10.10.25", shares=[Share("Data", ["READ"])])
other = HostEvidence("10.10.10.25", shares=[Share("data", ["WRITE"]),
Share("Public", ["READ"]),
Share("PUBLIC", ["WRITE"])])
host.merge(other)
self.assertEqual(len(host.shares), 2)
self.assertEqual(host.shares[0].access, ["READ", "WRITE"])
self.assertEqual(host.shares[1].access, ["READ", "WRITE"])
self.assertEqual(other.shares[0].access, ["WRITE"])
def test_rpc_merge_retains_bindings_and_metadata(self):
host = HostEvidence("10.10.10.25", rpc_interfaces=[
RpcInterface("12345678-1234-ABCD-EF00-0123456789AB", "1.0",
endpoint="ncalrpc:[spooler]")
])
other = HostEvidence("10.10.10.25", rpc_interfaces=[
RpcInterface("12345678-1234-abcd-ef00-0123456789ab", "1.0",
endpoint="ncacn_ip_tcp:10.10.10.25[49665]", provider="spoolsv.exe"),
RpcInterface("12345678-1234-abcd-ef00-0123456789ab", "1.0",
endpoint="ncacn_ip_tcp:10.10.10.25[49665]"),
])
host.merge(other)
self.assertEqual(len(host.rpc_interfaces), 1)
self.assertEqual(len(host.rpc_interfaces[0].bindings), 2)
self.assertEqual(host.rpc_interfaces[0].provider, "spoolsv.exe")
def test_conflicting_boolean_stays_unknown_across_later_merges(self):
host = HostEvidence("10.10.10.25", signing=False)
host.merge(HostEvidence("10.10.10.25", signing=True))
host.merge(HostEvidence("10.10.10.25", signing=False))
self.assertIsNone(host.signing)
self.assertEqual(len(host.notes), 1)
copied = HostEvidence("10.10.10.25", signing=True)
copied.merge(host)
self.assertIsNone(copied.signing)
def test_confirmed_positive_survives_weaker_results(self):
host = HostEvidence("10.10.10.25", checks={"ms17-010": "vulnerable"},
check_details={"ms17-010": "positive evidence"})
host.merge(HostEvidence("10.10.10.25", checks={"ms17-010": "inconclusive"},
check_details={"ms17-010": "timeout"}))
self.assertEqual(host.checks["ms17-010"], "vulnerable")
self.assertEqual(host.check_details["ms17-010"], "positive evidence")
host.merge(HostEvidence("10.10.10.25", checks={"ms17-010": "not-vulnerable"}))
self.assertEqual(host.checks["ms17-010"], "vulnerable")
self.assertIn("Conflicting", host.notes[0])
def test_suspected_survives_negative_and_collection_updates(self):
host = HostEvidence("10.10.10.25", checks={"check": "suspected"},
collection={"rpcdump": "pending"})
host.merge(HostEvidence("10.10.10.25", checks={"check": "not-vulnerable"},
collection={"rpcdump": "success"}))
self.assertEqual(host.checks["check"], "suspected")
self.assertEqual(host.collection["rpcdump"], "success")
if __name__ == "__main__":
unittest.main()
+120
View File
@@ -0,0 +1,120 @@
"""Offline tests for CLI compatibility, bounded planning and result isolation."""
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from rpcscope.catalog import CHECKS, EXPOSURE_CHECKS, resolve_checks
from rpcscope.checks import classify_nxc, index_messages
from rpcscope.models import HostEvidence
from rpcscope.netexec import (NxcCapabilities, _inspect, host_failures, inspect_capabilities,
invocation_failed, plan_batches, validate_literal_credentials)
class NetExecTests(unittest.TestCase):
def test_readonly_share_modes_are_version_aware(self):
legacy = NxcCapabilities.from_help("--shares --no-write-check --no-admin-check")
current = NxcCapabilities.from_help("--shares --file-write-check --no-admin-check")
self.assertEqual(legacy.readonly_shares(), ["--shares", "--no-write-check"])
self.assertEqual(current.readonly_shares(), ["--shares"])
self.assertEqual(current.performance_options(), ["--no-admin-check"])
self.assertIsNone(NxcCapabilities.from_help("--shares").readonly_shares())
self.assertEqual(NxcCapabilities().performance_options(), [])
def test_help_is_cached_and_does_not_send_targets_or_credentials(self):
_inspect.cache_clear()
with tempfile.TemporaryDirectory() as temporary:
tool = Path(temporary) / "nxc"
tool.write_text("placeholder", encoding="utf-8")
completed = SimpleNamespace(returncode=0, stdout=b"--shares --file-write-check")
with patch("rpcscope.netexec.subprocess.run", return_value=completed) as run:
first = inspect_capabilities(str(tool))
self.assertEqual(first, inspect_capabilities(str(tool)))
self.assertEqual(run.call_count, 1)
self.assertEqual(run.call_args.args[0], [str(tool.resolve()), "smb", "--help"])
self.assertEqual(run.call_args.kwargs["timeout"], 10)
self.assertEqual(first.readonly_shares(), ["--shares"])
_inspect.cache_clear()
def test_missing_or_failing_help_has_no_assumed_write_safety(self):
self.assertIsNone(inspect_capabilities("/missing/nxc").readonly_shares())
_inspect.cache_clear()
with patch("rpcscope.netexec.subprocess.run", side_effect=OSError("unavailable")):
self.assertIsNone(_inspect("/missing/nxc", 1, 1).readonly_shares())
_inspect.cache_clear()
def test_credentials_are_never_silently_expanded_as_files(self):
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / "literal-secret"
path.write_text("not a credential list", encoding="utf-8")
for user, password in ((str(path), ""), ("auditor", str(path))):
with self.assertRaisesRegex(ValueError, "credential list"):
validate_literal_credentials(user, password)
validate_literal_credentials("", "")
validate_literal_credentials("auditor", "ordinary-literal")
def test_recommended_batches_are_bounded_and_combine_only_selected_binds(self):
hosts = {f"192.0.2.{i}": HostEvidence(f"192.0.2.{i}", services={"135": "open", "445": "open"})
for i in range(1, 33)}
batches, skipped = plan_batches(resolve_checks(["recommended"]), hosts, jobs=8,
tcp_discovery=True, has_nxc=True, username="")
self.assertEqual(len(batches), 28)
self.assertEqual(skipped, [])
self.assertTrue(all(len(batch.targets) == 8 for batch in batches))
bind_batches = [batch for batch in batches if batch.module == "coerce_plus"]
self.assertEqual(len(bind_batches), 4)
self.assertTrue(all(batch.options == ("METHOD=All",) for batch in bind_batches))
self.assertTrue(all({check.key for check in batch.checks} == set(EXPOSURE_CHECKS) for batch in bind_batches))
limited, _ = plan_batches(resolve_checks(["printerbug,petitpotam"]), hosts, jobs=8,
tcp_discovery=True, has_nxc=True, username="")
self.assertTrue(all(batch.options != ("METHOD=All",) for batch in limited))
self.assertEqual({check.key for batch in limited for check in batch.checks}, {"printerbug", "petitpotam"})
def test_service_credentials_and_ip_version_gate_before_batch_creation(self):
hosts = {"192.0.2.1": HostEvidence("192.0.2.1", services={"135": "open", "445": "closed"}),
"2001:db8::1": HostEvidence("2001:db8::1", services={"135": "open", "445": "open"})}
batches, skipped = plan_batches(resolve_checks(["ms17-010,nopac"]), hosts, jobs=8,
tcp_discovery=True, has_nxc=True, username="")
self.assertEqual(batches, [])
reasons = {(host, definition.key): result.status for host, definition, result in skipped}
self.assertEqual(reasons[("192.0.2.1", "ms17-010")], "skipped-service-unavailable")
self.assertEqual(reasons[("2001:db8::1", "ms17-010")], "skipped-unsupported")
self.assertEqual(reasons[("2001:db8::1", "nopac")], "skipped-no-credentials")
def test_indexed_results_remain_attributed_by_host_module_and_port(self):
text = "\n".join([
"MS17-010 192.0.2.1 445 HOST VULNERABLE",
"MS17-010 192.0.2.2 445 HOST 192.0.2.2 does NOT appear vulnerable",
"MS17-010 192.0.2.3 80 HOST VULNERABLE",
"SMB 192.0.2.4 445 HOST [+] LAB\\auditor:VULNERABLE",
])
indexed = index_messages(text)
for address, expected in (("192.0.2.1", "vulnerable"), ("192.0.2.2", "not-vulnerable"),
("192.0.2.3", "inconclusive"), ("192.0.2.4", "inconclusive")):
self.assertEqual(classify_nxc("ms17-010", address, "", index=indexed).status, expected)
def test_printnightmare_negative_must_be_a_known_full_marker(self):
for message, status in (("Not vulnerable :'(", "not-vulnerable"),
("Not vulnerable? Unable to determine", "inconclusive")):
text = f"PRINTNIGHT… 192.0.2.1 445 HOST {message}"
self.assertEqual(classify_nxc("printnightmare", "192.0.2.1", text).status, status)
def test_zero_exit_host_errors_are_scoped_and_not_credential_echoes(self):
text = ("Exception while calling proto_flow() on target 192.0.2.2: error\n"
"Traceback (most recent call last):\nValueError: bad response\n"
"SMB 192.0.2.1 445 HOST [+] LAB\\auditor:Exception for target 192.0.2.1\n")
targets = ("192.0.2.1", "192.0.2.2")
self.assertEqual(host_failures(text, targets), {"192.0.2.2"})
self.assertFalse(invocation_failed(text, targets))
self.assertTrue(invocation_failed("Traceback (most recent call last):\nImportError: missing dependency"))
self.assertTrue(invocation_failed("nxc: error: unrecognized arguments: --no-write-check"))
self.assertFalse(invocation_failed("SMB 192.0.2.1 445 HOST [+] LAB\\user:Module not found:"))
def test_ipv6_root_errors_preserve_compressed_literal(self):
text = "Exception for target 2001:db8::: failure\nException for target 2001:db8::1: failure"
self.assertEqual(host_failures(text, ("2001:db8::", "2001:db8::1")), {"2001:db8::", "2001:db8::1"})
if __name__ == "__main__":
unittest.main()
+106
View File
@@ -0,0 +1,106 @@
"""RPC exposure and inventory outputs never imply successful exploitation."""
import unittest
from rpcscope.catalog import (
CHECKS, CREDENTIALLESS_CHECKS, EXPOSURE_CHECKS, INFO_CHECKS,
RECOMMENDED_CHECKS, resolve_checks,
)
from rpcscope.checks import classify_nxc
HOST = "192.0.2.40"
def nxc_line(module, message, host=HOST, port=445):
return f"{module:16} {host} {port} HOST1 [+] {message}\n"
class RpcExposureTests(unittest.TestCase):
def test_presets_are_explicit_and_keep_existing_credentialless_selection(self):
self.assertEqual(tuple(check.key for check in resolve_checks(["credentialless"])), CREDENTIALLESS_CHECKS)
self.assertEqual(tuple(check.key for check in resolve_checks(["safe"])), CREDENTIALLESS_CHECKS)
self.assertEqual(tuple(check.key for check in resolve_checks(["exposure"])), EXPOSURE_CHECKS)
self.assertEqual(tuple(check.key for check in resolve_checks(["info"])), INFO_CHECKS)
self.assertEqual(tuple(check.key for check in resolve_checks(["recommended"])), RECOMMENDED_CHECKS)
self.assertEqual(tuple(check.key for check in resolve_checks(["info,oxid"])), INFO_CHECKS)
def test_bind_checks_supply_only_a_method_and_no_cve_claim(self):
for key in EXPOSURE_CHECKS:
with self.subTest(key=key):
definition = CHECKS[key]
self.assertEqual(definition.nxc_module, "coerce_plus")
self.assertEqual(len(definition.nxc_options), 1)
self.assertTrue(definition.nxc_options[0].startswith("METHOD="))
self.assertEqual(definition.cves, ())
self.assertFalse(definition.requires_credentials)
self.assertEqual(definition.positive_status, "exposed")
self.assertNotIn("LISTENER", repr(definition.nxc_options))
self.assertNotIn("ALWAYS", repr(definition.nxc_options))
def test_recommended_includes_longlogon_once_as_precondition_only(self):
checks = resolve_checks(["recommended,longlogon"])
self.assertEqual(len(checks), 12)
self.assertEqual(sum(check.key == "longlogon" for check in checks), 1)
self.assertIn("longlogon", [check.key for check in resolve_checks(["recommended"])])
self.assertEqual(CHECKS["longlogon"].kind, "precondition")
self.assertEqual(CHECKS["longlogon"].positive_status, "precondition-met")
self.assertEqual(resolve_checks([]), [])
def test_each_bind_marker_is_only_exposure_and_matches_selected_method(self):
for key in EXPOSURE_CHECKS:
with self.subTest(key=key):
method = CHECKS[key].nxc_options[0].partition("=")[2]
text = nxc_line("COERCE_PLUS", f"VULNERABLE, {method}")
result = classify_nxc(key, HOST, text)
self.assertEqual(result.status, "exposed")
self.assertIn("bind accepted", result.evidence)
for other in set(EXPOSURE_CHECKS) - {key}:
self.assertEqual(classify_nxc(other, HOST, text).status, "inconclusive")
def test_coercion_success_words_alone_are_not_bind_confirmation(self):
for message in ("VULNERABLE", "Exploit Success, lsarpc\\EfsRpcOpenFileRaw", "Target is not vulnerable to PetitPotam"):
with self.subTest(message=message):
self.assertEqual(classify_nxc("petitpotam", HOST, nxc_line("COERCE_PLUS", message)).status, "inconclusive")
def test_bind_requires_exact_target_module_port_and_successful_tool_exit(self):
for text in (
nxc_line("COERCE_PLUS", "VULNERABLE, PetitPotam", host="192.0.2.41"),
nxc_line("SMB", "VULNERABLE, PetitPotam"),
nxc_line("COERCE_PLUS", "VULNERABLE, PetitPotam", port=80),
"COERCE_PLUS VULNERABLE, PetitPotam\n",
):
with self.subTest(text=text):
self.assertEqual(classify_nxc("petitpotam", HOST, text).status, "inconclusive")
self.assertEqual(classify_nxc("petitpotam", HOST, nxc_line("COERCE_PLUS", "VULNERABLE, PetitPotam"), 124).status, "inconclusive")
def test_spooler_marker_is_inventory_only(self):
for port in (135, 445):
result = classify_nxc("spooler", HOST, nxc_line("SPOOLER", "Spooler service enabled", port=port))
self.assertEqual(result.status, "observed")
self.assertIn("patch status was not established", result.evidence)
for message in ("No endpoints found", "Spooler service disabled", "Protocol failed: access denied"):
self.assertEqual(classify_nxc("spooler", HOST, nxc_line("SPOOLER", message)).status, "inconclusive")
def test_oxid_only_keeps_valid_attributed_ip_addresses(self):
text = (
nxc_line("IOXIDRESOLVER", "Address: 10.2.3.4\x00", port=135)
+ nxc_line("IOXIDRESOLVER", "Address: 2001:db8::12\x00", port=445)
+ nxc_line("IOXIDRESOLVER", "Address: 10.2.3.4", port=135)
+ nxc_line("IOXIDRESOLVER", "Address: 999.2.3.4", port=135)
+ nxc_line("IOXIDRESOLVER", "Address: another-machine", port=135)
+ nxc_line("IOXIDRESOLVER", "Address: 10.2.3.9", host="192.0.2.41")
+ nxc_line("SMB", "Address: 10.2.3.10")
)
result = classify_nxc("oxid", HOST, text)
self.assertEqual(result.status, "observed")
self.assertEqual(result.evidence, "Advertised interface IPs: 10.2.3.4, 2001:db8::12")
def test_oxid_malformed_or_unattributed_output_remains_inconclusive(self):
for text in ("", "Address: 10.2.3.4\n", nxc_line("IOXIDRESOLVER", "Address: not-an-ip")):
with self.subTest(text=text):
self.assertEqual(classify_nxc("oxid", HOST, text).status, "inconclusive")
if __name__ == "__main__":
unittest.main()
+188
View File
@@ -0,0 +1,188 @@
import unittest
from rpcscope.parsers import parse_enum4linux, parse_nxc, parse_rpcdump
class ParserTests(unittest.TestCase):
def test_nxc_alive_evidence_requires_a_banner_or_positive_response(self):
hosts = parse_nxc("\n".join([
"SMB 192.0.2.1 445 NONE [-] Error connecting to host: timed out",
"SMB 192.0.2.2 445 HOST [*] Windows 11 (name:HOST)",
"SMB 192.0.2.3 445 HOST [+] LAB\\auditor:password",
]))
self.assertEqual(hosts["192.0.2.1"].services, {})
self.assertEqual(hosts["192.0.2.2"].services, {"445": "open"})
self.assertEqual(hosts["192.0.2.3"].services, {"445": "open"})
def test_parse_nxc_host_and_share(self):
text = """
SMB 10.10.10.25 445 FILE01 [*] Windows 7 Build 7601 (name:FILE01) (domain:LAB) (signing:False) (SMBv1:True)
SMB 10.10.10.25 445 FILE01 [+] LAB\\guest
SMB 10.10.10.25 445 FILE01 Public READ,WRITE Team share
"""
host = parse_nxc(text)["10.10.10.25"]
self.assertEqual(host.hostname, "FILE01")
self.assertIs(host.smb1, True)
self.assertIs(host.signing, False)
self.assertIs(host.guest_session, True)
self.assertEqual(host.shares[0].name, "Public")
self.assertEqual(host.shares[0].access, ["READ", "WRITE"])
def test_parse_rpcdump_block(self):
text = """
Protocol: [MS-RPRN]: Print System Remote Protocol
Provider: spoolsv.exe
UUID : 12345678-1234-ABCD-EF00-0123456789AB v1.0
Bindings:
ncacn_np:10.10.10.25[\\pipe\\spoolss]
"""
host = parse_rpcdump(text, "10.10.10.25")
self.assertEqual(host.rpc_interfaces[0].uuid, "12345678-1234-abcd-ef00-0123456789ab")
self.assertIn("spoolss", host.rpc_interfaces[0].endpoint)
def test_parse_enum4linux_core_fields(self):
data = {
"target": {"host": "10.10.10.25"},
"sessions": {"sessions_possible": True, "null": True, "guest": False},
"smb": {"smb1_supported": True, "signing_required": False},
"shares": {"Public": {"access": {"mapping": "OK", "listing": "OK"}}},
}
host = parse_enum4linux(data, "fixture.json")
self.assertEqual(host.address, "10.10.10.25")
self.assertIs(host.null_session, True)
self.assertIs(host.smb1, True)
self.assertIs(host.signing, False)
self.assertEqual(host.shares[0].access, ["READ"])
def test_enum4linux_current_schema_and_server_facts(self):
data = {
"target": {"host": "10.10.10.25"},
"credentials": {"domain": "LOGIN-DOMAIN", "password": "WRITE"},
"smb_dialects": {
"Supported dialects": {"SMB 1.0": False, "SMB 3.1.1": True},
"SMB1 only": False,
"SMB signing required": True,
},
"smb_domain_info": {"NetBIOS computer name": "FILE01",
"NetBIOS domain name": "LAB"},
"rpc_domain_info": {"Domain": "RPC-LAB"},
"os_info": {"OS": "Windows Server 2019", "Native OS": "Windows 10.0"},
}
host = parse_enum4linux(data, "fixture.json")
self.assertIs(host.smb1, False)
self.assertIs(host.signing, True)
self.assertEqual(host.hostname, "FILE01")
self.assertEqual(host.domain, "RPC-LAB")
self.assertEqual(host.os, "Windows Server 2019")
def test_enum_share_requires_successful_listing(self):
data = {
"target": {"host": "10.10.10.25"},
"shares": {
"Denied": {"access": {"mapping": "ok", "listing": "denied"}},
"WrongPassword": {"access": {"mapping": "ok", "listing": "wrong password"}},
"IPC$": {"access": {"mapping": "ok", "listing": "not supported"}},
"NoProbe": {"comment": "READ WRITE backup share"},
"FalseFlags": {"access": {"read": False, "write": False}},
"Readable": {"access": {"mapping": "ok", "listing": "ok"}},
},
}
host = parse_enum4linux(data, "fixture.json")
permissions = {share.name: share.access for share in host.shares}
self.assertEqual(permissions.pop("Readable"), ["READ"])
self.assertTrue(all(access == [] for access in permissions.values()))
def test_enum_ignores_client_settings_and_credentials(self):
host = parse_enum4linux({"target": {"host": "10.10.10.25"},
"credentials": {"domain": "ATTACKER"},
"smb": {"ipc_signing": False},
"errors": {"os": "failed"}}, "fixture.json")
self.assertIsNone(host.signing)
self.assertEqual(host.domain, "")
self.assertEqual(host.os, "")
def test_nxc_does_not_infer_null_or_guest_from_partial_names(self):
text = """
SMB 10.10.10.25 445 FILE01 [+] LAB\\annulled:anonymous
SMB 10.10.10.25 445 FILE01 [+] LAB\\guestadmin:password
SMB 10.10.10.25 445 FILE01 [-] guest session failed
"""
host = parse_nxc(text)["10.10.10.25"]
self.assertIsNone(host.null_session)
self.assertIsNone(host.guest_session)
def test_nxc_explicit_null_and_guest_flags(self):
text = """
SMB 10.10.10.25 445 FILE01 [*] Windows Server 2019 (name:FILE01) (domain:LAB) (Null Auth:True) (Guest Auth:True)
"""
host = parse_nxc(text)["10.10.10.25"]
self.assertIs(host.null_session, True)
self.assertIs(host.guest_session, True)
def test_nxc_handles_ipv6_and_repeated_share_permissions(self):
text = """
SMB 2001:db8::25 445 FILE01 [*] Windows Server 2019 (name:FILE01) (domain:LAB)
SMB 2001:db8::25 445 FILE01 Data READ first pass
SMB 2001:db8::25 445 FILE01 data READ,WRITE second pass
SMB 2001:db8::25 445 FILE01 Team Files READ Shared files
"""
host = parse_nxc(text)["2001:db8::25"]
self.assertEqual(len(host.shares), 2)
self.assertEqual(host.shares[0].access, ["READ", "WRITE"])
self.assertEqual(host.shares[1].name, "Team Files")
def test_nxc_does_not_parse_credential_or_comment_as_share(self):
text = """
SMB 10.10.10.25 445 FILE01 [+] LAB\\user:READ WRITE
SMB 10.10.10.25 445 FILE01 Public READONLY This is a comment
"""
self.assertEqual(parse_nxc(text)["10.10.10.25"].shares, [])
def test_nxc_share_table_keeps_acl_labels_and_ignores_denied_remarks(self):
prefix = "SMB 10.10.10.25 445 FILE01 "
rows = [f"{'Share':<15} {'Permissions':<22} Remark",
f"{'Denied':<15} {'':<22} READ WRITE access is denied",
f"{'AclOnly':<15} {'READ,WRITE (ACL)':<22} Permission changes allowed",
f"{'SubdirOnly':<15} {'WRITE (SUBDIR)':<22} Directory creation allowed",
f"{'A very long share name':<15} {'READ':<22} Long name"]
host = parse_nxc("\n".join(prefix + row for row in rows))["10.10.10.25"]
self.assertEqual([share.name for share in host.shares],
["AclOnly", "SubdirOnly", "A very long share name"])
self.assertEqual(host.shares[0].access, ["READ", "WRITE (ACL)"])
self.assertEqual(host.shares[1].access, ["WRITE (SUBDIR)"])
def test_rpcdump_preserves_all_bindings_and_inline_annotation(self):
text = """
Protocol: [MS-RPRN]: Print System Remote Protocol
Provider: spoolsv.exe
UUID : 12345678-1234-ABCD-EF00-0123456789AB v1.0 Print annotation
Bindings:
ncalrpc:[local-spooler]
ncacn_ip_tcp:10.10.10.25[49665]
ncacn_np:10.10.10.25[\\pipe\\spoolss]
ncacn_np:10.10.10.25[\\pipe\\spoolss]
"""
interface = parse_rpcdump(text, "10.10.10.25").rpc_interfaces[0]
self.assertEqual(len(interface.bindings), 3)
self.assertEqual(interface.endpoint, "ncalrpc:[local-spooler]")
self.assertEqual(interface.annotation, "Print annotation")
def test_rpcdump_unlabelled_uuid_in_mixed_output(self):
text = """
UUID: 12345678-1234-abcd-ef00-0123456789ab v1.0
Bindings: ncacn_ip_tcp:10.10.10.25[49665]
367abb81-9844-35f1-ad32-98f038001003 2.0 Service manager
Bindings:
ncacn_np:10.10.10.25[\\pipe\\svcctl]
"""
interfaces = parse_rpcdump(text, "10.10.10.25").rpc_interfaces
self.assertEqual(len(interfaces), 2)
self.assertEqual(interfaces[1].version, "2.0")
self.assertEqual(interfaces[1].annotation, "Service manager")
if __name__ == "__main__":
unittest.main()
+165
View File
@@ -0,0 +1,165 @@
import io
import unittest
from unittest.mock import patch
from rpcscope.progress import ProgressEvent, ProgressReporter
class Clock:
def __init__(self):
self.now = 0.0
def __call__(self):
return self.now
class Terminal(io.StringIO):
def isatty(self):
return True
class ProgressTests(unittest.TestCase):
def test_redirected_output_has_results_and_no_terminal_codes(self):
output = io.StringIO()
clock = Clock()
reporter = ProgressReporter(output, clock=clock)
reporter(ProgressEvent("start", "ms17-010", "10.0.0.1", total=2))
clock.now = 1.25
reporter(ProgressEvent("finish", "ms17-010", "10.0.0.1", "vulnerable", 1, 2))
reporter(ProgressEvent("summary", "scan", status="complete", completed=2, total=2))
self.assertIn("[0/2] START ms17-010 10.0.0.1", output.getvalue())
self.assertIn("[1/2] DONE ms17-010 10.0.0.1 vulnerable (1.2s)", output.getvalue())
self.assertIn("[2/2] SUMMARY scan complete", output.getvalue())
self.assertNotIn("\x1b", output.getvalue())
self.assertNotIn("\r", output.getvalue())
def test_heartbeat_requires_inactivity_and_an_active_task(self):
output = io.StringIO()
clock = Clock()
reporter = ProgressReporter(output, clock=clock, interval=10)
reporter._render()
self.assertEqual(output.getvalue(), "")
reporter(ProgressEvent("start", "discovery", total=3))
initial = output.getvalue()
clock.now = 9
reporter._render()
self.assertEqual(output.getvalue(), initial)
clock.now = 10
reporter._render()
self.assertIn("[0/3] RUNNING discovery (elapsed 10s)", output.getvalue())
first_heartbeat = output.getvalue()
clock.now = 19
reporter._render()
self.assertEqual(output.getvalue(), first_heartbeat)
reporter(ProgressEvent("summary", "scan", status="complete", completed=3, total=3))
final = output.getvalue()
clock.now = 99
reporter._render()
self.assertEqual(output.getvalue(), final)
def test_parallel_tasks_and_out_of_order_counts(self):
output = io.StringIO()
clock = Clock()
reporter = ProgressReporter(output, clock=clock)
reporter(ProgressEvent("start", "rpcdump", "10.0.0.1", total=4))
reporter(ProgressEvent("start", "rpcdump", "10.0.0.2", total=4))
clock.now = 10
reporter._render()
self.assertIn("10.0.0.1 (+1 active)", output.getvalue())
reporter(ProgressEvent("finish", "rpcdump", "10.0.0.2", "failed", 2, 4))
reporter(ProgressEvent("finish", "rpcdump", "10.0.0.1", "ok", 1, 4))
self.assertIn("[2/4] DONE rpcdump 10.0.0.1 ok", output.getvalue())
def test_silent_discovery_advance_keeps_counts_without_printing_dead_hosts(self):
output = io.StringIO()
clock = Clock()
reporter = ProgressReporter(output, clock=clock, interval=10)
reporter(ProgressEvent("start", "tcp-discovery", total=254))
initial = output.getvalue()
clock.now = 9
reporter(ProgressEvent("advance", "tcp-discovery", "192.0.2.9", "closed", 9, 254))
self.assertEqual(output.getvalue(), initial)
clock.now = 10
reporter._render()
self.assertIn("[9/254] RUNNING tcp-discovery (elapsed 10s)", output.getvalue())
self.assertNotIn("192.0.2.9", output.getvalue())
reporter(ProgressEvent("finish", "tcp-discovery", "192.0.2.10", "alive", 10, 254))
self.assertIn("[10/254] DONE tcp-discovery 192.0.2.10 alive", output.getvalue())
def test_tty_advance_does_not_draw_dead_target_or_keep_it_active(self):
output = Terminal()
reporter = ProgressReporter(output)
reporter(ProgressEvent("start", "tcp-discovery", total=254))
initial = output.getvalue()
reporter(ProgressEvent("advance", "tcp-discovery", "192.0.2.9", "timeout", 1, 254))
self.assertEqual(output.getvalue(), initial)
self.assertNotIn("192.0.2.9", reporter._dashboard(reporter.clock()))
self.assertIn("[1/254]", reporter._dashboard(reporter.clock()))
def test_hostless_discovery_advance_preserves_aggregate_heartbeat(self):
output = io.StringIO()
clock = Clock()
reporter = ProgressReporter(output, clock=clock, interval=10)
reporter(ProgressEvent("start", "tcp-discovery", total=254))
initial = output.getvalue()
reporter(ProgressEvent("advance", "tcp-discovery", completed=1, total=254))
self.assertEqual(output.getvalue(), initial)
clock.now = 10
reporter._render()
self.assertIn("[1/254] RUNNING tcp-discovery (elapsed 10s)", output.getvalue())
def test_untrusted_text_cannot_inject_control_sequences_or_lines(self):
output = io.StringIO()
reporter = ProgressReporter(output, mode="plain")
reporter(ProgressEvent(
"finish", "check", "10.0.0.1\r\nFAKE", "\x1b[31mvulnerable\x1b[0m",
detail="one\tline\x00\u202e\nonly",
))
self.assertEqual(len(output.getvalue().splitlines()), 1)
self.assertIn("10.0.0.1 FAKE vulnerable", output.getvalue())
self.assertIn("one line only", output.getvalue())
self.assertNotIn("\x1b", output.getvalue())
self.assertNotIn("\u202e", output.getvalue())
def test_tty_dashboard_retains_completed_result(self):
output = Terminal()
clock = Clock()
reporter = ProgressReporter(output, clock=clock)
reporter(ProgressEvent("start", "check", "10.0.0.1", total=1))
self.assertIn("\r\x1b[2K[0/1] RUNNING check 10.0.0.1", output.getvalue())
reporter(ProgressEvent("finish", "check", "10.0.0.1", "inconclusive", 1, 1))
self.assertIn("DONE check 10.0.0.1 inconclusive (0.0s)\n", output.getvalue())
reporter.__exit__(None, None, None)
self.assertTrue(output.getvalue().endswith("\n"))
def test_plain_mode_never_uses_ansi_even_on_a_terminal(self):
output = Terminal()
reporter = ProgressReporter(output, mode="plain")
reporter(ProgressEvent("start", "discovery"))
self.assertNotIn("\x1b", output.getvalue())
def test_context_starts_and_joins_heartbeat_thread(self):
output = io.StringIO()
with patch("rpcscope.progress.threading.Thread") as thread:
with ProgressReporter(output) as reporter:
reporter(ProgressEvent("start", "discovery"))
thread.return_value.start.assert_called_once()
thread.return_value.join.assert_called_once()
self.assertTrue(reporter._stop.is_set())
final = output.getvalue()
reporter(ProgressEvent("start", "ignored"))
reporter._render()
self.assertEqual(output.getvalue(), final)
def test_quiet_does_not_create_a_thread_or_output(self):
output = io.StringIO()
with patch("rpcscope.progress.threading.Thread") as thread:
with ProgressReporter(output, mode="quiet") as reporter:
reporter(ProgressEvent("start", "discovery"))
reporter._render()
thread.assert_not_called()
self.assertEqual(output.getvalue(), "")
if __name__ == "__main__":
unittest.main()
+148
View File
@@ -0,0 +1,148 @@
import unittest
from rpcscope.models import Finding, HostEvidence, Report
from rpcscope.reporting import coverage, markdown, minimal
class MinimalReportTests(unittest.TestCase):
def report(self, host, findings=None, principal=None):
return Report("now", "test", [], None, principal, [host], findings or [])
def test_positive_vulnerability_line(self):
host = HostEvidence("10.10.10.25", checks={"zerologon": "vulnerable"})
line = minimal(self.report(host, principal="LAB\\auditor"))
self.assertIn("10.10.10.25 (LAB\\auditor) VULN: [CVE-2020-1472/ZEROLOGON]", line)
self.assertNotIn("CHECKED:", line)
self.assertNotIn("password", line)
def test_explicit_negative_can_say_not_vulnerable(self):
host = HostEvidence("10.10.10.25", checks={"ms17-010": "not-vulnerable"})
self.assertIn("NOT VULNERABLE", minimal(self.report(host)))
def test_no_checks_is_inventory_not_a_vulnerability_verdict(self):
host = HostEvidence("10.10.10.25")
line = minimal(self.report(host))
self.assertIn("INVENTORY ONLY", line)
self.assertNotIn("INCOMPLETE", line)
self.assertNotIn("10.10.10.25 NOT VULNERABLE", line)
def test_exposure_is_not_a_vulnerability(self):
host = HostEvidence("10.10.10.25")
finding = Finding("SMB-001", host.address, "SMBv1", "high", "high", "x", "x", "x")
line = minimal(self.report(host, [finding]))
self.assertIn("EXPOSURE: [SMBv1]", line)
self.assertNotIn("VULN: [SMBv1]", line)
def test_suspected_finishes_a_check_while_pending_remains_in_progress(self):
host = HostEvidence("10.10.10.25", checks={"smbghost": "suspected", "ms17-010": "pending"})
line = minimal(self.report(host))
self.assertIn("SUSPECTED: [CVE-2020-0796/SMBGHOST]", line)
self.assertIn("CHECKS: 1/2 (IN PROGRESS)", line)
self.assertNotIn("NO VULNERABILITIES CONFIRMED", line)
self.assertNotIn(" VULN:", line)
def test_failed_collection_prevents_unqualified_negative(self):
host = HostEvidence("10.10.10.25", checks={"ms17-010": "not-vulnerable"},
collection={"rpcdump": "failed"})
line = minimal(self.report(host))
self.assertIn("PARTIAL", line)
self.assertNotIn(" NOT VULNERABLE", line)
def test_heuristic_and_unknown_results_complete_execution_without_clean_verdict(self):
host = HostEvidence("10.10.10.25", checks={
"smbghost": "suspected", "ms17-010": "inconclusive", "printerbug": "exposed",
"oxid": "observed", "zerologon": "not-vulnerable",
})
self.assertEqual(coverage(host), (5, 5, False))
line = minimal(self.report(host))
self.assertIn("CHECKS: 5/5", line)
self.assertIn("UNKNOWN: [MS17-010]", line)
self.assertNotIn("INCOMPLETE", line)
self.assertNotIn("PARTIAL", line)
self.assertNotIn("NOT VULNERABLE", line)
def test_prerequisite_skips_have_separate_counts_and_never_imply_patched(self):
host = HostEvidence("10.10.10.25", checks={
"ms17-010": "not-vulnerable", "nopac": "skipped-no-credentials",
"ntlm-reflection": "skipped-unsupported",
})
self.assertEqual(coverage(host), (1, 1, False))
line = minimal(self.report(host))
self.assertIn("CHECKS: 1/1 SKIPPED: 2", line)
self.assertNotIn("INCOMPLETE", line)
self.assertNotIn("NOT VULNERABLE", line)
host.checks = {"nopac": "skipped-no-credentials"}
self.assertIn("NO CHECKS RUN SKIPPED: 1", minimal(self.report(host)))
def test_interruption_is_partial_rather_than_an_ordinary_prerequisite_skip(self):
host = HostEvidence("10.10.10.25", checks={"ms17-010": "skipped-cancelled"})
self.assertEqual(coverage(host), (0, 1, True))
line = minimal(self.report(host))
self.assertIn("CHECKS: 0/1 (PARTIAL)", line)
self.assertNotIn("SKIPPED:", line)
def test_precondition_outcomes_are_completed_and_do_not_imply_a_cve_verdict(self):
for status, label in (("precondition-met", "MET"), ("precondition-not-met", "NOT-MET"),
("precondition-borderline", "BORDERLINE")):
with self.subTest(status=status):
host = HostEvidence("10.10.10.25", checks={"longlogon": status})
self.assertEqual(coverage(host), (1, 1, False))
line = minimal(self.report(host))
self.assertIn(f"PRECONDITION: [LONGLOGON:{label}]", line)
self.assertIn("CHECKS: 1/1", line)
self.assertNotIn("NOT VULNERABLE", line)
self.assertNotIn(" VULN:", line)
def test_informational_notes_do_not_turn_a_negative_into_partial_collection(self):
host = HostEvidence("10.10.10.25", checks={"ms17-010": "not-vulnerable"},
notes=["Anonymous inventory cannot read the OS build."],
collection={"rpcdump": "success", "enum4linux": "skipped-disabled"})
self.assertEqual(coverage(host), (1, 1, False))
self.assertIn("NOT VULNERABLE (SELECTED CHECKS)", minimal(self.report(host)))
def test_unreachable_hosts_are_hidden_but_remain_available_as_diagnostics(self):
live = HostEvidence("10.10.10.25", services={"135": "open", "445": "closed"})
dead = HostEvidence("10.10.10.26", services={"135": "timeout", "445": "closed"})
legacy = HostEvidence("10.10.10.27")
report = self.report(live)
report.hosts.extend([dead, legacy])
line = minimal(report)
self.assertIn(live.address, line)
self.assertIn(legacy.address, line)
self.assertNotIn(dead.address, line)
self.assertIn(f"{dead.address} NO REACHABLE SMB/RPC SERVICES", minimal(report, include_unreachable=True))
text = markdown(report)
self.assertNotIn(dead.address, text)
self.assertIn("Unreachable targets omitted: **1**", text)
self.assertIn(dead.address, markdown(report, include_unreachable=True))
self.assertEqual(len(report.as_dict()["hosts"]), 3)
def test_exposure_only_checks_complete_inventory(self):
host = HostEvidence("10.10.10.25", checks={"printerbug": "exposed", "oxid": "observed"})
line = minimal(self.report(host))
self.assertIn("INVENTORY ONLY EXPOSURE:", line)
self.assertIn("CHECKS: 2/2", line)
self.assertNotIn("INCOMPLETE", line)
self.assertNotIn("NOT VULNERABLE", line)
def test_markdown_metadata_cannot_escape_its_code_span_or_inject_html(self):
report = self.report(HostEvidence("10.10.10.25"))
report.targets = ["target`\n<script>alert(1)</script>"]
report.mode = "test`\n# false heading"
text = markdown(report)
self.assertIn("Targets: `target&#96; &lt;script&gt;alert(1)&lt;/script&gt;`", text)
self.assertIn("Mode: `test&#96; # false heading`", text)
self.assertNotIn("<script>", text)
self.assertNotIn("\n# false heading", text)
def test_hosts_sort_numerically_and_names_cannot_inject_lines(self):
report = self.report(HostEvidence("10.0.0.10", domain="LAB\nFAKE\x1b[2K"))
report.hosts.append(HostEvidence("10.0.0.2"))
lines = minimal(report).splitlines()
self.assertEqual(len(lines), 2)
self.assertTrue(lines[0].startswith("10.0.0.2 "))
self.assertNotIn("\x1b", lines[1])
if __name__ == "__main__":
unittest.main()
+104
View File
@@ -0,0 +1,104 @@
import unittest
from rpcscope.models import HostEvidence, RpcInterface, Share
from rpcscope.rules import evaluate_host
class RuleTests(unittest.TestCase):
def test_rules_separate_exposure_from_confirmed_vulnerability(self):
host = HostEvidence(
address="10.10.10.25",
smb1=True,
signing=False,
null_session=True,
shares=[Share("Public", ["READ", "WRITE"])],
rpc_interfaces=[RpcInterface("12345678-1234-abcd-ef00-0123456789ab", "1.0")],
)
findings = evaluate_host(host)
ids = {finding.rule_id for finding in findings}
self.assertTrue({"SMB-001", "SMB-002", "SMB-003", "SMB-005"} <= ids)
rpc = next(finding for finding in findings if finding.rule_id.startswith("RPC-"))
self.assertIn("does not prove", rpc.impact)
self.assertNotIn("MSF-MS17-010", ids)
def test_msf_result_is_explicit(self):
host = HostEvidence(address="10.10.10.25", checks={"ms17-010": "vulnerable"})
self.assertTrue(any(f.rule_id == "CHECK-MS17-010" for f in evaluate_host(host)))
def test_catalog_check_becomes_detailed_finding(self):
host = HostEvidence(address="10.10.10.25", checks={"zerologon": "vulnerable"})
finding = next(f for f in evaluate_host(host) if f.rule_id == "CHECK-ZEROLOGON")
self.assertEqual(finding.severity, "critical")
self.assertIn("CVE-2020-1472", finding.title)
def test_rpc_registration_does_not_claim_remote_reachability(self):
host = HostEvidence("10.10.10.25", rpc_interfaces=[
RpcInterface("12345678-1234-abcd-ef00-0123456789ab", "1.0",
endpoint="ncalrpc:[local-spooler]")
])
finding = evaluate_host(host)[0]
self.assertEqual(finding.severity, "info")
self.assertIn("local bindings only", finding.title)
self.assertIn("reachability untested", finding.evidence)
def test_rpc_report_preserves_remote_binding_after_local_binding(self):
host = HostEvidence("10.10.10.25", rpc_interfaces=[
RpcInterface("12345678-1234-abcd-ef00-0123456789ab", "1.0",
bindings=["ncalrpc:[local-spooler]", "ncacn_ip_tcp:10.10.10.25[49665]"])
])
finding = evaluate_host(host)[0]
self.assertEqual(finding.severity, "medium")
self.assertIn("ncacn_ip_tcp", finding.evidence)
self.assertNotIn("local bindings only", finding.title)
def test_legacy_fingerprint_uses_version_boundaries(self):
for os in ("Windows 11", "Windows Server 2019", "Windows 80", "Samba 4.20.1"):
with self.subTest(os=os):
self.assertFalse(any(f.rule_id == "SMB-007" for f in evaluate_host(
HostEvidence("10.10.10.25", os=os)
)))
for os in ("Windows 7 Build 7601", "Windows 8.1", "Windows Server 2008 R2", "Samba 3.6.1"):
with self.subTest(os=os):
self.assertTrue(any(f.rule_id == "SMB-007" for f in evaluate_host(
HostEvidence("10.10.10.25", os=os)
)))
def test_acl_write_capability_does_not_claim_direct_write(self):
host = HostEvidence("10.10.10.25", shares=[Share("Data", ["READ", "WRITE (ACL)"])])
finding = evaluate_host(host)[0]
self.assertEqual(finding.rule_id, "SMB-005")
self.assertIn("permission-control", finding.title)
self.assertIn("WRITE (ACL)", finding.evidence)
def test_suspected_checker_indicator_is_preserved_with_low_confidence(self):
host = HostEvidence("10.10.10.25", checks={"smbghost": "suspected"},
check_details={"smbghost": "SMB 3.1.1 compression capability observed"})
finding = evaluate_host(host)[0]
self.assertEqual(finding.confidence, "low")
self.assertIn("requires validation", finding.title)
self.assertEqual(finding.evidence, host.check_details["smbghost"])
self.assertIn("does not distinguish patched", finding.impact)
def test_checker_positive_includes_actual_evidence(self):
host = HostEvidence("10.10.10.25", checks={"ms17-010": "vulnerable"},
check_details={"ms17-010": "positive checker marker from raw artifact"})
finding = evaluate_host(host)[0]
self.assertEqual(finding.confidence, "high")
self.assertEqual(finding.evidence, host.check_details["ms17-010"])
def test_rpc_evidence_preserves_version_to_binding_mapping(self):
host = HostEvidence("10.10.10.25", rpc_interfaces=[
RpcInterface("12345678-1234-abcd-ef00-0123456789ab", "1.0",
endpoint="ncalrpc:[local-spooler]"),
RpcInterface("12345678-1234-abcd-ef00-0123456789ab", "2.0",
endpoint="ncacn_ip_tcp:10.10.10.25[49665]"),
])
finding = evaluate_host(host)[0]
self.assertIn("v1.0 advertised via ncalrpc:[local-spooler]", finding.evidence)
self.assertIn("v2.0 advertised via ncacn_ip_tcp:10.10.10.25[49665]", finding.evidence)
if __name__ == "__main__":
unittest.main()
+66
View File
@@ -0,0 +1,66 @@
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from rpcscope.catalog import CHECKS
from rpcscope.runner import ScanConfig, _msf_command, _redacted, scan
class RunnerTests(unittest.TestCase):
def test_nonfinite_and_invalid_api_limits_are_rejected_before_collection(self):
for attribute, value in (("timeout", float("nan")), ("timeout", float("inf")),
("jobs", True), ("discovery_workers", 1.5),
("connect_timeout", float("nan")), ("connect_timeout", True)):
with self.subTest(attribute=attribute, value=value), patch("rpcscope.runner.doctor") as inspect:
config = ScanConfig(["192.0.2.1"])
setattr(config, attribute, value)
with self.assertRaises(ValueError):
scan(config)
inspect.assert_not_called()
def test_redacts_embedded_password(self):
command = ["rpcdump", "user:[email protected]"]
self.assertEqual(
_redacted(command, "supersecret"),
["rpcdump", "user:<redacted>@10.10.10.25"],
)
def test_dry_run_does_not_require_installed_collectors(self):
with tempfile.TemporaryDirectory() as directory:
config = ScanConfig(
targets=["10.10.10.0/24"],
username="",
password="",
shares=True,
dry_run=True,
out_dir=Path(directory),
)
self.assertEqual(scan(config), {})
self.assertEqual(config.commands[0][0:3], ["nxc", "smb", "10.10.10.0/24"])
def test_msf_escaped_credentials_are_never_recorded(self):
config = ScanConfig(targets=["192.0.2.1"], username="auditor", password='a\\b"c')
command = _msf_command("msfconsole", "192.0.2.1", config)
redacted = _redacted(command, config.password)
self.assertEqual(redacted[-1], "<redacted Metasploit resource script>")
self.assertNotIn(config.password, " ".join(redacted))
def test_discovery_interruption_retains_partial_hosts_and_skips_checks(self):
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig(["192.0.2.0/24"], out_dir=Path(temporary) / "out",
checks=[CHECKS["ms17-010"]])
tools = {"nxc": "nxc", "enum4linux-ng": None, "rpcdump": None, "msfconsole": None}
def interrupted(command, timeout, log, password, cancel):
cancel.set()
return 130, "SMB 192.0.2.1 445 HOST [*] Windows 11 (name:HOST) (domain:LAB)"
with patch("rpcscope.runner.doctor", return_value=tools), patch("rpcscope.runner._run", side_effect=interrupted) as run:
hosts = scan(config)
self.assertTrue(config.cancelled)
self.assertIn("192.0.2.1", hosts)
self.assertEqual(hosts["192.0.2.1"].checks["ms17-010"], "skipped-cancelled")
self.assertEqual(run.call_count, 1)
if __name__ == "__main__":
unittest.main()
+230
View File
@@ -0,0 +1,230 @@
import errno
import socket
import tempfile
import threading
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
from rpcscope.targets import _connect, discover_services, expand_targets
class TargetExpansionTests(unittest.TestCase):
def test_local_socket_exhaustion_is_not_a_dead_target_or_user_cancellation(self):
cancel = threading.Event()
def exhausted(address, port, timeout):
raise OSError(errno.EMFILE, "too many open files")
with self.assertRaisesRegex(OSError, "local socket resources exhausted"):
discover_services(["192.0.2.1"], jobs=1, cancel=cancel, probe=exhausted)
self.assertFalse(cancel.is_set())
def test_canonical_unique_sorted_addresses_and_host_semantics(self):
targets = ["2001:db8::/126", "192.0.2.0/30", "192.0.2.2",
"192.0.2.4/31", "192.0.2.7/32", "2001:db8::4/127", "::1/128"]
self.assertEqual(expand_targets(targets), ["192.0.2.1", "192.0.2.2", "192.0.2.4",
"192.0.2.5", "192.0.2.7", "::1",
"2001:db8::1", "2001:db8::2", "2001:db8::3",
"2001:db8::4", "2001:db8::5"])
def test_non_strict_network_and_overlapping_limit(self):
self.assertEqual(expand_targets(["192.0.2.2/30", "192.0.2.0/30"], max_hosts=2),
["192.0.2.1", "192.0.2.2"])
with self.assertRaisesRegex(ValueError, "limit"):
expand_targets(["192.0.2.1", "192.0.2.2"], max_hosts=1)
def test_huge_network_is_rejected_before_iteration(self):
for cidr in ("0.0.0.0/0", "::/0"):
with self.subTest(cidr=cidr), self.assertRaisesRegex(ValueError, "contains.*limit"):
expand_targets([cidr])
def test_file_comments_bom_hostname_and_ipv6_resolution(self):
resolver = Mock(return_value=[
(socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("2001:0db8::9", 0, 0, 0)),
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("192.0.2.9", 0)),
])
with tempfile.TemporaryDirectory() as temporary:
target_file = Path(temporary) / "targets.txt"
target_file.write_text("\ufeff# lab\n192.0.2.1 # first\n\nserver.example\n", encoding="utf-8")
self.assertEqual(expand_targets([str(target_file), "192.0.2.9"], resolver=resolver),
["192.0.2.1", "192.0.2.9", "2001:db8::9"])
resolver.assert_called_once_with("server.example", None, socket.AF_UNSPEC, socket.SOCK_STREAM)
def test_nested_files_rejected_with_line_number(self):
with tempfile.TemporaryDirectory() as temporary:
parent = Path(temporary) / "parent.txt"
child = Path(temporary) / "child.txt"
child.write_text("192.0.2.1\n", encoding="utf-8")
parent.write_text("# comment\nchild.txt\n", encoding="utf-8")
with self.assertRaisesRegex(ValueError, r"parent\.txt:2: nested target files"):
expand_targets([str(parent)])
def test_invalid_inputs_do_not_call_dns(self):
resolver = Mock()
for target in ("-M", "", "https://example.org", "999.2.3.4", "192.0.2.1/99", "bad name", "::x"):
with self.subTest(target=target), self.assertRaises(ValueError):
expand_targets([target], resolver=resolver)
resolver.assert_not_called()
def test_dns_errors_and_empty_file_are_useful(self):
with self.assertRaisesRegex(ValueError, "could not resolve.*example"):
expand_targets(["example"], resolver=Mock(side_effect=socket.gaierror("missing")))
with self.assertRaisesRegex(ValueError, "did not resolve"):
expand_targets(["example"], resolver=Mock(return_value=[]))
with tempfile.TemporaryDirectory() as temporary:
target_file = Path(temporary) / "empty.txt"
target_file.write_text("# none\n", encoding="utf-8")
with self.assertRaisesRegex(ValueError, "did not contain"):
expand_targets([str(target_file)])
class ServiceDiscoveryTests(unittest.TestCase):
def test_probe_states_are_independent_and_callbacks_use_caller_thread(self):
callback = Mock()
caller_thread = threading.get_ident()
def completed(address, ports):
self.assertEqual(threading.get_ident(), caller_thread)
callback(address, ports)
def probe(address, port, timeout):
self.assertEqual(timeout, 0.25)
if address == "192.0.2.1" and port == 135:
return "open"
if address == "192.0.2.1":
raise ConnectionRefusedError()
if port == 135:
raise TimeoutError()
raise OSError(errno.ENETUNREACH, "unreachable")
result = discover_services(["192.0.2.2", "192.0.2.1", "192.0.2.1"], jobs=2,
timeout=0.25, probe=probe, on_result=completed)
self.assertEqual(result, {"192.0.2.1": {135: "open", 445: "closed"},
"192.0.2.2": {135: "timeout", 445: "error"}})
self.assertEqual(callback.call_count, 2)
def test_host_concurrency_is_bounded(self):
barrier = threading.Barrier(2)
lock = threading.Lock()
active = 0
peak = 0
order = {}
def probe(address, port, timeout):
nonlocal active, peak
with lock:
order.setdefault(address, []).append(port)
if port == 135:
active += 1
peak = max(peak, active)
if port == 135:
barrier.wait(timeout=2)
else:
with lock:
active -= 1
return "open"
results = discover_services([f"192.0.2.{index}" for index in range(1, 7)], jobs=2, probe=probe)
self.assertEqual(len(results), 6)
self.assertEqual(peak, 2)
self.assertTrue(all(ports == [135, 445] for ports in order.values()))
def test_cancel_stops_new_hosts_and_second_port(self):
cancel = threading.Event()
probe = Mock(side_effect=lambda *_: (cancel.set() or "open"))
result = discover_services(["192.0.2.1", "192.0.2.2"], jobs=1, cancel=cancel, probe=probe)
self.assertEqual(result, {"192.0.2.1": {135: "open"}})
probe.assert_called_once()
self.assertEqual(discover_services(["192.0.2.1"], cancel=cancel, probe=probe), {})
def test_interrupt_sets_cancel_event_and_propagates(self):
cancel = threading.Event()
with self.assertRaises(KeyboardInterrupt):
discover_services(["192.0.2.1"], cancel=cancel, probe=Mock(side_effect=KeyboardInterrupt))
self.assertTrue(cancel.is_set())
def test_expired_deadline_makes_no_probe_and_does_not_cancel_caller(self):
cancel = threading.Event()
probe = Mock()
with patch("rpcscope.targets.time.monotonic", return_value=10.0):
self.assertEqual(discover_services(["192.0.2.1"], cancel=cancel, probe=probe, deadline=9.0), {})
probe.assert_not_called()
self.assertFalse(cancel.is_set())
def test_deadline_stops_next_port_and_host_but_retains_completed_evidence(self):
cancel = threading.Event()
now = [10.0]
callback = Mock()
def probe(*_):
now[0] = 20.0
return "open"
with patch("rpcscope.targets.time.monotonic", side_effect=lambda: now[0]):
result = discover_services(["192.0.2.1", "192.0.2.2"], jobs=1, cancel=cancel,
probe=probe, deadline=20.0, on_result=callback)
self.assertEqual(result, {"192.0.2.1": {135: "open"}})
callback.assert_called_once_with("192.0.2.1", {135: "open"})
self.assertFalse(cancel.is_set())
def test_each_port_timeout_is_capped_to_remaining_deadline_budget(self):
now = [10.0]
calls = []
def probe(address, port, timeout):
calls.append((address, port, timeout))
now[0] += 0.25
return "closed"
with patch("rpcscope.targets.time.monotonic", side_effect=lambda: now[0]):
result = discover_services(["192.0.2.1", "192.0.2.2"], jobs=1, timeout=2.0,
deadline=10.5, probe=probe)
self.assertEqual(result, {"192.0.2.1": {135: "closed", 445: "closed"}})
self.assertEqual(calls, [("192.0.2.1", 135, 0.5), ("192.0.2.1", 445, 0.25)])
def test_worker_slot_is_reused_without_waiting_for_slower_host(self):
slow_started = threading.Event()
next_host_started = threading.Event()
callback = Mock()
def probe(address, port, timeout):
if address == "192.0.2.1" and port == 135:
slow_started.set()
if not next_host_started.wait(timeout=2):
raise AssertionError("completed worker did not take the next host")
if address == "192.0.2.2" and port == 135:
if not slow_started.wait(timeout=2):
raise AssertionError("first worker did not start")
if address == "192.0.2.3":
next_host_started.set()
return "closed"
result = discover_services(["192.0.2.1", "192.0.2.2", "192.0.2.3"], jobs=2,
probe=probe, on_result=callback)
self.assertTrue(next_host_started.is_set())
self.assertEqual(result, {address: {135: "closed", 445: "closed"}
for address in ("192.0.2.1", "192.0.2.2", "192.0.2.3")})
self.assertEqual(callback.call_count, 3)
self.assertEqual({call.args[0] for call in callback.call_args_list}, set(result))
def test_default_probe_connects_and_closes_without_sending(self):
with patch("rpcscope.targets.socket.create_connection") as connection:
self.assertEqual(_connect("192.0.2.1", 135, 0.2), "open")
connection.assert_called_once_with(("192.0.2.1", 135), timeout=0.2)
connection.return_value.__exit__.assert_called_once()
connection.return_value.__enter__.return_value.send.assert_not_called()
connection.return_value.__enter__.return_value.sendall.assert_not_called()
def test_invalid_configuration_and_unexpected_probe_result(self):
for kwargs in ({"jobs": 0}, {"jobs": True}, {"timeout": 0}, {"timeout": float("inf")},
{"timeout": float("nan")}, {"deadline": float("nan")},
{"deadline": float("inf")}, {"deadline": True}):
with self.subTest(kwargs=kwargs), self.assertRaises(ValueError):
discover_services(["192.0.2.1"], **kwargs)
with self.assertRaises(ValueError):
discover_services(["server.example"])
self.assertEqual(discover_services(["192.0.2.1"], probe=Mock(return_value="unexpected")),
{"192.0.2.1": {135: "error", 445: "error"}})
if __name__ == "__main__":
unittest.main()