Skip to content

Commit fdfde91

Browse files
authored
Merge pull request #21 from popey/inform-user-via-dialogs
Add user notifications for long-running operations
2 parents 0a5a288 + bab7e9e commit fdfde91

2 files changed

Lines changed: 212 additions & 44 deletions

File tree

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,9 @@ Point grummage at an SBOM (Software Bill of Materials):
102102
grummage ./example_sboms/nextcloud-latest-syft-sbom.json
103103
```
104104

105-
Grummage will load the SBOM and pass it through Grype to build the vulnerability list.
106-
Use the cursor keys or mouse to navigate the tree on the left pane.
105+
Grummage will check the grype vulnerability database, update it if needed, then load the SBOM and analyze it with Grype. A loading screen shows progress during these operations.
106+
107+
Once loaded, use the cursor keys or mouse to navigate the tree on the left pane.
107108
Press Enter or mouse click on a vulnerability to obtain limited details.
108109

109110
### Keys:

grummage.py

Lines changed: 209 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@
66
import tempfile
77
import re
88

9+
from textual import work
910
from textual.app import App
10-
from textual.containers import Container, Horizontal, VerticalScroll
11-
from textual.widgets import Tree, Footer, Static
11+
from textual.containers import Container, Horizontal, VerticalScroll, Vertical
12+
from textual.screen import ModalScreen
13+
from textual.widgets import Tree, Footer, Static, Label, LoadingIndicator
1214
from textual.widgets import Markdown
15+
from textual.worker import get_current_worker
1316

1417
def format_urls_as_markdown(text):
1518
"""Convert plain URLs in text to markdown links, skipping already formatted markdown links."""
@@ -50,6 +53,56 @@ def install_grype():
5053
print(f"Failed to install grype: {e}")
5154
sys.exit(1)
5255

56+
class LoadingScreen(ModalScreen):
57+
"""Modal screen showing loading progress."""
58+
59+
DEFAULT_CSS = """
60+
LoadingScreen {
61+
align: center middle;
62+
}
63+
64+
LoadingScreen > Vertical {
65+
width: 60;
66+
height: auto;
67+
background: $surface;
68+
border: thick $primary;
69+
padding: 2;
70+
}
71+
72+
LoadingScreen > Vertical > Label {
73+
width: 100%;
74+
content-align: center middle;
75+
text-style: bold;
76+
}
77+
78+
LoadingScreen > Vertical > #status {
79+
width: 100%;
80+
content-align: center middle;
81+
margin-top: 1;
82+
}
83+
84+
LoadingScreen > Vertical > LoadingIndicator {
85+
width: 100%;
86+
height: 3;
87+
margin-top: 1;
88+
}
89+
"""
90+
91+
def __init__(self):
92+
super().__init__()
93+
self.status_label = Label("Initializing...", id="status")
94+
95+
def compose(self):
96+
"""Create child widgets for the loading screen."""
97+
with Vertical():
98+
yield Label("Grummage")
99+
yield self.status_label
100+
yield LoadingIndicator()
101+
102+
def update_status(self, message):
103+
"""Update the status message."""
104+
self.status_label.update(message)
105+
53106
class Grummage(App):
54107
BINDINGS = [
55108
("v", "load_tree_by_vulnerability", "by Vuln"),
@@ -106,11 +159,76 @@ async def on_mount(self):
106159
await self.mount(Footer())
107160
self.debug_log("on_mount: Layout mounted")
108161

109-
# Load the SBOM from file or stdin
162+
# Show loading screen and load the SBOM from file or stdin
163+
self.loading_screen = LoadingScreen()
164+
await self.push_screen(self.loading_screen)
110165
await self.load_sbom()
111166

112-
async def load_sbom(self):
113-
"""Load the SBOM from a file or stdin."""
167+
def check_grype_db(self):
168+
"""Check if grype vulnerability database needs updating."""
169+
try:
170+
result = subprocess.run(
171+
["grype", "db", "check", "-o", "json"],
172+
capture_output=True,
173+
text=True
174+
)
175+
176+
db_status = json.loads(result.stdout)
177+
return db_status.get("updateAvailable", False)
178+
except Exception as e:
179+
self.debug_log(f"Error checking grype database: {e}")
180+
return False
181+
182+
def update_grype_db(self):
183+
"""Update the grype vulnerability database (blocking)."""
184+
try:
185+
self.debug_log("Starting grype database update")
186+
result = subprocess.run(
187+
["grype", "db", "update"],
188+
capture_output=True,
189+
text=True
190+
)
191+
192+
if result.returncode == 0:
193+
self.notify("Vulnerability database updated successfully", severity="information")
194+
self.debug_log("Grype database updated successfully")
195+
return True
196+
else:
197+
self.notify(f"Database update failed: {result.stderr}", severity="error")
198+
self.debug_log(f"Grype database update failed: {result.stderr}")
199+
return False
200+
except Exception as e:
201+
self.notify(f"Database update error: {e}", severity="error")
202+
self.debug_log(f"Exception during database update: {e}")
203+
return False
204+
205+
def update_loading_status(self, message):
206+
"""Update both loading screen and status bar."""
207+
if hasattr(self, 'loading_screen') and self.loading_screen:
208+
self.loading_screen.update_status(message)
209+
self.status_bar.update(f"Status: {message}")
210+
211+
@work(thread=True, exclusive=True)
212+
def load_sbom_worker(self):
213+
"""Load SBOM and run grype analysis in worker thread."""
214+
# Check and update grype database if needed
215+
self.app.call_from_thread(self.update_loading_status, "Checking vulnerability database...")
216+
self.debug_log("Checking grype database status")
217+
218+
if self.check_grype_db():
219+
self.app.call_from_thread(self.update_loading_status, "Updating vulnerability database - this may take a minute...")
220+
self.debug_log("Database update available, starting update")
221+
if not self.update_grype_db():
222+
# Update failed, abort
223+
self.app.call_from_thread(self.update_loading_status, "Database update failed")
224+
self.app.call_from_thread(self.notify, "Database update failed", severity="error")
225+
self.app.call_from_thread(self.pop_screen)
226+
return
227+
else:
228+
self.debug_log("Database is up to date")
229+
230+
# Load SBOM
231+
self.app.call_from_thread(self.update_loading_status, "Loading SBOM file...")
114232
if self.sbom_file:
115233
# Load SBOM from the provided file path
116234
self.debug_log(f"Loading SBOM from file: {self.sbom_file}")
@@ -122,31 +240,26 @@ async def load_sbom(self):
122240
sbom_json = json.load(sys.stdin)
123241
except json.JSONDecodeError as e:
124242
self.debug_log(f"Error reading SBOM from stdin: {e}")
125-
self.status_bar.update("Status: Failed to read SBOM from stdin.")
243+
self.app.call_from_thread(self.update_loading_status, "Failed to read SBOM from stdin")
244+
self.app.call_from_thread(self.notify, "Failed to read SBOM from stdin", severity="error")
245+
self.app.call_from_thread(self.pop_screen)
126246
return
127247

128-
# Run Grype analysis on the loaded SBOM JSON
129-
self.vulnerability_report = self.call_grype(sbom_json)
130-
if self.vulnerability_report and "matches" in self.vulnerability_report:
131-
self.load_tree_by_package_name()
132-
self.status_bar.update("Status: Vulnerability data loaded. Press N, T, V, S to change views, or E to explain.")
133-
self.debug_log("Vulnerability data loaded into tree")
134-
else:
135-
self.status_bar.update("Status: No vulnerabilities found or unable to load data.")
136-
self.debug_log("No vulnerability data found")
248+
if not sbom_json:
249+
self.app.call_from_thread(self.update_loading_status, "Failed to load SBOM")
250+
self.app.call_from_thread(self.notify, "Failed to load SBOM", severity="error")
251+
self.app.call_from_thread(self.pop_screen)
252+
return
137253

138-
def load_json(self, file_path):
139-
"""Load SBOM JSON from a file."""
140-
try:
141-
with open(file_path, "r") as file:
142-
return json.load(file)
143-
except Exception as e:
144-
self.debug_log(f"Error loading SBOM JSON: {e}")
145-
return None
254+
# Now run grype analysis (still in same worker thread)
255+
self.run_grype_analysis(sbom_json)
146256

147-
def call_grype(self, sbom_json):
148-
"""Call Grype with the SBOM JSON to generate a vulnerability report."""
257+
def run_grype_analysis(self, sbom_json):
258+
"""Run grype analysis on SBOM (called from worker thread)."""
149259
try:
260+
self.app.call_from_thread(self.update_loading_status, "Analyzing SBOM with grype...")
261+
self.debug_log("Starting grype analysis")
262+
150263
# Create a temporary file to store the SBOM JSON
151264
with tempfile.NamedTemporaryFile(delete=False, mode='w', suffix='.json') as temp_file:
152265
json.dump(sbom_json, temp_file)
@@ -159,20 +272,61 @@ def call_grype(self, sbom_json):
159272
text=True
160273
)
161274

162-
# Print stdout and stderr for debugging
163-
#print("Grype STDOUT:", result.stdout)
164-
#print("Grype STDERR:", result.stderr)
275+
# Clean up temp file
276+
try:
277+
os.unlink(temp_file_path)
278+
except:
279+
pass
165280

166281
if result.returncode != 0:
167-
print("Grype encountered an error:", result.stderr)
168-
return None
282+
self.debug_log(f"Grype encountered an error: {result.stderr}")
283+
self.app.call_from_thread(self.update_loading_status, "Grype analysis failed")
284+
self.app.call_from_thread(self.notify, f"Grype analysis failed: {result.stderr}", severity="error")
285+
self.app.call_from_thread(self.pop_screen)
286+
return
169287

170-
# Return the parsed JSON if no errors occurred
171-
return json.loads(result.stdout)
288+
# Parse the JSON result
289+
vulnerability_report = json.loads(result.stdout)
290+
291+
# Update UI from thread
292+
self.app.call_from_thread(self.on_grype_complete, vulnerability_report)
172293

173294
except Exception as e:
174-
print("Error running Grype:", e)
295+
self.debug_log(f"Error running Grype: {e}")
296+
self.app.call_from_thread(self.update_loading_status, "Error running grype")
297+
self.app.call_from_thread(self.notify, f"Error running grype: {e}", severity="error")
298+
self.app.call_from_thread(self.pop_screen)
299+
300+
async def load_sbom(self):
301+
"""Initiate SBOM loading and analysis."""
302+
self.load_sbom_worker()
303+
304+
def load_json(self, file_path):
305+
"""Load SBOM JSON from a file."""
306+
try:
307+
with open(file_path, "r") as file:
308+
return json.load(file)
309+
except Exception as e:
310+
self.debug_log(f"Error loading SBOM JSON: {e}")
175311
return None
312+
313+
def on_grype_complete(self, vulnerability_report):
314+
"""Handle completion of grype analysis (called from worker thread)."""
315+
self.vulnerability_report = vulnerability_report
316+
317+
if self.vulnerability_report and "matches" in self.vulnerability_report:
318+
num_vulns = len(self.vulnerability_report["matches"])
319+
self.load_tree_by_package_name()
320+
self.status_bar.update("Status: Vulnerability data loaded. Press N, T, V, S to change views, or E to explain.")
321+
self.notify(f"Analysis complete - found {num_vulns} vulnerabilities", severity="information")
322+
self.debug_log(f"Vulnerability data loaded into tree: {num_vulns} matches")
323+
else:
324+
self.status_bar.update("Status: No vulnerabilities found.")
325+
self.notify("No vulnerabilities found", severity="information")
326+
self.debug_log("No vulnerability data found")
327+
328+
# Dismiss the loading screen now that we're done
329+
self.pop_screen()
176330

177331
def load_tree_by_package_name(self):
178332
"""Display vulnerabilities organized by package name."""
@@ -305,7 +459,7 @@ async def on_key(self, event):
305459
self.status_bar.update("Status: Viewing by severity.")
306460
elif key == "e" and self.selected_vuln_id and self.detailed_text:
307461
self.status_bar.update(f"Status: Explaining {self.selected_vuln_id} in {self.selected_package_name} ({self.selected_package_version})")
308-
await self.explain_vulnerability(self.selected_vuln_id)
462+
self.explain_vulnerability_worker(self.selected_vuln_id, self.detailed_text)
309463

310464

311465
async def on_tree_node_selected(self, event):
@@ -344,9 +498,13 @@ def on_unmount(self):
344498
"""Close the log file when the application exits."""
345499
self.debug_log_file.close()
346500

347-
async def explain_vulnerability(self, vuln_id):
348-
"""Call Grype to explain a vulnerability by its ID and display the output."""
501+
@work(thread=True, exclusive=True)
502+
def explain_vulnerability_worker(self, vuln_id, detailed_text):
503+
"""Call Grype to explain a vulnerability by its ID (worker thread)."""
349504
try:
505+
self.app.call_from_thread(self.notify, f"Requesting explanation for {vuln_id}...", severity="information")
506+
self.debug_log(f"Starting grype explain for {vuln_id}")
507+
350508
# First, run Grype on the user-provided SBOM file to get the JSON report
351509
analyze_result = subprocess.run(
352510
["grype", self.sbom_file, "-o", "json"],
@@ -356,8 +514,12 @@ async def explain_vulnerability(self, vuln_id):
356514

357515
# Check if the SBOM analysis was successful
358516
if analyze_result.returncode != 0:
359-
self.details_display.update(f"Error analyzing SBOM: {analyze_result.stderr}")
360517
self.debug_log(f"Error analyzing SBOM for explanation: {analyze_result.stderr}")
518+
self.app.call_from_thread(
519+
self.details_display.update,
520+
f"# Error\n\nError analyzing SBOM: {analyze_result.stderr}"
521+
)
522+
self.app.call_from_thread(self.notify, "Failed to analyze SBOM for explanation", severity="error")
361523
return
362524

363525
# Run Grype's explain command with the specific vulnerability ID
@@ -372,19 +534,24 @@ async def explain_vulnerability(self, vuln_id):
372534
if explain_result.returncode == 0:
373535
explanation = explain_result.stdout
374536
combined_text = (
375-
f"{self.detailed_text}\n\n\n" # Add two blank lines for spacing
537+
f"{detailed_text}\n\n\n" # Add two blank lines for spacing
376538
f"## Explanation for {vuln_id}:\n\n"
377539
f"{explanation}"
378540
)
379541
combined_text = format_urls_as_markdown(combined_text)
380-
self.details_display.update(combined_text)
542+
self.app.call_from_thread(self.details_display.update, combined_text)
543+
self.app.call_from_thread(self.notify, f"Explanation for {vuln_id} loaded", severity="information")
381544
self.debug_log(f"Displaying explanation for {vuln_id}")
382545
else:
383-
self.details_display.update(f"# Error\n\nFailed to explain {vuln_id}.\n\nError: {explain_result.stderr}")
546+
error_text = f"# Error\n\nFailed to explain {vuln_id}.\n\nError: {explain_result.stderr}"
547+
self.app.call_from_thread(self.details_display.update, error_text)
548+
self.app.call_from_thread(self.notify, f"Failed to explain {vuln_id}", severity="error")
384549
self.debug_log(f"Error explaining {vuln_id}: {explain_result.stderr}")
385-
550+
386551
except Exception as e:
387-
self.details_display.update(f"# Error\n\nError explaining {vuln_id}: {e}")
552+
error_text = f"# Error\n\nError explaining {vuln_id}: {e}"
553+
self.app.call_from_thread(self.details_display.update, error_text)
554+
self.app.call_from_thread(self.notify, f"Error explaining {vuln_id}", severity="error")
388555
self.debug_log(f"Exception in explain_vulnerability: {e}")
389556

390557

0 commit comments

Comments
 (0)