149 lines
7.4 KiB
Python
149 lines
7.4 KiB
Python
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_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: [CVE-2020-0796/SMBGHOST]", 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<script>alert(1)</script>"]
|
|
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("<script>", text)
|
|
self.assertNotIn("\n# false heading", text)
|
|
|
|
def test_hosts_sort_numerically_and_names_cannot_inject_lines(self):
|
|
report = self.report(HostEvidence("10.0.0.10", domain="LAB\nFAKE\x1b[2K"))
|
|
report.hosts.append(HostEvidence("10.0.0.2"))
|
|
lines = minimal(report).splitlines()
|
|
self.assertEqual(len(lines), 2)
|
|
self.assertTrue(lines[0].startswith("10.0.0.2 "))
|
|
self.assertNotIn("\x1b", lines[1])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|