github.com/jfrog/frogbot@v1.1.1-0.20231221090046-821a26f50338/scanrepository/scanrepository.go (about)

     1  package scanrepository
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"path/filepath"
     8  	"regexp"
     9  	"strings"
    10  
    11  	"github.com/jfrog/frogbot/packagehandlers"
    12  	"github.com/jfrog/frogbot/utils"
    13  	"github.com/jfrog/frogbot/utils/outputwriter"
    14  	"github.com/jfrog/froggit-go/vcsclient"
    15  	"github.com/jfrog/froggit-go/vcsutils"
    16  	"github.com/jfrog/gofrog/version"
    17  	"github.com/jfrog/jfrog-cli-core/v2/utils/coreutils"
    18  	"github.com/jfrog/jfrog-cli-core/v2/xray/formats"
    19  	xrayutils "github.com/jfrog/jfrog-cli-core/v2/xray/utils"
    20  	"github.com/jfrog/jfrog-client-go/utils/io/fileutils"
    21  	"github.com/jfrog/jfrog-client-go/utils/log"
    22  	"golang.org/x/exp/maps"
    23  	"golang.org/x/exp/slices"
    24  )
    25  
    26  type ScanRepositoryCmd struct {
    27  	// The interface that Frogbot utilizes to format and style the displayed messages on the Git providers
    28  	outputwriter.OutputWriter
    29  	// dryRun is used for testing purposes, mocking part of the git commands that requires networking
    30  	dryRun bool
    31  	// When dryRun is enabled, dryRunRepoPath specifies the repository local path to clone
    32  	dryRunRepoPath string
    33  	// The scanDetails of the current scan
    34  	scanDetails *utils.ScanDetails
    35  	// The base working directory
    36  	baseWd string
    37  	// The git client the command performs git operations with
    38  	gitManager *utils.GitManager
    39  	// Determines whether to open a pull request for each vulnerability fix or to aggregate all fixes into one pull request
    40  	aggregateFixes bool
    41  	// The current project technology
    42  	projectTech []coreutils.Technology
    43  	// Stores all package manager handlers for detected issues
    44  	handlers map[coreutils.Technology]packagehandlers.PackageHandler
    45  }
    46  
    47  func (cfp *ScanRepositoryCmd) Run(repoAggregator utils.RepoAggregator, client vcsclient.VcsClient, frogbotRepoConnection *utils.UrlAccessChecker) (err error) {
    48  	if err = utils.ValidateSingleRepoConfiguration(&repoAggregator); err != nil {
    49  		return err
    50  	}
    51  	repository := repoAggregator[0]
    52  	repository.OutputWriter.SetHasInternetConnection(frogbotRepoConnection.IsConnected())
    53  	return cfp.scanAndFixRepository(&repository, client)
    54  }
    55  
    56  func (cfp *ScanRepositoryCmd) scanAndFixRepository(repository *utils.Repository, client vcsclient.VcsClient) (err error) {
    57  	if err = cfp.setCommandPrerequisites(repository, client); err != nil {
    58  		return
    59  	}
    60  	for _, branch := range repository.Branches {
    61  		cfp.scanDetails.SetBaseBranch(branch)
    62  		cfp.scanDetails.SetXscGitInfoContext(branch, repository.Project, client)
    63  		if err = cfp.scanAndFixBranch(repository); err != nil {
    64  			return
    65  		}
    66  	}
    67  	return
    68  }
    69  
    70  func (cfp *ScanRepositoryCmd) scanAndFixBranch(repository *utils.Repository) (err error) {
    71  	clonedRepoDir, restoreBaseDir, err := cfp.cloneRepositoryAndCheckoutToBranch()
    72  	if err != nil {
    73  		return
    74  	}
    75  	cfp.baseWd = clonedRepoDir
    76  	defer func() {
    77  		// On dry run don't delete the folder as we want to validate results
    78  		if cfp.dryRun {
    79  			return
    80  		}
    81  		err = errors.Join(err, restoreBaseDir(), fileutils.RemoveTempDir(clonedRepoDir))
    82  	}()
    83  	for i := range repository.Projects {
    84  		cfp.scanDetails.Project = &repository.Projects[i]
    85  		cfp.projectTech = []coreutils.Technology{}
    86  		if err = cfp.scanAndFixProject(repository); err != nil {
    87  			return
    88  		}
    89  	}
    90  	return
    91  }
    92  
    93  func (cfp *ScanRepositoryCmd) setCommandPrerequisites(repository *utils.Repository, client vcsclient.VcsClient) (err error) {
    94  	// Set the scan details
    95  	cfp.scanDetails = utils.NewScanDetails(client, &repository.Server, &repository.Git).
    96  		SetXrayGraphScanParams(repository.Watches, repository.JFrogProjectKey, len(repository.AllowedLicenses) > 0).
    97  		SetFailOnInstallationErrors(*repository.FailOnSecurityIssues).
    98  		SetFixableOnly(repository.FixableOnly).
    99  		SetMinSeverity(repository.MinSeverity)
   100  	repositoryInfo, err := client.GetRepositoryInfo(context.Background(), cfp.scanDetails.RepoOwner, cfp.scanDetails.RepoName)
   101  	if err != nil {
   102  		return
   103  	}
   104  	cfp.scanDetails.Git.RepositoryCloneUrl = repositoryInfo.CloneInfo.HTTP
   105  	// Set the flag for aggregating fixes to generate a unified pull request for fixing vulnerabilities
   106  	cfp.aggregateFixes = repository.Git.AggregateFixes
   107  	// Set the outputwriter interface for the relevant vcs git provider
   108  	cfp.OutputWriter = outputwriter.GetCompatibleOutputWriter(repository.GitProvider)
   109  	// Set the git client to perform git operations
   110  	cfp.gitManager, err = utils.NewGitManager().
   111  		SetAuth(cfp.scanDetails.Username, cfp.scanDetails.Token).
   112  		SetDryRun(cfp.dryRun, cfp.dryRunRepoPath).
   113  		SetRemoteGitUrl(cfp.scanDetails.Git.RepositoryCloneUrl)
   114  	if err != nil {
   115  		return
   116  	}
   117  	_, err = cfp.gitManager.SetGitParams(cfp.scanDetails.Git)
   118  	return
   119  }
   120  
   121  func (cfp *ScanRepositoryCmd) scanAndFixProject(repository *utils.Repository) error {
   122  	var fixNeeded bool
   123  	// A map that contains the full project paths as a keys
   124  	// The value is a map of vulnerable package names -> the scanDetails of the vulnerable packages.
   125  	// That means we have a map of all the vulnerabilities that were found in a specific folder, along with their full scanDetails.
   126  	vulnerabilitiesByPathMap := make(map[string]map[string]*utils.VulnerabilityDetails)
   127  	projectFullPathWorkingDirs := utils.GetFullPathWorkingDirs(cfp.scanDetails.Project.WorkingDirs, cfp.baseWd)
   128  	for _, fullPathWd := range projectFullPathWorkingDirs {
   129  		scanResults, err := cfp.scan(fullPathWd)
   130  		if err != nil {
   131  			return err
   132  		}
   133  
   134  		if repository.GitProvider.String() == vcsutils.GitHub.String() {
   135  			// Uploads Sarif results to GitHub in order to view the scan in the code scanning UI
   136  			// Currently available on GitHub only
   137  			if err = utils.UploadSarifResultsToGithubSecurityTab(scanResults, repository, cfp.scanDetails.BaseBranch(), cfp.scanDetails.Client()); err != nil {
   138  				log.Warn(err)
   139  			}
   140  		}
   141  
   142  		// Prepare the vulnerabilities map for each working dir path
   143  		currPathVulnerabilities, err := cfp.getVulnerabilitiesMap(scanResults, scanResults.IsMultipleProject())
   144  		if err != nil {
   145  			return err
   146  		}
   147  		if len(currPathVulnerabilities) > 0 {
   148  			fixNeeded = true
   149  		}
   150  		vulnerabilitiesByPathMap[fullPathWd] = currPathVulnerabilities
   151  	}
   152  	if fixNeeded {
   153  		return cfp.fixVulnerablePackages(vulnerabilitiesByPathMap)
   154  	}
   155  	return nil
   156  }
   157  
   158  // Audit the dependencies of the current commit.
   159  func (cfp *ScanRepositoryCmd) scan(currentWorkingDir string) (*xrayutils.Results, error) {
   160  	// Audit commit code
   161  	auditResults, err := cfp.scanDetails.RunInstallAndAudit(currentWorkingDir)
   162  	if err != nil {
   163  		return nil, err
   164  	}
   165  	log.Info("Xray scan completed")
   166  	contextualAnalysisResultsExists := len(auditResults.ExtendedScanResults.ApplicabilityScanResults) > 0
   167  	entitledForJas := auditResults.ExtendedScanResults.EntitledForJas
   168  	cfp.OutputWriter.SetJasOutputFlags(entitledForJas, contextualAnalysisResultsExists)
   169  	cfp.projectTech = auditResults.GetScaScannedTechnologies()
   170  	return auditResults, nil
   171  }
   172  
   173  func (cfp *ScanRepositoryCmd) getVulnerabilitiesMap(scanResults *xrayutils.Results, isMultipleRoots bool) (map[string]*utils.VulnerabilityDetails, error) {
   174  	vulnerabilitiesMap, err := cfp.createVulnerabilitiesMap(scanResults, isMultipleRoots)
   175  	if err != nil {
   176  		return nil, err
   177  	}
   178  
   179  	// Nothing to fix, return
   180  	if len(vulnerabilitiesMap) == 0 {
   181  		log.Info("Didn't find vulnerable dependencies with existing fix versions for", cfp.scanDetails.RepoName)
   182  	}
   183  	return vulnerabilitiesMap, nil
   184  }
   185  
   186  func (cfp *ScanRepositoryCmd) fixVulnerablePackages(vulnerabilitiesByWdMap map[string]map[string]*utils.VulnerabilityDetails) (err error) {
   187  	if cfp.aggregateFixes {
   188  		return cfp.fixIssuesSinglePR(vulnerabilitiesByWdMap)
   189  	}
   190  	return cfp.fixIssuesSeparatePRs(vulnerabilitiesByWdMap)
   191  }
   192  
   193  func (cfp *ScanRepositoryCmd) fixIssuesSeparatePRs(vulnerabilitiesMap map[string]map[string]*utils.VulnerabilityDetails) error {
   194  	var err error
   195  	for fullPath, vulnerabilities := range vulnerabilitiesMap {
   196  		if e := cfp.fixProjectVulnerabilities(fullPath, vulnerabilities); e != nil {
   197  			err = errors.Join(err, fmt.Errorf("the following errors occured while fixing vulnerabilities in %s:\n%s", fullPath, e))
   198  		}
   199  	}
   200  	return err
   201  }
   202  
   203  func (cfp *ScanRepositoryCmd) fixProjectVulnerabilities(fullProjectPath string, vulnerabilities map[string]*utils.VulnerabilityDetails) (err error) {
   204  	// Update the working directory to the project's current working directory
   205  	projectWorkingDir := utils.GetRelativeWd(fullProjectPath, cfp.baseWd)
   206  
   207  	// 'CD' into the relevant working directory
   208  	if projectWorkingDir != "" {
   209  		var restoreDirFunc func() error
   210  		if restoreDirFunc, err = utils.Chdir(projectWorkingDir); err != nil {
   211  			return
   212  		}
   213  		defer func() {
   214  			err = errors.Join(err, restoreDirFunc())
   215  		}()
   216  	}
   217  
   218  	// Fix every vulnerability in a separate pull request and branch
   219  	for _, vulnerability := range vulnerabilities {
   220  		if e := cfp.fixSinglePackageAndCreatePR(vulnerability); e != nil {
   221  			err = errors.Join(err, cfp.handleUpdatePackageErrors(e))
   222  		}
   223  
   224  		// After fixing the current vulnerability, checkout to the base branch to start fixing the next vulnerability
   225  		if e := cfp.gitManager.Checkout(cfp.scanDetails.BaseBranch()); e != nil {
   226  			err = errors.Join(err, cfp.handleUpdatePackageErrors(e))
   227  			return
   228  		}
   229  	}
   230  
   231  	return
   232  }
   233  
   234  func (cfp *ScanRepositoryCmd) fixMultiplePackages(fullProjectPath string, vulnerabilities map[string]*utils.VulnerabilityDetails) (fixedVulnerabilities []*utils.VulnerabilityDetails, err error) {
   235  	// Update the working directory to the project's current working directory
   236  	projectWorkingDir := utils.GetRelativeWd(fullProjectPath, cfp.baseWd)
   237  
   238  	// 'CD' into the relevant working directory
   239  	if projectWorkingDir != "" {
   240  		var restoreDir func() error
   241  		restoreDir, err = utils.Chdir(projectWorkingDir)
   242  		if err != nil {
   243  			return nil, err
   244  		}
   245  		defer func() {
   246  			err = errors.Join(err, restoreDir())
   247  		}()
   248  	}
   249  	for _, vulnDetails := range vulnerabilities {
   250  		if e := cfp.updatePackageToFixedVersion(vulnDetails); e != nil {
   251  			err = errors.Join(err, cfp.handleUpdatePackageErrors(e))
   252  			continue
   253  		}
   254  		fixedVulnerabilities = append(fixedVulnerabilities, vulnDetails)
   255  		log.Info(fmt.Sprintf("Updated dependency '%s' to version '%s'", vulnDetails.ImpactedDependencyName, vulnDetails.SuggestedFixedVersion))
   256  	}
   257  	return
   258  }
   259  
   260  // fixIssuesSinglePR fixes all the vulnerabilities in a single aggregated pull request.
   261  // If an existing aggregated fix is present, it checks for different scan results.
   262  // If the scan results are the same, no action is taken.
   263  // Otherwise, it performs a force push to the same branch and reopens the pull request if it was closed.
   264  // Only one aggregated pull request should remain open at all times.
   265  func (cfp *ScanRepositoryCmd) fixIssuesSinglePR(vulnerabilitiesMap map[string]map[string]*utils.VulnerabilityDetails) (err error) {
   266  	aggregatedFixBranchName := cfp.gitManager.GenerateAggregatedFixBranchName(cfp.scanDetails.BaseBranch(), cfp.projectTech)
   267  	existingPullRequestDetails, err := cfp.getOpenPullRequestBySourceBranch(aggregatedFixBranchName)
   268  	if err != nil {
   269  		return
   270  	}
   271  	return cfp.aggregateFixAndOpenPullRequest(vulnerabilitiesMap, aggregatedFixBranchName, existingPullRequestDetails)
   272  }
   273  
   274  // Handles possible error of update package operation
   275  // When the expected custom error occurs, log to debug.
   276  // else, return the error
   277  func (cfp *ScanRepositoryCmd) handleUpdatePackageErrors(err error) error {
   278  	var errUnsupportedFix *utils.ErrUnsupportedFix
   279  	if errors.As(err, &errUnsupportedFix) {
   280  		log.Debug(strings.TrimSpace(err.Error()))
   281  		return nil
   282  	}
   283  	return err
   284  }
   285  
   286  // Creates a branch for the fixed package and open pull request against the target branch.
   287  // In case a branch already exists on remote, we skip it.
   288  func (cfp *ScanRepositoryCmd) fixSinglePackageAndCreatePR(vulnDetails *utils.VulnerabilityDetails) (err error) {
   289  	fixVersion := vulnDetails.SuggestedFixedVersion
   290  	log.Debug("Attempting to fix", fmt.Sprintf("%s:%s", vulnDetails.ImpactedDependencyName, vulnDetails.ImpactedDependencyVersion), "with", fixVersion)
   291  	fixBranchName, err := cfp.gitManager.GenerateFixBranchName(cfp.scanDetails.BaseBranch(), vulnDetails.ImpactedDependencyName, fixVersion)
   292  	if err != nil {
   293  		return
   294  	}
   295  	existsInRemote, err := cfp.gitManager.BranchExistsInRemote(fixBranchName)
   296  	if err != nil {
   297  		return
   298  	}
   299  	if existsInRemote {
   300  		log.Info(fmt.Sprintf("A pull request updating the dependency '%s' to version '%s' already exists. Skipping...", vulnDetails.ImpactedDependencyName, vulnDetails.SuggestedFixedVersion))
   301  		return
   302  	}
   303  
   304  	workTreeIsClean, err := cfp.gitManager.IsClean()
   305  	if !workTreeIsClean {
   306  		var removeTempDirCallback func() error
   307  		err, removeTempDirCallback = cfp.gitManager.CreateBranchAndCheckoutWithCopyingFilesDiff(fixBranchName)
   308  		defer func() {
   309  			err = errors.Join(err, removeTempDirCallback())
   310  		}()
   311  		if err != nil {
   312  			err = fmt.Errorf("failed while creating branch %s: %s", fixBranchName, err.Error())
   313  			return
   314  		}
   315  	} else {
   316  		if err = cfp.gitManager.CreateBranchAndCheckout(fixBranchName); err != nil {
   317  			err = fmt.Errorf("failed while creating branch %s: %s", fixBranchName, err.Error())
   318  			return
   319  		}
   320  	}
   321  
   322  	if err = cfp.updatePackageToFixedVersion(vulnDetails); err != nil {
   323  		return
   324  	}
   325  	if err = cfp.openFixingPullRequest(fixBranchName, vulnDetails); err != nil {
   326  		return fmt.Errorf("failed while creating a fixing pull request for: %s with version: %s with error: \n%s",
   327  			vulnDetails.ImpactedDependencyName, fixVersion, err.Error())
   328  	}
   329  	log.Info(fmt.Sprintf("Created Pull Request updating dependency '%s' to version '%s'", vulnDetails.ImpactedDependencyName, vulnDetails.SuggestedFixedVersion))
   330  	return
   331  }
   332  
   333  func (cfp *ScanRepositoryCmd) openFixingPullRequest(fixBranchName string, vulnDetails *utils.VulnerabilityDetails) (err error) {
   334  	log.Debug("Checking if there are changes to commit")
   335  	isClean, err := cfp.gitManager.IsClean()
   336  	if err != nil {
   337  		return
   338  	}
   339  	if isClean {
   340  		return fmt.Errorf("there were no changes to commit after fixing the package '%s'", vulnDetails.ImpactedDependencyName)
   341  	}
   342  	commitMessage := cfp.gitManager.GenerateCommitMessage(vulnDetails.ImpactedDependencyName, vulnDetails.SuggestedFixedVersion)
   343  	if err = cfp.gitManager.AddAllAndCommit(commitMessage); err != nil {
   344  		return
   345  	}
   346  	if err = cfp.gitManager.Push(false, fixBranchName); err != nil {
   347  		return
   348  	}
   349  	pullRequestTitle, prBody, err := cfp.preparePullRequestDetails(vulnDetails)
   350  	if err != nil {
   351  		return
   352  	}
   353  	log.Debug("Creating Pull Request form:", fixBranchName, " to:", cfp.scanDetails.BaseBranch())
   354  	return cfp.scanDetails.Client().CreatePullRequest(context.Background(), cfp.scanDetails.RepoOwner, cfp.scanDetails.RepoName, fixBranchName, cfp.scanDetails.BaseBranch(), pullRequestTitle, prBody)
   355  }
   356  
   357  // openAggregatedPullRequest handles the opening or updating of a pull request when the aggregate mode is active.
   358  // If a pull request is already open, Frogbot will update the branch and the pull request body.
   359  func (cfp *ScanRepositoryCmd) openAggregatedPullRequest(fixBranchName string, pullRequestInfo *vcsclient.PullRequestInfo, vulnerabilities []*utils.VulnerabilityDetails) (err error) {
   360  	commitMessage := cfp.gitManager.GenerateAggregatedCommitMessage(cfp.projectTech)
   361  	if err = cfp.gitManager.AddAllAndCommit(commitMessage); err != nil {
   362  		return
   363  	}
   364  	if err = cfp.gitManager.Push(true, fixBranchName); err != nil {
   365  		return
   366  	}
   367  	pullRequestTitle, prBody, err := cfp.preparePullRequestDetails(vulnerabilities...)
   368  	if err != nil {
   369  		return
   370  	}
   371  	if pullRequestInfo == nil {
   372  		log.Info("Creating Pull Request from:", fixBranchName, "to:", cfp.scanDetails.BaseBranch())
   373  		return cfp.scanDetails.Client().CreatePullRequest(context.Background(), cfp.scanDetails.RepoOwner, cfp.scanDetails.RepoName, fixBranchName, cfp.scanDetails.BaseBranch(), pullRequestTitle, prBody)
   374  	}
   375  	log.Info("Updating Pull Request from:", fixBranchName, "to:", cfp.scanDetails.BaseBranch())
   376  	return cfp.scanDetails.Client().UpdatePullRequest(context.Background(), cfp.scanDetails.RepoOwner, cfp.scanDetails.RepoName, pullRequestTitle, prBody, pullRequestInfo.Target.Name, int(pullRequestInfo.ID), vcsutils.Open)
   377  }
   378  
   379  func (cfp *ScanRepositoryCmd) preparePullRequestDetails(vulnerabilitiesDetails ...*utils.VulnerabilityDetails) (prTitle string, prBody string, err error) {
   380  	if cfp.dryRun && cfp.aggregateFixes {
   381  		// For testings, don't compare pull request body as scan results order may change.
   382  		return cfp.gitManager.GenerateAggregatedPullRequestTitle(cfp.projectTech), "", nil
   383  	}
   384  	vulnerabilitiesRows := utils.ExtractVulnerabilitiesDetailsToRows(vulnerabilitiesDetails)
   385  	prBody = utils.GenerateFixPullRequestDetails(vulnerabilitiesRows, cfp.OutputWriter)
   386  	if cfp.aggregateFixes {
   387  		var scanHash string
   388  		if scanHash, err = utils.VulnerabilityDetailsToMD5Hash(vulnerabilitiesRows...); err != nil {
   389  			return
   390  		}
   391  		return cfp.gitManager.GenerateAggregatedPullRequestTitle(cfp.projectTech), prBody + outputwriter.MarkdownComment(fmt.Sprintf("Checksum: %s", scanHash)), nil
   392  	}
   393  	// In separate pull requests there is only one vulnerability
   394  	vulnDetails := vulnerabilitiesDetails[0]
   395  	pullRequestTitle := cfp.gitManager.GeneratePullRequestTitle(vulnDetails.ImpactedDependencyName, vulnDetails.SuggestedFixedVersion)
   396  	return pullRequestTitle, prBody, nil
   397  }
   398  
   399  func (cfp *ScanRepositoryCmd) cloneRepositoryAndCheckoutToBranch() (tempWd string, restoreDir func() error, err error) {
   400  	if cfp.dryRun {
   401  		tempWd = filepath.Join(cfp.dryRunRepoPath, cfp.scanDetails.RepoName)
   402  	} else {
   403  		// Create temp working directory
   404  		if tempWd, err = fileutils.CreateTempDir(); err != nil {
   405  			return
   406  		}
   407  	}
   408  	log.Debug("Created temp working directory:", tempWd)
   409  
   410  	// Clone the content of the repo to the new working directory
   411  	if err = cfp.gitManager.Clone(tempWd, cfp.scanDetails.BaseBranch()); err != nil {
   412  		return
   413  	}
   414  
   415  	// 'CD' into the temp working directory
   416  	restoreDir, err = utils.Chdir(tempWd)
   417  	return
   418  }
   419  
   420  // Create a vulnerabilities map - a map with 'impacted package' as a key and all the necessary information of this vulnerability as value.
   421  func (cfp *ScanRepositoryCmd) createVulnerabilitiesMap(scanResults *xrayutils.Results, isMultipleRoots bool) (map[string]*utils.VulnerabilityDetails, error) {
   422  	vulnerabilitiesMap := map[string]*utils.VulnerabilityDetails{}
   423  	for _, scanResult := range scanResults.GetScaScansXrayResults() {
   424  		if len(scanResult.Vulnerabilities) > 0 {
   425  			vulnerabilities, err := xrayutils.PrepareVulnerabilities(scanResult.Vulnerabilities, scanResults, isMultipleRoots, true)
   426  			if err != nil {
   427  				return nil, err
   428  			}
   429  			for i := range vulnerabilities {
   430  				if err = cfp.addVulnerabilityToFixVersionsMap(&vulnerabilities[i], vulnerabilitiesMap); err != nil {
   431  					return nil, err
   432  				}
   433  			}
   434  		} else if len(scanResult.Violations) > 0 {
   435  			violations, _, _, err := xrayutils.PrepareViolations(scanResult.Violations, scanResults, isMultipleRoots, true)
   436  			if err != nil {
   437  				return nil, err
   438  			}
   439  			for i := range violations {
   440  				if err = cfp.addVulnerabilityToFixVersionsMap(&violations[i], vulnerabilitiesMap); err != nil {
   441  					return nil, err
   442  				}
   443  			}
   444  		}
   445  	}
   446  	if len(vulnerabilitiesMap) > 0 {
   447  		log.Debug("Frogbot will attempt to resolve the following vulnerable dependencies:\n", strings.Join(maps.Keys(vulnerabilitiesMap), ",\n"))
   448  	}
   449  	return vulnerabilitiesMap, nil
   450  }
   451  
   452  func (cfp *ScanRepositoryCmd) addVulnerabilityToFixVersionsMap(vulnerability *formats.VulnerabilityOrViolationRow, vulnerabilitiesMap map[string]*utils.VulnerabilityDetails) error {
   453  	if len(vulnerability.FixedVersions) == 0 {
   454  		return nil
   455  	}
   456  	if len(cfp.projectTech) == 0 {
   457  		cfp.projectTech = []coreutils.Technology{vulnerability.Technology}
   458  	}
   459  	vulnFixVersion := getMinimalFixVersion(vulnerability.ImpactedDependencyVersion, vulnerability.FixedVersions)
   460  	if vulnFixVersion == "" {
   461  		return nil
   462  	}
   463  	if vulnDetails, exists := vulnerabilitiesMap[vulnerability.ImpactedDependencyName]; exists {
   464  		// More than one vulnerability can exist on the same impacted package.
   465  		// Among all possible fix versions that fix the above-impacted package, we select the maximum fix version.
   466  		vulnDetails.UpdateFixVersionIfMax(vulnFixVersion)
   467  	} else {
   468  		isDirectDependency, err := utils.IsDirectDependency(vulnerability.ImpactPaths)
   469  		if err != nil {
   470  			return err
   471  		}
   472  		// First appearance of a version that fixes the current impacted package
   473  		newVulnDetails := utils.NewVulnerabilityDetails(*vulnerability, vulnFixVersion)
   474  		newVulnDetails.SetIsDirectDependency(isDirectDependency)
   475  		vulnerabilitiesMap[vulnerability.ImpactedDependencyName] = newVulnDetails
   476  	}
   477  	// Set the fixed version array to the relevant fixed version so that only that specific fixed version will be displayed
   478  	vulnerability.FixedVersions = []string{vulnerabilitiesMap[vulnerability.ImpactedDependencyName].SuggestedFixedVersion}
   479  	return nil
   480  }
   481  
   482  // Updates impacted package, can return ErrUnsupportedFix.
   483  func (cfp *ScanRepositoryCmd) updatePackageToFixedVersion(vulnDetails *utils.VulnerabilityDetails) (err error) {
   484  	if err = isBuildToolsDependency(vulnDetails); err != nil {
   485  		return
   486  	}
   487  
   488  	if cfp.handlers == nil {
   489  		cfp.handlers = make(map[coreutils.Technology]packagehandlers.PackageHandler)
   490  	}
   491  
   492  	handler := cfp.handlers[vulnDetails.Technology]
   493  	if handler == nil {
   494  		handler = packagehandlers.GetCompatiblePackageHandler(vulnDetails, cfp.scanDetails)
   495  		cfp.handlers[vulnDetails.Technology] = handler
   496  	} else if _, unsupported := handler.(*packagehandlers.UnsupportedPackageHandler); unsupported {
   497  		return
   498  	}
   499  
   500  	return cfp.handlers[vulnDetails.Technology].UpdateDependency(vulnDetails)
   501  }
   502  
   503  // The getRemoteBranchScanHash function extracts the checksum written inside the pull request body and returns it.
   504  func (cfp *ScanRepositoryCmd) getRemoteBranchScanHash(prBody string) string {
   505  	// The pattern matches the string "Checksum: <checksum>", followed by one or more word characters (letters, digits, or underscores).
   506  	re := regexp.MustCompile(`Checksum: (\w+)`)
   507  	match := re.FindStringSubmatch(prBody)
   508  
   509  	// The first element is the entire matched string, and the second element is the checksum value.
   510  	// If the length of match is not equal to 2, it means that the pattern was not found or the captured group is missing.
   511  	if len(match) != 2 {
   512  		log.Debug("Checksum not found in the aggregated pull request. Frogbot will proceed to update the existing pull request.")
   513  		return ""
   514  	}
   515  
   516  	return match[1]
   517  }
   518  
   519  func (cfp *ScanRepositoryCmd) getOpenPullRequestBySourceBranch(branchName string) (prInfo *vcsclient.PullRequestInfo, err error) {
   520  	list, err := cfp.scanDetails.Client().ListOpenPullRequestsWithBody(context.Background(), cfp.scanDetails.RepoOwner, cfp.scanDetails.RepoName)
   521  	if err != nil {
   522  		return
   523  	}
   524  	for _, pr := range list {
   525  		if pr.Source.Name == branchName {
   526  			log.Debug("Found pull request from source branch ", branchName)
   527  			return &pr, nil
   528  		}
   529  	}
   530  	log.Debug("No pull request found from source branch ", branchName)
   531  	return
   532  }
   533  
   534  func (cfp *ScanRepositoryCmd) aggregateFixAndOpenPullRequest(vulnerabilitiesMap map[string]map[string]*utils.VulnerabilityDetails, aggregatedFixBranchName string, existingPullRequestInfo *vcsclient.PullRequestInfo) (err error) {
   535  	log.Info("-----------------------------------------------------------------")
   536  	log.Info("Starting aggregated dependencies fix")
   537  
   538  	workTreeIsClean, err := cfp.gitManager.IsClean()
   539  	if !workTreeIsClean {
   540  		var removeTempDirCallback func() error
   541  		err, removeTempDirCallback = cfp.gitManager.CreateBranchAndCheckoutWithCopyingFilesDiff(aggregatedFixBranchName)
   542  		defer func() {
   543  			err = errors.Join(err, removeTempDirCallback())
   544  		}()
   545  		if err != nil {
   546  			err = fmt.Errorf("failed while creating branch %s: %s", aggregatedFixBranchName, err.Error())
   547  			return
   548  		}
   549  	} else {
   550  		if err = cfp.gitManager.CreateBranchAndCheckout(aggregatedFixBranchName); err != nil {
   551  			err = fmt.Errorf("failed while creating branch %s: %s", aggregatedFixBranchName, err.Error())
   552  			return
   553  		}
   554  	}
   555  
   556  	// Fix all packages in the same branch if expected error accrued, log and continue.
   557  	var fixedVulnerabilities []*utils.VulnerabilityDetails
   558  	for fullPath, vulnerabilities := range vulnerabilitiesMap {
   559  		currentFixes, e := cfp.fixMultiplePackages(fullPath, vulnerabilities)
   560  		if e != nil {
   561  			err = errors.Join(err, fmt.Errorf("the following errors occured while fixing vulnerabilities in %s:\n%s", fullPath, e))
   562  			continue
   563  		}
   564  		fixedVulnerabilities = append(fixedVulnerabilities, currentFixes...)
   565  	}
   566  	updateRequired, e := cfp.isUpdateRequired(fixedVulnerabilities, existingPullRequestInfo)
   567  	if e != nil {
   568  		err = errors.Join(err, e)
   569  		return
   570  	}
   571  	if !updateRequired {
   572  		log.Info("The existing pull request is in sync with the latest scan, and no further updates are required.")
   573  		return
   574  	}
   575  	if len(fixedVulnerabilities) > 0 {
   576  		if e = cfp.openAggregatedPullRequest(aggregatedFixBranchName, existingPullRequestInfo, fixedVulnerabilities); e != nil {
   577  			err = errors.Join(err, fmt.Errorf("failed while creating aggreagted pull request. Error: \n%s", e.Error()))
   578  		}
   579  	}
   580  	log.Info("-----------------------------------------------------------------")
   581  	err = errors.Join(err, cfp.gitManager.Checkout(cfp.scanDetails.BaseBranch()))
   582  	return
   583  }
   584  
   585  // Performs a comparison of the Xray scan results hashes between an existing pull request's remote source branch
   586  // and the current source branch to identify any differences.
   587  func (cfp *ScanRepositoryCmd) isUpdateRequired(fixedVulnerabilities []*utils.VulnerabilityDetails, prInfo *vcsclient.PullRequestInfo) (updateRequired bool, err error) {
   588  	if prInfo == nil {
   589  		updateRequired = true
   590  		return
   591  	}
   592  	log.Info("Aggregated pull request already exists, verifying if update is needed...")
   593  	log.Debug("Comparing current scan results to existing", prInfo.Target.Name, "scan results")
   594  	fixedVulnerabilitiesRows := utils.ExtractVulnerabilitiesDetailsToRows(fixedVulnerabilities)
   595  	currentScanHash, err := utils.VulnerabilityDetailsToMD5Hash(fixedVulnerabilitiesRows...)
   596  	if err != nil {
   597  		return
   598  	}
   599  	remoteBranchScanHash := cfp.getRemoteBranchScanHash(prInfo.Body)
   600  	updateRequired = currentScanHash != remoteBranchScanHash
   601  	if updateRequired {
   602  		log.Info("The existing pull request is not in sync with the latest scan, updating pull request...")
   603  	}
   604  	return
   605  }
   606  
   607  // getMinimalFixVersion find the minimal version that fixes the current impactedPackage;
   608  // fixVersions is a sorted array. The function returns the first version in the array, that is larger than impactedPackageVersion.
   609  func getMinimalFixVersion(impactedPackageVersion string, fixVersions []string) string {
   610  	// Trim 'v' prefix in case of Go package
   611  	currVersionStr := strings.TrimPrefix(impactedPackageVersion, "v")
   612  	currVersion := version.NewVersion(currVersionStr)
   613  	for _, fixVersion := range fixVersions {
   614  		fixVersionCandidate := parseVersionChangeString(fixVersion)
   615  		if currVersion.Compare(fixVersionCandidate) > 0 {
   616  			return fixVersionCandidate
   617  		}
   618  	}
   619  	return ""
   620  }
   621  
   622  // 1.0         --> 1.0 ≤ x
   623  // (,1.0]      --> x ≤ 1.0
   624  // (,1.0)      --> x < 1.0
   625  // [1.0]       --> x == 1.0
   626  // (1.0,)      --> 1.0 >= x
   627  // (1.0, 2.0)  --> 1.0 < x < 2.0
   628  // [1.0, 2.0]  --> 1.0 ≤ x ≤ 2.0
   629  func parseVersionChangeString(fixVersion string) string {
   630  	latestVersion := strings.Split(fixVersion, ",")[0]
   631  	if latestVersion[0] == '(' {
   632  		return ""
   633  	}
   634  	latestVersion = strings.Trim(latestVersion, "[")
   635  	latestVersion = strings.Trim(latestVersion, "]")
   636  	return latestVersion
   637  }
   638  
   639  // Skip build tools dependencies (for example, pip)
   640  // that are not defined in the descriptor file and cannot be fixed by a PR.
   641  func isBuildToolsDependency(vulnDetails *utils.VulnerabilityDetails) error {
   642  	//nolint:typecheck // Ignoring typecheck error: The linter fails to deduce the returned type as []string from utils.BuildToolsDependenciesMap, despite its declaration in utils/utils.go as map[coreutils.Technology][]string.
   643  	if slices.Contains(utils.BuildToolsDependenciesMap[vulnDetails.Technology], vulnDetails.ImpactedDependencyName) {
   644  		return &utils.ErrUnsupportedFix{
   645  			PackageName:  vulnDetails.ImpactedDependencyName,
   646  			FixedVersion: vulnDetails.SuggestedFixedVersion,
   647  			ErrorType:    utils.BuildToolsDependencyFixNotSupported,
   648  		}
   649  	}
   650  	return nil
   651  }