121 lines
7.2 KiB
Python
121 lines
7.2 KiB
Python
"""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()
|