66import tempfile
77import re
88
9+ from textual import work
910from 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
1214from textual .widgets import Markdown
15+ from textual .worker import get_current_worker
1316
1417def 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+
53106class 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 \n Error 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 \n Failed to explain { vuln_id } .\n \n Error: { explain_result .stderr } " )
546+ error_text = f"# Error\n \n Failed to explain { vuln_id } .\n \n Error: { 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 \n Error explaining { vuln_id } : { e } " )
552+ error_text = f"# Error\n \n Error 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