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