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.
@@ -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
@@ -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
@@ -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