Skip to content

Commit 48d0c62

Browse files
committed
feat: Phase 3+4 - rich reporting and live TUI dashboard
Phase 3 - Rich Reporting: - HTML report generator with interactive expand/collapse findings - JSON report generator with structured output - PDF report generator via weasyprint - AI explainer module for executive summary + per-finding explanations - bf report now supports --format markdown|json|html|pdf Phase 4 - Live TUI Dashboard: - ScanProgressScreen with live progress bar, step updates, findings count, log - FindingsScreen with sortable DataTable of all findings - FindingDetailScreen showing full finding details - AssetInventoryScreen for discovered assets - ScanHistoryScreen with query from database - ConfigScreen for toggling beginner mode, AI, scope enforcement - WelcomeScreen with quick/deep/expert buttons, history/settings nav - ScanOrchestrator wired into TUI via progress_callback - action_scan stub replaced with real ScanProgressScreen push All 95 tests pass, mypy: 0 errors
1 parent bae188b commit 48d0c62

8 files changed

Lines changed: 652 additions & 91 deletions

File tree

bugfinder/cli/app.py

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,16 @@
55
from textual.app import App, ComposeResult
66
from textual.containers import Horizontal, Vertical
77
from textual.screen import Screen
8-
from textual.widgets import Button, Footer, Header, Input, ListView, Static
8+
from textual.widgets import Button, Footer, Header, Input, ListItem, ListView, Static
9+
10+
from bugfinder.cli.screens import (
11+
AssetInventoryScreen,
12+
ConfigScreen,
13+
FindingsScreen,
14+
ScanHistoryScreen,
15+
ScanProgressScreen,
16+
)
17+
from bugfinder.target.detector import detect_target_type
918

1019

