@@ -231,107 +231,149 @@ def _store_active_versions(self, active_packages: Dict[str, str]):
231231
232232 def _perform_security_scan (self , packages : Dict [str , str ]):
233233 """
234- FIXED: Runs a security check using `safety`. The log output is now
235- context-aware and gracefully handles cases where `safety` is not
236- installed in the target Python interpreter. It is resilient to non-JSON output.
234+ FIXED: Runs a security check using `safety`. Now properly handles the JSON
235+ output wrapped with deprecation warnings and correctly counts vulnerabilities.
237236 """
238237 scan_type = 'bulk' if len (packages ) > 1 else 'targeted'
239238 if len (packages ) == 0 :
240239 scan_type = 'targeted'
240+
241241 print (f'🛡️ Performing { scan_type } security scan for { len (packages )} active package(s)...' )
242+
242243 if not packages :
243244 print (_ (' - No active packages found to scan.' ))
244245 self .security_report = {}
245246 return
247+
246248 python_exe = self .config .get ('python_executable' , sys .executable )
249+
250+ # Check if safety is available
247251 try :
248252 subprocess .run ([python_exe , '-m' , 'safety' , '--version' ], check = True , capture_output = True , timeout = 10 )
249253 except (subprocess .CalledProcessError , FileNotFoundError , subprocess .TimeoutExpired ):
250254 print (f" ⚠️ Warning: The 'safety' package is not installed for the active Python interpreter ({ Path (python_exe ).name } )." )
251255 print (_ (" 💡 To enable this feature, run: '{} -m pip install safety'" ).format (python_exe ))
252256 self .security_report = {}
253257 return
258+
259+ # Create requirements file
254260 with tempfile .NamedTemporaryFile (mode = 'w' , delete = False , suffix = '.txt' , encoding = 'utf-8' ) as reqs_file :
255261 reqs_file_path = reqs_file .name
256262 for name , version in packages .items ():
257263 reqs_file .write (f'{ name } =={ version } \n ' )
264+
258265 try :
259266 cmd = [python_exe , '-m' , 'safety' , 'check' , '-r' , reqs_file_path , '--json' ]
260267 result = subprocess .run (cmd , capture_output = True , text = True , timeout = 120 , encoding = 'utf-8' )
268+
261269 if result .stdout :
262270 raw_output = result .stdout .strip ()
263- json_start_index = raw_output .find ('[' )
264- if json_start_index == - 1 :
265- json_start_index = raw_output .find ('{' )
266- if json_start_index != - 1 :
267- json_string = raw_output [json_start_index :]
268- bracket_count = 0
269- brace_count = 0
270- json_end_index = 0
271+
272+ # Find JSON boundaries - safety wraps JSON with deprecation warnings
273+ json_start = - 1
274+ json_end = - 1
275+
276+ # Look for the opening brace/bracket
277+ brace_pos = raw_output .find ('{' )
278+ bracket_pos = raw_output .find ('[' )
279+
280+ if brace_pos != - 1 and bracket_pos != - 1 :
281+ json_start = min (brace_pos , bracket_pos )
282+ elif brace_pos != - 1 :
283+ json_start = brace_pos
284+ elif bracket_pos != - 1 :
285+ json_start = bracket_pos
286+
287+ if json_start != - 1 :
288+ # Find the matching closing brace/bracket
289+ opening_char = raw_output [json_start ]
290+ closing_char = '}' if opening_char == '{' else ']'
291+
292+ # Count nesting to find the proper end
293+ count = 0
271294 in_string = False
272295 escape_next = False
273- for i , char in enumerate (json_string ):
296+
297+ for i in range (json_start , len (raw_output )):
298+ char = raw_output [i ]
299+
274300 if escape_next :
275301 escape_next = False
276302 continue
303+
277304 if char == '\\ ' :
278305 escape_next = True
279306 continue
280- if char == '"' and (not escape_next ):
307+
308+ if char == '"' and not escape_next :
281309 in_string = not in_string
282310 continue
311+
283312 if not in_string :
284- if char == '[' :
285- bracket_count += 1
286- elif char == ']' :
287- bracket_count -= 1
288- elif char == '{' :
289- brace_count += 1
290- elif char == '}' :
291- brace_count -= 1
292- if bracket_count == 0 and brace_count == 0 and (i > 0 ):
293- json_end_index = i + 1
294- break
295- if json_end_index > 0 :
296- clean_json = json_string [:json_end_index ]
297- else :
298- clean_json = json_string
299- try :
300- self .security_report = json .loads (clean_json )
301- except json .JSONDecodeError :
302- lines = raw_output .split ('\n ' )
303- json_lines = []
304- collecting = False
305- for line in lines :
306- if line .strip ().startswith ('[' ) or line .strip ().startswith ('{' ):
307- collecting = True
308- if collecting :
309- json_lines .append (line )
310- try :
311- potential_json = '\n ' .join (json_lines )
312- self .security_report = json .loads (potential_json )
313+ if char == opening_char :
314+ count += 1
315+ elif char == closing_char :
316+ count -= 1
317+ if count == 0 :
318+ json_end = i + 1
313319 break
314- except json .JSONDecodeError :
315- continue
316- if not hasattr (self , 'security_report' ) or not self .security_report :
320+
321+ if json_end > json_start :
322+ json_content = raw_output [json_start :json_end ]
323+ try :
324+ self .security_report = json .loads (json_content )
325+ except json .JSONDecodeError as e :
326+ print (f' ⚠️ Could not parse safety JSON output: { e } ' )
327+ self .security_report = {}
328+ else :
329+ # Fallback: try parsing from json_start to end and let json.loads find the boundary
330+ try :
331+ # This will likely fail due to extra content, but worth trying
332+ self .security_report = json .loads (raw_output [json_start :])
333+ except json .JSONDecodeError :
334+ print (' ⚠️ Could not determine JSON boundaries' )
317335 self .security_report = {}
318336 else :
337+ print (' ⚠️ No JSON found in safety output' )
319338 self .security_report = {}
339+
320340 if result .stderr :
321341 print (_ (' ⚠️ Safety command produced warnings. Stderr: {}' ).format (result .stderr .strip ()))
322342 else :
323343 self .security_report = {}
324344 if result .stderr :
325345 print (_ (' ⚠️ Safety command failed. Stderr: {}' ).format (result .stderr .strip ()))
326- except json .JSONDecodeError as e :
327- print (f' ⚠️ Could not parse safety JSON output. This can happen with very old versions of `safety`. Error: { e } ' )
328- self .security_report = {}
346+
329347 except Exception as e :
330348 print (_ (' ⚠️ An unexpected error occurred during the security scan: {}' ).format (e ))
331349 self .security_report = {}
332350 finally :
333- os .unlink (reqs_file_path )
334- issue_count = len (self .security_report ) if isinstance (self .security_report , (list , dict )) else 0
351+ if os .path .exists (reqs_file_path ):
352+ os .unlink (reqs_file_path )
353+
354+ # Count actual vulnerabilities from the parsed safety report
355+ issue_count = 0
356+
357+ if isinstance (self .security_report , dict ):
358+ # Modern safety format has a 'vulnerabilities' key with the actual vulnerabilities
359+ if 'vulnerabilities' in self .security_report :
360+ vulnerabilities = self .security_report ['vulnerabilities' ]
361+ if isinstance (vulnerabilities , list ):
362+ issue_count = len (vulnerabilities )
363+ # Also check report_meta for vulnerabilities_found (more reliable)
364+ elif 'report_meta' in self .security_report :
365+ meta = self .security_report ['report_meta' ]
366+ if isinstance (meta , dict ) and 'vulnerabilities_found' in meta :
367+ issue_count = int (meta ['vulnerabilities_found' ])
368+ # Legacy format fallback
369+ else :
370+ for key , value in self .security_report .items ():
371+ if isinstance (value , list ) and key not in [
372+ 'scanned_packages' , 'scanned' , 'scanned_full_path' ,
373+ 'target_languages' , 'announcements' , 'ignored_vulnerabilities'
374+ ]:
375+ issue_count += len (value )
376+
335377 print (_ ('✅ Security scan complete. Found {} potential issues.' ).format (issue_count ))
336378
337379 def run (self , targeted_packages : Optional [List [str ]]= None , newly_active_packages : Optional [Dict [str , str ]]= None ):
0 commit comments