Files
rpcscope/tests/test_reporting.py

162 lines
8.1 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_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<script>alert(1)</script>"]
report.mode = "test`\n# false heading"
text = markdown(report)
self.assertIn("Targets: `target&#96; &lt;script&gt;alert(1)&lt;/script&gt;`", text)
self.assertIn("Mode: `test&#96; # 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()