import unittest from rpcscope.models import Finding, HostEvidence, Report from rpcscope.reporting import coverage, markdown, minimal 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")) self.assertIn("10.10.10.25 (LAB\\auditor) VULN: [CVE-2020-1472/ZEROLOGON]", line) self.assertNotIn("CHECKED:", line) self.assertNotIn("password", line) def test_explicit_negative_can_say_not_vulnerable(self): host = HostEvidence("10.10.10.25", checks={"ms17-010": "not-vulnerable"}) self.assertIn("NOT VULNERABLE", minimal(self.report(host))) def test_no_checks_is_inventory_not_a_vulnerability_verdict(self): host = HostEvidence("10.10.10.25") line = minimal(self.report(host)) self.assertIn("INVENTORY ONLY", line) self.assertNotIn("INCOMPLETE", line) self.assertNotIn("10.10.10.25 NOT VULNERABLE", line) def test_exposure_is_not_a_vulnerability(self): host = HostEvidence("10.10.10.25") finding = Finding("SMB-001", host.address, "SMBv1", "high", "high", "x", "x", "x") line = minimal(self.report(host, [finding])) self.assertIn("EXPOSURE: [SMBv1]", line) self.assertNotIn("VULN: [SMBv1]", line) 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: [SMB3-COMPRESSION]", line) self.assertIn("CHECKS: 1/2 (IN PROGRESS)", line) self.assertNotIn("NO VULNERABILITIES CONFIRMED", line) self.assertNotIn(" VULN:", line) def test_failed_collection_prevents_unqualified_negative(self): host = HostEvidence("10.10.10.25", checks={"ms17-010": "not-vulnerable"}, collection={"rpcdump": "failed"}) line = minimal(self.report(host)) self.assertIn("PARTIAL", line) self.assertNotIn(" NOT VULNERABLE", line) def test_heuristic_and_unknown_results_complete_execution_without_clean_verdict(self): host = HostEvidence("10.10.10.25", checks={ "smbghost": "suspected", "ms17-010": "inconclusive", "printerbug": "exposed", "oxid": "observed", "zerologon": "not-vulnerable", }) self.assertEqual(coverage(host), (5, 5, False)) line = minimal(self.report(host)) self.assertIn("CHECKS: 5/5", line) self.assertIn("UNKNOWN: [MS17-010]", line) self.assertNotIn("INCOMPLETE", line) self.assertNotIn("PARTIAL", line) self.assertNotIn("NOT VULNERABLE", line) def test_prerequisite_skips_have_separate_counts_and_never_imply_patched(self): host = HostEvidence("10.10.10.25", checks={ "ms17-010": "not-vulnerable", "nopac": "skipped-no-credentials", "ntlm-reflection": "skipped-unsupported", }) self.assertEqual(coverage(host), (1, 1, False)) line = minimal(self.report(host)) self.assertIn("CHECKS: 1/1 SKIPPED: 2", line) self.assertNotIn("INCOMPLETE", line) self.assertNotIn("NOT VULNERABLE", line) host.checks = {"nopac": "skipped-no-credentials"} self.assertIn("NO CHECKS RUN SKIPPED: 1", minimal(self.report(host))) def test_interruption_is_partial_rather_than_an_ordinary_prerequisite_skip(self): host = HostEvidence("10.10.10.25", checks={"ms17-010": "skipped-cancelled"}) self.assertEqual(coverage(host), (0, 1, True)) line = minimal(self.report(host)) self.assertIn("CHECKS: 0/1 (PARTIAL)", line) self.assertNotIn("SKIPPED:", line) def test_precondition_outcomes_are_completed_and_do_not_imply_a_cve_verdict(self): for status, label in (("precondition-met", "MET"), ("precondition-not-met", "NOT-MET"), ("precondition-borderline", "BORDERLINE")): with self.subTest(status=status): host = HostEvidence("10.10.10.25", checks={"longlogon": status}) self.assertEqual(coverage(host), (1, 1, False)) line = minimal(self.report(host)) self.assertIn(f"PRECONDITION: [LONGLOGON:{label}]", line) self.assertIn("CHECKS: 1/1", line) self.assertNotIn("NOT VULNERABLE", line) self.assertNotIn(" VULN:", line) def test_informational_notes_do_not_turn_a_negative_into_partial_collection(self): host = HostEvidence("10.10.10.25", checks={"ms17-010": "not-vulnerable"}, notes=["Anonymous inventory cannot read the OS build."], collection={"rpcdump": "success", "enum4linux": "skipped-disabled"}) self.assertEqual(coverage(host), (1, 1, False)) self.assertIn("NOT VULNERABLE (SELECTED CHECKS)", minimal(self.report(host))) def test_unreachable_hosts_are_hidden_but_remain_available_as_diagnostics(self): live = HostEvidence("10.10.10.25", services={"135": "open", "445": "closed"}) dead = HostEvidence("10.10.10.26", services={"135": "timeout", "445": "closed"}) legacy = HostEvidence("10.10.10.27") report = self.report(live) report.hosts.extend([dead, legacy]) line = minimal(report) self.assertIn(live.address, line) self.assertIn(legacy.address, line) self.assertNotIn(dead.address, line) self.assertIn(f"{dead.address} NO REACHABLE SMB/RPC SERVICES", minimal(report, include_unreachable=True)) text = markdown(report) self.assertNotIn(dead.address, text) self.assertIn("Unreachable targets omitted: **1**", text) self.assertIn(dead.address, markdown(report, include_unreachable=True)) self.assertEqual(len(report.as_dict()["hosts"]), 3) def test_exposure_only_checks_complete_inventory(self): host = HostEvidence("10.10.10.25", checks={"printerbug": "exposed", "oxid": "observed"}) line = minimal(self.report(host)) self.assertIn("INVENTORY ONLY EXPOSURE:", line) self.assertIn("CHECKS: 2/2", line) self.assertNotIn("INCOMPLETE", line) self.assertNotIn("NOT VULNERABLE", line) def test_markdown_metadata_cannot_escape_its_code_span_or_inject_html(self): report = self.report(HostEvidence("10.10.10.25")) report.targets = ["target`\n"] report.mode = "test`\n# false heading" text = markdown(report) self.assertIn("Targets: `target` <script>alert(1)</script>`", text) self.assertIn("Mode: `test` # false heading`", text) self.assertNotIn("