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/__init__.py
ADDED
scanfuse/cli.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import tempfile
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.markup import escape
|
|
10
|
+
|
|
11
|
+
from scanfuse import __version__
|
|
12
|
+
from scanfuse.engine import OutputExistsError, ScanfuseEngine
|
|
13
|
+
from scanfuse.parsers.base import PartialImportError
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
name="scanfuse",
|
|
17
|
+
help="Fast, local-first pentest finding extractor & markdown report engine.",
|
|
18
|
+
add_completion=False,
|
|
19
|
+
)
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
class InputFormat(StrEnum):
|
|
23
|
+
auto = "auto"
|
|
24
|
+
nmap = "nmap"
|
|
25
|
+
nuclei = "nuclei"
|
|
26
|
+
zap = "zap"
|
|
27
|
+
burp = "burp"
|
|
28
|
+
nessus = "nessus"
|
|
29
|
+
trivy = "trivy"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class OutputFormat(StrEnum):
|
|
33
|
+
markdown = "markdown"
|
|
34
|
+
json = "json"
|
|
35
|
+
csv = "csv"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Severity(StrEnum):
|
|
39
|
+
critical = "Critical"
|
|
40
|
+
high = "High"
|
|
41
|
+
medium = "Medium"
|
|
42
|
+
low = "Low"
|
|
43
|
+
info = "Info"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Exit codes: 0 = ok, 1 = the run was incomplete or failed, 2 = invalid usage or the output
|
|
47
|
+
# directory already contains files (use --replace).
|
|
48
|
+
EXIT_INCOMPLETE = 1
|
|
49
|
+
EXIT_USAGE = 2
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _version_callback(value: bool) -> None:
|
|
53
|
+
if value:
|
|
54
|
+
console.print(f"scanfuse {__version__}")
|
|
55
|
+
raise typer.Exit()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_config(config_data: object) -> tuple[list[str], str | None]:
|
|
59
|
+
"""Validate the content of .scanfuse.yaml.
|
|
60
|
+
|
|
61
|
+
Returns the ignore rules and an error message. If the message is set, the
|
|
62
|
+
rules are empty. A single string for ignore_titles is accepted as a list
|
|
63
|
+
with one entry.
|
|
64
|
+
"""
|
|
65
|
+
if config_data is None:
|
|
66
|
+
return [], None
|
|
67
|
+
if not isinstance(config_data, dict):
|
|
68
|
+
return [], "the file must contain a mapping (key: value)."
|
|
69
|
+
value = config_data.get("ignore_titles")
|
|
70
|
+
if value is None:
|
|
71
|
+
return [], None
|
|
72
|
+
if isinstance(value, str):
|
|
73
|
+
return [value], None
|
|
74
|
+
if isinstance(value, list) and all(isinstance(item, str) for item in value):
|
|
75
|
+
return list(value), None
|
|
76
|
+
return [], "ignore_titles must be a list of strings."
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _load_ignore_rules(config_path: Path) -> tuple[list[str], str | None]:
|
|
80
|
+
"""Read .scanfuse.yaml. Returns (rules, error). On any error the rules are empty."""
|
|
81
|
+
import yaml
|
|
82
|
+
try:
|
|
83
|
+
with open(config_path, encoding="utf-8") as cf:
|
|
84
|
+
config_data = yaml.safe_load(cf)
|
|
85
|
+
except yaml.YAMLError as e:
|
|
86
|
+
problem = getattr(e, "problem", None) or str(e).splitlines()[0]
|
|
87
|
+
mark = getattr(e, "problem_mark", None)
|
|
88
|
+
where = f" (line {mark.line + 1})" if mark is not None else ""
|
|
89
|
+
return [], f"invalid YAML{where}: {problem}"
|
|
90
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
91
|
+
return [], f"cannot read the file: {e}"
|
|
92
|
+
return parse_config(config_data)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command()
|
|
96
|
+
def parse(
|
|
97
|
+
file_paths: list[str] = typer.Argument(None, help="Paths to the scan files (or '-' for stdin)"),
|
|
98
|
+
format: InputFormat = typer.Option(
|
|
99
|
+
InputFormat.auto, "--format", "-f", case_sensitive=False, help="Input format",
|
|
100
|
+
),
|
|
101
|
+
output: str = typer.Option("report", "--output", "-o", help="Output directory"),
|
|
102
|
+
format_out: OutputFormat = typer.Option(
|
|
103
|
+
OutputFormat.markdown, "--format-out", case_sensitive=False,
|
|
104
|
+
help="Output format. markdown also writes index.md and report.html.",
|
|
105
|
+
),
|
|
106
|
+
min_severity: Severity | None = typer.Option(
|
|
107
|
+
None, "--min-severity", "-s", case_sensitive=False, help="Minimum severity to include",
|
|
108
|
+
),
|
|
109
|
+
host_filter: str = typer.Option(
|
|
110
|
+
None, "--host",
|
|
111
|
+
help="Only include findings affecting this host/IP (exact match, ignoring case; * and ? are wildcards)",
|
|
112
|
+
),
|
|
113
|
+
template_dir: Path = typer.Option(
|
|
114
|
+
None, "--template-dir", "-t", exists=True, file_okay=False, dir_okay=True,
|
|
115
|
+
help="Custom directory containing Jinja2 templates (finding.md.j2, index.md.j2)",
|
|
116
|
+
),
|
|
117
|
+
replace: bool = typer.Option(
|
|
118
|
+
False, "--replace",
|
|
119
|
+
help="Overwrite an existing report in the output directory. Without it, scanfuse stops if files would be overwritten.",
|
|
120
|
+
),
|
|
121
|
+
csv_excel: bool = typer.Option(
|
|
122
|
+
False, "--csv-excel", help="CSV with UTF-8 byte order mark and ';' separator, for Excel",
|
|
123
|
+
),
|
|
124
|
+
version: bool = typer.Option(
|
|
125
|
+
False, "--version", callback=_version_callback, is_eager=True, help="Show the version and exit",
|
|
126
|
+
),
|
|
127
|
+
):
|
|
128
|
+
"""
|
|
129
|
+
Parse scan files and extract findings.
|
|
130
|
+
|
|
131
|
+
Exit codes: 0 = complete, 1 = incomplete (an input could not be read, or .scanfuse.yaml is
|
|
132
|
+
invalid) or failed, 2 = invalid options or the output files already exist.
|
|
133
|
+
"""
|
|
134
|
+
input_format = format.value
|
|
135
|
+
output_format = format_out.value
|
|
136
|
+
min_severity_name = min_severity.value if min_severity else None
|
|
137
|
+
|
|
138
|
+
if not file_paths and not sys.stdin.isatty():
|
|
139
|
+
file_paths = ["-"]
|
|
140
|
+
|
|
141
|
+
if not file_paths:
|
|
142
|
+
console.print("[red]Error: No input files provided.[/red]")
|
|
143
|
+
raise typer.Exit(code=EXIT_INCOMPLETE)
|
|
144
|
+
|
|
145
|
+
out_path = Path(output)
|
|
146
|
+
if out_path.exists() and not out_path.is_dir():
|
|
147
|
+
console.print(f"[red]Error: output path is a file, not a directory: {escape(output)}[/red]")
|
|
148
|
+
raise typer.Exit(code=EXIT_USAGE)
|
|
149
|
+
|
|
150
|
+
engine = ScanfuseEngine(output_dir=output, template_dir=str(template_dir) if template_dir else None)
|
|
151
|
+
|
|
152
|
+
all_findings = []
|
|
153
|
+
errors: list[dict[str, str]] = []
|
|
154
|
+
|
|
155
|
+
for file_path in file_paths:
|
|
156
|
+
is_temp = False
|
|
157
|
+
target_path = file_path
|
|
158
|
+
|
|
159
|
+
if file_path == "-":
|
|
160
|
+
fd, target_path = tempfile.mkstemp(suffix=".tmp")
|
|
161
|
+
with open(fd, "wb") as f:
|
|
162
|
+
f.write(sys.stdin.buffer.read())
|
|
163
|
+
is_temp = True
|
|
164
|
+
|
|
165
|
+
display_path = "stdin" if is_temp else target_path
|
|
166
|
+
console.print(f"[bold blue]scanfuse[/bold blue] > Parsing {escape(display_path)} (Format: {input_format})")
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
findings = engine.process_file(target_path, input_format)
|
|
170
|
+
all_findings.extend(findings)
|
|
171
|
+
console.print(f"[green]OK[/green] Found {len(findings)} findings in {escape(display_path)}.")
|
|
172
|
+
except PartialImportError as e:
|
|
173
|
+
all_findings.extend(e.findings)
|
|
174
|
+
message = str(e).replace(target_path, display_path)
|
|
175
|
+
console.print(f"[red]Error parsing {escape(display_path)}:[/red] {escape(message)}")
|
|
176
|
+
errors.append({"file": display_path, "message": message})
|
|
177
|
+
except Exception as e:
|
|
178
|
+
message = str(e).replace(target_path, display_path)
|
|
179
|
+
console.print(f"[red]Error parsing {escape(display_path)}:[/red] {escape(message)}")
|
|
180
|
+
errors.append({"file": display_path, "message": message})
|
|
181
|
+
finally:
|
|
182
|
+
if is_temp:
|
|
183
|
+
os.remove(target_path)
|
|
184
|
+
|
|
185
|
+
if all_findings:
|
|
186
|
+
ignore_rules: list[str] = []
|
|
187
|
+
config_path = Path(".scanfuse.yaml")
|
|
188
|
+
if config_path.exists():
|
|
189
|
+
ignore_rules, config_error = _load_ignore_rules(config_path)
|
|
190
|
+
if config_error:
|
|
191
|
+
console.print(f"[red]Error: .scanfuse.yaml: {escape(config_error)} Ignore rules were not applied.[/red]")
|
|
192
|
+
errors.append({"file": ".scanfuse.yaml",
|
|
193
|
+
"message": f"{config_error} Ignore rules were not applied."})
|
|
194
|
+
else:
|
|
195
|
+
console.print(f"[bold blue]scanfuse[/bold blue] > Loaded {len(ignore_rules)} ignore rules from .scanfuse.yaml")
|
|
196
|
+
|
|
197
|
+
all_findings = engine.filter_findings(all_findings, min_severity=min_severity_name, host_filter=host_filter, ignore_rules=ignore_rules)
|
|
198
|
+
|
|
199
|
+
if not all_findings:
|
|
200
|
+
console.print("[yellow]No findings left after applying filters.[/yellow]")
|
|
201
|
+
raise typer.Exit(code=EXIT_INCOMPLETE if errors else 0)
|
|
202
|
+
|
|
203
|
+
# Deduplicate
|
|
204
|
+
from scanfuse.utils import deduplicate_findings
|
|
205
|
+
original_count = len(all_findings)
|
|
206
|
+
all_findings = deduplicate_findings(all_findings)
|
|
207
|
+
if len(all_findings) < original_count:
|
|
208
|
+
console.print(f"[bold yellow]scanfuse[/bold yellow] > Merged {original_count} findings into {len(all_findings)}.")
|
|
209
|
+
|
|
210
|
+
console.print(f"[bold blue]scanfuse[/bold blue] > Exporting {len(all_findings)} findings to {escape(output)}/")
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
if output_format == "json":
|
|
214
|
+
engine.export_json(all_findings, errors=errors, replace=replace)
|
|
215
|
+
elif output_format == "csv":
|
|
216
|
+
engine.export_csv(all_findings, errors=errors, replace=replace, excel=csv_excel)
|
|
217
|
+
else:
|
|
218
|
+
engine.export_findings(all_findings, errors=errors, replace=replace)
|
|
219
|
+
except OutputExistsError as e:
|
|
220
|
+
console.print(f"[red]Error: {escape(str(e))}[/red]")
|
|
221
|
+
raise typer.Exit(code=EXIT_USAGE)
|
|
222
|
+
except OSError as e:
|
|
223
|
+
console.print(f"[red]Error: could not write the report: {escape(str(e))}[/red]")
|
|
224
|
+
raise typer.Exit(code=EXIT_INCOMPLETE)
|
|
225
|
+
console.print("[green]OK[/green] Done.")
|
|
226
|
+
|
|
227
|
+
if not all_findings and not errors:
|
|
228
|
+
console.print("[yellow]The input contains no findings. Nothing was exported.[/yellow]")
|
|
229
|
+
|
|
230
|
+
if errors:
|
|
231
|
+
console.print(f"[yellow]The report is incomplete: {len(errors)} problem(s), see above.[/yellow]")
|
|
232
|
+
raise typer.Exit(code=EXIT_INCOMPLETE)
|
|
233
|
+
|
|
234
|
+
if __name__ == "__main__":
|
|
235
|
+
app()
|