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/engine.py
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import fnmatch
|
|
3
|
+
import html
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import unicodedata
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from jinja2 import ChoiceLoader, Environment, FileSystemLoader
|
|
16
|
+
|
|
17
|
+
from scanfuse.html_report import render_report_fragment
|
|
18
|
+
from scanfuse.models import Finding, Host
|
|
19
|
+
from scanfuse.parsers.nmap import NmapParser
|
|
20
|
+
from scanfuse.parsers.nuclei import NucleiParser
|
|
21
|
+
from scanfuse.parsers.zap import ZapParser
|
|
22
|
+
from scanfuse.utils import unique_slug
|
|
23
|
+
|
|
24
|
+
SEVERITIES = ("Critical", "High", "Medium", "Low", "Info")
|
|
25
|
+
SEVERITY_RANK = {name: len(SEVERITIES) - i for i, name in enumerate(SEVERITIES)}
|
|
26
|
+
INPUT_FORMATS = ("auto", "nmap", "nuclei", "zap", "burp", "nessus", "trivy")
|
|
27
|
+
OUTPUT_FORMATS = ("markdown", "json", "csv")
|
|
28
|
+
JSON_SCHEMA_VERSION = 2
|
|
29
|
+
|
|
30
|
+
_MD_SPECIAL = re.compile(r"([\\`*_\[\]])")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class OutputExistsError(Exception):
|
|
34
|
+
"""The output directory already contains files that would be overwritten."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def md_escape(value: object) -> str:
|
|
38
|
+
"""Make untrusted text safe for Markdown: it shows as plain text and cannot add HTML,
|
|
39
|
+
links, headings, lists or emphasis."""
|
|
40
|
+
text = "" if value is None else str(value)
|
|
41
|
+
text = html.escape(text, quote=False)
|
|
42
|
+
text = _MD_SPECIAL.sub(r"\\\1", text)
|
|
43
|
+
text = re.sub(r"^(\s*)([#+\-])(?=\s|$)", r"\1\\\2", text, flags=re.MULTILINE)
|
|
44
|
+
text = re.sub(r"^(\s*)(\d+)([.)])(?=\s|$)", r"\1\2\\\3", text, flags=re.MULTILINE)
|
|
45
|
+
text = re.sub(r"^(\s*)(=+|-{2,})\s*$", r"\1\\\2", text, flags=re.MULTILINE)
|
|
46
|
+
return text
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_LIST_ITEM = re.compile(r"(?:[*+-]|\d+[.)])\s")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _separate_lists(text: str) -> str:
|
|
53
|
+
"""Put a blank line before and after a list, so that Markdown does not join it with the
|
|
54
|
+
paragraph above or below. Items of one list stay together."""
|
|
55
|
+
out: list[str] = []
|
|
56
|
+
in_list = False
|
|
57
|
+
for line in text.split("\n"):
|
|
58
|
+
is_item = bool(_LIST_ITEM.match(line))
|
|
59
|
+
if line and is_item != in_list and out and out[-1] != "":
|
|
60
|
+
out.append("")
|
|
61
|
+
out.append(line)
|
|
62
|
+
in_list = is_item if line else False
|
|
63
|
+
return "\n".join(out)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def md_text(value: object) -> str:
|
|
67
|
+
"""For longer scanner text (description, remediation): raw HTML is escaped, Markdown
|
|
68
|
+
formatting such as emphasis, code and links is kept, but the text cannot start a heading,
|
|
69
|
+
so it cannot fake a section of the report."""
|
|
70
|
+
text = html.escape("" if value is None else str(value), quote=False)
|
|
71
|
+
# Indented lines would become code blocks in Markdown (four spaces). Scanner texts use
|
|
72
|
+
# indentation for layout only, so leading blanks are removed.
|
|
73
|
+
text = re.sub(r"^[ \t]+", "", text, flags=re.MULTILINE)
|
|
74
|
+
text = _separate_lists(text)
|
|
75
|
+
text = re.sub(r"^(\s*)(#+)", r"\1\\\2", text, flags=re.MULTILINE)
|
|
76
|
+
text = re.sub(r"^(\s*)(=+|-{2,})\s*$", r"\1\\\2", text, flags=re.MULTILINE)
|
|
77
|
+
return text
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def code_fence(data: object) -> str:
|
|
81
|
+
"""A backtick fence that is longer than any run of backticks inside `data`."""
|
|
82
|
+
longest = max((len(run) for run in re.findall(r"`+", str(data))), default=0)
|
|
83
|
+
return "`" * max(3, longest + 1)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def csv_safe(cell: object) -> str:
|
|
87
|
+
"""Neutralize spreadsheet formulas. Looks at the text after Unicode normalization, so that
|
|
88
|
+
full-width '=' or a leading no-break space do not get through."""
|
|
89
|
+
val = "" if cell is None else str(cell)
|
|
90
|
+
if not val:
|
|
91
|
+
return val
|
|
92
|
+
probe = unicodedata.normalize("NFKC", val).lstrip()
|
|
93
|
+
if probe.startswith(("=", "+", "-", "@")) or val.startswith(("\t", "\r", "\n", " ")):
|
|
94
|
+
return f"'{val}"
|
|
95
|
+
return val
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class ScanfuseEngine:
|
|
99
|
+
def __init__(self, output_dir: str, template_dir: str | None = None):
|
|
100
|
+
self.output_dir = Path(output_dir).resolve()
|
|
101
|
+
self.custom_template_dir = Path(template_dir).resolve() if template_dir else None
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------------------------ input
|
|
104
|
+
|
|
105
|
+
def process_file(self, file_path: str, format_hint: str) -> list[Finding]:
|
|
106
|
+
"""
|
|
107
|
+
Determine parser, parse file, and return findings.
|
|
108
|
+
"""
|
|
109
|
+
path = Path(file_path)
|
|
110
|
+
if not path.exists():
|
|
111
|
+
raise ValueError(f"File not found: {file_path}")
|
|
112
|
+
if path.is_dir():
|
|
113
|
+
raise ValueError(f"Expected a file but got a directory: {file_path}")
|
|
114
|
+
|
|
115
|
+
if format_hint == "auto":
|
|
116
|
+
format_hint = self._detect_format(file_path)
|
|
117
|
+
|
|
118
|
+
from scanfuse.parsers.base import BaseParser
|
|
119
|
+
parser: BaseParser
|
|
120
|
+
if format_hint == "nmap":
|
|
121
|
+
parser = NmapParser()
|
|
122
|
+
elif format_hint == "nuclei":
|
|
123
|
+
parser = NucleiParser()
|
|
124
|
+
elif format_hint == "zap":
|
|
125
|
+
parser = ZapParser()
|
|
126
|
+
elif format_hint == "burp":
|
|
127
|
+
from scanfuse.parsers.burp import BurpParser
|
|
128
|
+
parser = BurpParser()
|
|
129
|
+
elif format_hint == "nessus":
|
|
130
|
+
from scanfuse.parsers.nessus import NessusParser
|
|
131
|
+
parser = NessusParser()
|
|
132
|
+
elif format_hint == "trivy":
|
|
133
|
+
from scanfuse.parsers.trivy import TrivyParser
|
|
134
|
+
parser = TrivyParser()
|
|
135
|
+
else:
|
|
136
|
+
raise ValueError(f"Unknown format: {format_hint}")
|
|
137
|
+
|
|
138
|
+
from defusedxml.common import DefusedXmlException
|
|
139
|
+
from pydantic import ValidationError
|
|
140
|
+
try:
|
|
141
|
+
return parser.parse(file_path)
|
|
142
|
+
except DefusedXmlException as e:
|
|
143
|
+
raise ValueError(
|
|
144
|
+
f"{file_path}: the XML uses entity or DTD declarations, which scanfuse does not "
|
|
145
|
+
f"process for safety ({type(e).__name__})"
|
|
146
|
+
)
|
|
147
|
+
except ValidationError as e:
|
|
148
|
+
raise ValueError(f"Schema validation failed for findings in {file_path}:\n{e}")
|
|
149
|
+
except OSError as e:
|
|
150
|
+
raise ValueError(f"File I/O error reading {file_path}: {e}")
|
|
151
|
+
|
|
152
|
+
# Burp puts a long DTD in front of the first tag (the <issues> tag starts after about 3.4 KB),
|
|
153
|
+
# so the start of the file is read generously.
|
|
154
|
+
_DETECT_BYTES = 64 * 1024
|
|
155
|
+
_XML_ROOTS = (
|
|
156
|
+
("OWASPZAPReport", "zap"),
|
|
157
|
+
("nmaprun", "nmap"),
|
|
158
|
+
("issues", "burp"),
|
|
159
|
+
("NessusClientData_v2", "nessus"),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def _detect_format(file_path: str) -> str:
|
|
164
|
+
try:
|
|
165
|
+
with open(file_path, encoding="utf-8-sig") as f:
|
|
166
|
+
header = f.read(ScanfuseEngine._DETECT_BYTES)
|
|
167
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
168
|
+
raise ValueError(f"Could not read {file_path}: {e}")
|
|
169
|
+
start = header.lstrip()
|
|
170
|
+
if start.startswith("<"):
|
|
171
|
+
for tag, name in ScanfuseEngine._XML_ROOTS:
|
|
172
|
+
if re.search(rf"<{tag}[\s>/]", header):
|
|
173
|
+
return name
|
|
174
|
+
raise ValueError(
|
|
175
|
+
f"Could not detect the format of {file_path}: unknown XML root element. Use --format."
|
|
176
|
+
)
|
|
177
|
+
if start.startswith(("{", "[")):
|
|
178
|
+
# Trivy writes either an object with SchemaVersion/ArtifactName (current versions) or,
|
|
179
|
+
# in older versions, a list of results that have a Target.
|
|
180
|
+
is_trivy_object = start.startswith("{") and '"SchemaVersion"' in header and '"ArtifactName"' in header
|
|
181
|
+
is_trivy_list = start.startswith("[") and '"Target"' in header and any(
|
|
182
|
+
f'"{key}"' in header for key in ("Vulnerabilities", "Misconfigurations", "Secrets", "Class")
|
|
183
|
+
)
|
|
184
|
+
return "trivy" if is_trivy_object or is_trivy_list else "nuclei"
|
|
185
|
+
raise ValueError(f"Could not detect the format of {file_path}. Use --format.")
|
|
186
|
+
|
|
187
|
+
# ---------------------------------------------------------------- filters
|
|
188
|
+
|
|
189
|
+
def filter_findings(self, findings: list[Finding], min_severity: str | None = None, host_filter: str | None = None, ignore_rules: list[str] | None = None) -> list[Finding]:
|
|
190
|
+
filtered = findings
|
|
191
|
+
|
|
192
|
+
if ignore_rules:
|
|
193
|
+
if isinstance(ignore_rules, str):
|
|
194
|
+
ignore_rules = [ignore_rules]
|
|
195
|
+
ignore_lower = [str(r).lower() for r in ignore_rules]
|
|
196
|
+
def is_ignored(title: str) -> bool:
|
|
197
|
+
t_lower = title.lower()
|
|
198
|
+
return any(rule in t_lower for rule in ignore_lower)
|
|
199
|
+
filtered = [f for f in filtered if not is_ignored(f.title)]
|
|
200
|
+
|
|
201
|
+
if min_severity:
|
|
202
|
+
wanted = {s.lower(): s for s in SEVERITIES}.get(min_severity.lower())
|
|
203
|
+
if wanted is None:
|
|
204
|
+
raise ValueError(
|
|
205
|
+
f"Unknown severity '{min_severity}'. Use one of: {', '.join(SEVERITIES)}"
|
|
206
|
+
)
|
|
207
|
+
min_level = SEVERITY_RANK[wanted]
|
|
208
|
+
filtered = [f for f in filtered if SEVERITY_RANK.get(f.severity, 1) >= min_level]
|
|
209
|
+
|
|
210
|
+
if host_filter:
|
|
211
|
+
# Exact match on IP or host name (ignoring case). '*' and '?' are wildcards.
|
|
212
|
+
pattern = host_filter.lower()
|
|
213
|
+
def match_host(h: Host) -> bool:
|
|
214
|
+
if fnmatch.fnmatchcase(h.ip.lower(), pattern):
|
|
215
|
+
return True
|
|
216
|
+
return bool(h.hostname and fnmatch.fnmatchcase(h.hostname.lower(), pattern))
|
|
217
|
+
|
|
218
|
+
# Filter out findings that don't affect the host.
|
|
219
|
+
# Also, filter the hosts list inside the finding so we don't report other hosts.
|
|
220
|
+
new_filtered = []
|
|
221
|
+
for f in filtered:
|
|
222
|
+
matching_hosts = [h for h in f.hosts if match_host(h)]
|
|
223
|
+
if matching_hosts:
|
|
224
|
+
f_copy = f.model_copy()
|
|
225
|
+
f_copy.hosts = matching_hosts
|
|
226
|
+
new_filtered.append(f_copy)
|
|
227
|
+
filtered = new_filtered
|
|
228
|
+
|
|
229
|
+
return filtered
|
|
230
|
+
|
|
231
|
+
# ----------------------------------------------------------------- output
|
|
232
|
+
|
|
233
|
+
def _publish(self, kind: str, produce: Callable[[Path], list[str]], replace: bool) -> list[str]:
|
|
234
|
+
"""Write a report so that an existing report is never overwritten by accident and is kept
|
|
235
|
+
intact if writing fails.
|
|
236
|
+
|
|
237
|
+
`produce(staging_dir)` writes all files into a private staging directory and returns
|
|
238
|
+
their names. Only after that succeeded, the files are moved into the output directory.
|
|
239
|
+
Without `replace`, existing files with the same names abort the export. With `replace`,
|
|
240
|
+
they are overwritten and files of an earlier export of the same kind that are no longer
|
|
241
|
+
part of the report are removed. Files that scanfuse did not write are never removed.
|
|
242
|
+
"""
|
|
243
|
+
if self.output_dir.exists() and not self.output_dir.is_dir():
|
|
244
|
+
raise OSError(f"Output path exists and is not a directory: {self.output_dir}")
|
|
245
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
246
|
+
staging = Path(tempfile.mkdtemp(prefix=".scanfuse-staging-", dir=self.output_dir))
|
|
247
|
+
keep_staging = False
|
|
248
|
+
try:
|
|
249
|
+
names = produce(staging)
|
|
250
|
+
existing = sorted(n for n in names if (self.output_dir / n).exists())
|
|
251
|
+
if existing and not replace:
|
|
252
|
+
shown = ", ".join(existing[:5]) + (" ..." if len(existing) > 5 else "")
|
|
253
|
+
raise OutputExistsError(
|
|
254
|
+
f"{len(existing)} file(s) already exist in {self.output_dir} ({shown}). "
|
|
255
|
+
"Use --replace to overwrite them, or choose another output directory."
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
manifest = self.output_dir / f".scanfuse-manifest-{kind}.json"
|
|
259
|
+
old_names: list[str] = []
|
|
260
|
+
if manifest.exists():
|
|
261
|
+
try:
|
|
262
|
+
old_names = list(json.loads(manifest.read_text(encoding="utf-8")).get("files", []))
|
|
263
|
+
except (OSError, ValueError):
|
|
264
|
+
old_names = []
|
|
265
|
+
|
|
266
|
+
staged_manifest = staging / manifest.name
|
|
267
|
+
staged_manifest.write_text(json.dumps({"files": sorted(names)}, indent=2), encoding="utf-8")
|
|
268
|
+
stale_names = sorted(
|
|
269
|
+
name for name in set(old_names) - set(names)
|
|
270
|
+
if replace and isinstance(name, str) and name
|
|
271
|
+
and Path(name).name == name and not name.startswith(".")
|
|
272
|
+
)
|
|
273
|
+
affected = list(dict.fromkeys([*names, manifest.name, *stale_names]))
|
|
274
|
+
backup = staging / "backup"
|
|
275
|
+
backup.mkdir()
|
|
276
|
+
originals = set()
|
|
277
|
+
for name in affected:
|
|
278
|
+
target = self.output_dir / name
|
|
279
|
+
if target.exists():
|
|
280
|
+
shutil.copy2(target, backup / name)
|
|
281
|
+
originals.add(name)
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
for name in names:
|
|
285
|
+
os.replace(staging / name, self.output_dir / name)
|
|
286
|
+
os.replace(staged_manifest, manifest)
|
|
287
|
+
for name in stale_names:
|
|
288
|
+
(self.output_dir / name).unlink(missing_ok=True)
|
|
289
|
+
except Exception:
|
|
290
|
+
try:
|
|
291
|
+
for name in affected:
|
|
292
|
+
target = self.output_dir / name
|
|
293
|
+
if name in originals:
|
|
294
|
+
shutil.copy2(backup / name, target)
|
|
295
|
+
else:
|
|
296
|
+
target.unlink(missing_ok=True)
|
|
297
|
+
except Exception as rollback_error:
|
|
298
|
+
keep_staging = True
|
|
299
|
+
raise OSError(f"Report recovery failed; backups retained in {backup}") from rollback_error
|
|
300
|
+
raise
|
|
301
|
+
return names
|
|
302
|
+
finally:
|
|
303
|
+
if not keep_staging:
|
|
304
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
305
|
+
|
|
306
|
+
def _template_env(self) -> Environment:
|
|
307
|
+
if hasattr(sys, '_MEIPASS'):
|
|
308
|
+
# PyInstaller creates a temp folder and stores path in _MEIPASS
|
|
309
|
+
base_dir = Path(sys._MEIPASS) / "scanfuse"
|
|
310
|
+
else:
|
|
311
|
+
base_dir = Path(__file__).parent
|
|
312
|
+
|
|
313
|
+
builtin_template_dir = base_dir / "templates"
|
|
314
|
+
|
|
315
|
+
# Support fallback to builtins if custom template is missing
|
|
316
|
+
loaders: list[FileSystemLoader] = []
|
|
317
|
+
if self.custom_template_dir and self.custom_template_dir.exists():
|
|
318
|
+
loaders.append(FileSystemLoader(str(self.custom_template_dir)))
|
|
319
|
+
loaders.append(FileSystemLoader(str(builtin_template_dir)))
|
|
320
|
+
|
|
321
|
+
env = Environment(loader=ChoiceLoader(loaders), autoescape=False) # noqa: S701
|
|
322
|
+
env.filters['md'] = md_escape
|
|
323
|
+
env.filters['mdtext'] = md_text
|
|
324
|
+
env.filters['fence'] = code_fence
|
|
325
|
+
return env
|
|
326
|
+
|
|
327
|
+
def export_findings(self, findings: list[Finding], errors: list[dict[str, str]] | None = None,
|
|
328
|
+
replace: bool = False) -> list[str]:
|
|
329
|
+
"""
|
|
330
|
+
Export findings to markdown files using Jinja2 templates, plus index.md and report.html.
|
|
331
|
+
`errors` lists inputs that could not be read; the report is then marked as incomplete.
|
|
332
|
+
"""
|
|
333
|
+
errors = errors or []
|
|
334
|
+
if not findings:
|
|
335
|
+
return []
|
|
336
|
+
env = self._template_env()
|
|
337
|
+
finding_template = env.get_template("finding.md.j2")
|
|
338
|
+
index_template = env.get_template("index.md.j2")
|
|
339
|
+
|
|
340
|
+
def produce(staging: Path) -> list[str]:
|
|
341
|
+
names: list[str] = []
|
|
342
|
+
rendered_findings = []
|
|
343
|
+
slugs_seen: set[str] = set()
|
|
344
|
+
for finding in findings:
|
|
345
|
+
finding.slug = unique_slug(finding.title, slugs_seen)
|
|
346
|
+
content = finding_template.render(finding=finding)
|
|
347
|
+
(staging / f"{finding.slug}.md").write_text(content, encoding="utf-8")
|
|
348
|
+
names.append(f"{finding.slug}.md")
|
|
349
|
+
rendered_findings.append(content)
|
|
350
|
+
|
|
351
|
+
by_severity = sorted(
|
|
352
|
+
findings, key=lambda f: (-SEVERITY_RANK.get(f.severity, 0), f.title.casefold())
|
|
353
|
+
)
|
|
354
|
+
index_content = index_template.render(
|
|
355
|
+
findings=findings, findings_sorted=by_severity, errors=errors,
|
|
356
|
+
complete=not errors,
|
|
357
|
+
)
|
|
358
|
+
(staging / "index.md").write_text(index_content, encoding="utf-8")
|
|
359
|
+
names.append("index.md")
|
|
360
|
+
|
|
361
|
+
# Generate standalone HTML
|
|
362
|
+
try:
|
|
363
|
+
html_text = self._render_html(index_content, findings, rendered_findings)
|
|
364
|
+
(staging / "report.html").write_text(html_text, encoding="utf-8")
|
|
365
|
+
names.append("report.html")
|
|
366
|
+
except ImportError:
|
|
367
|
+
pass # Markdown module not installed
|
|
368
|
+
return names
|
|
369
|
+
|
|
370
|
+
return self._publish("markdown", produce, replace)
|
|
371
|
+
|
|
372
|
+
@staticmethod
|
|
373
|
+
def _render_html(index_content: str, findings: list[Finding], rendered: list[str]) -> str:
|
|
374
|
+
index_html = render_report_fragment(index_content)
|
|
375
|
+
finding_htmls = []
|
|
376
|
+
for finding, content in zip(findings, rendered):
|
|
377
|
+
html_c = render_report_fragment(content)
|
|
378
|
+
clean_title = html.escape(finding.title.lower(), quote=True)
|
|
379
|
+
# Add an id corresponding to the slug for the anchor links
|
|
380
|
+
finding_htmls.append(f'<div id="{finding.slug}" class="finding-block" data-severity="{finding.severity}" data-title="{clean_title}"><hr>\n{html_c}</div>')
|
|
381
|
+
|
|
382
|
+
html_body = index_html + "\n" + "\n".join(finding_htmls)
|
|
383
|
+
|
|
384
|
+
out = "<!DOCTYPE html>\n<html>\n<head>\n<meta charset='utf-8'>\n"
|
|
385
|
+
out += "<meta name='viewport' content='width=device-width, initial-scale=1'>\n"
|
|
386
|
+
out += "<meta name='color-scheme' content='light dark'>\n"
|
|
387
|
+
out += "<title>Pentest Report</title>\n"
|
|
388
|
+
# Colours follow the reader's system setting (light or dark).
|
|
389
|
+
out += """<style>
|
|
390
|
+
:root { --fg: #24292f; --bg: #ffffff; --muted-bg: #f6f8fa; --border: #d0d7de; --head-bg: #eaeef2; --link: #0969da; }
|
|
391
|
+
@media (prefers-color-scheme: dark) {
|
|
392
|
+
:root { --fg: #e6edf3; --bg: #0d1117; --muted-bg: #161b22; --border: #30363d; --head-bg: #21262d; --link: #4493f8; }
|
|
393
|
+
}
|
|
394
|
+
body { font-family: system-ui, sans-serif; margin: 40px auto; max-width: 900px; padding: 0 12px; line-height: 1.6; color: var(--fg); background: var(--bg); }
|
|
395
|
+
a { color: var(--link); }
|
|
396
|
+
table { border-collapse: collapse; width: 100%; margin: 20px 0; }
|
|
397
|
+
th, td { border: 1px solid var(--border); padding: 8px; text-align: left; }
|
|
398
|
+
th { background-color: var(--head-bg); }
|
|
399
|
+
pre { background: var(--muted-bg); padding: 15px; overflow-x: auto; border: 1px solid var(--border); }
|
|
400
|
+
code { background: var(--muted-bg); }
|
|
401
|
+
hr { margin: 40px 0; border: 0; border-top: 2px solid var(--border); }
|
|
402
|
+
.filter-bar { padding: 15px; background: var(--muted-bg); border: 1px solid var(--border); margin-bottom: 20px; border-radius: 4px; display: flex; flex-wrap: wrap; gap: 10px; }
|
|
403
|
+
.filter-bar input, .filter-bar select { padding: 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--fg); }
|
|
404
|
+
"""
|
|
405
|
+
out += "</style>\n</head>\n<body>\n"
|
|
406
|
+
|
|
407
|
+
# Inject Filter Bar
|
|
408
|
+
out += """
|
|
409
|
+
<div class="filter-bar">
|
|
410
|
+
<input type="text" id="searchTitle" placeholder="Search by title..." aria-label="Search by title">
|
|
411
|
+
<select id="filterSeverity" aria-label="Filter by severity">
|
|
412
|
+
<option value="All">All Severities</option>
|
|
413
|
+
<option value="Critical">Critical</option>
|
|
414
|
+
<option value="High">High</option>
|
|
415
|
+
<option value="Medium">Medium</option>
|
|
416
|
+
<option value="Low">Low</option>
|
|
417
|
+
<option value="Info">Info</option>
|
|
418
|
+
</select>
|
|
419
|
+
</div>
|
|
420
|
+
<script>
|
|
421
|
+
document.getElementById('searchTitle').addEventListener('input', filterFindings);
|
|
422
|
+
document.getElementById('filterSeverity').addEventListener('change', filterFindings);
|
|
423
|
+
function filterFindings() {
|
|
424
|
+
const search = document.getElementById('searchTitle').value.toLowerCase();
|
|
425
|
+
const sev = document.getElementById('filterSeverity').value;
|
|
426
|
+
document.querySelectorAll('.finding-block').forEach(el => {
|
|
427
|
+
const titleMatch = el.getAttribute('data-title').includes(search);
|
|
428
|
+
const sevMatch = sev === 'All' || el.getAttribute('data-severity') === sev;
|
|
429
|
+
el.style.display = (titleMatch && sevMatch) ? 'block' : 'none';
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
</script>
|
|
433
|
+
"""
|
|
434
|
+
out += html_body
|
|
435
|
+
out += "\n</body>\n</html>"
|
|
436
|
+
return out
|
|
437
|
+
|
|
438
|
+
def export_json(self, findings: list[Finding], errors: list[dict[str, str]] | None = None,
|
|
439
|
+
replace: bool = False) -> list[str]:
|
|
440
|
+
"""Export findings to report.json.
|
|
441
|
+
|
|
442
|
+
Format (schema_version 2): an object with `schema_version`, `complete` (false if an input
|
|
443
|
+
could not be read), `errors` and `findings`. Version 1 was a bare list of findings.
|
|
444
|
+
"""
|
|
445
|
+
errors = errors or []
|
|
446
|
+
|
|
447
|
+
def produce(staging: Path) -> list[str]:
|
|
448
|
+
data: dict[str, Any] = {
|
|
449
|
+
"schema_version": JSON_SCHEMA_VERSION,
|
|
450
|
+
"complete": not errors,
|
|
451
|
+
"errors": errors,
|
|
452
|
+
"findings": [f.model_dump() for f in findings],
|
|
453
|
+
}
|
|
454
|
+
(staging / "report.json").write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
455
|
+
return ["report.json"]
|
|
456
|
+
|
|
457
|
+
return self._publish("json", produce, replace)
|
|
458
|
+
|
|
459
|
+
def export_csv(self, findings: list[Finding], errors: list[dict[str, str]] | None = None,
|
|
460
|
+
replace: bool = False, excel: bool = False) -> list[str]:
|
|
461
|
+
"""Export findings to a flat CSV file.
|
|
462
|
+
|
|
463
|
+
Every row carries an 'Import Status' ('complete' or 'incomplete') so that a partial run
|
|
464
|
+
is visible in the file itself. With `excel`, the file starts with a UTF-8 byte order
|
|
465
|
+
mark and uses ';' as separator, which German-language Excel expects.
|
|
466
|
+
"""
|
|
467
|
+
errors = errors or []
|
|
468
|
+
status = "complete" if not errors else f"incomplete ({len(errors)} input error(s))"
|
|
469
|
+
|
|
470
|
+
def produce(staging: Path) -> list[str]:
|
|
471
|
+
encoding = "utf-8-sig" if excel else "utf-8"
|
|
472
|
+
with open(staging / "report.csv", "w", newline="", encoding=encoding) as f:
|
|
473
|
+
writer = csv.writer(f, delimiter=";" if excel else ",")
|
|
474
|
+
# The first eight columns keep the order of scanfuse 0.1. New columns come last.
|
|
475
|
+
writer.writerow(["Title", "Severity", "CVSS", "Hosts", "Source Tool", "Description",
|
|
476
|
+
"Remediation", "Evidence", "CVSS Vector", "Port", "Identifiers",
|
|
477
|
+
"References", "Other Reports", "Import Status"])
|
|
478
|
+
for finding in findings:
|
|
479
|
+
hosts_str = ", ".join(
|
|
480
|
+
f"{h.ip} ({h.hostname})" if h.hostname else h.ip for h in finding.hosts
|
|
481
|
+
)
|
|
482
|
+
cvss_str = str(finding.cvss_score) if finding.cvss_score is not None else ""
|
|
483
|
+
evidence_str = "\n---\n".join(
|
|
484
|
+
f"{e.type}{f' [{e.source}]' if e.source else ''}:\n{e.data}"
|
|
485
|
+
for e in finding.evidence
|
|
486
|
+
)
|
|
487
|
+
others = "\n---\n".join(
|
|
488
|
+
f"{s.source_tool} | {s.severity}"
|
|
489
|
+
f"{f' | CVSS {s.cvss_score}' if s.cvss_score is not None else ''}"
|
|
490
|
+
f"{f' {s.cvss_vector}' if s.cvss_vector else ''}\n"
|
|
491
|
+
f"{s.description}"
|
|
492
|
+
f"{f' Remediation: {s.remediation}' if s.remediation else ''}"
|
|
493
|
+
for s in finding.sources
|
|
494
|
+
)
|
|
495
|
+
writer.writerow([csv_safe(v) for v in [
|
|
496
|
+
finding.title, finding.severity, cvss_str, hosts_str, finding.source_tool,
|
|
497
|
+
finding.description, finding.remediation, evidence_str,
|
|
498
|
+
finding.cvss_vector, finding.port, ", ".join(finding.identifiers),
|
|
499
|
+
"\n".join(finding.references), others, status,
|
|
500
|
+
]])
|
|
501
|
+
return ["report.csv"]
|
|
502
|
+
|
|
503
|
+
return self._publish("csv", produce, replace)
|
scanfuse/html_report.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Render untrusted report Markdown as restricted HTML fragments."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
import markdown
|
|
6
|
+
import nh3
|
|
7
|
+
|
|
8
|
+
REPORT_TAGS = {
|
|
9
|
+
"a", "b", "blockquote", "br", "code", "del", "div", "em",
|
|
10
|
+
"h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "li", "ol",
|
|
11
|
+
"p", "pre", "s", "strong", "table", "tbody", "td", "th", "thead", "tr", "ul",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def render_report_fragment(content: str) -> str:
|
|
16
|
+
content = re.sub(r'\]\(([^)]+)\.md\)', r'](#\1)', content)
|
|
17
|
+
rendered = markdown.markdown(content, extensions=["tables", "fenced_code"])
|
|
18
|
+
return nh3.clean(
|
|
19
|
+
rendered,
|
|
20
|
+
tags=REPORT_TAGS,
|
|
21
|
+
attributes={"a": {"href", "title"}},
|
|
22
|
+
url_schemes={"http", "https", "mailto"},
|
|
23
|
+
)
|
scanfuse/models.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
|
|
2
|
+
from pydantic import BaseModel, Field
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Host(BaseModel):
|
|
6
|
+
ip: str
|
|
7
|
+
hostname: str | None = None
|
|
8
|
+
os: str | None = None
|
|
9
|
+
|
|
10
|
+
class Evidence(BaseModel):
|
|
11
|
+
type: str = Field(..., description="e.g., 'http-request', 'port-scan'")
|
|
12
|
+
data: str = Field(..., description="The raw evidence payload or output")
|
|
13
|
+
source: str | None = Field(
|
|
14
|
+
default=None,
|
|
15
|
+
description="Tool that reported this evidence. Set when findings of several tools are merged.",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
class SourceRecord(BaseModel):
|
|
19
|
+
"""What one tool reported for a finding. Kept when findings are merged."""
|
|
20
|
+
source_tool: str
|
|
21
|
+
title: str
|
|
22
|
+
severity: str = Field(..., pattern="^(Critical|High|Medium|Low|Info)$")
|
|
23
|
+
cvss_score: float | None = None
|
|
24
|
+
cvss_vector: str | None = None
|
|
25
|
+
description: str
|
|
26
|
+
remediation: str | None = None
|
|
27
|
+
identifiers: list[str] = Field(default_factory=list)
|
|
28
|
+
port: str | None = None
|
|
29
|
+
|
|
30
|
+
class Finding(BaseModel):
|
|
31
|
+
title: str
|
|
32
|
+
severity: str = Field(..., pattern="^(Critical|High|Medium|Low|Info)$")
|
|
33
|
+
cvss_score: float | None = None
|
|
34
|
+
cvss_vector: str | None = None
|
|
35
|
+
description: str
|
|
36
|
+
remediation: str | None = None
|
|
37
|
+
hosts: list[Host] = Field(default_factory=list)
|
|
38
|
+
evidence: list[Evidence] = Field(default_factory=list)
|
|
39
|
+
source_tool: str = Field(..., description="e.g., 'nmap', 'nuclei'")
|
|
40
|
+
slug: str | None = None
|
|
41
|
+
identifiers: list[str] = Field(
|
|
42
|
+
default_factory=list,
|
|
43
|
+
description="CVE/GHSA ids and tool ids such as 'nessus-plugin:12345' or 'CWE-89'",
|
|
44
|
+
)
|
|
45
|
+
references: list[str] = Field(default_factory=list, description="http(s) links the scanner gives for this finding")
|
|
46
|
+
port: str | None = Field(default=None, description="e.g. '443/tcp'")
|
|
47
|
+
sources: list[SourceRecord] = Field(
|
|
48
|
+
default_factory=list,
|
|
49
|
+
description="One record per merged finding. Empty if the finding was not merged.",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
class Report(BaseModel):
|
|
53
|
+
title: str
|
|
54
|
+
findings: list[Finding] = Field(default_factory=list)
|