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
|
@@ -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
|
scanfuse/parsers/zap.py
ADDED
|
@@ -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 %}
|