Initial RPCScope release
This commit is contained in:
commit
9dc7289652
40 files changed
+6688
No files matched your search
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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` <script>alert(1)</script>`", text)
|
||||
self.assertIn("Mode: `test` # 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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user