Initial RPCScope release

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

No files matched your search

+295
View File
@@ -0,0 +1,295 @@
"""Exercise default discovery and new adapters with no target network traffic."""
import contextlib
import io
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from rpcscope.catalog import CHECKS, EXPOSURE_CHECKS, resolve_checks
from rpcscope.checks import CheckResult
from rpcscope.cli import main
from rpcscope.reporting import coverage, minimal
from rpcscope.models import HostEvidence, Report, RpcInterface
from rpcscope.rules import evaluate
from rpcscope.runner import ScanConfig, scan
RPC_ONLY = "192.0.2.1"
SMB_ONLY = "192.0.2.2"
TOOLS = {"nxc": "nxc", "enum4linux-ng": "enum", "rpcdump": "rpcdump", "msfconsole": None}
def discovered(addresses, on_result, **kwargs):
states = {address: {135: "open" if address == RPC_ONLY else "closed",
445: "closed" if address == RPC_ONLY else "open"} for address in addresses}
for address, ports in states.items():
on_result(address, ports)
return states
class DiscoveryIntegrationTests(unittest.TestCase):
def test_rpc_only_hosts_survive_and_closed_services_are_not_scanned(self):
calls = []
def run(command, timeout, log, password, **kwargs):
calls.append(command)
if command[0] == "rpcdump":
self.assertEqual(command[-1], RPC_ONLY)
return 0, f"UUID: 12345678-1234-abcd-ef00-0123456789ab v1.0\nBindings:\n ncacn_ip_tcp:{RPC_ONLY}[50000]\n"
if "-M" in command:
self.assertEqual(command[2], SMB_ONLY)
return 0, f"MS17-010 {SMB_ONLY} 445 HOST {SMB_ONLY} does NOT appear vulnerable"
self.assertNotIn(RPC_ONLY, command)
return 0, f"SMB {SMB_ONLY} 445 HOST [*] Windows 11 (name:HOST) (domain:LAB)"
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig(["192.0.2.0/30"], out_dir=Path(temporary), tcp_discovery=True,
use_enum4linux=False, checks=[CHECKS["ms17-010"]])
events = []
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner.discover_services", side_effect=discovered), \
patch("rpcscope.runner._run", side_effect=run):
hosts = scan(config, progress=events.append)
self.assertEqual(set(hosts), {RPC_ONLY, SMB_ONLY})
self.assertEqual(hosts[RPC_ONLY].collection["rpcdump"], "success")
self.assertEqual(hosts[SMB_ONLY].collection["rpcdump"], "skipped-service-unavailable")
self.assertEqual(hosts[RPC_ONLY].checks["ms17-010"], "skipped-service-unavailable")
self.assertEqual(hosts[SMB_ONLY].checks["ms17-010"], "not-vulnerable")
self.assertEqual(events[-1].completed, events[-1].total)
def test_bind_checks_pass_method_only_and_keep_all_evidence(self):
commands = []
def run(command, timeout, log, password, **kwargs):
commands.append(command)
if "-M" in command:
method = command[command.index("-o") + 1].partition("=")[2]
return 0, f"COERCE_PLUS {SMB_ONLY} 445 HOST VULNERABLE, {method}"
return 0, f"SMB {SMB_ONLY} 445 HOST [*] Windows 11 (name:HOST) (domain:LAB)"
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig([SMB_ONLY], out_dir=Path(temporary), checks=resolve_checks(["exposure"]),
use_enum4linux=False, use_rpcdump=False)
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=run):
hosts = scan(config)
host = hosts[SMB_ONLY]
self.assertEqual(len(host.check_attempts), len(EXPOSURE_CHECKS))
self.assertTrue(all(status == "exposed" for status in host.checks.values()))
self.assertNotIn("LISTENER", str(commands))
self.assertNotIn("ALWAYS", str(commands))
report = Report("now", "test", [], None, None, [host], evaluate([host]))
line = minimal(report)
self.assertIn("INVENTORY ONLY", line)
self.assertIn("EXPOSURE:", line)
self.assertNotIn("VULN:", line)
self.assertNotIn("NOT VULNERABLE", line)
self.assertTrue(all(finding.severity == "info" for finding in report.findings))
def test_default_cli_discovers_ports_without_expanding_oxid_scope(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "out"
captured = io.StringIO()
def run(command, timeout, log, password, **kwargs):
if "-M" in command:
return 0, f"IOXIDRESOLVER {SMB_ONLY} 445 HOST Address: 10.99.0.99"
return 0, f"SMB {SMB_ONLY} 445 HOST [*] Windows 11 (name:HOST) (domain:LAB)"
def all_open(addresses, on_result, **kwargs):
for address in addresses:
on_result(address, {135: "open", 445: "open"})
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner.discover_services", side_effect=all_open), \
patch("rpcscope.runner._run", side_effect=run), contextlib.redirect_stdout(captured), contextlib.redirect_stderr(io.StringIO()):
code = main(["scan", SMB_ONLY, "--checks", "oxid", "--no-enum4linux", "--no-rpcdump",
"--authorised", "--no-progress", "-o", str(output)])
self.assertEqual(code, 0)
report = json.loads((output / "report.json").read_text(encoding="utf-8"))
self.assertEqual([host["address"] for host in report["hosts"]], [SMB_ONLY])
self.assertIn("10.99.0.99", report["hosts"][0]["check_details"]["oxid"])
self.assertIn("INFO: [OXID-INTERFACES]", captured.getvalue())
def test_review_catalog_is_not_an_executable_check(self):
captured = io.StringIO()
with contextlib.redirect_stdout(captured):
self.assertEqual(main(["catalog", "--advisories"]), 0)
self.assertIn("CVE-2026-41089", captured.getvalue())
self.assertIn("review-only", captured.getvalue())
with self.assertRaises(ValueError):
resolve_checks(["CVE-2026-41089"])
def test_netlogon_and_print_rpc_registrations_have_distinct_ids(self):
host = HostEvidence(RPC_ONLY, rpc_interfaces=[
RpcInterface("12345678-1234-abcd-ef00-01234567cffb", "1.0"),
RpcInterface("12345678-1234-abcd-ef00-0123456789ab", "1.0"),
])
findings = evaluate([host])
self.assertEqual(len({finding.rule_id for finding in findings}), 2)
line = minimal(Report("now", "test", [], None, None, [host], findings))
self.assertIn("[MS-NRPC]", line)
self.assertIn("[MS-RPRN]", line)
self.assertNotIn(" VULN:", line)
def test_dead_targets_stop_at_discovery_and_are_silent(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "out"
diagnostics = io.StringIO()
def timeout(addresses, on_result, **kwargs):
for address in addresses:
on_result(address, {135: "timeout", 445: "timeout"})
captured = io.StringIO()
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner.discover_services", side_effect=timeout), \
patch("rpcscope.runner._run") as run, contextlib.redirect_stdout(captured), contextlib.redirect_stderr(diagnostics):
code = main(["scan", RPC_ONLY, "--checks", "recommended", "--authorised", "-o", str(output)])
self.assertEqual(code, 0)
self.assertIn("SUMMARY scan complete", diagnostics.getvalue())
self.assertNotIn(RPC_ONLY, diagnostics.getvalue())
self.assertEqual(captured.getvalue(), "")
report = json.loads((output / "report.json").read_text())
self.assertEqual(report["hosts"][0]["checks"], {})
self.assertEqual(report["hosts"][0]["check_attempts"], [])
self.assertNotIn("enum4linux", report["hosts"][0]["collection"])
self.assertEqual((output / "report.txt").read_text(), "")
self.assertNotIn(f"- `{RPC_ONLY}`", (output / "report.md").read_text())
run.assert_not_called()
def test_only_live_host_pipelines_are_scheduled_and_uncertainty_completes(self):
dead = "192.0.2.3"
def probe(addresses, on_result, **kwargs):
self.assertEqual(kwargs["jobs"], 256)
for address in addresses:
on_result(address, {135: "timeout", 445: "open" if address == SMB_ONLY else "closed"})
def run(command, timeout, log, password, **kwargs):
self.assertNotIn(dead, command)
if "-M" in command:
return 0, f"SMBGHOST {SMB_ONLY} 445 HOST Potentially vulnerable to SMBGhost (CVE-2020-0796)"
return 0, f"SMB {SMB_ONLY} 445 HOST [*] Windows 11 (domain:LAB)"
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig([SMB_ONLY, dead], out_dir=Path(temporary), tcp_discovery=True,
use_enum4linux=False, use_rpcdump=False, checks=[CHECKS["smbghost"]])
events = []
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner.discover_services", side_effect=probe), \
patch("rpcscope.runner._run", side_effect=run) as execute:
hosts = scan(config, progress=events.append)
self.assertEqual(execute.call_count, 2)
self.assertEqual(hosts[dead].checks, {})
self.assertEqual(coverage(hosts[SMB_ONLY]), (1, 1, False))
self.assertEqual(events[-1].status, "complete")
self.assertEqual(events[-1].completed, events[-1].total)
self.assertFalse(any(event.host == dead for event in events))
def test_silent_module_is_unknown_not_execution_failure(self):
def run(command, timeout, log, password, **kwargs):
return (0, "") if "-M" in command else (0, f"SMB {SMB_ONLY} 445 HOST [*] Windows 11")
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig([SMB_ONLY], out_dir=Path(temporary), checks=[CHECKS["zerologon"]],
use_enum4linux=False, use_rpcdump=False)
events = []
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=run):
hosts = scan(config, progress=events.append)
host = hosts[SMB_ONLY]
self.assertEqual(host.checks["zerologon"], "inconclusive")
self.assertEqual(coverage(host), (1, 1, False))
self.assertEqual(events[-1].status, "complete")
text = minimal(Report("now", "test", [], None, None, [host], evaluate([host])))
self.assertIn("UNKNOWN:", text)
self.assertIn("CHECKS: 1/1", text)
self.assertNotIn("PARTIAL", text)
self.assertNotIn("NOT VULNERABLE", text)
def test_checker_process_failure_remains_partial(self):
def run(command, timeout, log, password, **kwargs):
return (124, "timeout") if "-M" in command else (0, f"SMB {SMB_ONLY} 445 HOST [*] Windows 11")
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig([SMB_ONLY], out_dir=Path(temporary), checks=[CHECKS["ms17-010"]],
use_enum4linux=False, use_rpcdump=False)
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=run):
hosts = scan(config)
self.assertTrue(coverage(hosts[SMB_ONLY])[2])
self.assertEqual(hosts[SMB_ONLY].collection["check:ms17-010"], "failed")
self.assertTrue(config.errors)
def test_legacy_discovery_does_not_fabricate_dead_literal_hosts(self):
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig([SMB_ONLY], out_dir=Path(temporary), checks=[CHECKS["ms17-010"]])
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", return_value=(0, "")) as execute:
hosts = scan(config)
self.assertEqual(hosts, {})
self.assertEqual(execute.call_count, 1)
self.assertEqual(config.errors, [])
def test_legacy_connection_error_line_is_not_an_alive_host(self):
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig([SMB_ONLY], out_dir=Path(temporary), checks=[CHECKS["ms17-010"]])
text = f"SMB {SMB_ONLY} 445 NONE [-] Error connecting to host: timed out"
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", return_value=(0, text)) as execute:
self.assertEqual(scan(config), {})
self.assertEqual(execute.call_count, 1)
def test_native_longlogon_runs_without_netexec_and_keeps_precondition_separate(self):
def rpc_host(addresses, on_result, **kwargs):
for address in addresses:
on_result(address, {135: "open", 445: "closed"})
tools = {key: None for key in TOOLS}
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig([RPC_ONLY], out_dir=Path(temporary), tcp_discovery=True,
use_enum4linux=False, use_rpcdump=False, checks=[CHECKS["longlogon"]])
with patch("rpcscope.runner.doctor", return_value=tools), patch("rpcscope.runner.discover_services", side_effect=rpc_host), \
patch("rpcscope.runner._run") as external, patch("rpcscope.runner.probe_longlogon", return_value=CheckResult(
"precondition-met", "validated minimal legacy response; empirical estimate, not patch detection",
)) as native:
hosts = scan(config)
native.assert_called_once()
self.assertEqual(native.call_args.args, (RPC_ONLY,))
external.assert_not_called()
host = hosts[RPC_ONLY]
self.assertEqual(host.check_attempts[0].backend, "native")
self.assertEqual(host.checks["longlogon"], "precondition-met")
self.assertTrue(Path(host.check_attempts[0].source).is_file())
self.assertEqual(coverage(host), (1, 1, False))
line = minimal(Report("now", "test", [], None, None, [host], evaluate([host])))
self.assertIn("PRECONDITION: [LONGLOGON:MET]", line)
self.assertNotIn("VULN:", line)
self.assertNotIn("NOT VULNERABLE", line)
self.assertTrue(all(finding.severity == "info" for finding in evaluate([host])))
self.assertEqual(config.errors, [])
def test_native_longlogon_is_not_attempted_on_dead_targets(self):
def dead(addresses, on_result, **kwargs):
for address in addresses:
on_result(address, {135: "closed", 445: "closed"})
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig([RPC_ONLY], out_dir=Path(temporary), tcp_discovery=True,
use_enum4linux=False, use_rpcdump=False, checks=[CHECKS["longlogon"]])
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner.discover_services", side_effect=dead), \
patch("rpcscope.runner.probe_longlogon") as native:
hosts = scan(config)
native.assert_not_called()
self.assertEqual(hosts[RPC_ONLY].checks, {})
def test_native_dry_run_records_plan_without_sending_packets(self):
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig([RPC_ONLY], out_dir=Path(temporary) / "absent", dry_run=True,
use_enum4linux=False, use_rpcdump=False, checks=[CHECKS["longlogon"]])
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner.probe_longlogon") as native:
self.assertEqual(scan(config), {})
native.assert_not_called()
self.assertFalse(config.out_dir.exists())
self.assertTrue(any(command[0] == "<native:longlogon>" for command in config.commands))
self.assertFalse(any("-M" in command for command in config.commands))
def test_discovery_deadline_does_not_treat_unprobed_ports_as_completed(self):
def interrupted_scope(addresses, on_result, **kwargs):
on_result(addresses[0], {135: "closed"})
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig([RPC_ONLY], out_dir=Path(temporary), tcp_discovery=True,
use_enum4linux=False, use_rpcdump=False, checks=[CHECKS["longlogon"]])
events = []
with patch("rpcscope.runner.doctor", return_value=TOOLS), \
patch("rpcscope.runner.discover_services", side_effect=interrupted_scope), \
patch("rpcscope.runner.probe_longlogon") as native:
hosts = scan(config, progress=events.append)
native.assert_not_called()
self.assertEqual(hosts[RPC_ONLY].services["445"], "unprobed")
self.assertEqual(hosts[RPC_ONLY].collection["tcp-discovery"], "incomplete")
self.assertEqual(hosts[RPC_ONLY].checks, {})
self.assertEqual(events[-1].status, "incomplete")
self.assertTrue(config.errors)
if __name__ == "__main__":
unittest.main()