1120
class BugFinderTUI(App[Any]):
@@ -51,13 +60,29 @@ class BugFinderTUI(App[Any]):
5160
height: 8;
5261
border: solid gray;
5362
}
63+
.detail-container {
64+
padding: 1;
65+
}
66+
.scan-header {
67+
width: 100%;
68+
padding: 1;
69+
}
70+
#scan-meta {
71+
width: 100%;
72+
padding: 0 1;
73+
}
74+
#scan-actions {
75+
width: 100%;
76+
padding: 1;
77+
}
5478
"""
5579

5680
def compose(self) -> ComposeResult:
5781
yield WelcomeScreen()
5882

5983
def action_scan(self, target: str, mode: str) -> None:
60-
self.notify(f"Starting {mode} scan on {target}", severity="information")
84+
target_type = detect_target_type(target)
85+
self.push_screen(ScanProgressScreen(target, target_type.value, profile=mode))
6186

6287

6388
class WelcomeScreen(Screen[Any]):
@@ -73,9 +98,20 @@ def compose(self) -> ComposeResult:
7398
Button("Deep Scan", variant="default", id="deep"),
7499
Button("Expert Mode", variant="warning", id="expert"),
75100
),
101+
Horizontal(
102+
Button("Scan History", id="history", variant="default"),
103+
Button("Settings", id="settings", variant="default"),
104+
Button("Quit", id="quit", variant="error"),
105+
),
76106
Static("", id="recent-label"),
77-
Static("[bold]Recent Projects[/bold]", id="recent-header"),
78-
ListView(id="recent-list"),
107+
Static("[bold]Common Commands[/bold]", id="recent-header"),
108+
ListView(
109+
ListItem(Static("bf scan <target> --profile auto")),
110+
ListItem(Static("bf report <scan-id> --format html")),
111+
ListItem(Static("bf list_agents")),
112+
ListItem(Static("bf config nvidia.api_key <key>")),
113+
id="recent-list",
114+
),
79115
id="main-container",
80116
)
81117
yield Footer()
@@ -85,12 +121,25 @@ def on_mount(self) -> None:
85121

86122
def on_button_pressed(self, event: Button.Pressed) -> None:
87123
target = self.query_one("#target-input", Input).value.strip()
88-
if not target:
124+
bid = event.button.id or ""
125+
126+
if bid == "quit":
127+
self.app.exit()
128+
return
129+
if bid == "settings":
130+
self.app.push_screen(ConfigScreen())
131+
return
132+
if bid == "history":
133+
self.app.push_screen(ScanHistoryScreen())
134+
return
135+
136+
if not target and bid in ("quick", "deep", "expert"):
89137
self.notify("Please enter a target", severity="warning")
90138
return
139+
91140
tui = self.app
92141
if isinstance(tui, BugFinderTUI):
93-
tui.action_scan(target, event.button.id or "quick")
142+
tui.action_scan(target, bid)
94143

95144
def on_input_submitted(self, event: Input.Submitted) -> None:
96145
target = event.value.strip()

bugfinder/cli/commands.py

Lines changed: 29 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -134,38 +134,55 @@ async def _generate_report() -> None:
134134
]
135135

136136
if format == "markdown":
137+
from bugfinder.reporting.markdown import generate_markdown_report
137138
content = generate_markdown_report(
138139
target=scan.target,
139140
target_type=scan.target_type,
140141
findings=f_list,
141142
assets=a_list,
142143
)
143144
elif format == "json":
144-
content = json.dumps(
145-
{
146-
"scan_id": scan_id,
147-
"target": scan.target,
148-
"target_type": scan.target_type,
149-
"findings": f_list,
150-
"assets": a_list,
151-
},
152-
indent=2,
145+
from bugfinder.reporting.json import generate_json_report
146+
content = generate_json_report(
147+
target=scan.target,
148+
target_type=scan.target_type,
149+
findings=f_list,
150+
assets=a_list,
153151
)
154152
elif format == "html":
155-
content = _generate_html_report(
153+
from bugfinder.reporting.html import generate_html_report
154+
content = generate_html_report(
155+
scan.target,
156+
scan.target_type,
157+
f_list,
158+
a_list,
159+
)
160+
elif format == "pdf":
161+
from bugfinder.reporting.pdf import generate_pdf_report
162+
pdf_bytes = generate_pdf_report(
156163
scan.target,
157164
scan.target_type,
158165
f_list,
159166
a_list,
160167
)
168+
if output:
169+
out_path = Path(output)
170+
out_path.parent.mkdir(parents=True, exist_ok=True)
171+
out_path.write_bytes(pdf_bytes)
172+
rprint(f"[green]PDF report saved to {out_path}[/green]")
173+
else:
174+
out_path = Path(f"report_{scan_id[:8]}.pdf")
175+
out_path.write_bytes(pdf_bytes)
176+
rprint(f"[green]PDF report saved to {out_path}[/green]")
177+
return
161178
else:
162179
rprint(f"[red]Unsupported format: {format}[/red]")
163180
raise typer.Exit(1)
164181

165182
if output:
166183
out_path = Path(output)
167184
out_path.parent.mkdir(parents=True, exist_ok=True)
168-
out_path.write_text(content, encoding="utf-8")
185+
out_path.write_text(content, encoding="utf-8") if isinstance(content, str) else out_path.write_bytes(content)
169186
rprint(f"[green]Report saved to {out_path}[/green]")
170187
else:
171188
rprint(content)
@@ -390,80 +407,7 @@ async def on_progress(p: ScanProgress) -> None:
390407
asyncio.run(_run())
391408

392409

393-
def _generate_html_report(
394-
target: str,
395-
target_type: str,
396-
findings: list[dict[str, Any]],
397-
assets: list[dict[str, Any]],
398-
) -> str:
399-
sev_order = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
400-
sorted_findings = sorted(findings, key=lambda f: sev_order.get(f.get("severity", "info"), 99))
401-
402-
f_rows = ""
403-
for f in sorted_findings:
404-
sev = f.get("severity", "info")
405-
f_rows += f"""
406-
<tr>
407-
<td><span class="severity-{sev}">{sev.upper()}</span></td>
408-
<td>{f.get('title', 'Untitled')}</td>
409-
<td>{f.get('confidence', 'needs_review')}</td>
410-
<td>{f.get('category', 'general')}</td>
411-
</tr>"""
412-
413-
a_rows = ""
414-
for a in assets[:50]:
415-
a_rows += f"""
416-
<tr>
417-
<td>{a.get('name', a.get('id', 'unknown'))}</td>
418-
<td>{a.get('asset_type', 'unknown')}</td>
419-
<td>{str(a.get('value', ''))[:60]}</td>
420-
</tr>"""
421-
422-
return f"""<!DOCTYPE html>
423-
<html lang="en">
424-
<head>
425-
<meta charset="UTF-8">
426-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
427-
<title>BugFinder Report - {target}</title>
428-
<style>
429-
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 960px; margin: 0 auto; padding: 2rem; color: #333; }}
430-
h1, h2, h3 {{ color: #1a1a1a; }}
431-
table {{ width: 100%; border-collapse: collapse; margin: 1rem 0; }}
432-
th, td {{ padding: 0.5rem; text-align: left; border-bottom: 1px solid #ddd; }}
433-
th {{ background: #f5f5f5; }}
434-
.severity-critical {{ color: #d32f2f; font-weight: bold; }}
435-
.severity-high {{ color: #f57c00; font-weight: bold; }}
436-
.severity-medium {{ color: #fbc02d; font-weight: bold; }}
437-
.severity-low {{ color: #1976d2; }}
438-
.severity-info {{ color: #757575; }}
439-
.summary {{ display: flex; gap: 2rem; margin: 1rem 0; }}
440-
.summary-card {{ flex: 1; padding: 1rem; border-radius: 8px; background: #f5f5f5; text-align: center; }}
441-
.summary-card h3 {{ margin: 0; font-size: 2rem; }}
442-
.footer {{ margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #ddd; color: #757575; font-size: 0.875rem; }}
443-
</style>
444-
</head>
445-
<body>
446-
<h1>BugFinder Security Assessment Report</h1>
447-
<p><strong>Target:</strong> {target} | <strong>Type:</strong> {target_type}</p>
448-
<div class="summary">
449-
<div class="summary-card"><h3>{len(findings)}</h3><p>Findings</p></div>
450-
<div class="summary-card"><h3>{len(assets)}</h3><p>Assets</p></div>
451-
</div>
452-
<h2>Findings</h2>
453-
<table>
454-
<thead><tr><th>Severity</th><th>Title</th><th>Confidence</th><th>Category</th></tr></thead>
455-
<tbody>{f_rows}</tbody>
456-
</table>
457-
<h2>Assets</h2>
458-
<table>
459-
<thead><tr><th>Name</th><th>Type</th><th>Value</th></tr></thead>
460-
<tbody>{a_rows}</tbody>
461-
</table>
462-
<div class="footer">
463-
<p>Generated by BugFinder v{__version__}</p>
464-
</div>
465-
</body>
466-
</html>"""
410+
467411

468412

469413
def _format_duration(seconds: float) -> str:

0 commit comments

Comments
 (0)