scanfuse 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scanfuse/__init__.py +4 -0
- scanfuse/cli.py +235 -0
- scanfuse/engine.py +503 -0
- scanfuse/html_report.py +23 -0
- scanfuse/models.py +54 -0
- scanfuse/parsers/__init__.py +3 -0
- scanfuse/parsers/base.py +142 -0
- scanfuse/parsers/burp.py +135 -0
- scanfuse/parsers/nessus.py +126 -0
- scanfuse/parsers/nmap.py +137 -0
- scanfuse/parsers/nuclei.py +157 -0
- scanfuse/parsers/trivy.py +187 -0
- scanfuse/parsers/zap.py +124 -0
- scanfuse/templates/finding.md.j2 +67 -0
- scanfuse/templates/index.md.j2 +44 -0
- scanfuse/utils.py +184 -0
- scanfuse-0.2.0.dist-info/METADATA +208 -0
- scanfuse-0.2.0.dist-info/RECORD +21 -0
- scanfuse-0.2.0.dist-info/WHEEL +4 -0
- scanfuse-0.2.0.dist-info/entry_points.txt +2 -0
- scanfuse-0.2.0.dist-info/licenses/LICENSE +21 -0
scanfuse/parsers/base.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from html.parser import HTMLParser
|
|
4
|
+
|
|
5
|
+
from scanfuse.models import Finding
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PartialImportError(ValueError):
|
|
9
|
+
"""Input contains usable findings and errors that must be reported."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, message: str, findings: list[Finding]) -> None:
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.findings = findings
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_CVE = re.compile(r"CVE-\d{4}-\d{4,}", re.IGNORECASE)
|
|
17
|
+
_CWE_TEXT = re.compile(r"\bCWE[-\s#:]*(\d{1,5})\b", re.IGNORECASE)
|
|
18
|
+
_URL = re.compile(r"https?://[^\s<>\"'\\]+", re.IGNORECASE)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _TextExtractor(HTMLParser):
|
|
22
|
+
"""Turns the small HTML subset that scanners put into text fields into plain text."""
|
|
23
|
+
|
|
24
|
+
_BLOCK = {"p", "div", "ul", "ol", "table", "tr", "h1", "h2", "h3", "h4", "h5", "h6", "pre", "blockquote"}
|
|
25
|
+
|
|
26
|
+
def __init__(self) -> None:
|
|
27
|
+
super().__init__(convert_charrefs=True)
|
|
28
|
+
self.parts: list[str] = []
|
|
29
|
+
self._skip = 0
|
|
30
|
+
|
|
31
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
32
|
+
if tag in ("script", "style"):
|
|
33
|
+
self._skip += 1
|
|
34
|
+
elif tag == "br":
|
|
35
|
+
self.parts.append("\n")
|
|
36
|
+
elif tag == "li":
|
|
37
|
+
self.parts.append("\n- ")
|
|
38
|
+
elif tag in self._BLOCK:
|
|
39
|
+
self.parts.append("\n\n")
|
|
40
|
+
|
|
41
|
+
def handle_endtag(self, tag: str) -> None:
|
|
42
|
+
if tag in ("script", "style") and self._skip:
|
|
43
|
+
self._skip -= 1
|
|
44
|
+
elif tag in self._BLOCK or tag == "li":
|
|
45
|
+
self.parts.append("\n")
|
|
46
|
+
|
|
47
|
+
def handle_data(self, data: str) -> None:
|
|
48
|
+
if not self._skip:
|
|
49
|
+
self.parts.append(data)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def html_to_text(value: object) -> str:
|
|
53
|
+
"""Plain text from a field that may contain HTML tags or HTML entities.
|
|
54
|
+
|
|
55
|
+
Line breaks and list items are kept. Scripts are dropped. Text without markup is returned
|
|
56
|
+
with entities decoded and surrounding space removed.
|
|
57
|
+
"""
|
|
58
|
+
text = "" if value is None else str(value)
|
|
59
|
+
if "<" not in text and "&" not in text:
|
|
60
|
+
return text.strip()
|
|
61
|
+
parser = _TextExtractor()
|
|
62
|
+
parser.feed(text)
|
|
63
|
+
parser.close()
|
|
64
|
+
result = "".join(parser.parts).replace("\xa0", " ")
|
|
65
|
+
result = re.sub(r"[ \t]+\n", "\n", result)
|
|
66
|
+
result = re.sub(r"\n{3,}", "\n\n", result)
|
|
67
|
+
return result.strip()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def extract_urls(*values: object) -> list[str]:
|
|
71
|
+
"""http(s) URLs found in strings, HTML or lists of them, without duplicates."""
|
|
72
|
+
found: list[str] = []
|
|
73
|
+
items: list[object] = []
|
|
74
|
+
for value in values:
|
|
75
|
+
items.extend(value if isinstance(value, (list, tuple)) else [value])
|
|
76
|
+
for item in items:
|
|
77
|
+
text = str(item or "")
|
|
78
|
+
for match in _URL.findall(text):
|
|
79
|
+
url = match.rstrip(".,;:)]}")
|
|
80
|
+
if url and url not in found:
|
|
81
|
+
found.append(url)
|
|
82
|
+
return found
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def cwe_ids_from_text(*values: object) -> list[str]:
|
|
86
|
+
"""CWE ids named in free text, as 'CWE-79'."""
|
|
87
|
+
found: list[str] = []
|
|
88
|
+
for value in values:
|
|
89
|
+
for number in _CWE_TEXT.findall(str(value or "")):
|
|
90
|
+
cwe = f"CWE-{int(number)}"
|
|
91
|
+
if int(number) > 0 and cwe not in found:
|
|
92
|
+
found.append(cwe)
|
|
93
|
+
return found
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def clean_ids(*values: object) -> list[str]:
|
|
97
|
+
"""Return unique, non-empty identifier strings in the order given."""
|
|
98
|
+
result: list[str] = []
|
|
99
|
+
for value in values:
|
|
100
|
+
if isinstance(value, (list, tuple)):
|
|
101
|
+
items = list(value)
|
|
102
|
+
else:
|
|
103
|
+
items = [value]
|
|
104
|
+
for item in items:
|
|
105
|
+
if item is None:
|
|
106
|
+
continue
|
|
107
|
+
text = str(item).strip()
|
|
108
|
+
if text and text not in result:
|
|
109
|
+
result.append(text)
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def cve_ids(value: object) -> list[str]:
|
|
114
|
+
"""Extract CVE ids from a string or list of strings."""
|
|
115
|
+
found: list[str] = []
|
|
116
|
+
items = value if isinstance(value, (list, tuple)) else [value]
|
|
117
|
+
for item in items:
|
|
118
|
+
for match in _CVE.findall(str(item or "")):
|
|
119
|
+
cve = match.upper()
|
|
120
|
+
if cve not in found:
|
|
121
|
+
found.append(cve)
|
|
122
|
+
return found
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def cwe_id(value: object) -> str | None:
|
|
126
|
+
"""Return 'CWE-<n>' for a numeric or 'CWE-n' value, otherwise None."""
|
|
127
|
+
text = str(value or "").strip().upper().removeprefix("CWE-")
|
|
128
|
+
return f"CWE-{text}" if text.isdigit() and int(text) > 0 else None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class BaseParser(ABC):
|
|
132
|
+
"""
|
|
133
|
+
Abstract base class for all scanfuse parsers.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
@abstractmethod
|
|
137
|
+
def parse(self, file_path: str) -> list[Finding]:
|
|
138
|
+
"""
|
|
139
|
+
Parse the given file and return a list of Finding objects.
|
|
140
|
+
Must handle malformed files gracefully and raise ValueError if the format is fundamentally wrong.
|
|
141
|
+
"""
|
|
142
|
+
pass
|
scanfuse/parsers/burp.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import xml.etree.ElementTree as ET
|
|
3
|
+
|
|
4
|
+
import defusedxml.ElementTree as defused_et
|
|
5
|
+
|
|
6
|
+
from scanfuse.models import Evidence, Finding, Host
|
|
7
|
+
from scanfuse.parsers.base import (
|
|
8
|
+
BaseParser,
|
|
9
|
+
clean_ids,
|
|
10
|
+
cve_ids,
|
|
11
|
+
cwe_ids_from_text,
|
|
12
|
+
extract_urls,
|
|
13
|
+
html_to_text,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BurpParser(BaseParser):
|
|
18
|
+
def parse(self, file_path: str) -> list[Finding]:
|
|
19
|
+
findings = []
|
|
20
|
+
try:
|
|
21
|
+
context = iter(defused_et.iterparse(file_path, events=("start", "end")))
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
event, root = next(context)
|
|
25
|
+
except StopIteration:
|
|
26
|
+
raise ValueError("Empty XML file")
|
|
27
|
+
|
|
28
|
+
if root.tag != "issues":
|
|
29
|
+
raise ValueError("Not a valid Burp XML file (missing <issues> root tag)")
|
|
30
|
+
|
|
31
|
+
for event, issue_elem in context:
|
|
32
|
+
if event == "end" and issue_elem.tag == "issue":
|
|
33
|
+
name = html_to_text(issue_elem.findtext("name", default="")) or "Unknown Vulnerability"
|
|
34
|
+
severity_raw = issue_elem.findtext("severity", default="Information")
|
|
35
|
+
|
|
36
|
+
# Map Burp severity to Finding severity model
|
|
37
|
+
severity_map = {
|
|
38
|
+
"High": "High",
|
|
39
|
+
"Medium": "Medium",
|
|
40
|
+
"Low": "Low",
|
|
41
|
+
"Information": "Info",
|
|
42
|
+
"False positive": "Info"
|
|
43
|
+
}
|
|
44
|
+
severity = severity_map.get(severity_raw, "Info")
|
|
45
|
+
|
|
46
|
+
host_elem = issue_elem.find("host")
|
|
47
|
+
ip = host_elem.get("ip", "unknown") if host_elem is not None else "unknown"
|
|
48
|
+
hostname = host_elem.text if host_elem is not None else None
|
|
49
|
+
host_model = Host(ip=ip, hostname=hostname)
|
|
50
|
+
|
|
51
|
+
# Burp writes HTML into these fields. scanfuse keeps the text.
|
|
52
|
+
bg = html_to_text(issue_elem.findtext("issueBackground", default=""))
|
|
53
|
+
detail = html_to_text(issue_elem.findtext("issueDetail", default=""))
|
|
54
|
+
desc = "\n\n".join(part for part in (detail, bg) if part)
|
|
55
|
+
|
|
56
|
+
remediation = "\n\n".join(
|
|
57
|
+
part for part in (
|
|
58
|
+
html_to_text(issue_elem.findtext("remediationBackground", default="")),
|
|
59
|
+
html_to_text(issue_elem.findtext("remediationDetail", default="")),
|
|
60
|
+
) if part
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
evidence_list = []
|
|
64
|
+
|
|
65
|
+
path = issue_elem.findtext("path", default="").strip()
|
|
66
|
+
if path:
|
|
67
|
+
evidence_list.append(Evidence(type="path", data=path))
|
|
68
|
+
location = html_to_text(issue_elem.findtext("location", default=""))
|
|
69
|
+
confidence = issue_elem.findtext("confidence", default="").strip()
|
|
70
|
+
if confidence or (location and location != path):
|
|
71
|
+
meta = []
|
|
72
|
+
if confidence:
|
|
73
|
+
meta.append(f"Confidence: {confidence}")
|
|
74
|
+
if location and location != path:
|
|
75
|
+
meta.append(f"Location: {location}")
|
|
76
|
+
evidence_list.append(Evidence(type="burp-details", data="\n".join(meta)))
|
|
77
|
+
|
|
78
|
+
req_resps = issue_elem.findall("requestresponse")
|
|
79
|
+
for rr in req_resps:
|
|
80
|
+
req_elem = rr.find("request")
|
|
81
|
+
res_elem = rr.find("response")
|
|
82
|
+
|
|
83
|
+
text = ""
|
|
84
|
+
if req_elem is not None and req_elem.text:
|
|
85
|
+
req_data = req_elem.text
|
|
86
|
+
if req_elem.get("base64") == "true":
|
|
87
|
+
try:
|
|
88
|
+
req_data = base64.b64decode(req_data).decode("utf-8", errors="replace")
|
|
89
|
+
except Exception: # noqa: S110
|
|
90
|
+
pass
|
|
91
|
+
text += f"Request:\n{req_data}\n\n"
|
|
92
|
+
|
|
93
|
+
if res_elem is not None and res_elem.text:
|
|
94
|
+
res_data = res_elem.text
|
|
95
|
+
if res_elem.get("base64") == "true":
|
|
96
|
+
try:
|
|
97
|
+
res_data = base64.b64decode(res_data).decode("utf-8", errors="replace")
|
|
98
|
+
except Exception: # noqa: S110
|
|
99
|
+
pass
|
|
100
|
+
text += f"Response:\n{res_data}"
|
|
101
|
+
|
|
102
|
+
if text.strip():
|
|
103
|
+
evidence_list.append(Evidence(type="http-request", data=text.strip()))
|
|
104
|
+
|
|
105
|
+
type_id = issue_elem.findtext("type", default="").strip()
|
|
106
|
+
references_html = issue_elem.findtext("references", default="")
|
|
107
|
+
classifications = issue_elem.findtext("vulnerabilityClassifications", default="")
|
|
108
|
+
identifiers = clean_ids(
|
|
109
|
+
f"burp-type:{type_id}" if type_id else None,
|
|
110
|
+
cwe_ids_from_text(classifications, references_html),
|
|
111
|
+
cve_ids(classifications + " " + references_html),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
finding = Finding(
|
|
115
|
+
title=name,
|
|
116
|
+
severity=severity,
|
|
117
|
+
description=desc,
|
|
118
|
+
remediation=remediation if remediation else None,
|
|
119
|
+
hosts=[host_model],
|
|
120
|
+
evidence=evidence_list,
|
|
121
|
+
identifiers=identifiers,
|
|
122
|
+
references=extract_urls(references_html, classifications),
|
|
123
|
+
source_tool="burp"
|
|
124
|
+
)
|
|
125
|
+
findings.append(finding)
|
|
126
|
+
|
|
127
|
+
issue_elem.clear()
|
|
128
|
+
root.clear()
|
|
129
|
+
|
|
130
|
+
except ET.ParseError as e:
|
|
131
|
+
raise ValueError(f"Invalid XML in {file_path}: {e}")
|
|
132
|
+
except OSError as e:
|
|
133
|
+
raise ValueError(f"File I/O error reading {file_path}: {e}")
|
|
134
|
+
|
|
135
|
+
return findings
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import xml.etree.ElementTree as ET
|
|
2
|
+
|
|
3
|
+
import defusedxml.ElementTree as defused_et
|
|
4
|
+
|
|
5
|
+
from scanfuse.models import Evidence, Finding, Host
|
|
6
|
+
from scanfuse.parsers.base import BaseParser, clean_ids, cve_ids, cwe_id, extract_urls
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class NessusParser(BaseParser):
|
|
10
|
+
def parse(self, file_path: str) -> list[Finding]:
|
|
11
|
+
findings = []
|
|
12
|
+
try:
|
|
13
|
+
context = iter(defused_et.iterparse(file_path, events=("start", "end")))
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
event, root = next(context)
|
|
17
|
+
except StopIteration:
|
|
18
|
+
raise ValueError("Empty XML file")
|
|
19
|
+
|
|
20
|
+
if root.tag != "NessusClientData_v2":
|
|
21
|
+
raise ValueError("Not a valid Nessus file (missing <NessusClientData_v2> root tag)")
|
|
22
|
+
|
|
23
|
+
severity_map = {
|
|
24
|
+
"0": "Info",
|
|
25
|
+
"1": "Low",
|
|
26
|
+
"2": "Medium",
|
|
27
|
+
"3": "High",
|
|
28
|
+
"4": "Critical"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for event, report_host in context:
|
|
32
|
+
if event == "end" and report_host.tag == "ReportHost":
|
|
33
|
+
# Extract Host Properties
|
|
34
|
+
host_ip = "unknown"
|
|
35
|
+
host_fqdn = None
|
|
36
|
+
host_os = None
|
|
37
|
+
|
|
38
|
+
host_props = report_host.find("HostProperties")
|
|
39
|
+
if host_props is not None:
|
|
40
|
+
for tag in host_props.findall("tag"):
|
|
41
|
+
name = tag.get("name")
|
|
42
|
+
if name == "host-ip":
|
|
43
|
+
host_ip = tag.text or "unknown"
|
|
44
|
+
elif name == "host-fqdn":
|
|
45
|
+
host_fqdn = tag.text
|
|
46
|
+
elif name == "operating-system":
|
|
47
|
+
host_os = tag.text
|
|
48
|
+
|
|
49
|
+
# Fallback if host-ip is missing but ReportHost name is an IP
|
|
50
|
+
if host_ip == "unknown":
|
|
51
|
+
host_ip = report_host.get("name", "unknown")
|
|
52
|
+
|
|
53
|
+
host_model = Host(ip=host_ip, hostname=host_fqdn, os=host_os)
|
|
54
|
+
|
|
55
|
+
# Extract Report Items (Findings)
|
|
56
|
+
for item in report_host.findall("ReportItem"):
|
|
57
|
+
sev_num = item.get("severity", "0")
|
|
58
|
+
severity = severity_map.get(sev_num, "Info")
|
|
59
|
+
|
|
60
|
+
name = item.get("pluginName", "Unknown Plugin")
|
|
61
|
+
|
|
62
|
+
description = item.findtext("description", default="").strip()
|
|
63
|
+
synopsis = item.findtext("synopsis", default="").strip()
|
|
64
|
+
if synopsis and synopsis not in description:
|
|
65
|
+
description = f"{synopsis}\n\n{description}".strip()
|
|
66
|
+
solution = item.findtext("solution", default="").strip()
|
|
67
|
+
if solution.lower() in ("n/a", "none", "na"):
|
|
68
|
+
solution = ""
|
|
69
|
+
plugin_output = item.findtext("plugin_output", default="").strip()
|
|
70
|
+
cvss = item.findtext("cvss_base_score", default="").strip()
|
|
71
|
+
cvss_vector = item.findtext("cvss_vector", default="").strip()
|
|
72
|
+
if not cvss: # newer plugins only carry CVSS v3
|
|
73
|
+
cvss = item.findtext("cvss3_base_score", default="").strip()
|
|
74
|
+
cvss_vector = item.findtext("cvss3_vector", default="").strip()
|
|
75
|
+
|
|
76
|
+
evidence_list = []
|
|
77
|
+
|
|
78
|
+
port = item.get("port", "0")
|
|
79
|
+
protocol = item.get("protocol", "")
|
|
80
|
+
if port != "0":
|
|
81
|
+
port_text = f"Port: {port}/{protocol}"
|
|
82
|
+
svc_name = item.get("svc_name")
|
|
83
|
+
if svc_name:
|
|
84
|
+
port_text += f"\nService: {svc_name}"
|
|
85
|
+
evidence_list.append(Evidence(type="port-info", data=port_text))
|
|
86
|
+
|
|
87
|
+
if plugin_output:
|
|
88
|
+
evidence_list.append(Evidence(type="plugin-output", data=plugin_output))
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
cvss_val = float(cvss) if cvss else None
|
|
92
|
+
except ValueError:
|
|
93
|
+
cvss_val = None
|
|
94
|
+
|
|
95
|
+
plugin_id = item.get("pluginID")
|
|
96
|
+
identifiers = clean_ids(
|
|
97
|
+
f"nessus-plugin:{plugin_id}" if plugin_id else None,
|
|
98
|
+
cve_ids([c.text for c in item.findall("cve")]),
|
|
99
|
+
cwe_id(item.findtext("cwe")),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
finding = Finding(
|
|
103
|
+
title=name,
|
|
104
|
+
severity=severity,
|
|
105
|
+
description=description,
|
|
106
|
+
remediation=solution if solution else None,
|
|
107
|
+
cvss_score=cvss_val,
|
|
108
|
+
cvss_vector=cvss_vector if cvss_vector else None,
|
|
109
|
+
hosts=[host_model],
|
|
110
|
+
evidence=evidence_list,
|
|
111
|
+
source_tool="nessus",
|
|
112
|
+
identifiers=identifiers,
|
|
113
|
+
references=extract_urls(item.findtext("see_also", default="")),
|
|
114
|
+
port=f"{port}/{protocol}" if port != "0" else None,
|
|
115
|
+
)
|
|
116
|
+
findings.append(finding)
|
|
117
|
+
|
|
118
|
+
report_host.clear()
|
|
119
|
+
root.clear()
|
|
120
|
+
|
|
121
|
+
except ET.ParseError as e:
|
|
122
|
+
raise ValueError(f"Invalid XML in {file_path}: {e}")
|
|
123
|
+
except OSError as e:
|
|
124
|
+
raise ValueError(f"File I/O error reading {file_path}: {e}")
|
|
125
|
+
|
|
126
|
+
return findings
|
scanfuse/parsers/nmap.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import xml.etree.ElementTree as ET
|
|
2
|
+
|
|
3
|
+
import defusedxml.ElementTree as defused_et
|
|
4
|
+
|
|
5
|
+
from scanfuse.models import Evidence, Finding, Host
|
|
6
|
+
from scanfuse.parsers.base import BaseParser, clean_ids
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _best_os_guess(host_elem: ET.Element) -> str | None:
|
|
10
|
+
"""The operating system guess with the highest accuracy, e.g. 'Linux 5.0 - 5.7 (97%)'."""
|
|
11
|
+
os_elem = host_elem.find("os")
|
|
12
|
+
if os_elem is None:
|
|
13
|
+
return None
|
|
14
|
+
best_name, best_accuracy = None, -1
|
|
15
|
+
for match in os_elem.findall("osmatch"):
|
|
16
|
+
name = match.get("name")
|
|
17
|
+
try:
|
|
18
|
+
accuracy = int(match.get("accuracy", "0"))
|
|
19
|
+
except ValueError:
|
|
20
|
+
accuracy = 0
|
|
21
|
+
if name and accuracy > best_accuracy:
|
|
22
|
+
best_name, best_accuracy = name, accuracy
|
|
23
|
+
if best_name is None:
|
|
24
|
+
return None
|
|
25
|
+
return f"{best_name} ({best_accuracy}%)"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NmapParser(BaseParser):
|
|
29
|
+
def parse(self, file_path: str) -> list[Finding]:
|
|
30
|
+
findings = []
|
|
31
|
+
try:
|
|
32
|
+
context = iter(defused_et.iterparse(file_path, events=("start", "end")))
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
event, root = next(context)
|
|
36
|
+
except StopIteration:
|
|
37
|
+
raise ValueError("Empty XML file")
|
|
38
|
+
|
|
39
|
+
if root.tag != "nmaprun":
|
|
40
|
+
raise ValueError("Not a valid Nmap XML file (missing <nmaprun> root tag)")
|
|
41
|
+
|
|
42
|
+
for event, host_elem in context:
|
|
43
|
+
if event == "end" and host_elem.tag == "host":
|
|
44
|
+
status = host_elem.find("status")
|
|
45
|
+
if status is not None and status.get("state") != "up":
|
|
46
|
+
host_elem.clear()
|
|
47
|
+
root.clear()
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
ip = ""
|
|
51
|
+
for address in host_elem.findall("address"):
|
|
52
|
+
if address.get("addrtype") in ("ipv4", "ipv6"):
|
|
53
|
+
ip = address.get("addr")
|
|
54
|
+
break
|
|
55
|
+
|
|
56
|
+
if not ip:
|
|
57
|
+
host_elem.clear()
|
|
58
|
+
root.clear()
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
hostname = None
|
|
62
|
+
hostnames = host_elem.find("hostnames")
|
|
63
|
+
if hostnames is not None:
|
|
64
|
+
for hn in hostnames.findall("hostname"):
|
|
65
|
+
if hn.get("name"):
|
|
66
|
+
hostname = hn.get("name")
|
|
67
|
+
break
|
|
68
|
+
|
|
69
|
+
host_model = Host(ip=ip, hostname=hostname, os=_best_os_guess(host_elem))
|
|
70
|
+
|
|
71
|
+
ports_elem = host_elem.find("ports")
|
|
72
|
+
if ports_elem is not None:
|
|
73
|
+
for port_elem in ports_elem.findall("port"):
|
|
74
|
+
state_elem = port_elem.find("state")
|
|
75
|
+
if state_elem is None or state_elem.get("state") != "open":
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
protocol = port_elem.get("protocol", "tcp")
|
|
79
|
+
portid = port_elem.get("portid", "")
|
|
80
|
+
|
|
81
|
+
service_elem = port_elem.find("service")
|
|
82
|
+
service_name = "unknown"
|
|
83
|
+
product = ""
|
|
84
|
+
version = ""
|
|
85
|
+
if service_elem is not None:
|
|
86
|
+
service_name = service_elem.get("name", service_name)
|
|
87
|
+
product = service_elem.get("product", "")
|
|
88
|
+
version = service_elem.get("version", "")
|
|
89
|
+
|
|
90
|
+
cpes = (
|
|
91
|
+
[c.text.strip() for c in service_elem.findall("cpe") if c.text and c.text.strip()]
|
|
92
|
+
if service_elem is not None else []
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
service_desc = service_name
|
|
96
|
+
if product:
|
|
97
|
+
service_desc += f" ({f'{product} {version}'.strip()})"
|
|
98
|
+
|
|
99
|
+
title = f"Open Port: {portid}/{protocol} ({service_desc})"
|
|
100
|
+
|
|
101
|
+
evidence_text = f"Port: {portid}/{protocol}\nState: open\nService: {service_name}"
|
|
102
|
+
if product:
|
|
103
|
+
evidence_text += f"\nProduct: {product}"
|
|
104
|
+
if version:
|
|
105
|
+
evidence_text += f"\nVersion: {version}"
|
|
106
|
+
|
|
107
|
+
for script in port_elem.findall("script"):
|
|
108
|
+
script_id = script.get("id", "")
|
|
109
|
+
script_output = script.get("output", "")
|
|
110
|
+
evidence_text += f"\nScript {script_id}: {script_output}"
|
|
111
|
+
|
|
112
|
+
evidence = Evidence(
|
|
113
|
+
type="port-scan",
|
|
114
|
+
data=evidence_text.strip()
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
finding = Finding(
|
|
118
|
+
title=title,
|
|
119
|
+
severity="Info",
|
|
120
|
+
description=f"An open {protocol} port was discovered on port {portid} running {service_desc}.",
|
|
121
|
+
hosts=[host_model],
|
|
122
|
+
evidence=[evidence],
|
|
123
|
+
source_tool="nmap",
|
|
124
|
+
identifiers=clean_ids(cpes),
|
|
125
|
+
port=f"{portid}/{protocol}" if portid else None,
|
|
126
|
+
)
|
|
127
|
+
findings.append(finding)
|
|
128
|
+
|
|
129
|
+
host_elem.clear()
|
|
130
|
+
root.clear()
|
|
131
|
+
|
|
132
|
+
except ET.ParseError as e:
|
|
133
|
+
raise ValueError(f"Invalid XML in {file_path}: {e}")
|
|
134
|
+
except OSError as e:
|
|
135
|
+
raise ValueError(f"File I/O error reading {file_path}: {e}")
|
|
136
|
+
|
|
137
|
+
return findings
|