"""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), 24) 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()