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,157 @@
1
+ import json
2
+
3
+ from scanfuse.models import Evidence, Finding, Host
4
+ from scanfuse.parsers.base import (
5
+ BaseParser,
6
+ PartialImportError,
7
+ clean_ids,
8
+ cve_ids,
9
+ cwe_id,
10
+ extract_urls,
11
+ )
12
+
13
+
14
+ class NucleiParser(BaseParser):
15
+ def parse(self, file_path: str) -> list[Finding]:
16
+ findings = []
17
+ invalid_lines: list[int] = []
18
+
19
+ with open(file_path, encoding="utf-8") as f:
20
+ for line_idx, line in enumerate(f, start=1):
21
+ line = line.strip()
22
+ if not line:
23
+ continue
24
+
25
+ try:
26
+ data = json.loads(line)
27
+ if not self._looks_like_nuclei(data):
28
+ continue
29
+ findings.append(self._parse_dict(data))
30
+ except json.JSONDecodeError:
31
+ invalid_lines.append(line_idx)
32
+
33
+ if not findings or invalid_lines:
34
+ # Fallback: maybe it's a JSON array instead of JSONL
35
+ with open(file_path, encoding="utf-8") as f:
36
+ try:
37
+ data = json.load(f)
38
+ if isinstance(data, list):
39
+ findings = []
40
+ for item in data:
41
+ if self._looks_like_nuclei(item):
42
+ findings.append(self._parse_dict(item))
43
+ elif self._looks_like_nuclei(data):
44
+ findings = [self._parse_dict(data)]
45
+ invalid_lines = []
46
+ except json.JSONDecodeError:
47
+ pass
48
+ if not findings:
49
+ raise ValueError("Not a valid Nuclei JSON/JSONL file.")
50
+
51
+ if invalid_lines:
52
+ raise PartialImportError(f"Invalid JSON on line {invalid_lines[0]}", findings)
53
+
54
+ return findings
55
+
56
+ @staticmethod
57
+ def _looks_like_nuclei(data: object) -> bool:
58
+ """A Nuclei result has at least one of these keys. Other JSON objects are not findings."""
59
+ return isinstance(data, dict) and any(
60
+ key in data for key in ("template-id", "info", "matched-at")
61
+ )
62
+
63
+ def _parse_dict(self, data: dict) -> Finding:
64
+ info = data.get("info")
65
+ if not isinstance(info, dict):
66
+ info = {}
67
+ template_id = data.get("template-id", "unknown")
68
+ name = info.get("name") or template_id
69
+
70
+ severity_raw = info.get("severity") or "info"
71
+ if not isinstance(severity_raw, str):
72
+ severity_raw = str(severity_raw)
73
+ severity_raw = severity_raw.lower()
74
+
75
+ # Map Nuclei severity to Finding severity model
76
+ severity_map = {
77
+ "critical": "Critical",
78
+ "high": "High",
79
+ "medium": "Medium",
80
+ "low": "Low",
81
+ "info": "Info",
82
+ "unknown": "Info"
83
+ }
84
+ severity = severity_map.get(severity_raw, "Info")
85
+
86
+ description = info.get("description") or f"Nuclei template {template_id} matched."
87
+ remediation = info.get("remediation")
88
+
89
+ classification = info.get("classification")
90
+ if not isinstance(classification, dict):
91
+ classification = {}
92
+ cvss_score_raw = classification.get("cvss-score")
93
+ try:
94
+ cvss_score = float(cvss_score_raw) if cvss_score_raw is not None else None
95
+ except (ValueError, TypeError):
96
+ cvss_score = None
97
+
98
+ cvss_vector = classification.get("cvss-metrics")
99
+ if not isinstance(cvss_vector, str):
100
+ cvss_vector = None
101
+
102
+ # Handle host extraction
103
+ ip = data.get("ip")
104
+ host_val = data.get("host")
105
+ hostname = None
106
+
107
+ # If ip is missing but host is an IP, we could try to guess,
108
+ # but let's stick to using ip as ip and host as hostname if different.
109
+ if not ip and host_val:
110
+ ip = host_val
111
+ elif host_val and ip and host_val != ip:
112
+ hostname = host_val
113
+ elif host_val and not ip:
114
+ ip = host_val
115
+
116
+ if not ip:
117
+ ip = "unknown"
118
+
119
+ host_model = Host(ip=ip, hostname=hostname)
120
+
121
+ # Handle evidence
122
+ evidence_list = []
123
+ req = data.get("request")
124
+ resp = data.get("response")
125
+ matched_at = data.get("matched-at")
126
+
127
+ if req or resp:
128
+ ev_text = ""
129
+ if req:
130
+ ev_text += f"Request:\n{req}\n"
131
+ if resp:
132
+ ev_text += f"\nResponse:\n{resp}"
133
+ evidence_list.append(Evidence(type="http-request", data=ev_text.strip()))
134
+ elif matched_at:
135
+ evidence_list.append(Evidence(type="match", data=f"Matched at: {matched_at}"))
136
+
137
+ cwe_raw = classification.get("cwe-id")
138
+ cwes = [cwe_id(c) for c in cwe_raw] if isinstance(cwe_raw, list) else [cwe_id(cwe_raw)]
139
+ identifiers = clean_ids(
140
+ f"nuclei-template:{template_id}" if data.get("template-id") else None,
141
+ cve_ids(classification.get("cve-id")),
142
+ cwes,
143
+ )
144
+
145
+ return Finding(
146
+ title=name,
147
+ severity=severity,
148
+ cvss_score=cvss_score,
149
+ cvss_vector=cvss_vector,
150
+ description=description,
151
+ remediation=remediation,
152
+ hosts=[host_model],
153
+ evidence=evidence_list,
154
+ identifiers=identifiers,
155
+ references=extract_urls(info.get("reference")),
156
+ source_tool="nuclei"
157
+ )
@@ -0,0 +1,187 @@
1
+ import json
2
+
3
+ from scanfuse.models import Evidence, Finding, Host
4
+ from scanfuse.parsers.base import BaseParser, clean_ids, cve_ids, cwe_id, extract_urls
5
+
6
+
7
+ class TrivyParser(BaseParser):
8
+ def parse(self, file_path: str) -> list[Finding]:
9
+ findings = []
10
+
11
+ with open(file_path, encoding="utf-8") as f:
12
+ try:
13
+ data = json.load(f)
14
+ except json.JSONDecodeError as e:
15
+ raise ValueError(f"Invalid JSON in {file_path}: {e}")
16
+
17
+ # Older Trivy versions write a bare list of results. Current versions write an object.
18
+ if isinstance(data, list):
19
+ if not data or not all(isinstance(item, dict) and "Target" in item for item in data):
20
+ raise ValueError(f"Not a valid Trivy JSON file: {file_path}")
21
+ data = {"ArtifactName": str(data[0].get("Target") or "unknown-artifact"), "Results": data}
22
+
23
+ # Basic check to see if it looks like a Trivy report
24
+ if not isinstance(data, dict) or ("SchemaVersion" not in data and "ArtifactName" not in data):
25
+ raise ValueError(f"Not a valid Trivy JSON file: {file_path}")
26
+
27
+ artifact_name = data.get("ArtifactName", "unknown-artifact")
28
+ host_model = Host(ip="unknown", hostname=artifact_name)
29
+
30
+ severity_map = {
31
+ "CRITICAL": "Critical",
32
+ "HIGH": "High",
33
+ "MEDIUM": "Medium",
34
+ "LOW": "Low",
35
+ "UNKNOWN": "Info"
36
+ }
37
+
38
+ results = data.get("Results") or []
39
+ if not isinstance(results, list):
40
+ results = []
41
+
42
+ for result in results:
43
+ if not isinstance(result, dict):
44
+ continue
45
+
46
+ target = result.get("Target", "Unknown Target")
47
+
48
+ # Parse Vulnerabilities (CVEs)
49
+ vulns = result.get("Vulnerabilities") or []
50
+ if not isinstance(vulns, list):
51
+ vulns = []
52
+ for vuln in vulns:
53
+ if not isinstance(vuln, dict):
54
+ continue
55
+ vuln_id = vuln.get("VulnerabilityID", "Unknown ID")
56
+ pkg_name = vuln.get("PkgName", "")
57
+ title_raw = vuln.get("Title")
58
+ title = str(title_raw).strip() if title_raw else f"{vuln_id} in {pkg_name}"
59
+
60
+ desc_raw = vuln.get("Description")
61
+ desc = str(desc_raw).strip() if desc_raw else ""
62
+ sev_raw = (vuln.get("Severity") or "UNKNOWN").upper()
63
+ severity = severity_map.get(sev_raw, "Info")
64
+
65
+ installed_ver = vuln.get("InstalledVersion", "")
66
+ fixed_ver = vuln.get("FixedVersion", "")
67
+
68
+ remediation = f"Update {pkg_name} from {installed_ver} to {fixed_ver}." if fixed_ver else None
69
+
70
+ # Extract CVSS if present
71
+ cvss_score = None
72
+ cvss_vector = None
73
+ cvss_data = vuln.get("CVSS", {})
74
+ if isinstance(cvss_data, dict) and cvss_data:
75
+ # Prefer NVD V3, fallback to whatever is there
76
+ for provider, scores in cvss_data.items():
77
+ if isinstance(scores, dict) and "V3Score" in scores:
78
+ cvss_score = scores["V3Score"]
79
+ cvss_vector = scores.get("V3Vector")
80
+ if provider == "nvd":
81
+ break
82
+
83
+ evidence_list = [Evidence(type="target", data=target)]
84
+ if pkg_name:
85
+ evidence_list.append(Evidence(type="package", data=f"Package: {pkg_name}\nInstalled: {installed_ver}"))
86
+
87
+ cwe_ids = vuln.get("CweIDs")
88
+ vuln_identifiers = clean_ids(
89
+ cve_ids(vuln_id) or (vuln_id if vuln_id != "Unknown ID" else None),
90
+ [cwe_id(c) for c in cwe_ids] if isinstance(cwe_ids, list) else None,
91
+ )
92
+
93
+ finding = Finding(
94
+ title=title,
95
+ severity=severity,
96
+ description=desc,
97
+ remediation=remediation,
98
+ identifiers=vuln_identifiers,
99
+ cvss_score=cvss_score,
100
+ cvss_vector=cvss_vector,
101
+ references=extract_urls(vuln.get("PrimaryURL"), vuln.get("References")),
102
+ hosts=[host_model],
103
+ evidence=evidence_list,
104
+ source_tool="trivy"
105
+ )
106
+ findings.append(finding)
107
+
108
+ # Parse Misconfigurations (IaC)
109
+ misconfs = result.get("Misconfigurations") or []
110
+ if not isinstance(misconfs, list):
111
+ misconfs = []
112
+ for misconf in misconfs:
113
+ if not isinstance(misconf, dict):
114
+ continue
115
+ misconf_id = misconf.get("ID", "Unknown ID")
116
+ title_raw = misconf.get("Title")
117
+ title = str(title_raw).strip() if title_raw else misconf_id
118
+
119
+ desc_raw = misconf.get("Description")
120
+ desc = str(desc_raw).strip() if desc_raw else ""
121
+
122
+ sev_raw = (misconf.get("Severity") or "UNKNOWN").upper()
123
+ severity = severity_map.get(sev_raw, "Info")
124
+
125
+ rem_raw = misconf.get("Resolution")
126
+ remediation = str(rem_raw).strip() if rem_raw else ""
127
+
128
+ evidence_list = [Evidence(type="target", data=target)]
129
+ cause = misconf.get("CauseMetadata", {})
130
+ if isinstance(cause, dict) and cause:
131
+ cause_text = f"Provider: {cause.get('Provider', '')}\nService: {cause.get('Service', '')}"
132
+ code_data = cause.get("Code")
133
+ if isinstance(code_data, dict) and "Lines" in code_data:
134
+ lines = code_data.get("Lines") or []
135
+ if isinstance(lines, list):
136
+ code_str = "\n".join([str(line.get("Content", "")) for line in lines if isinstance(line, dict)])
137
+ if code_str:
138
+ cause_text += f"\nCode snippet:\n{code_str}"
139
+ elif code_data:
140
+ cause_text += f"\nCode snippet:\n{code_data}"
141
+ evidence_list.append(Evidence(type="cause", data=cause_text.strip()))
142
+ elif cause:
143
+ evidence_list.append(Evidence(type="cause", data=str(cause).strip()))
144
+
145
+ finding = Finding(
146
+ title=title,
147
+ severity=severity,
148
+ description=desc,
149
+ remediation=remediation if remediation else None,
150
+ identifiers=clean_ids(f"trivy-id:{misconf_id}" if misconf.get("ID") else None),
151
+ references=extract_urls(misconf.get("PrimaryURL"), misconf.get("References")),
152
+ hosts=[host_model],
153
+ evidence=evidence_list,
154
+ source_tool="trivy"
155
+ )
156
+ findings.append(finding)
157
+
158
+ # Parse Secrets
159
+ secrets = result.get("Secrets") or []
160
+ if not isinstance(secrets, list):
161
+ secrets = []
162
+ for secret in secrets:
163
+ if not isinstance(secret, dict):
164
+ continue
165
+ sec_id = secret.get("RuleID", "Unknown Secret")
166
+ title_raw = secret.get("Title")
167
+ title = str(title_raw).strip() if title_raw else sec_id
168
+ desc = f"Secret detected: {sec_id}"
169
+ sev_raw = (secret.get("Severity") or "CRITICAL").upper()
170
+ severity = severity_map.get(sev_raw, "Critical")
171
+
172
+ evidence_list = [Evidence(type="target", data=target)]
173
+ if secret.get("Match"):
174
+ evidence_list.append(Evidence(type="match", data=f"Match: {secret.get('Match')}"))
175
+
176
+ finding = Finding(
177
+ title=title,
178
+ severity=severity,
179
+ description=desc,
180
+ identifiers=clean_ids(f"trivy-rule:{sec_id}" if secret.get("RuleID") else None),
181
+ hosts=[host_model],
182
+ evidence=evidence_list,
183
+ source_tool="trivy"
184
+ )
185
+ findings.append(finding)
186
+
187
+ return findings
@@ -0,0 +1,124 @@
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 (
7
+ BaseParser,
8
+ clean_ids,
9
+ cve_ids,
10
+ cwe_id,
11
+ extract_urls,
12
+ html_to_text,
13
+ )
14
+
15
+
16
+ def _instance_text(elem: ET.Element) -> str:
17
+ """Request, parameter, attack, evidence and extra info of one alert instance."""
18
+ uri = elem.findtext("uri", default="").strip()
19
+ method = elem.findtext("method", default="").strip()
20
+ lines = []
21
+ if uri or method:
22
+ lines.append(f"Request: {method} {uri}".replace("Request: ", "Request: ").strip())
23
+ for label, tag in (("Parameter", "param"), ("Attack", "attack"), ("Evidence", "evidence")):
24
+ value = html_to_text(elem.findtext(tag, default=""))
25
+ if value:
26
+ lines.append(f"{label}: {value}")
27
+ if elem.tag == "instance":
28
+ other = html_to_text(elem.findtext("otherinfo", default=""))
29
+ if other:
30
+ lines.append(f"Other info: {other}")
31
+ return "\n".join(lines)
32
+
33
+
34
+ class ZapParser(BaseParser):
35
+ def parse(self, file_path: str) -> list[Finding]:
36
+ findings = []
37
+ try:
38
+ context = iter(defused_et.iterparse(file_path, events=("start", "end")))
39
+
40
+ try:
41
+ event, root = next(context)
42
+ except StopIteration:
43
+ raise ValueError("Empty XML file")
44
+
45
+ if root.tag != "OWASPZAPReport":
46
+ raise ValueError("Not a valid ZAP XML file (missing <OWASPZAPReport> root tag)")
47
+
48
+ risk_map = {
49
+ "0": "Info",
50
+ "1": "Low",
51
+ "2": "Medium",
52
+ "3": "High"
53
+ }
54
+
55
+ for event, site_elem in context:
56
+ if event == "end" and site_elem.tag == "site":
57
+ host_val = site_elem.get("host", "unknown")
58
+ host_model = Host(ip=host_val)
59
+
60
+ alerts_elem = site_elem.find("alerts")
61
+ if alerts_elem is None:
62
+ site_elem.clear()
63
+ root.clear()
64
+ continue
65
+
66
+ for alert_elem in alerts_elem.findall("alertitem"):
67
+ # Newer reports have <name>; ZAP 1.x reports only have <alert>.
68
+ name = html_to_text(
69
+ alert_elem.findtext("name") or alert_elem.findtext("alert") or ""
70
+ ) or "Unknown Vulnerability"
71
+ riskcode = alert_elem.findtext("riskcode", default="0")
72
+ severity = risk_map.get(riskcode, "Info")
73
+
74
+ desc = html_to_text(alert_elem.findtext("desc", default=""))
75
+ solution = html_to_text(alert_elem.findtext("solution", default=""))
76
+ reference_text = alert_elem.findtext("reference", default="")
77
+
78
+ evidence_list = []
79
+ instances_elem = alert_elem.find("instances")
80
+ if instances_elem is not None:
81
+ for inst in instances_elem.findall("instance"):
82
+ text = _instance_text(inst)
83
+ if text:
84
+ evidence_list.append(Evidence(type="http-request", data=text))
85
+ else:
86
+ # ZAP 1.x: uri, param and attack are children of the alert itself.
87
+ text = _instance_text(alert_elem)
88
+ if text:
89
+ evidence_list.append(Evidence(type="http-request", data=text))
90
+ other_info = html_to_text(alert_elem.findtext("otherinfo", default=""))
91
+ if other_info:
92
+ evidence_list.append(Evidence(type="other-info", data=other_info))
93
+
94
+ plugin_id = alert_elem.findtext("pluginid", default="").strip()
95
+ wasc = alert_elem.findtext("wascid", default="").strip()
96
+ identifiers = clean_ids(
97
+ f"zap-plugin:{plugin_id}" if plugin_id else None,
98
+ cwe_id(alert_elem.findtext("cweid", default="")),
99
+ f"WASC-{wasc}" if wasc.isdigit() and int(wasc) > 0 else None,
100
+ cve_ids(reference_text),
101
+ )
102
+
103
+ finding = Finding(
104
+ title=name,
105
+ severity=severity,
106
+ description=desc,
107
+ remediation=solution if solution else None,
108
+ hosts=[host_model],
109
+ evidence=evidence_list,
110
+ identifiers=identifiers,
111
+ references=extract_urls(reference_text),
112
+ source_tool="zap"
113
+ )
114
+ findings.append(finding)
115
+
116
+ site_elem.clear()
117
+ root.clear()
118
+
119
+ except ET.ParseError as e:
120
+ raise ValueError(f"Invalid XML in {file_path}: {e}")
121
+ except OSError as e:
122
+ raise ValueError(f"File I/O error reading {file_path}: {e}")
123
+
124
+ return findings
@@ -0,0 +1,67 @@
1
+ # {{ finding.title | md }}
2
+
3
+ **Severity**: {{ finding.severity | md }}
4
+ {% if finding.cvss_score is not none %}
5
+
6
+ **CVSS Score**: {{ finding.cvss_score }}{% if finding.cvss_vector %} ({{ finding.cvss_vector | md }}){% endif %}
7
+ {% endif %}
8
+
9
+ **Source**: {{ finding.source_tool | md }}
10
+ {% if finding.port %}
11
+
12
+ **Port**: {{ finding.port | md }}
13
+ {% endif %}
14
+ {% if finding.identifiers %}
15
+
16
+ **Identifiers**: {% for ident in finding.identifiers %}{{ ident | md }}{% if not loop.last %}, {% endif %}{% endfor %}
17
+ {% endif %}
18
+
19
+ ## Description
20
+ {{ finding.description | mdtext }}
21
+
22
+ {% if finding.remediation %}
23
+ ## Remediation
24
+ {{ finding.remediation | mdtext }}
25
+ {% endif %}
26
+
27
+ {% if finding.references %}
28
+ ## References
29
+ {% for url in finding.references %}
30
+ - <{{ url }}>
31
+ {% endfor %}
32
+
33
+ {% endif %}
34
+ {% if finding.sources %}
35
+ ## Reported by
36
+ {% for s in finding.sources %}
37
+
38
+ ### {{ s.source_tool | md }}
39
+
40
+ **Severity**: {{ s.severity | md }}{% if s.cvss_score is not none %}, **CVSS Score**: {{ s.cvss_score }}{% if s.cvss_vector %} ({{ s.cvss_vector | md }}){% endif %}{% endif %}
41
+
42
+ {{ s.description | mdtext }}
43
+ {% if s.remediation %}
44
+
45
+ **Remediation**: {{ s.remediation | mdtext }}
46
+ {% endif %}
47
+ {% endfor %}
48
+ {% endif %}
49
+
50
+ ## Hosts
51
+ {% for host in finding.hosts %}
52
+ - {{ host.ip | md }}{% if host.hostname %} ({{ host.hostname | md }}){% endif %}{% if host.os %} - OS: {{ host.os | md }}{% endif %}
53
+ {% else %}
54
+ No host information.
55
+ {% endfor %}
56
+
57
+ ## Evidence
58
+ {% for ev in finding.evidence %}
59
+ ### {{ ev.type | md }}{% if ev.source %} ({{ ev.source | md }}){% endif %}
60
+
61
+ {% set fence = ev.data | fence %}
62
+ {{ fence }}
63
+ {{ ev.data }}
64
+ {{ fence }}
65
+ {% else %}
66
+ No evidence provided.
67
+ {% endfor %}
@@ -0,0 +1,44 @@
1
+ # Pentest Report Summary
2
+
3
+ ## Import status
4
+ {% if complete %}
5
+ Complete. All inputs were read.
6
+ {% else %}
7
+ **INCOMPLETE.** {{ errors|length }} input problem(s). The findings below come only from inputs that could be read.
8
+ {% for e in errors %}
9
+ - {{ e.file | md }}: {{ e.message | md }}
10
+ {% endfor %}
11
+ {% endif %}
12
+
13
+ ## Executive Summary
14
+ Total Findings: {{ findings|length }}
15
+
16
+ ### Risk Matrix
17
+ | Severity | Count |
18
+ |----------|-------|
19
+ | Critical | {{ findings|selectattr('severity', 'equalto', 'Critical')|list|length }} |
20
+ | High | {{ findings|selectattr('severity', 'equalto', 'High')|list|length }} |
21
+ | Medium | {{ findings|selectattr('severity', 'equalto', 'Medium')|list|length }} |
22
+ | Low | {{ findings|selectattr('severity', 'equalto', 'Low')|list|length }} |
23
+ | Info | {{ findings|selectattr('severity', 'equalto', 'Info')|list|length }} |
24
+
25
+ ## Findings Overview
26
+ {% for finding in findings_sorted %}
27
+ - [{{ finding.severity | md }}] [{{ finding.title | md }}]({{ finding.slug }}.md) (Source: {{ finding.source_tool | md }}, {{ finding.hosts | length }} host{{ "" if finding.hosts | length == 1 else "s" }})
28
+ {% endfor %}
29
+
30
+ ## Affected Hosts
31
+ {% set hosts = {} %}
32
+ {% for finding in findings %}
33
+ {% for host in finding.hosts %}
34
+ {% if host.ip not in hosts %}
35
+ {% set _ = hosts.update({host.ip: 1}) %}
36
+ {% endif %}
37
+ {% endfor %}
38
+ {% endfor %}
39
+
40
+ {% for ip in hosts %}
41
+ - {{ ip | md }}
42
+ {% else %}
43
+ No host data extracted.
44
+ {% endfor %}