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/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)
@@ -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)
@@ -0,0 +1,3 @@
1
+ """
2
+ Parser modules for different security tools.
3
+ """