55import sys
66import tempfile
77import re
8+ import urllib .request
89
910from textual import work
1011from textual .app import App
@@ -24,6 +25,10 @@ def format_urls_as_markdown(text):
2425 url_pattern = r'https?://(?:[-\w.]|(?:%[\da-fA-F]{2}))+(?:/[^)\s]*)?'
2526 return re .sub (url_pattern , lambda m : f'[{ m .group ()} ]({ m .group ()} )' , text )
2627
28+ def is_running_in_snap ():
29+ """Check if grummage is running inside a snap package."""
30+ return any (key .startswith ("SNAP_" ) for key in os .environ )
31+
2732def is_grype_installed ():
2833 """Check if the grype binary is available in the system's PATH."""
2934 return any (
@@ -32,6 +37,55 @@ def is_grype_installed():
3237 for path in os .environ ["PATH" ].split (os .pathsep )
3338 )
3439
40+ def get_grype_version ():
41+ """Get the installed grype version."""
42+ try :
43+ result = subprocess .run (
44+ ["grype" , "version" ],
45+ capture_output = True ,
46+ text = True
47+ )
48+ if result .returncode == 0 :
49+ # Parse output like "Application: grype Version: 0.97.0"
50+ for line in result .stdout .splitlines ():
51+ if "Version:" in line :
52+ version = line .split ("Version:" )[1 ].strip ()
53+ # Remove 'v' prefix if present
54+ return version .lstrip ('v' )
55+ return None
56+ except Exception :
57+ return None
58+
59+ def get_latest_grype_version ():
60+ """Check the latest grype version from anchore toolbox."""
61+ try :
62+ req = urllib .request .Request (
63+ "https://toolbox-data.anchore.io/grype/releases/latest/VERSION" ,
64+ headers = {"User-Agent" : "grummage" }
65+ )
66+ with urllib .request .urlopen (req , timeout = 5 ) as response :
67+ version = response .read ().decode ('utf-8' ).strip ()
68+ # Remove 'v' prefix if present
69+ return version .lstrip ('v' )
70+ except Exception :
71+ return None
72+
73+ def compare_versions (current , latest ):
74+ """Compare two version strings. Returns True if latest is newer than current."""
75+ try :
76+ # Split versions into parts and compare
77+ current_parts = [int (x ) for x in current .split ('.' )]
78+ latest_parts = [int (x ) for x in latest .split ('.' )]
79+
80+ # Pad shorter version with zeros
81+ max_len = max (len (current_parts ), len (latest_parts ))
82+ current_parts .extend ([0 ] * (max_len - len (current_parts )))
83+ latest_parts .extend ([0 ] * (max_len - len (latest_parts )))
84+
85+ return latest_parts > current_parts
86+ except Exception :
87+ return False
88+
3589def prompt_install_grype ():
3690 """Prompt the user to install grype if it's not installed."""
3791 response = input (
@@ -215,6 +269,28 @@ def update_loading_status(self, message):
215269 @work (thread = True , exclusive = True )
216270 def load_sbom_worker (self ):
217271 """Load SBOM and run grype analysis in worker thread."""
272+ # Check grype binary version (skip if running in snap)
273+ if not is_running_in_snap ():
274+ self .app .call_from_thread (self .update_loading_status , "Checking grype version..." )
275+ self .debug_log ("Checking grype binary version" )
276+
277+ current_version = get_grype_version ()
278+ latest_version = get_latest_grype_version ()
279+
280+ if current_version and latest_version :
281+ self .debug_log (f"Grype version: current={ current_version } , latest={ latest_version } " )
282+ if compare_versions (current_version , latest_version ):
283+ self .app .call_from_thread (
284+ self .notify ,
285+ f"Grype update available: v{ latest_version } (installed: v{ current_version } )" ,
286+ severity = "warning"
287+ )
288+ self .debug_log (f"Grype update available: { current_version } -> { latest_version } " )
289+ else :
290+ self .debug_log ("Could not check grype version" )
291+ else :
292+ self .debug_log ("Running in snap, skipping grype version check" )
293+
218294 # Check and update grype database if needed
219295 self .app .call_from_thread (self .update_loading_status , "Checking vulnerability database..." )
220296 self .debug_log ("Checking grype database status" )
0 commit comments