Improve evidence classification and compact CLI reporting
This commit is contained in:
1 parent
9dc7289652
commit
dda4f8563f
28 files changed
+945
-87
No files matched your search
@@ -29,6 +29,64 @@ def alive(addresses, on_result, **kwargs):
|
||||
|
||||
|
||||
class BatchExecutionTests(unittest.TestCase):
|
||||
def test_rpcdump_exit_zero_requires_reliable_inventory_and_no_protocol_error(self):
|
||||
endpoint = "UUID: 12345678-1234-abcd-ef00-0123456789ab v1.0\nBindings:\n ncacn_ip_tcp:192.0.2.1[50000]\n"
|
||||
cases = [
|
||||
("[-] Protocol failed: access denied\n[*] No endpoints found.\n", "failed", True),
|
||||
("CRITICAL:root:Protocol failed: refused\n", "failed", True),
|
||||
("", "failed", True),
|
||||
("[*] Received one endpoint.\n", "failed", True),
|
||||
(endpoint + "[-] Protocol failed: incomplete lookup\n", "failed", True),
|
||||
("[*] No endpoints found.\n", "success", False),
|
||||
(endpoint + "Provider: ModuleNotFoundError: remote annotation\n", "success", False),
|
||||
]
|
||||
for text, status, has_errors in cases:
|
||||
with self.subTest(text=text), tempfile.TemporaryDirectory() as temporary:
|
||||
config = ScanConfig(["192.0.2.1"], out_dir=Path(temporary), use_enum4linux=False, use_rpcdump=True)
|
||||
def execute(command, timeout, log, password, **kwargs):
|
||||
return (0, text) if command[0] == "fake-rpc" else (0, banner("192.0.2.1"))
|
||||
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=execute):
|
||||
host = scan(config)["192.0.2.1"]
|
||||
self.assertEqual(host.collection["rpcdump"], status)
|
||||
self.assertEqual(bool(config.errors), has_errors)
|
||||
self.assertEqual(coverage(host)[2], has_errors)
|
||||
if endpoint in text:
|
||||
self.assertTrue(host.rpc_interfaces) # keep partial evidence
|
||||
|
||||
def test_local_rpcdump_dependency_error_stops_queued_copies_not_other_checks(self):
|
||||
addresses = ["192.0.2.1", "192.0.2.2", "192.0.2.3"]
|
||||
calls = []
|
||||
def execute(command, timeout, log, password, **kwargs):
|
||||
calls.append(command)
|
||||
if command[0] == "fake-rpc":
|
||||
return 1, "Traceback (most recent call last):\nModuleNotFoundError: No module named 'impacket'"
|
||||
if "-M" in command:
|
||||
return 0, f"MS17-010 {command[2]} 445 HOST {command[2]} does NOT appear vulnerable"
|
||||
return 0, "\n".join(banner(address) for address in addresses)
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
config = ScanConfig(addresses, out_dir=Path(temporary), jobs=1, use_enum4linux=False,
|
||||
use_rpcdump=True, 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(sum(command[0] == "fake-rpc" for command in calls), 1)
|
||||
self.assertEqual(sum("-M" in command for command in calls), 3)
|
||||
self.assertEqual(hosts[addresses[0]].collection["rpcdump"], "failed")
|
||||
self.assertEqual(hosts[addresses[1]].collection["rpcdump"], "skipped-local-error")
|
||||
self.assertTrue(all(host.checks["ms17-010"] == "not-vulnerable" for host in hosts.values()))
|
||||
self.assertIn("ModuleNotFoundError", config.errors[0])
|
||||
self.assertTrue(any(item["stage"] == "rpcdump" and item["exit_code"] == 1 for item in config.timings))
|
||||
|
||||
def test_remote_rpcdump_failure_does_not_disable_other_targets(self):
|
||||
addresses = ["192.0.2.1", "192.0.2.2"]
|
||||
def execute(command, timeout, log, password, **kwargs):
|
||||
return (1, "RPC access denied") if command[0] == "fake-rpc" else (0, "\n".join(banner(address) for address in addresses))
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
config = ScanConfig(addresses, out_dir=Path(temporary), jobs=1, use_enum4linux=False, use_rpcdump=True)
|
||||
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=execute):
|
||||
hosts = scan(config)
|
||||
self.assertTrue(all(host.collection["rpcdump"] == "failed" for host in hosts.values()))
|
||||
self.assertEqual(len(config.errors), 2)
|
||||
|
||||
def test_duplicate_api_checks_are_executed_and_counted_once(self):
|
||||
definition = resolve_checks(["ms17-010"])[0]
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
@@ -84,7 +142,7 @@ class BatchExecutionTests(unittest.TestCase):
|
||||
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):
|
||||
def test_recommended_32_host_scan_uses_24_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):
|
||||
@@ -118,11 +176,12 @@ class BatchExecutionTests(unittest.TestCase):
|
||||
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")):
|
||||
patch("rpcscope.runner.probe_longlogon", return_value=CheckResult("precondition-not-met", "not patch detection")), \
|
||||
patch("rpcscope.runner.probe_smb_compression", return_value=CheckResult("exposed", "compression capability only")):
|
||||
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(check_commands), 24)
|
||||
self.assertEqual(len(commands), 25) # one separate inventory invocation
|
||||
self.assertEqual(len(hosts), 32)
|
||||
self.assertGreater(len(snapshots), 1)
|
||||
for host in hosts.values():
|
||||
@@ -130,7 +189,7 @@ class BatchExecutionTests(unittest.TestCase):
|
||||
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["smbghost"], "exposed")
|
||||
self.assertEqual(host.checks["oxid"], "observed")
|
||||
self.assertTrue(all(host.checks[key] == "exposed" for key in EXPOSURE_CHECKS))
|
||||
self.assertEqual(config.errors, [])
|
||||
|
||||
+25
-4
@@ -50,11 +50,33 @@ class CheckerOutputTests(unittest.TestCase):
|
||||
) + "\x1b[0m"
|
||||
self.assertEqual(classify_nxc("printnightmare", HOST, text).status, "suspected")
|
||||
|
||||
def test_smbghost_capability_indicator_is_only_suspected(self):
|
||||
def test_smbghost_capability_indicator_is_not_a_cve_finding(self):
|
||||
text = nxc_line(
|
||||
"SMBGHOST", "[+] Potentially vulnerable to SMBGhost (CVE-2020-0796)",
|
||||
)
|
||||
self.assertEqual(classify_nxc("smbghost", HOST, text).status, "suspected")
|
||||
self.assertEqual(classify_nxc("smbghost", HOST, text).status, "exposed")
|
||||
|
||||
def test_zerologon_exhausted_attempts_are_a_scoped_negative(self):
|
||||
result = classify_nxc("zerologon", HOST, nxc_line("ZEROLOGON", "[+] Attack failed. Target is probably patched."))
|
||||
self.assertEqual(result.status, "not-vulnerable")
|
||||
self.assertIn("probabilistic", result.evidence)
|
||||
|
||||
def test_denial_blocks_probe_instead_of_implying_patched(self):
|
||||
result = classify_nxc("printnightmare", HOST, nxc_line("PRINTNIGHTMARE", "[-] Failed to bind: STATUS_ACCESS_DENIED"))
|
||||
self.assertEqual(result.status, "skipped-access-denied")
|
||||
self.assertEqual(classify_nxc("ms17-010", HOST, nxc_line("SMB", "[-] LAB\\: STATUS_LOGON_FAILURE")).status, "skipped-auth-denied")
|
||||
self.assertEqual(classify_nxc("ms17-010", HOST, nxc_line("SMB", "[+] LAB\\user:STATUS_LOGON_FAILURE")).status, "inconclusive")
|
||||
|
||||
def test_mixed_rpc_methods_do_not_hide_positive_binds_behind_another_denial(self):
|
||||
text = nxc_line("COERCE_PLUS", "[-] Failed to bind: STATUS_ACCESS_DENIED") + nxc_line("COERCE_PLUS", "[+] VULNERABLE, PetitPotam")
|
||||
self.assertEqual(classify_nxc("petitpotam", HOST, text).status, "exposed")
|
||||
text = nxc_line("PRINTNIGHTMARE", "[-] Failed to bind: STATUS_ACCESS_DENIED") + nxc_line(
|
||||
"PRINTNIGHTMARE", "[+] Vulnerable, next step https://github.com/ly4k/PrintNightmare")
|
||||
self.assertEqual(classify_nxc("printnightmare", HOST, text).status, "inconclusive")
|
||||
|
||||
def test_zerologon_mixed_positive_negative_is_unknown_not_patched(self):
|
||||
text = nxc_line("ZEROLOGON", "[+] VULNERABLE") + nxc_line("ZEROLOGON", "[+] Attack failed. Target is probably patched.")
|
||||
self.assertEqual(classify_nxc("zerologon", HOST, text).status, "inconclusive")
|
||||
|
||||
def test_nonzero_exit_fails_closed_even_with_result_markers(self):
|
||||
for message in ("[+] VULNERABLE", f"[-] {HOST} does NOT appear vulnerable"):
|
||||
@@ -80,8 +102,7 @@ class CheckerOutputTests(unittest.TestCase):
|
||||
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"),
|
||||
("zerologon", "ZEROLOGON", "[-] Error while connecting to host: DCERPCException, which means this is probably not a DC!"),
|
||||
("smbghost", "SMBGHOST", "[-] Error while connecting to host: timed out"),
|
||||
]
|
||||
for key, module, message in cases:
|
||||
|
||||
+7
-4
@@ -7,6 +7,7 @@ from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from rpcscope.cli import main
|
||||
from rpcscope.checks import CheckResult
|
||||
|
||||
|
||||
TOOLS = {"nxc": "nxc", "enum4linux-ng": None, "rpcdump": None, "msfconsole": None}
|
||||
@@ -25,7 +26,8 @@ class CliTests(unittest.TestCase):
|
||||
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.assertEqual(sum("-M" in command for command in commands), 10 if isolated else 6)
|
||||
self.assertTrue(any(command[0] == "<native:smb-compression>" for command in commands))
|
||||
self.assertTrue(any(command[0] == "<native:longlogon>" for command in commands))
|
||||
|
||||
def test_tcp_share_dry_run_shows_gating_without_guessing_live_capabilities(self):
|
||||
@@ -68,7 +70,7 @@ class CliTests(unittest.TestCase):
|
||||
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)])
|
||||
"--discovery", "smb", "--no-enum4linux", "--no-rpcdump", "--progress", "plain", "--format", "full", "-o", str(destination)])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn("[3/3] SUMMARY scan complete", diagnostics.getvalue())
|
||||
self.assertIn("VULN: [MS17-010]", captured.getvalue())
|
||||
@@ -97,15 +99,16 @@ class CliTests(unittest.TestCase):
|
||||
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), \
|
||||
patch("rpcscope.runner.probe_smb_compression", return_value=CheckResult("inconclusive", "no response")), \
|
||||
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.assertIn("1 hosts | 0 confirmed", output.getvalue())
|
||||
self.assertNotIn("PARTIAL", output.getvalue())
|
||||
self.assertNotIn("NOT VULNERABLE", output.getvalue())
|
||||
self.assertIn("SUMMARY scan complete", diagnostics.getvalue())
|
||||
self.assertIn("COMPLETE |", diagnostics.getvalue())
|
||||
|
||||
def test_dry_run_has_no_files_and_keeps_json_on_stdout(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import io
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from rpcscope.console import compact, use_color
|
||||
from rpcscope.models import HostEvidence, Report
|
||||
from rpcscope.progress import ProgressEvent, ProgressReporter
|
||||
from rpcscope.rules import evaluate
|
||||
|
||||
|
||||
class Terminal(io.StringIO):
|
||||
def isatty(self):
|
||||
return True
|
||||
|
||||
|
||||
def report(hosts, errors=None):
|
||||
return Report("now", "test", [], None, None, hosts, evaluate(hosts), errors or [])
|
||||
|
||||
|
||||
class ConsoleTests(unittest.TestCase):
|
||||
def test_conflicting_results_are_not_counted_as_confirmed(self):
|
||||
host = HostEvidence("192.0.2.1", checks={"ms17-010": "vulnerable"})
|
||||
host.merge(HostEvidence(host.address, checks={"ms17-010": "not-vulnerable"}))
|
||||
text = compact(report([host]))
|
||||
self.assertIn("CONFLICT[MS17-010]", text)
|
||||
self.assertNotIn("VULN[MS17-010]", text)
|
||||
self.assertIn("0 confirmed", text)
|
||||
self.assertIn("1 conflicting check", text)
|
||||
finding = report([host]).findings[0]
|
||||
self.assertEqual(finding.confidence, "low")
|
||||
self.assertIn("conflict", finding.title)
|
||||
|
||||
def test_optional_signing_is_not_labelled_disabled(self):
|
||||
host = HostEvidence("192.0.2.1", signing=False)
|
||||
text = compact(report([host]))
|
||||
self.assertIn("SIGNING-OPTIONAL", text)
|
||||
self.assertNotIn("SIGNING-OFF", text)
|
||||
|
||||
def test_pending_and_interrupted_output_are_explicit(self):
|
||||
host = HostEvidence("192.0.2.1", checks={"ms17-010": "pending"}, collection={"rpcdump": "pending"})
|
||||
current = report([host])
|
||||
current.mode = "interrupted"
|
||||
text = compact(current)
|
||||
self.assertIn("PENDING[rpcdump]", text)
|
||||
self.assertNotIn("ERROR[rpcdump]", text)
|
||||
self.assertIn("INTERRUPTED", text)
|
||||
self.assertIn("UNKNOWN:1", text)
|
||||
|
||||
def test_compact_percentage_resets_explicitly_for_audit_phase(self):
|
||||
output = Terminal()
|
||||
reporter = ProgressReporter(output, mode="compact", color="never")
|
||||
reporter(ProgressEvent("start", "tcp-discovery", total=509))
|
||||
reporter(ProgressEvent("advance", "tcp-discovery", completed=508, total=509))
|
||||
reporter(ProgressEvent("finish", "discovery", completed=509, total=649))
|
||||
reporter(ProgressEvent("start", "batch:ms17-010", completed=509, total=649))
|
||||
self.assertIn("0% audit [0/140]", reporter._dashboard(reporter.clock()))
|
||||
reporter(ProgressEvent("finish", "ms17-010", "192.0.2.1", completed=579, total=649))
|
||||
self.assertIn("50% audit [70/140]", reporter._dashboard(reporter.clock()))
|
||||
|
||||
def test_compact_separates_probe_results_and_collection_failures(self):
|
||||
host = HostEvidence("192.0.2.1", hostname="HOST", domain="DOMAIN", checks={
|
||||
"ms17-010": "not-vulnerable", "smbghost": "exposed",
|
||||
"printnightmare": "skipped-access-denied", "zerologon": "inconclusive",
|
||||
}, check_details={"zerologon": "checker exited with status 124"}, collection={"rpcdump": "failed"})
|
||||
text = compact(report([host]))
|
||||
self.assertIn("192.0.2.1 (HOST)", text)
|
||||
self.assertIn("NOT-VULN[MS17-010]", text)
|
||||
self.assertIn("EXPOSED[SMB3-COMPRESSION]", text)
|
||||
self.assertIn("SKIP[access-denied:1]", text)
|
||||
self.assertIn("UNKNOWN:1(timeout:1)", text)
|
||||
self.assertIn("ERROR[rpcdump]", text)
|
||||
self.assertNotIn("PARTIAL", text)
|
||||
self.assertNotIn("PORTS:", text)
|
||||
self.assertNotIn("4/4", text)
|
||||
self.assertNotIn("\x1b", text)
|
||||
|
||||
def test_dead_hosts_are_hidden_and_uncertainty_is_not_a_clean_verdict(self):
|
||||
text = compact(report([HostEvidence("192.0.2.2", services={"445": "timeout"}),
|
||||
HostEvidence("192.0.2.1", checks={"ms17-010": "inconclusive"})]))
|
||||
self.assertNotIn("192.0.2.2", text)
|
||||
self.assertNotIn("NOT-VULN", text)
|
||||
self.assertIn("UNKNOWN:1", text)
|
||||
|
||||
def test_verbose_contains_evidence_and_local_error_notes(self):
|
||||
host = HostEvidence("192.0.2.1", checks={"ms17-010": "inconclusive"},
|
||||
check_details={"ms17-010": "no SMB1 response"})
|
||||
text = compact(report([host], ["rpcdump: ModuleNotFoundError"]), verbose=True)
|
||||
self.assertIn("MS17-010: inconclusive", text)
|
||||
self.assertIn("no SMB1 response", text)
|
||||
self.assertIn("ModuleNotFoundError", text)
|
||||
|
||||
def test_color_detection_respects_redirects_and_no_color(self):
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
self.assertTrue(use_color(Terminal()))
|
||||
self.assertFalse(use_color(io.StringIO()))
|
||||
self.assertFalse(use_color(Terminal(), "never"))
|
||||
with patch.dict("os.environ", {"NO_COLOR": "1"}):
|
||||
self.assertFalse(use_color(Terminal()))
|
||||
self.assertTrue(use_color(io.StringIO(), "always"))
|
||||
|
||||
def test_terminal_control_characters_in_host_and_evidence_are_removed(self):
|
||||
host = HostEvidence("192.0.2.1", hostname="FAKE\x1b[2K\nNAME", checks={"ms17-010": "vulnerable"})
|
||||
text = compact(report([host]))
|
||||
self.assertIn("(FAKE NAME)", text)
|
||||
self.assertNotIn("\x1b", text)
|
||||
self.assertEqual(len(text.splitlines()), 2)
|
||||
self.assertIn("\x1b[31;1mVULN[MS17-010]", compact(report([host]), color=True))
|
||||
|
||||
def test_compact_tty_uses_a_bar_without_completed_task_lines(self):
|
||||
output = Terminal()
|
||||
reporter = ProgressReporter(output, mode="compact", color="never")
|
||||
reporter(ProgressEvent("start", "discovery", total=100))
|
||||
reporter(ProgressEvent("finish", "discovery", completed=1, total=100))
|
||||
reporter(ProgressEvent("start", "batch:ms17-010", total=100))
|
||||
reporter(ProgressEvent("finish", "ms17-010", "192.0.2.1", "vulnerable", 99, 100))
|
||||
reporter._render()
|
||||
reporter(ProgressEvent("summary", "scan", status="complete", completed=100, total=100))
|
||||
self.assertIn("[###############-]", output.getvalue())
|
||||
self.assertIn("99%", output.getvalue())
|
||||
self.assertNotIn("DONE", output.getvalue())
|
||||
self.assertNotIn("192.0.2.1", output.getvalue())
|
||||
self.assertIn("COMPLETE |", output.getvalue())
|
||||
self.assertTrue(output.getvalue().endswith("\n"))
|
||||
|
||||
def test_compact_redirected_output_has_only_phase_and_final_lines(self):
|
||||
output = io.StringIO()
|
||||
reporter = ProgressReporter(output, mode="compact")
|
||||
reporter(ProgressEvent("start", "discovery", total=4))
|
||||
for i in range(4):
|
||||
reporter(ProgressEvent("finish", "check", f"192.0.2.{i}", "unknown", i + 1, 4))
|
||||
reporter(ProgressEvent("summary", "scan", status="incomplete", completed=4, total=4))
|
||||
self.assertEqual(len(output.getvalue().splitlines()), 2)
|
||||
self.assertNotIn("\x1b", output.getvalue())
|
||||
@@ -101,7 +101,7 @@ class DiscoveryIntegrationTests(unittest.TestCase):
|
||||
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())
|
||||
self.assertIn("INFO[OXID]", captured.getvalue())
|
||||
|
||||
def test_review_catalog_is_not_an_executable_check(self):
|
||||
captured = io.StringIO()
|
||||
@@ -136,9 +136,9 @@ class DiscoveryIntegrationTests(unittest.TestCase):
|
||||
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.assertIn("COMPLETE |", diagnostics.getvalue())
|
||||
self.assertNotIn(RPC_ONLY, diagnostics.getvalue())
|
||||
self.assertEqual(captured.getvalue(), "")
|
||||
self.assertEqual(captured.getvalue(), "No reachable SMB/RPC hosts.\n")
|
||||
report = json.loads((output / "report.json").read_text())
|
||||
self.assertEqual(report["hosts"][0]["checks"], {})
|
||||
self.assertEqual(report["hosts"][0]["check_attempts"], [])
|
||||
@@ -163,9 +163,12 @@ class DiscoveryIntegrationTests(unittest.TestCase):
|
||||
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.probe_smb_compression", return_value=CheckResult("exposed", "compression only")) as native, \
|
||||
patch("rpcscope.runner._run", side_effect=run) as execute:
|
||||
hosts = scan(config, progress=events.append)
|
||||
self.assertEqual(execute.call_count, 2)
|
||||
self.assertEqual(execute.call_count, 1)
|
||||
native.assert_called_once()
|
||||
self.assertEqual(native.call_args.args, (SMB_ONLY,))
|
||||
self.assertEqual(hosts[dead].checks, {})
|
||||
self.assertEqual(coverage(hosts[SMB_ONLY]), (1, 1, False))
|
||||
self.assertEqual(events[-1].status, "complete")
|
||||
|
||||
@@ -59,6 +59,13 @@ class ModelTests(unittest.TestCase):
|
||||
collection={"rpcdump": "success"}))
|
||||
self.assertEqual(host.checks["check"], "suspected")
|
||||
self.assertEqual(host.collection["rpcdump"], "success")
|
||||
self.assertEqual(host.conflicting_checks(), {"check"})
|
||||
|
||||
def test_capability_conflict_is_retained_in_both_merge_orders(self):
|
||||
for first, second in (("exposed", "not-exposed"), ("not-exposed", "exposed")):
|
||||
host = HostEvidence("192.0.2.1", checks={"smbghost": first})
|
||||
host.merge(HostEvidence("192.0.2.1", checks={"smbghost": second}))
|
||||
self.assertEqual(host.conflicting_checks(), {"smbghost"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -59,7 +59,7 @@ class NetExecTests(unittest.TestCase):
|
||||
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(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"]
|
||||
|
||||
@@ -124,7 +124,7 @@ class ProgressTests(unittest.TestCase):
|
||||
def test_tty_dashboard_retains_completed_result(self):
|
||||
output = Terminal()
|
||||
clock = Clock()
|
||||
reporter = ProgressReporter(output, clock=clock)
|
||||
reporter = ProgressReporter(output, clock=clock, color="never")
|
||||
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))
|
||||
|
||||
+14
-1
@@ -8,6 +8,19 @@ class MinimalReportTests(unittest.TestCase):
|
||||
def report(self, host, findings=None, principal=None):
|
||||
return Report("now", "test", [], None, principal, [host], findings or [])
|
||||
|
||||
def test_missing_requested_collector_is_not_complete_coverage(self):
|
||||
host = HostEvidence("192.0.2.1", checks={"ms17-010": "not-vulnerable"}, collection={"rpcdump": "missing"})
|
||||
self.assertTrue(coverage(host)[2])
|
||||
self.assertNotIn("NOT VULNERABLE", minimal(self.report(host)))
|
||||
|
||||
def test_conflict_does_not_print_positive_or_clean_verdict(self):
|
||||
host = HostEvidence("192.0.2.1", checks={"ms17-010": "vulnerable"})
|
||||
host.merge(HostEvidence(host.address, checks={"ms17-010": "not-vulnerable"}))
|
||||
text = minimal(self.report(host))
|
||||
self.assertIn("CONFLICT: [MS17-010]", text)
|
||||
self.assertNotIn(" VULN:", text)
|
||||
self.assertNotIn("NOT VULNERABLE", text)
|
||||
|
||||
def test_positive_vulnerability_line(self):
|
||||
host = HostEvidence("10.10.10.25", checks={"zerologon": "vulnerable"})
|
||||
line = minimal(self.report(host, principal="LAB\\auditor"))
|
||||
@@ -36,7 +49,7 @@ class MinimalReportTests(unittest.TestCase):
|
||||
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("SUSPECTED: [SMB3-COMPRESSION]", line)
|
||||
self.assertIn("CHECKS: 1/2 (IN PROGRESS)", line)
|
||||
self.assertNotIn("NO VULNERABILITIES CONFIRMED", line)
|
||||
self.assertNotIn(" VULN:", line)
|
||||
|
||||
+9
-1
@@ -5,6 +5,14 @@ from rpcscope.rules import evaluate_host
|
||||
|
||||
|
||||
class RuleTests(unittest.TestCase):
|
||||
def test_negative_capability_with_retained_conflict_still_gets_review_finding(self):
|
||||
host = HostEvidence("192.0.2.1", checks={"smbghost": "exposed"})
|
||||
host.merge(HostEvidence(host.address, checks={"smbghost": "not-exposed"}))
|
||||
findings = evaluate_host(host)
|
||||
self.assertEqual(len(findings), 1)
|
||||
self.assertIn("conflict", findings[0].title)
|
||||
self.assertEqual(findings[0].confidence, "low")
|
||||
|
||||
def test_rules_separate_exposure_from_confirmed_vulnerability(self):
|
||||
host = HostEvidence(
|
||||
address="10.10.10.25",
|
||||
@@ -78,7 +86,7 @@ class RuleTests(unittest.TestCase):
|
||||
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)
|
||||
self.assertIn("never SMBGhost patch state", finding.impact)
|
||||
|
||||
def test_checker_positive_includes_actual_evidence(self):
|
||||
host = HostEvidence("10.10.10.25", checks={"ms17-010": "vulnerable"},
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Synthetic protocol frames only: never connect to remote scan targets."""
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from rpcscope.smb_negotiate import negotiate_request, parse_negotiate, probe_smb_compression
|
||||
|
||||
|
||||
def context(kind, data):
|
||||
value = struct.pack("<HHI", kind, len(data), 0) + data
|
||||
return value + b"\x00" * (-len(value) % 8)
|
||||
|
||||
|
||||
def response(dialect=0x311, algorithms=(1,), compression=True):
|
||||
data = bytearray(128)
|
||||
data[:4] = b"\xfeSMB"
|
||||
struct.pack_into("<H", data, 4, 64)
|
||||
struct.pack_into("<I", data, 16, 1)
|
||||
struct.pack_into("<HHHH", data, 64, 65, 1, dialect, 1 + compression if dialect == 0x311 else 0)
|
||||
if dialect == 0x311:
|
||||
struct.pack_into("<I", data, 124, 128)
|
||||
data += context(1, struct.pack("<HHH", 1, 0, 1))
|
||||
if compression:
|
||||
data += context(3, struct.pack("<HHI", len(algorithms), 0, 0) + struct.pack(f"<{len(algorithms)}H", *algorithms))
|
||||
return bytes(data)
|
||||
|
||||
|
||||
class SmbNegotiateTests(unittest.TestCase):
|
||||
def test_request_is_small_normal_negotiate_with_correct_context_offsets(self):
|
||||
frame = negotiate_request()
|
||||
size = int.from_bytes(frame[:4], "big")
|
||||
self.assertEqual(size, len(frame) - 4)
|
||||
self.assertLess(size, 256)
|
||||
data = frame[4:]
|
||||
self.assertEqual(data[:4], b"\xfeSMB")
|
||||
self.assertEqual(struct.unpack_from("<H", data, 12)[0], 0)
|
||||
self.assertEqual(struct.unpack_from("<H", data, 64)[0], 36)
|
||||
offset, count = struct.unpack_from("<IH", data, 92)
|
||||
self.assertEqual((offset, count), (112, 2))
|
||||
self.assertEqual(struct.unpack_from("<HHI", data, offset), (1, 38, 0))
|
||||
self.assertEqual(struct.unpack_from("<HHI", data, 160), (3, 14, 0))
|
||||
|
||||
def test_valid_capability_is_exposure_not_cve(self):
|
||||
for algorithms in ((1,), (1, 2, 3), (0, 1)):
|
||||
with self.subTest(algorithms=algorithms):
|
||||
outcome = parse_negotiate(response(algorithms=algorithms))
|
||||
self.assertEqual(outcome.status, "exposed")
|
||||
self.assertIn("patch state unknown", outcome.evidence)
|
||||
|
||||
def test_absent_capability_is_not_a_patch_verdict(self):
|
||||
for frame in (response(0x202), response(0x210), response(0x300), response(0x302),
|
||||
response(compression=False), response(algorithms=(0,))):
|
||||
outcome = parse_negotiate(frame)
|
||||
self.assertEqual(outcome.status, "not-exposed")
|
||||
self.assertIn("not a patch/CVE verdict", outcome.evidence)
|
||||
|
||||
def test_malformed_offsets_lengths_and_wrong_responses_never_become_negative(self):
|
||||
modifications = [(0, b"\xffSMB"), (4, b"\x20\x00"), (8, b"\x22\x00\x00\xc0"),
|
||||
(12, b"\x01\x00"), (16, b"\x00\x00\x00\x00"),
|
||||
(16, b"\x03\x00\x00\x00"), (20, b"\x80\x00\x00\x00"),
|
||||
(24, b"\x01"), (64, b"\x40\x00"), (68, b"\xff\xff"),
|
||||
(70, b"\xff\xff"), (124, b"\x01\x00\x00\x00"),
|
||||
(124, b"\x80\xff\x00\x00"), (130, b"\xff\xff"),
|
||||
(136, b"\x02"), (140, b"\x02"),
|
||||
(144, b"\x01\x00"), (152, b"\xff\xff")]
|
||||
for offset, value in modifications:
|
||||
with self.subTest(offset=offset, value=value):
|
||||
data = bytearray(response())
|
||||
data[offset:offset + len(value)] = value
|
||||
with self.assertRaises(ValueError):
|
||||
parse_negotiate(bytes(data))
|
||||
for end in (0, 4, 64, 127, 140, 155):
|
||||
with self.subTest(end=end), self.assertRaises(ValueError):
|
||||
parse_negotiate(response()[:end])
|
||||
|
||||
def test_older_dialect_reserved_fields_are_ignored(self):
|
||||
for dialect in (0x202, 0x210, 0x300, 0x302):
|
||||
with self.subTest(dialect=dialect):
|
||||
data = bytearray(response(dialect))
|
||||
struct.pack_into("<H", data, 70, 65535)
|
||||
struct.pack_into("<I", data, 124, 0xffffffff)
|
||||
self.assertEqual(parse_negotiate(bytes(data)).status, "not-exposed")
|
||||
|
||||
def test_padding_reserved_bytes_and_chained_support_do_not_hide_capability(self):
|
||||
data = bytearray(response())
|
||||
struct.pack_into("<I", data, 132, 0xffffffff)
|
||||
struct.pack_into("<H", data, 154, 0xffff)
|
||||
struct.pack_into("<I", data, 156, 1)
|
||||
self.assertEqual(parse_negotiate(bytes(data)).status, "exposed")
|
||||
|
||||
def test_server_algorithm_list_uses_intersection_with_our_offer(self):
|
||||
self.assertEqual(parse_negotiate(response(algorithms=(1, 4, 5))).status, "exposed")
|
||||
self.assertEqual(parse_negotiate(response(algorithms=(4, 5))).status, "not-exposed")
|
||||
with self.assertRaises(ValueError):
|
||||
parse_negotiate(response(algorithms=(32,)))
|
||||
|
||||
def test_duplicate_unknown_contexts_are_ignored_but_known_duplicates_are_invalid(self):
|
||||
data = bytearray(response())
|
||||
struct.pack_into("<H", data, 70, 4)
|
||||
data += context(0x100, b"") + context(0x100, b"")
|
||||
self.assertEqual(parse_negotiate(bytes(data)).status, "exposed")
|
||||
data = bytearray(response())
|
||||
struct.pack_into("<H", data, 70, 3)
|
||||
data += context(3, struct.pack("<HHIH", 1, 0, 0, 1))
|
||||
with self.assertRaises(ValueError):
|
||||
parse_negotiate(bytes(data))
|
||||
|
||||
def test_fragmented_tcp_response_is_read_completely_without_login(self):
|
||||
payload = response()
|
||||
frame = len(payload).to_bytes(4, "big") + payload
|
||||
connection = MagicMock()
|
||||
connection.recv.side_effect = [bytes([byte]) for byte in frame]
|
||||
with patch("rpcscope.smb_negotiate.socket.create_connection") as connect:
|
||||
connect.return_value.__enter__.return_value = connection
|
||||
outcome = probe_smb_compression("192.0.2.1")
|
||||
self.assertEqual(outcome.status, "exposed")
|
||||
self.assertEqual(connection.sendall.call_count, 1)
|
||||
self.assertEqual(connection.sendall.call_args.args[0][4:8], b"\xfeSMB")
|
||||
self.assertEqual(connect.call_args.args, (("192.0.2.1", 445),))
|
||||
|
||||
def test_cancellation_does_not_connect(self):
|
||||
cancel = threading.Event()
|
||||
cancel.set()
|
||||
with patch("rpcscope.smb_negotiate.socket.create_connection") as connect:
|
||||
self.assertEqual(probe_smb_compression("192.0.2.1", cancel=cancel).status, "skipped-cancelled")
|
||||
connect.assert_not_called()
|
||||
|
||||
def test_timeout_eof_or_large_frame_remain_inconclusive(self):
|
||||
with patch("rpcscope.smb_negotiate.socket.create_connection", side_effect=socket.timeout("timed out")):
|
||||
self.assertEqual(probe_smb_compression("192.0.2.1").status, "inconclusive")
|
||||
for value in (b"", b"\x01\x00\x00\x00", b"\x00\x01\x00\x01"):
|
||||
connection = MagicMock()
|
||||
connection.recv.return_value = value
|
||||
with patch("rpcscope.smb_negotiate.socket.create_connection") as connect:
|
||||
connect.return_value.__enter__.return_value = connection
|
||||
self.assertEqual(probe_smb_compression("192.0.2.1").status, "inconclusive")
|
||||
self.assertEqual(connection.recv.call_count, 1)
|
||||
|
||||
def test_total_deadline_not_reset_per_fragment(self):
|
||||
connection = MagicMock()
|
||||
with patch("rpcscope.smb_negotiate.socket.create_connection") as connect, \
|
||||
patch("rpcscope.smb_negotiate.time.monotonic", side_effect=[0, 0, 0, 10]):
|
||||
connect.return_value.__enter__.return_value = connection
|
||||
outcome = probe_smb_compression("192.0.2.1", timeout=5)
|
||||
self.assertEqual(outcome.status, "inconclusive")
|
||||
self.assertIn("timeout", outcome.evidence)
|
||||
connection.recv.assert_not_called()
|
||||
Reference in new issue
Block a user