22
33import argparse
44import json
5+ import re
56import subprocess
67import tempfile
7- from dataclasses import dataclass
8+ from contextlib import contextmanager
9+ from dataclasses import (
10+ dataclass ,
11+ field ,
12+ )
813from inspect import cleandoc
914from json import loads
1015from pathlib import Path
16+ from subprocess import CompletedProcess
1117
1218import nox
1319import tomlkit
1420from nox import Session
1521
22+ from exasol .toolbox .security import GitHubVulnerabilityIssue
23+
1624
1725@dataclass (frozen = True )
1826class Package :
@@ -256,13 +264,15 @@ def _parse_args(session) -> argparse.Namespace:
256264 )
257265 return parser .parse_args (args = session .posargs )
258266
259- def run (self , session : Session ) -> None :
260- args = self ._parse_args (session )
261-
262- command = ["poetry" , "run" , "pip-audit" , "-f" , "json" ]
267+ def audit (self ) -> tuple [dict , CompletedProcess ]:
268+ command = ("poetry" , "run" , "pip-audit" , "-f" , "json" )
263269 output = subprocess .run (command , capture_output = True )
264-
265270 audit_json = self ._filter_json_for_vulnerabilities (output .stdout )
271+ return audit_json , output
272+
273+ def run (self , session : Session ) -> None :
274+ args = self ._parse_args (session )
275+ audit_json , output = self .audit ()
266276 if args .output :
267277 with open (args .output , "w" ) as file :
268278 json .dump (audit_json , file )
@@ -271,8 +281,230 @@ def run(self, session: Session) -> None:
271281
272282 if output .returncode != 0 :
273283 session .warn (
274- f"Command { ' ' .join (command )} failed with exit code { output .returncode } " ,
284+ f"Command { ' ' .join (output .args )} failed with exit code { output .returncode } " ,
285+ )
286+
287+
288+ @dataclass (frozen = True )
289+ class PackageVersion :
290+ name : str
291+ version : str
292+
293+
294+ @dataclass
295+ class PackageVersionTracker :
296+ """
297+ Tracks direct dependencies for package versions before & after updates
298+
299+ Assumption:
300+ - The dependency ranges in the pyproject.toml allows users to often update
301+ transitive dependencies on their own. It is, therefore, more important for us to
302+ track the changes of direct dependencies and, if present, the resolution of both
303+ vulnerabilities for direct and transitive dependencies.
304+ """
305+
306+ before_env : set [PackageVersion ] = field (default_factory = set )
307+ after_env : set [PackageVersion ] = field (default_factory = set )
308+
309+ @staticmethod
310+ def _obtain_version_set () -> set [PackageVersion ]:
311+ def _get_package_version (line : str ) -> PackageVersion :
312+ pattern = r"\s+(\d+(?:\.\d+)*)\s+"
313+ groups = re .split (pattern , line )
314+ return PackageVersion (name = groups [0 ], version = groups [1 ])
315+
316+ command = ("poetry" , "show" , "--top-level" )
317+ result = subprocess .run (command , capture_output = True , check = True )
318+ return {
319+ _get_package_version (line )
320+ for line in result .stdout .decode ("utf-8" ).splitlines ()
321+ }
322+
323+ @property
324+ def changes (self ) -> tuple :
325+ before_update_dict = {pkg .name : pkg for pkg in self .before_env }
326+ after_update_dict = {pkg .name : pkg for pkg in self .after_env }
327+
328+ def _get_change_str (pkg_name : str ) -> str | None :
329+ if pkg_name not in after_update_dict .keys ():
330+ entry = before_update_dict [pkg_name ]
331+ return f"* Removed { entry .name } ({ entry .version } )"
332+ if pkg_name not in before_update_dict .keys ():
333+ entry = after_update_dict [pkg_name ]
334+ return f"* Added { entry .name } ({ entry .version } )"
335+ before_entry = before_update_dict [pkg_name ]
336+ after_entry = after_update_dict [pkg_name ]
337+ if before_entry .version != after_entry .version :
338+ return f"* Updated { pkg_name } ({ before_entry .version } → { after_entry .version } )"
339+ return None
340+
341+ all_packages = before_update_dict .keys () | after_update_dict .keys ()
342+ return tuple (
343+ change_str
344+ for pkg_name in all_packages
345+ if (change_str := _get_change_str (pkg_name ))
346+ )
347+
348+ @property
349+ def packages (self ) -> set [str ]:
350+ return {pkg .name for pkg in self .before_env }
351+
352+ def __enter__ (self ) -> PackageVersionTracker :
353+ self .before_env = self ._obtain_version_set ()
354+ return self
355+
356+ def __exit__ (self , exc_type , exc_val , exc_tb ):
357+ self .after_env = self ._obtain_version_set ()
358+
359+
360+ @contextmanager
361+ def managed_file (file_obj : argparse .FileType ):
362+ """Context manager to manage a file provided by argparse"""
363+ yield file_obj
364+
365+
366+ @dataclass
367+ class VulnerabilityTracker :
368+ """Tracks the resolution of GitHubVulnerabilityIssues before & after updates"""
369+
370+ to_resolve : set [GitHubVulnerabilityIssue ] = field (default_factory = set )
371+ resolved : set [GitHubVulnerabilityIssue ] = field (default_factory = set )
372+ not_resolved : set [GitHubVulnerabilityIssue ] = field (default_factory = set )
373+
374+ def __init__ (self , vulnerability_issues : argparse .FileType | None ):
375+ self .to_resolve : set [GitHubVulnerabilityIssue ] = self ._set_to_resolve (
376+ vulnerability_issues
377+ )
378+
379+ @staticmethod
380+ def _set_to_resolve (
381+ vulnerability_issues : argparse .FileType | None ,
382+ ) -> set [GitHubVulnerabilityIssue ]:
383+ if not vulnerability_issues :
384+ return set ()
385+ with managed_file (vulnerability_issues ) as f :
386+ lines = f .readlines ()
387+ return set (GitHubVulnerabilityIssue .extract_from_jsonl (lines ))
388+
389+ def _split_resolution_status (self ) -> None :
390+ to_resolve_by_cve = {vuln .cve : vuln for vuln in self .to_resolve }
391+ cves_to_resolve = to_resolve_by_cve .keys ()
392+ audit_json , _ = Audit ().audit ()
393+
394+ not_resolved : list = []
395+ for dependency in audit_json ["dependencies" ]:
396+ for v in dependency ["vulns" ]:
397+ vuln_ids = set ([v ["id" ]] + v ["aliases" ])
398+ if remaining_cves := (cves_to_resolve & vuln_ids ):
399+ not_resolved .extend (
400+ to_resolve_by_cve [cve ] for cve in remaining_cves
401+ )
402+ self .not_resolved = set (not_resolved )
403+ self .resolved = self .to_resolve - self .not_resolved
404+
405+ def get_packages (self ) -> set [str ]:
406+ return {vuln .coordinates .split (":" )[0 ] for vuln in self .to_resolve }
407+
408+ @property
409+ def issues_not_resolved (self ) -> tuple [str , ...]:
410+ return tuple (
411+ f"* Did NOT resolve { vuln .issue_url } ({ vuln .cve } )"
412+ for vuln in self .not_resolved
413+ )
414+
415+ @property
416+ def issues_resolved (self ) -> tuple [str , ...]:
417+ return tuple (
418+ f"* Closes { vuln .issue_url } ({ vuln .cve } )" for vuln in self .resolved
419+ )
420+
421+ @property
422+ def summary (self ) -> tuple [str , ...]:
423+ return tuple (
424+ cleandoc (
425+ f"""{ vuln .cve } in dependency `{ vuln .coordinates } `\n { vuln .description }
426+ """
275427 )
428+ for vuln in self .resolved
429+ )
430+
431+ @property
432+ def vulnerabilities_resolved (self ) -> tuple [str , ...]:
433+ def get_issue_number (issue_url : str ) -> str | None :
434+ pattern = r"/issues/(\d+)$"
435+ match = re .search (pattern , issue_url )
436+ return match .group (1 ) if match else None
437+
438+ return tuple (
439+ f"* #{ get_issue_number (vuln .issue_url )} Fixed vulnerability { vuln .cve } in `{ vuln .coordinates } `"
440+ for vuln in self .resolved
441+ )
442+
443+ def __enter__ (self ) -> VulnerabilityTracker :
444+ return self
445+
446+ def __exit__ (self , exc_type , exc_val , exc_tb ):
447+ self ._split_resolution_status ()
448+
449+
450+ @dataclass (frozen = True )
451+ class DependencyChanges :
452+ package_changes : tuple [str , ...]
453+ issues_resolved : tuple [str , ...]
454+ issues_not_resolved : tuple [str , ...]
455+ vulnerabilities_resolved : tuple [str , ...]
456+ vulnerabilities_resolved_summary : tuple [str , ...]
457+
458+
459+ class DependencyUpdate :
460+ """Update dependencies"""
461+
462+ @staticmethod
463+ def _parse_args (session ) -> argparse .Namespace :
464+ parser = argparse .ArgumentParser (
465+ description = "Updates dependencies & returns changes" ,
466+ usage = "nox -s dependency:audit -- -- [options]" ,
467+ )
468+ parser .add_argument (
469+ "-v" ,
470+ "--vulnerability-issues" ,
471+ type = argparse .FileType ("r" ),
472+ default = None ,
473+ help = "JSONL of vulnerabilities (of type `GitHubVulnerabilityIssue`)" ,
474+ )
475+ return parser .parse_args (args = session .posargs )
476+
477+ @staticmethod
478+ def _perform_basic_vulnerability_update (
479+ pkg_tracker : PackageVersionTracker , vuln_tracker : VulnerabilityTracker
480+ ) -> None :
481+ vuln_packages = vuln_tracker .get_packages ()
482+
483+ # vulnerabilities of direct dependencies require a pyproject.toml update
484+ vuln_direct_dependencies = vuln_packages .intersection (pkg_tracker .packages )
485+ if vuln_direct_dependencies :
486+ command = ("poetry" , "up" ) + tuple (vuln_direct_dependencies )
487+ subprocess .run (command , capture_output = True )
488+
489+ command = ("poetry" , "update" ) + tuple (vuln_packages )
490+ subprocess .run (command , capture_output = True )
491+
492+ def run (self , session : Session ) -> DependencyChanges :
493+ """Update the dependencies associated with GitHubVulnerabilityIssues"""
494+ args = self ._parse_args (session )
495+ with PackageVersionTracker () as pkg_tracker :
496+ with VulnerabilityTracker (args .vulnerability_issues ) as vuln_tracker :
497+ self ._perform_basic_vulnerability_update (
498+ pkg_tracker = pkg_tracker , vuln_tracker = vuln_tracker
499+ )
500+
501+ return DependencyChanges (
502+ package_changes = pkg_tracker .changes ,
503+ issues_resolved = vuln_tracker .issues_resolved ,
504+ issues_not_resolved = vuln_tracker .issues_not_resolved ,
505+ vulnerabilities_resolved = vuln_tracker .vulnerabilities_resolved ,
506+ vulnerabilities_resolved_summary = vuln_tracker .summary ,
507+ )
276508
277509
278510@nox .session (name = "dependency:licenses" , python = False )
@@ -288,3 +520,19 @@ def dependency_licenses(session: Session) -> None:
288520def audit (session : Session ) -> None :
289521 """Check for known vulnerabilities"""
290522 Audit ().run (session = session )
523+
524+
525+ @nox .session (name = "dependency:update" , python = False )
526+ def update (session : Session ) -> None :
527+ """Updates dependencies & returns changes"""
528+ dependency_changes = DependencyUpdate ().run (session )
529+ print ("Resolved issues" )
530+ print (* dependency_changes .issues_resolved , sep = "\n " )
531+ print ("\n Not resolved issues" )
532+ print (* dependency_changes .issues_not_resolved , sep = "\n " )
533+ print ("\n Summary" )
534+ print (* dependency_changes .vulnerabilities_resolved_summary , sep = "\n " )
535+ print ("\n Security fixes" )
536+ print (* dependency_changes .vulnerabilities_resolved , sep = "\n " )
537+ print ("\n Dependencies" )
538+ print (* dependency_changes .package_changes , sep = "\n " )
0 commit comments