From 2f01a02895b1912536dea62bd6f9a6482ccd1f0b Mon Sep 17 00:00:00 2001 From: asafambar Date: Wed, 13 Mar 2024 21:42:39 +0200 Subject: [PATCH 01/19] Support pip for Curation --- commands/audit/sca/python/python.go | 111 +++++++- commands/audit/sca/python/python_test.go | 104 +++++--- commands/audit/scarunner.go | 7 +- commands/curation/curationaudit.go | 49 +++- commands/curation/curationaudit_test.go | 56 +++- go.mod | 4 +- go.sum | 8 +- .../npm/npm-project/.jfrog/jfrog-cli.conf.v6 | 4 +- .../pip/pip-curation/.jfrog/projects/pip.yaml | 5 + .../python/pip/pip-curation/requirements.txt | 1 + .../pexpect-4.8.0-py2.py3-none-any.whl | Bin 0 -> 59024 bytes .../pip/pip-curation/resources/pexpect-resp | 70 +++++ .../pip/pip-curation/resources/pip-resp | 250 ++++++++++++++++++ .../ptyprocess-0.7.0-py2.py3-none-any.whl | Bin 0 -> 13993 bytes .../pip-curation/resources/ptyprocess-resp | 40 +++ utils/auditbasicparams.go | 10 + utils/paths.go | 8 + 17 files changed, 656 insertions(+), 71 deletions(-) create mode 100644 tests/testdata/projects/package-managers/python/pip/pip-curation/.jfrog/projects/pip.yaml create mode 100644 tests/testdata/projects/package-managers/python/pip/pip-curation/requirements.txt create mode 100644 tests/testdata/projects/package-managers/python/pip/pip-curation/resources/pexpect-4.8.0-py2.py3-none-any.whl create mode 100644 tests/testdata/projects/package-managers/python/pip/pip-curation/resources/pexpect-resp create mode 100644 tests/testdata/projects/package-managers/python/pip/pip-curation/resources/pip-resp create mode 100644 tests/testdata/projects/package-managers/python/pip/pip-curation/resources/ptyprocess-0.7.0-py2.py3-none-any.whl create mode 100644 tests/testdata/projects/package-managers/python/pip/pip-curation/resources/ptyprocess-resp diff --git a/commands/audit/sca/python/python.go b/commands/audit/sca/python/python.go index 3cdde455..41dc69ed 100644 --- a/commands/audit/sca/python/python.go +++ b/commands/audit/sca/python/python.go @@ -1,6 +1,7 @@ package python import ( + "encoding/json" "errors" "fmt" biutils "github.com/jfrog/build-info-go/utils" @@ -10,10 +11,12 @@ import ( "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" utils "github.com/jfrog/jfrog-cli-core/v2/utils/python" "github.com/jfrog/jfrog-cli-security/commands/audit/sca" + xrayutils2 "github.com/jfrog/jfrog-cli-security/utils" "github.com/jfrog/jfrog-client-go/utils/errorutils" "github.com/jfrog/jfrog-client-go/utils/io/fileutils" "github.com/jfrog/jfrog-client-go/utils/log" xrayUtils "github.com/jfrog/jfrog-client-go/xray/services/utils" + "os" "os/exec" "path/filepath" @@ -22,7 +25,7 @@ import ( ) const ( - pythonPackageTypeIdentifier = "pypi://" + PythonPackageTypeIdentifier = "pypi://" ) type AuditPython struct { @@ -30,10 +33,12 @@ type AuditPython struct { Tool pythonutils.PythonTool RemotePypiRepo string PipRequirementsFile string + IsCurationCmd bool + reportFilePath string } -func BuildDependencyTree(auditPython *AuditPython) (dependencyTree []*xrayUtils.GraphNode, uniqueDeps []string, err error) { - dependenciesGraph, directDependenciesList, err := getDependencies(auditPython) +func BuildDependencyTree(serverDetails *config.ServerDetails, tech coreutils.Technology, params xrayutils2.AuditParams) (dependencyTree []*xrayUtils.GraphNode, uniqueDeps []string, err error) { + dependenciesGraph, directDependenciesList, err := getDependencies(serverDetails, tech, params) if err != nil { return } @@ -41,7 +46,7 @@ func BuildDependencyTree(auditPython *AuditPython) (dependencyTree []*xrayUtils. uniqueDepsSet := datastructures.MakeSet[string]() for _, rootDep := range directDependenciesList { directDependency := &xrayUtils.GraphNode{ - Id: pythonPackageTypeIdentifier + rootDep, + Id: PythonPackageTypeIdentifier + rootDep, Nodes: []*xrayUtils.GraphNode{}, } populatePythonDependencyTree(directDependency, dependenciesGraph, uniqueDepsSet) @@ -56,7 +61,15 @@ func BuildDependencyTree(auditPython *AuditPython) (dependencyTree []*xrayUtils. return } -func getDependencies(auditPython *AuditPython) (dependenciesGraph map[string][]string, directDependencies []string, err error) { +func getDependencies(serverDetails *config.ServerDetails, tech coreutils.Technology, + params xrayutils2.AuditParams) (dependenciesGraph map[string][]string, directDependencies []string, err error) { + auditPython := &AuditPython{ + Server: serverDetails, + Tool: pythonutils.PythonTool(tech), + RemotePypiRepo: params.DepsRepo(), + PipRequirementsFile: params.PipRequirementsFile(), + IsCurationCmd: params.IsCurationCmd(), + } wd, err := os.Getwd() if errorutils.CheckError(err) != nil { return @@ -103,9 +116,62 @@ func getDependencies(auditPython *AuditPython) (dependenciesGraph map[string][]s sca.LogExecutableVersion("python") sca.LogExecutableVersion(string(auditPython.Tool)) } + if auditPython.IsCurationCmd { + pipUrls, err := processPipDownloadsUrlsFromReportFile() + if err != nil { + return + } + params.SetDownloadUrls(pipUrls) + } return } +func processPipDownloadsUrlsFromReportFile() (map[string]string, error) { + exist, err := fileutils.IsFileExists("report.json", false) + if err != nil { + return nil, err + } + if !exist { + err = errors.New("process failed, report file wasn't found, cant processed with curation command") + return nil, err + } + var reportBytes []byte + reportBytes, err = fileutils.ReadFile("report.json") + if err != nil { + return nil, err + } + pipReport := &pypiReport{} + if err = json.Unmarshal(reportBytes, pipReport); err != nil { + return nil, err + } + pipUrls := map[string]string{} + for _, dep := range pipReport.Install { + if dep.MetaData.Name != "" { + compId := PythonPackageTypeIdentifier + strings.ToLower(dep.MetaData.Name) + ":" + dep.MetaData.Version + pipUrls[compId] = strings.Replace(dep.DownloadInfo.Url, "api/curation/audit/", "", 1) + } + } + return pipUrls, nil +} + +type pypiReport struct { + Install []pypiReportInfo +} + +type pypiReportInfo struct { + DownloadInfo pypiDwonloadInfo `json:"download_info"` + MetaData pypiMetaData `json:"metadata"` +} + +type pypiDwonloadInfo struct { + Url string `json:"url"` +} + +type pypiMetaData struct { + Name string `json:"name"` + Version string `json:"version"` +} + func runPythonInstall(auditPython *AuditPython) (restoreEnv func() error, err error) { switch auditPython.Tool { case pythonutils.Pip: @@ -123,7 +189,7 @@ func installPoetryDeps(auditPython *AuditPython) (restoreEnv func() error, err e return nil } if auditPython.RemotePypiRepo != "" { - rtUrl, username, password, err := utils.GetPypiRepoUrlWithCredentials(auditPython.Server, auditPython.RemotePypiRepo) + rtUrl, username, password, err := utils.GetPypiRepoUrlWithCredentials(auditPython.Server, auditPython.RemotePypiRepo, false) if err != nil { return restoreEnv, err } @@ -162,15 +228,26 @@ func installPipDeps(auditPython *AuditPython) (restoreEnv func() error, err erro remoteUrl := "" if auditPython.RemotePypiRepo != "" { - remoteUrl, err = utils.GetPypiRepoUrl(auditPython.Server, auditPython.RemotePypiRepo) + remoteUrl, err = utils.GetPypiRepoUrl(auditPython.Server, auditPython.RemotePypiRepo, auditPython.IsCurationCmd) if err != nil { return } } - pipInstallArgs := getPipInstallArgs(auditPython.PipRequirementsFile, remoteUrl) + + var curationCachePip string + var reportFileName string + if auditPython.IsCurationCmd { + curationCachePip, err = xrayutils2.GetCurationPipCacheFolder() + if err != nil { + return + } + reportFileName = "report.json" + } + + pipInstallArgs := getPipInstallArgs(auditPython.PipRequirementsFile, remoteUrl, curationCachePip, reportFileName) err = executeCommand("python", pipInstallArgs...) if err != nil && auditPython.PipRequirementsFile == "" { - pipInstallArgs = getPipInstallArgs("requirements.txt", remoteUrl) + pipInstallArgs = getPipInstallArgs("requirements.txt", remoteUrl, curationCachePip, reportFileName) reqErr := executeCommand("python", pipInstallArgs...) if reqErr != nil { // Return Pip install error and log the requirements fallback error. @@ -194,8 +271,8 @@ func executeCommand(executable string, args ...string) error { return nil } -func getPipInstallArgs(requirementsFile, remoteUrl string) []string { - args := []string{"-m", "pip", "install"} +func getPipInstallArgs(requirementsFile string, remoteUrl string, cacheFolder string, reportFileName string) []string { + args := []string{"-m", "pip", "install", "--ignore-installed"} if requirementsFile == "" { // Run 'pip install .' args = append(args, ".") @@ -206,11 +283,17 @@ func getPipInstallArgs(requirementsFile, remoteUrl string) []string { if remoteUrl != "" { args = append(args, utils.GetPypiRemoteRegistryFlag(pythonutils.Pip), remoteUrl) } + if cacheFolder != "" { + args = append(args, "--cache-dir", cacheFolder) + } + if reportFileName != "" { + args = append(args, "--report", reportFileName) + } return args } func runPipenvInstallFromRemoteRegistry(server *config.ServerDetails, depsRepoName string) (err error) { - rtUrl, err := utils.GetPypiRepoUrl(server, depsRepoName) + rtUrl, err := utils.GetPypiRepoUrl(server, depsRepoName, false) if err != nil { return err } @@ -268,11 +351,11 @@ func populatePythonDependencyTree(currNode *xrayUtils.GraphNode, dependenciesGra return } uniqueDepsSet.Add(currNode.Id) - currDepChildren := dependenciesGraph[strings.TrimPrefix(currNode.Id, pythonPackageTypeIdentifier)] + currDepChildren := dependenciesGraph[strings.TrimPrefix(currNode.Id, PythonPackageTypeIdentifier)] // Recursively create & append all node's dependencies. for _, dependency := range currDepChildren { childNode := &xrayUtils.GraphNode{ - Id: pythonPackageTypeIdentifier + dependency, + Id: PythonPackageTypeIdentifier + dependency, Nodes: []*xrayUtils.GraphNode{}, Parent: currNode, } diff --git a/commands/audit/sca/python/python_test.go b/commands/audit/sca/python/python_test.go index 91ead416..91301521 100644 --- a/commands/audit/sca/python/python_test.go +++ b/commands/audit/sca/python/python_test.go @@ -1,13 +1,14 @@ package python import ( + "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" + xrayutils "github.com/jfrog/jfrog-cli-security/utils" "path/filepath" + "strings" "testing" "github.com/jfrog/jfrog-cli-core/v2/utils/tests" "github.com/jfrog/jfrog-cli-security/commands/audit/sca" - - "github.com/jfrog/build-info-go/utils/pythonutils" "github.com/stretchr/testify/assert" ) @@ -16,11 +17,11 @@ func TestBuildPipDependencyListSetuppy(t *testing.T) { _, cleanUp := sca.CreateTestWorkspace(t, filepath.Join("projects", "package-managers", "python", "pip", "pip", "setuppyproject")) defer cleanUp() // Run getModulesDependencyTrees - rootNode, uniqueDeps, err := BuildDependencyTree(&AuditPython{Tool: pythonutils.Pip}) + rootNode, uniqueDeps, err := BuildDependencyTree(nil, coreutils.Pip, &xrayutils.AuditBasicParams{}) assert.NoError(t, err) - assert.Contains(t, uniqueDeps, pythonPackageTypeIdentifier+"pexpect:4.8.0") - assert.Contains(t, uniqueDeps, pythonPackageTypeIdentifier+"ptyprocess:0.7.0") - assert.Contains(t, uniqueDeps, pythonPackageTypeIdentifier+"pip-example:1.2.3") + assert.Contains(t, uniqueDeps, PythonPackageTypeIdentifier+"pexpect:4.8.0") + assert.Contains(t, uniqueDeps, PythonPackageTypeIdentifier+"ptyprocess:0.7.0") + assert.Contains(t, uniqueDeps, PythonPackageTypeIdentifier+"pip-example:1.2.3") assert.Len(t, rootNode, 1) if len(rootNode) > 0 { assert.NotEmpty(t, rootNode[0].Nodes) @@ -35,15 +36,52 @@ func TestBuildPipDependencyListSetuppy(t *testing.T) { } } +func TestBuildPipDependencyListSetuppyForCuration(t *testing.T) { + // Create and change directory to test workspace + _, cleanUp := sca.CreateTestWorkspace(t, filepath.Join("projects", "package-managers", "python", "pip", "pip", "setuppyproject")) + defer cleanUp() + // Run getModulesDependencyTrees + params := &xrayutils.AuditBasicParams{} + params.SetIsCurationCmd(true) + rootNode, uniqueDeps, err := BuildDependencyTree(nil, coreutils.Pip, params) + assert.NoError(t, err) + assert.Contains(t, uniqueDeps, PythonPackageTypeIdentifier+"pexpect:4.8.0") + assert.Contains(t, uniqueDeps, PythonPackageTypeIdentifier+"ptyprocess:0.7.0") + assert.Contains(t, uniqueDeps, PythonPackageTypeIdentifier+"pip-example:1.2.3") + assert.Len(t, rootNode, 1) + if len(rootNode) > 0 { + assert.NotEmpty(t, rootNode[0].Nodes) + if rootNode[0].Nodes != nil { + // Test direct dependency + directDepNode := tests.GetAndAssertNode(t, rootNode[0].Nodes, "pip-example:1.2.3") + // Test child module + childNode := tests.GetAndAssertNode(t, directDepNode.Nodes, "pexpect:4.8.0") + // Test sub child module + tests.GetAndAssertNode(t, childNode.Nodes, "ptyprocess:0.7.0") + + downloadUrls := params.GetDownloadUrls() + assert.NotEmpty(t, downloadUrls) + url, exist := downloadUrls[PythonPackageTypeIdentifier+"ptyprocess:0.7.0"] + assert.True(t, exist) + strings.HasSuffix(url, "packages/packages/22/a6/858897256d0deac81") + + url, exist = downloadUrls[PythonPackageTypeIdentifier+"pexpect:4.8.0"] + assert.True(t, exist) + strings.HasSuffix(url, "packages/packages/39/7b/88dbb785881c28a10") + + } + } +} + func TestPipDependencyListRequirementsFallback(t *testing.T) { // Create and change directory to test workspace _, cleanUp := sca.CreateTestWorkspace(t, filepath.Join("projects", "package-managers", "python", "pip", "pip", "requirementsproject")) defer cleanUp() // No requirements file field specified, expect the command to use the fallback 'pip install -r requirements.txt' command - rootNode, uniqueDeps, err := BuildDependencyTree(&AuditPython{Tool: pythonutils.Pip}) + rootNode, uniqueDeps, err := BuildDependencyTree(nil, coreutils.Pip, &xrayutils.AuditBasicParams{}) assert.NoError(t, err) - assert.Contains(t, uniqueDeps, pythonPackageTypeIdentifier+"pexpect:4.7.0") - assert.Contains(t, uniqueDeps, pythonPackageTypeIdentifier+"ptyprocess:0.7.0") + assert.Contains(t, uniqueDeps, PythonPackageTypeIdentifier+"pexpect:4.7.0") + assert.Contains(t, uniqueDeps, PythonPackageTypeIdentifier+"ptyprocess:0.7.0") assert.Len(t, rootNode, 1) if assert.GreaterOrEqual(t, len(rootNode[0].Nodes), 2) { childNode := tests.GetAndAssertNode(t, rootNode[0].Nodes, "pexpect:4.7.0") @@ -59,10 +97,12 @@ func TestBuildPipDependencyListRequirements(t *testing.T) { _, cleanUp := sca.CreateTestWorkspace(t, filepath.Join("projects", "package-managers", "python", "pip", "pip", "requirementsproject")) defer cleanUp() // Run getModulesDependencyTrees - rootNode, uniqueDeps, err := BuildDependencyTree(&AuditPython{Tool: pythonutils.Pip, PipRequirementsFile: "requirements.txt"}) + params := &xrayutils.AuditBasicParams{} + params.SetPipRequirementsFile("requirements.txt") + rootNode, uniqueDeps, err := BuildDependencyTree(nil, coreutils.Pypi, params) assert.NoError(t, err) - assert.Contains(t, uniqueDeps, pythonPackageTypeIdentifier+"pexpect:4.7.0") - assert.Contains(t, uniqueDeps, pythonPackageTypeIdentifier+"ptyprocess:0.7.0") + assert.Contains(t, uniqueDeps, PythonPackageTypeIdentifier+"pexpect:4.7.0") + assert.Contains(t, uniqueDeps, PythonPackageTypeIdentifier+"ptyprocess:0.7.0") assert.Len(t, rootNode, 1) if len(rootNode) > 0 { assert.NotEmpty(t, rootNode[0].Nodes) @@ -80,12 +120,12 @@ func TestBuildPipenvDependencyList(t *testing.T) { _, cleanUp := sca.CreateTestWorkspace(t, filepath.Join("projects", "package-managers", "python", "pipenv", "pipenv", "pipenvproject")) defer cleanUp() expectedPipenvUniqueDeps := []string{ - pythonPackageTypeIdentifier + "toml:0.10.2", - pythonPackageTypeIdentifier + "pexpect:4.8.0", - pythonPackageTypeIdentifier + "ptyprocess:0.7.0", + PythonPackageTypeIdentifier + "toml:0.10.2", + PythonPackageTypeIdentifier + "pexpect:4.8.0", + PythonPackageTypeIdentifier + "ptyprocess:0.7.0", } // Run getModulesDependencyTrees - rootNode, uniqueDeps, err := BuildDependencyTree(&AuditPython{Tool: pythonutils.Pipenv}) + rootNode, uniqueDeps, err := BuildDependencyTree(nil, coreutils.Pypi, &xrayutils.AuditBasicParams{}) if err != nil { t.Fatal(err) } @@ -107,20 +147,20 @@ func TestBuildPoetryDependencyList(t *testing.T) { _, cleanUp := sca.CreateTestWorkspace(t, filepath.Join("projects", "package-managers", "python", "poetry", "my-poetry-project")) defer cleanUp() expectedPoetryUniqueDeps := []string{ - pythonPackageTypeIdentifier + "wcwidth:0.2.8", - pythonPackageTypeIdentifier + "colorama:0.4.6", - pythonPackageTypeIdentifier + "packaging:23.2", - pythonPackageTypeIdentifier + "python:", - pythonPackageTypeIdentifier + "pluggy:0.13.1", - pythonPackageTypeIdentifier + "py:1.11.0", - pythonPackageTypeIdentifier + "atomicwrites:1.4.1", - pythonPackageTypeIdentifier + "attrs:23.1.0", - pythonPackageTypeIdentifier + "more-itertools:10.1.0", - pythonPackageTypeIdentifier + "numpy:1.26.1", - pythonPackageTypeIdentifier + "pytest:5.4.3", + PythonPackageTypeIdentifier + "wcwidth:0.2.8", + PythonPackageTypeIdentifier + "colorama:0.4.6", + PythonPackageTypeIdentifier + "packaging:23.2", + PythonPackageTypeIdentifier + "python:", + PythonPackageTypeIdentifier + "pluggy:0.13.1", + PythonPackageTypeIdentifier + "py:1.11.0", + PythonPackageTypeIdentifier + "atomicwrites:1.4.1", + PythonPackageTypeIdentifier + "attrs:23.1.0", + PythonPackageTypeIdentifier + "more-itertools:10.1.0", + PythonPackageTypeIdentifier + "numpy:1.26.1", + PythonPackageTypeIdentifier + "pytest:5.4.3", } // Run getModulesDependencyTrees - rootNode, uniqueDeps, err := BuildDependencyTree(&AuditPython{Tool: pythonutils.Poetry}) + rootNode, uniqueDeps, err := BuildDependencyTree(nil, coreutils.Pypi, &xrayutils.AuditBasicParams{}) if err != nil { t.Fatal(err) } @@ -138,9 +178,9 @@ func TestBuildPoetryDependencyList(t *testing.T) { } func TestGetPipInstallArgs(t *testing.T) { - assert.Equal(t, []string{"-m", "pip", "install", "."}, getPipInstallArgs("", "")) - assert.Equal(t, []string{"-m", "pip", "install", "-r", "requirements.txt"}, getPipInstallArgs("requirements.txt", "")) + assert.Equal(t, []string{"-m", "pip", "install", "."}, getPipInstallArgs("", "", "", "")) + assert.Equal(t, []string{"-m", "pip", "install", "-r", "requirements.txt"}, getPipInstallArgs("requirements.txt", "", "", "")) - assert.Equal(t, []string{"-m", "pip", "install", ".", "-i", "https://user@pass:remote.url/repo"}, getPipInstallArgs("", "https://user@pass:remote.url/repo")) - assert.Equal(t, []string{"-m", "pip", "install", "-r", "requirements.txt", "-i", "https://user@pass:remote.url/repo"}, getPipInstallArgs("requirements.txt", "https://user@pass:remote.url/repo")) + assert.Equal(t, []string{"-m", "pip", "install", ".", "-i", "https://user@pass:remote.url/repo"}, getPipInstallArgs("", "https://user@pass:remote.url/repo", "", "")) + assert.Equal(t, []string{"-m", "pip", "install", "-r", "requirements.txt", "-i", "https://user@pass:remote.url/repo"}, getPipInstallArgs("requirements.txt", "https://user@pass:remote.url/repo", "", "")) } diff --git a/commands/audit/scarunner.go b/commands/audit/scarunner.go index 8efb3b18..1e883f7e 100644 --- a/commands/audit/scarunner.go +++ b/commands/audit/scarunner.go @@ -7,7 +7,6 @@ import ( "os" "time" - "github.com/jfrog/build-info-go/utils/pythonutils" "github.com/jfrog/gofrog/datastructures" "github.com/jfrog/jfrog-cli-core/v2/common/project" "github.com/jfrog/jfrog-cli-core/v2/utils/config" @@ -216,11 +215,7 @@ func GetTechDependencyTree(params xrayutils.AuditParams, tech coreutils.Technolo case coreutils.Go: fullDependencyTrees, uniqueDeps, err = _go.BuildDependencyTree(params) case coreutils.Pipenv, coreutils.Pip, coreutils.Poetry: - fullDependencyTrees, uniqueDeps, err = python.BuildDependencyTree(&python.AuditPython{ - Server: serverDetails, - Tool: pythonutils.PythonTool(tech), - RemotePypiRepo: params.DepsRepo(), - PipRequirementsFile: params.PipRequirementsFile()}) + fullDependencyTrees, uniqueDeps, err = python.BuildDependencyTree(serverDetails, tech, params) case coreutils.Nuget: fullDependencyTrees, uniqueDeps, err = nuget.BuildDependencyTree(params) default: diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index af582ceb..b1158330 100644 --- a/commands/curation/curationaudit.go +++ b/commands/curation/curationaudit.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" config "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-cli-security/commands/audit/sca/python" "net/http" "os" "path/filepath" @@ -12,6 +13,7 @@ import ( "sort" "strings" "sync" + "time" "github.com/jfrog/gofrog/datastructures" "github.com/jfrog/gofrog/parallel" @@ -59,6 +61,7 @@ var CurationOutputFormats = []string{string(outFormat.Table), string(outFormat.J var supportedTech = map[coreutils.Technology]func(ca *CurationAuditCommand) (bool, error){ coreutils.Npm: func(ca *CurationAuditCommand) (bool, error) { return true, nil }, + coreutils.Pip: func(ca *CurationAuditCommand) (bool, error) { return true, nil }, coreutils.Maven: func(ca *CurationAuditCommand) (bool, error) { return ca.checkSupportByVersionOrEnv(coreutils.Maven, MinArtiMavenSupport, MinArtiXraySupport, utils.CurationMavenSupport) }, @@ -152,6 +155,7 @@ type treeAnalyzer struct { repo string tech coreutils.Technology parallelRequests int + downloadUrls map[string]string } type CurationAuditCommand struct { @@ -276,11 +280,15 @@ func (ca *CurationAuditCommand) getAuditParamsByTech(tech coreutils.Technology) SetNpmOverwritePackageLock(true) case coreutils.Maven: ca.AuditParams.SetIsMavenDepTreeInstalled(true) + case coreutils.Pip: + ca.AuditParams.SetIsMavenDepTreeInstalled(true) } + return ca.AuditParams } func (ca *CurationAuditCommand) auditTree(tech coreutils.Technology, results map[string][]*PackageStatus) error { + start := time.Now() flattenGraph, fullDependenciesTrees, err := audit.GetTechDependencyTree(ca.getAuditParamsByTech(tech), tech) if err != nil { return err @@ -298,7 +306,13 @@ func (ca *CurationAuditCommand) auditTree(tech coreutils.Technology, results map return err } rootNode := fullDependenciesTrees[0] - _, projectName, projectScope, projectVersion := getUrlNameAndVersionByTech(tech, rootNode, "", "") + _, projectName, projectScope, projectVersion := getUrlNameAndVersionByTech(tech, rootNode, nil, "", "") + if projectName == "" { + workPath, err := os.Getwd() + if err == nil { + projectName = filepath.Base(workPath) + } + } if ca.Progress() != nil { ca.Progress().SetHeadlineMsg(fmt.Sprintf("Fetch curation status for %s graph with %v nodes project name: %s:%s", tech.ToFormal(), len(flattenGraph.Nodes)-1, projectName, projectVersion)) } @@ -318,6 +332,7 @@ func (ca *CurationAuditCommand) auditTree(tech coreutils.Technology, results map repo: ca.PackageManagerConfig.TargetRepo(), tech: tech, parallelRequests: ca.parallelRequests, + downloadUrls: ca.GetDownloadUrls(), } rootNodes := map[string]struct{}{} @@ -333,7 +348,8 @@ func (ca *CurationAuditCommand) auditTree(tech coreutils.Technology, results map sort.Slice(packagesStatus, func(i, j int) bool { return packagesStatus[i].ParentName < packagesStatus[j].ParentName }) - results[fmt.Sprintf("%s:%s", projectName, projectVersion)] = packagesStatus + results[strings.TrimSuffix(fmt.Sprintf("%s:%s", projectName, projectVersion), ":")] = packagesStatus + log.Info(fmt.Sprintf("total time: %v", time.Since(start))) return err } @@ -434,7 +450,7 @@ func (nc *treeAnalyzer) GraphsRelations(fullDependenciesTrees []*xrayUtils.Graph func (nc *treeAnalyzer) fillGraphRelations(node *xrayUtils.GraphNode, preProcessMap *sync.Map, packagesStatus *[]*PackageStatus, parent, parentVersion string, visited *datastructures.Set[string], isRoot bool) { for _, child := range node.Nodes { - packageUrls, name, scope, version := getUrlNameAndVersionByTech(nc.tech, child, nc.url, nc.repo) + packageUrls, name, scope, version := getUrlNameAndVersionByTech(nc.tech, child, nc.downloadUrls, nc.url, nc.repo) if isRoot { parent = name parentVersion = version @@ -495,7 +511,7 @@ func (nc *treeAnalyzer) fetchNodesStatus(graph *xrayUtils.GraphNode, p *sync.Map } func (nc *treeAnalyzer) fetchNodeStatus(node xrayUtils.GraphNode, p *sync.Map) error { - packageUrls, name, scope, version := getUrlNameAndVersionByTech(nc.tech, &node, nc.url, nc.repo) + packageUrls, name, scope, version := getUrlNameAndVersionByTech(nc.tech, &node, nc.downloadUrls, nc.url, nc.repo) if len(packageUrls) == 0 { return nil } @@ -601,13 +617,36 @@ func makeLegiblePolicyDetails(explanation, recommendation string) (string, strin return explanation, recommendation } -func getUrlNameAndVersionByTech(tech coreutils.Technology, node *xrayUtils.GraphNode, artiUrl, repo string) (downloadUrls []string, name string, scope string, version string) { +func getUrlNameAndVersionByTech(tech coreutils.Technology, node *xrayUtils.GraphNode, downloadUrlsMap map[string]string, artiUrl, repo string) (downloadUrls []string, name string, scope string, version string) { switch tech { case coreutils.Npm: return getNpmNameScopeAndVersion(node.Id, artiUrl, repo, coreutils.Npm.String()) case coreutils.Maven: return getMavenNameScopeAndVersion(node.Id, artiUrl, repo, node.Types) + + case coreutils.Pip: + downloadUrls, name, version = getPythonNameVersion(node.Id, downloadUrlsMap) + return + + } + return +} + +func getPythonNameVersion(id string, downloadUrlsMap map[string]string) (downloadUrls []string, name, version string) { + if downloadUrlsMap != nil { + if dl, ok := downloadUrlsMap[id]; ok { + downloadUrls = []string{dl} + } else { + log.Warn(fmt.Sprintf("couldn't find download url for node id %s", id)) + } + } + id = strings.TrimPrefix(id, python.PythonPackageTypeIdentifier) + allParts := strings.Split(id, ":") + if len(allParts) < 2 { + return } + name = allParts[0] + version = allParts[1] return } diff --git a/commands/curation/curationaudit_test.go b/commands/curation/curationaudit_test.go index 84896438..3892fce8 100644 --- a/commands/curation/curationaudit_test.go +++ b/commands/curation/curationaudit_test.go @@ -17,6 +17,7 @@ import ( "net/http/httptest" "os" "os/exec" + "path" "path/filepath" "regexp" "sort" @@ -412,7 +413,7 @@ func TestDoCurationAudit(t *testing.T) { defer callback() callback2 := clienttestutils.SetEnvWithCallbackAndAssert(t, "JFROG_CLI_CURATION_MAVEN", "true") defer callback2() - mockServer, config := curationServer(t, tt.expectedBuildRequest, tt.expectedRequest, tt.requestToFail, tt.requestToError) + mockServer, config := curationServer(t, tt.expectedBuildRequest, tt.expectedRequest, tt.requestToFail, tt.requestToError, tt.serveResources) defer mockServer.Close() configFilePath := WriteServerDetailsConfigFileBytes(t, config.ArtifactoryUrl, configurationDir) defer func() { @@ -428,8 +429,8 @@ func TestDoCurationAudit(t *testing.T) { require.NoError(t, err) if tt.preTestExec != "" { callbackPreTest := clienttestutils.ChangeDirWithCallback(t, rootDir, tt.pathToPreTest) - _, err := exec.Command(tt.preTestExec, tt.funcToGetGoals(t)...).CombinedOutput() - assert.NoError(t, err) + output, err := exec.Command(tt.preTestExec, tt.funcToGetGoals(t)...).CombinedOutput() + assert.NoErrorf(t, err, string(output)) callbackPreTest() } callback3 := clienttestutils.ChangeDirWithCallback(t, rootDir, strings.TrimSuffix(tt.pathToTest, string(os.PathSeparator)+".jfrog")) @@ -478,6 +479,7 @@ type testCase struct { pathToTest string pathToPreTest string preTestExec string + serveResources map[string]string funcToGetGoals func(t *testing.T) []string shouldIgnoreConfigFile bool expectedBuildRequest map[string]bool @@ -491,6 +493,41 @@ type testCase struct { func getTestCasesForDoCurationAudit() []testCase { tests := []testCase{ + { + name: "python tree - one blocked package", + pathToTest: filepath.Join(TestDataDir, "projects", "package-managers", "python", "pip", "pip-curation", ".jfrog"), + serveResources: map[string]string{ + "pip": filepath.Join("resources", "pip-resp"), + "pexpect": filepath.Join("resources", "pexpect-resp"), + "ptyprocess": filepath.Join("resources", "ptyprocess-resp"), + "pexpect-4.8.0-py2.py3-none-any.whl": filepath.Join("resources", "pexpect-4.8.0-py2.py3-none-any.whl"), + "ptyprocess-0.7.0-py2.py3-none-any.whl": filepath.Join("resources", "ptyprocess-0.7.0-py2.py3-none-any.whl"), + }, + requestToFail: map[string]bool{ + "/api/pypi/pypi-remote/packages/packages/39/7b/88dbb785881c28a102619d46423cb853b46dbccc70d3ac362d99773a78ce/pexpect-4.8.0-py2.py3-none-any.whl": false, + }, + expectedResp: map[string][]*PackageStatus{ + "pip-curation": { + { + Action: "blocked", + ParentVersion: "4.8.0", + ParentName: "pexpect", + BlockedPackageUrl: "/api/pypi/pypi-remote/packages/packages/39/7b/88dbb785881c28a102619d46423cb853b46dbccc70d3ac362d99773a78ce/pexpect-4.8.0-py2.py3-none-any.whl", + PackageName: "pexpect", + PackageVersion: "4.8.0", + BlockingReason: "Policy violations", + PkgType: "pip", + DepRelation: "direct", + Policy: []Policy{ + { + Policy: "pol1", + Condition: "cond1", + }, + }, + }, + }, + }, + }, { name: "maven tree - one blocked package", pathToPreTest: filepath.Join(TestDataDir, "projects", "package-managers", "maven", "maven-curation", "pretest"), @@ -611,7 +648,7 @@ func getTestCasesForDoCurationAudit() []testCase { return tests } -func curationServer(t *testing.T, expectedBuildRequest map[string]bool, expectedRequest map[string]bool, requestToFail map[string]bool, requestToError map[string]bool) (*httptest.Server, *config.ServerDetails) { +func curationServer(t *testing.T, expectedBuildRequest map[string]bool, expectedRequest map[string]bool, requestToFail map[string]bool, requestToError map[string]bool, resourceToServe map[string]string) (*httptest.Server, *config.ServerDetails) { mapLockReadWrite := sync.Mutex{} serverMock, config, _ := coretests.CreateRtRestsMockServer(t, func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodHead { @@ -628,12 +665,19 @@ func curationServer(t *testing.T, expectedBuildRequest map[string]bool, expected } } if r.Method == http.MethodGet { - if _, exist := expectedBuildRequest[r.RequestURI]; exist { - expectedBuildRequest[r.RequestURI] = true + if resourceToServe != nil { + if pathToRes, ok := resourceToServe[path.Base(r.RequestURI)]; ok && strings.Contains(r.RequestURI, "api/curation/audit") { + f, err := fileutils.ReadFile(pathToRes) + require.NoError(t, err) + w.Header().Add("content-type", "text/html") + w.Write(f) + return + } } if _, exist := expectedBuildRequest[r.RequestURI]; exist { expectedBuildRequest[r.RequestURI] = true } + if _, exist := requestToFail[r.RequestURI]; exist { w.WriteHeader(http.StatusForbidden) _, err := w.Write([]byte("{\n \"errors\": [\n {\n \"status\": 403,\n " + diff --git a/go.mod b/go.mod index cf3a24f8..44b7a53e 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.21 require ( github.com/gookit/color v1.5.4 github.com/jfrog/build-info-go v1.9.23 - github.com/jfrog/gofrog v1.6.0 + github.com/jfrog/gofrog v1.6.3 github.com/jfrog/jfrog-apps-config v1.0.1 github.com/jfrog/jfrog-cli-core/v2 v2.48.1 github.com/jfrog/jfrog-client-go v1.37.1 @@ -98,7 +98,7 @@ require ( gopkg.in/warnings.v0 v0.1.2 // indirect ) -replace github.com/jfrog/jfrog-cli-core/v2 => github.com/jfrog/jfrog-cli-core/v2 v2.31.1-0.20240303093859-ebce750e0f45 +replace github.com/jfrog/jfrog-cli-core/v2 => github.com/asafambar/jfrog-cli-core/v2 v2.0.0-20240312152209-88de746b7631 replace github.com/jfrog/jfrog-client-go => github.com/jfrog/jfrog-client-go v1.28.1-0.20240222155638-e55c7d7acbee diff --git a/go.sum b/go.sum index 08c895dd..17399035 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuW github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/asafambar/jfrog-cli-core/v2 v2.0.0-20240312152209-88de746b7631 h1:vWetc60TB2szGBsyYAqjScZ6vgh7GAHEtMnA7Vjk9G4= +github.com/asafambar/jfrog-cli-core/v2 v2.0.0-20240312152209-88de746b7631/go.mod h1:fTnA9KjwuMEWnqAFPPoLc6IzvYxD8SorqawESk74fP8= github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= @@ -98,12 +100,10 @@ github.com/jfrog/archiver/v3 v3.6.0 h1:OVZ50vudkIQmKMgA8mmFF9S0gA47lcag22N13iV3F github.com/jfrog/archiver/v3 v3.6.0/go.mod h1:fCAof46C3rAXgZurS8kNRNdSVMKBbZs+bNNhPYxLldI= github.com/jfrog/build-info-go v1.8.9-0.20240225113943-096bf22ca54c h1:M1QiuCYGCYN1IiGyxogrLzfetYGkkhE2pgDh5K4Wo9A= github.com/jfrog/build-info-go v1.8.9-0.20240225113943-096bf22ca54c/go.mod h1:QHcKuesY4MrBVBuEwwBz4uIsX6mwYuMEDV09ng4AvAU= -github.com/jfrog/gofrog v1.6.0 h1:jOwb37nHY2PnxePNFJ6e6279Pgkr3di05SbQQw47Mq8= -github.com/jfrog/gofrog v1.6.0/go.mod h1:SZ1EPJUruxrVGndOzHd+LTiwWYKMlHqhKD+eu+v5Hqg= +github.com/jfrog/gofrog v1.6.3 h1:F7He0+75HcgCe6SGTSHLFCBDxiE2Ja0tekvvcktW6wc= +github.com/jfrog/gofrog v1.6.3/go.mod h1:SZ1EPJUruxrVGndOzHd+LTiwWYKMlHqhKD+eu+v5Hqg= github.com/jfrog/jfrog-apps-config v1.0.1 h1:mtv6k7g8A8BVhlHGlSveapqf4mJfonwvXYLipdsOFMY= github.com/jfrog/jfrog-apps-config v1.0.1/go.mod h1:8AIIr1oY9JuH5dylz2S6f8Ym2MaadPLR6noCBO4C22w= -github.com/jfrog/jfrog-cli-core/v2 v2.31.1-0.20240303093859-ebce750e0f45 h1:KeP2+7KcZHWZ24FPdW3yLfzXI/O6U9KdUzgvGi58PGo= -github.com/jfrog/jfrog-cli-core/v2 v2.31.1-0.20240303093859-ebce750e0f45/go.mod h1:aE5kYuqiZxu6hHkAQm34BvtGjLR8rk0/PUWpl4u5g0Q= github.com/jfrog/jfrog-client-go v1.28.1-0.20240222155638-e55c7d7acbee h1:IrM+wE8WmsSm95vpYSEYle2mPAOVn1FrRTeScSNxgrw= github.com/jfrog/jfrog-client-go v1.28.1-0.20240222155638-e55c7d7acbee/go.mod h1:jcZYTyo9H4GtZ6eAYIfKm1ulxeTbshcBBA+YUbWlHNc= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= diff --git a/tests/testdata/projects/package-managers/npm/npm-project/.jfrog/jfrog-cli.conf.v6 b/tests/testdata/projects/package-managers/npm/npm-project/.jfrog/jfrog-cli.conf.v6 index 9d0a2fb5..1fe23a8c 100644 --- a/tests/testdata/projects/package-managers/npm/npm-project/.jfrog/jfrog-cli.conf.v6 +++ b/tests/testdata/projects/package-managers/npm/npm-project/.jfrog/jfrog-cli.conf.v6 @@ -1,8 +1,8 @@ { "servers": [ { - "url": "http://127.0.0.1:62857/", - "artifactoryUrl": "http://127.0.0.1:62857/", + "url": "http://127.0.0.1:63400/", + "artifactoryUrl": "http://127.0.0.1:63400/", "user": "admin", "password": "password", "serverId": "test" diff --git a/tests/testdata/projects/package-managers/python/pip/pip-curation/.jfrog/projects/pip.yaml b/tests/testdata/projects/package-managers/python/pip/pip-curation/.jfrog/projects/pip.yaml new file mode 100644 index 00000000..f22b29c1 --- /dev/null +++ b/tests/testdata/projects/package-managers/python/pip/pip-curation/.jfrog/projects/pip.yaml @@ -0,0 +1,5 @@ +version: 1 +type: pip +resolver: + repo: pypi-remote + serverId: test diff --git a/tests/testdata/projects/package-managers/python/pip/pip-curation/requirements.txt b/tests/testdata/projects/package-managers/python/pip/pip-curation/requirements.txt new file mode 100644 index 00000000..078b7085 --- /dev/null +++ b/tests/testdata/projects/package-managers/python/pip/pip-curation/requirements.txt @@ -0,0 +1 @@ +pexpect==4.8.0 \ No newline at end of file diff --git a/tests/testdata/projects/package-managers/python/pip/pip-curation/resources/pexpect-4.8.0-py2.py3-none-any.whl b/tests/testdata/projects/package-managers/python/pip/pip-curation/resources/pexpect-4.8.0-py2.py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..239a7508ef5f7b3787ea23c63052deec5434cdf1 GIT binary patch literal 59024 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 literal 0 HcmV?d00001 diff --git a/tests/testdata/projects/package-managers/python/pip/pip-curation/resources/pexpect-resp b/tests/testdata/projects/package-managers/python/pip/pip-curation/resources/pexpect-resp new file mode 100644 index 00000000..619cafc0 --- /dev/null +++ b/tests/testdata/projects/package-managers/python/pip/pip-curation/resources/pexpect-resp @@ -0,0 +1,70 @@ + + + + + Simple Index + + + + + pexpect-2.4.tar.gz + pexpect-3.0.tar.gz + pexpect-3.1.tar.gz + pexpect-3.2.tar.gz + pexpect-3.3.tar.gz + pexpect-4.0.1.tar.gz + pexpect-4.0.tar.gz + pexpect-4.1.0-py2.py3-none-any.whl + pexpect-4.1.0.tar.gz + pexpect-4.2.0-py2.py3-none-any.whl + pexpect-4.2.0.tar.gz + pexpect-4.2.1-py2.py3-none-any.whl + pexpect-4.2.1.tar.gz + pexpect-4.3.0-py2.py3-none-any.whl + pexpect-4.3.0.tar.gz + pexpect-4.3.1-py2.py3-none-any.whl + pexpect-4.3.1.tar.gz + pexpect-4.4.0-py2.py3-none-any.whl + pexpect-4.4.0.tar.gz + pexpect-4.5.0-py2.py3-none-any.whl + pexpect-4.5.0.tar.gz + pexpect-4.6.0-py2.py3-none-any.whl + pexpect-4.6.0.tar.gz + pexpect-4.7.0-py2.py3-none-any.whl + pexpect-4.7.0.tar.gz + pexpect-4.8.0-py2.py3-none-any.whl + pexpect-4.8.0.tar.gz + pexpect-4.9.0-py2.py3-none-any.whl + pexpect-4.9.0.tar.gz + + + \ No newline at end of file diff --git a/tests/testdata/projects/package-managers/python/pip/pip-curation/resources/pip-resp b/tests/testdata/projects/package-managers/python/pip/pip-curation/resources/pip-resp new file mode 100644 index 00000000..eb0cb731 --- /dev/null +++ b/tests/testdata/projects/package-managers/python/pip/pip-curation/resources/pip-resp @@ -0,0 +1,250 @@ + +Simple Index +pip-0.2.1.tar.gz +pip-0.2.tar.gz +pip-0.3.1.tar.gz +pip-0.3.tar.gz +pip-0.4.tar.gz +pip-0.5.1.tar.gz +pip-0.5.tar.gz +pip-0.6.1.tar.gz +pip-0.6.2.tar.gz +pip-0.6.3.tar.gz +pip-0.6.tar.gz +pip-0.7.1.tar.gz +pip-0.7.2.tar.gz +pip-0.7.tar.gz +pip-0.8.1.tar.gz +pip-0.8.2.tar.gz +pip-0.8.3.tar.gz +pip-0.8.tar.gz +pip-1.0.1.tar.gz +pip-1.0.2.tar.gz +pip-1.0.tar.gz +pip-1.1.tar.gz +pip-1.2.1.tar.gz +pip-1.2.tar.gz +pip-1.3.1.tar.gz +pip-1.3.tar.gz +pip-1.4.1.tar.gz +pip-1.4.tar.gz +pip-1.5.1-py2.py3-none-any.whl +pip-1.5.1.tar.gz +pip-1.5.2-py2.py3-none-any.whl +pip-1.5.2.tar.gz +pip-1.5.3-py2.py3-none-any.whl +pip-1.5.3.tar.gz +pip-1.5.4-py2.py3-none-any.whl +pip-1.5.4.tar.gz +pip-1.5.5-py2.py3-none-any.whl +pip-1.5.5.tar.gz +pip-1.5.6-py2.py3-none-any.whl +pip-1.5.6.tar.gz +pip-1.5.tar.gz +pip-10.0.0-py2.py3-none-any.whl +pip-10.0.0.tar.gz +pip-10.0.0b1-py2.py3-none-any.whl +pip-10.0.0b1.tar.gz +pip-10.0.0b2-py2.py3-none-any.whl +pip-10.0.0b2.tar.gz +pip-10.0.1-py2.py3-none-any.whl +pip-10.0.1.tar.gz +pip-18.0-py2.py3-none-any.whl +pip-18.0.tar.gz +pip-18.1-py2.py3-none-any.whl +pip-18.1.tar.gz +pip-19.0-py2.py3-none-any.whl +pip-19.0.1-py2.py3-none-any.whl +pip-19.0.1.tar.gz +pip-19.0.2-py2.py3-none-any.whl +pip-19.0.2.tar.gz +pip-19.0.3-py2.py3-none-any.whl +pip-19.0.3.tar.gz +pip-19.0.tar.gz +pip-19.1-py2.py3-none-any.whl +pip-19.1.1-py2.py3-none-any.whl +pip-19.1.1.tar.gz +pip-19.1.tar.gz +pip-19.2-py2.py3-none-any.whl +pip-19.2.1-py2.py3-none-any.whl +pip-19.2.1.tar.gz +pip-19.2.2-py2.py3-none-any.whl +pip-19.2.2.tar.gz +pip-19.2.3-py2.py3-none-any.whl +pip-19.2.3.tar.gz +pip-19.2.tar.gz +pip-19.3-py2.py3-none-any.whl +pip-19.3.1-py2.py3-none-any.whl +pip-19.3.1.tar.gz +pip-19.3.tar.gz +pip-20.0-py2.py3-none-any.whl +pip-20.0.1-py2.py3-none-any.whl +pip-20.0.1.tar.gz +pip-20.0.2-py2.py3-none-any.whl +pip-20.0.2.tar.gz +pip-20.0.tar.gz +pip-20.1-py2.py3-none-any.whl +pip-20.1.1-py2.py3-none-any.whl +pip-20.1.1.tar.gz +pip-20.1.tar.gz +pip-20.1b1-py2.py3-none-any.whl +pip-20.1b1.tar.gz +pip-20.2-py2.py3-none-any.whl +pip-20.2.1-py2.py3-none-any.whl +pip-20.2.1.tar.gz +pip-20.2.2-py2.py3-none-any.whl +pip-20.2.2.tar.gz +pip-20.2.3-py2.py3-none-any.whl +pip-20.2.3.tar.gz +pip-20.2.4-py2.py3-none-any.whl +pip-20.2.4.tar.gz +pip-20.2.tar.gz +pip-20.2b1-py2.py3-none-any.whl +pip-20.2b1.tar.gz +pip-20.3-py2.py3-none-any.whl +pip-20.3.1-py2.py3-none-any.whl +pip-20.3.1.tar.gz +pip-20.3.2-py2.py3-none-any.whl +pip-20.3.2.tar.gz +pip-20.3.3-py2.py3-none-any.whl +pip-20.3.3.tar.gz +pip-20.3.4-py2.py3-none-any.whl +pip-20.3.4.tar.gz +pip-20.3.tar.gz +pip-20.3b1-py2.py3-none-any.whl +pip-20.3b1.tar.gz +pip-21.0-py3-none-any.whl +pip-21.0.1-py3-none-any.whl +pip-21.0.1.tar.gz +pip-21.0.tar.gz +pip-21.1-py3-none-any.whl +pip-21.1.1-py3-none-any.whl +pip-21.1.1.tar.gz +pip-21.1.2-py3-none-any.whl +pip-21.1.2.tar.gz +pip-21.1.3-py3-none-any.whl +pip-21.1.3.tar.gz +pip-21.1.tar.gz +pip-21.2-py3-none-any.whl +pip-21.2.1-py3-none-any.whl +pip-21.2.1.tar.gz +pip-21.2.2-py3-none-any.whl +pip-21.2.2.tar.gz +pip-21.2.3-py3-none-any.whl +pip-21.2.3.tar.gz +pip-21.2.4-py3-none-any.whl +pip-21.2.4.tar.gz +pip-21.2.tar.gz +pip-21.3-py3-none-any.whl +pip-21.3.1-py3-none-any.whl +pip-21.3.1.tar.gz +pip-21.3.tar.gz +pip-22.0-py3-none-any.whl +pip-22.0.1-py3-none-any.whl +pip-22.0.1.tar.gz +pip-22.0.2-py3-none-any.whl +pip-22.0.2.tar.gz +pip-22.0.3-py3-none-any.whl +pip-22.0.3.tar.gz +pip-22.0.4-py3-none-any.whl +pip-22.0.4.tar.gz +pip-22.0.tar.gz +pip-22.1-py3-none-any.whl +pip-22.1.1-py3-none-any.whl +pip-22.1.1.tar.gz +pip-22.1.2-py3-none-any.whl +pip-22.1.2.tar.gz +pip-22.1.tar.gz +pip-22.1b1-py3-none-any.whl +pip-22.1b1.tar.gz +pip-22.2-py3-none-any.whl +pip-22.2.1-py3-none-any.whl +pip-22.2.1.tar.gz +pip-22.2.2-py3-none-any.whl +pip-22.2.2.tar.gz +pip-22.2.tar.gz +pip-22.3-py3-none-any.whl +pip-22.3.1-py3-none-any.whl +pip-22.3.1.tar.gz +pip-22.3.tar.gz +pip-23.0-py3-none-any.whl +pip-23.0.1-py3-none-any.whl +pip-23.0.1.tar.gz +pip-23.0.tar.gz +pip-23.1-py3-none-any.whl +pip-23.1.1-py3-none-any.whl +pip-23.1.1.tar.gz +pip-23.1.2-py3-none-any.whl +pip-23.1.2.tar.gz +pip-23.1.tar.gz +pip-23.2-py3-none-any.whl +pip-23.2.1-py3-none-any.whl +pip-23.2.1.tar.gz +pip-23.2.tar.gz +pip-23.3-py3-none-any.whl +pip-23.3.1-py3-none-any.whl +pip-23.3.1.tar.gz +pip-23.3.2-py3-none-any.whl +pip-23.3.2.tar.gz +pip-23.3.tar.gz +pip-24.0-py3-none-any.whl +pip-24.0.tar.gz +pip-6.0-py2.py3-none-any.whl +pip-6.0.1-py2.py3-none-any.whl +pip-6.0.1.tar.gz +pip-6.0.2-py2.py3-none-any.whl +pip-6.0.2.tar.gz +pip-6.0.3-py2.py3-none-any.whl +pip-6.0.3.tar.gz +pip-6.0.4-py2.py3-none-any.whl +pip-6.0.4.tar.gz +pip-6.0.5-py2.py3-none-any.whl +pip-6.0.5.tar.gz +pip-6.0.6-py2.py3-none-any.whl +pip-6.0.6.tar.gz +pip-6.0.7-py2.py3-none-any.whl +pip-6.0.7.tar.gz +pip-6.0.8-py2.py3-none-any.whl +pip-6.0.8.tar.gz +pip-6.0.tar.gz +pip-6.1.0-py2.py3-none-any.whl +pip-6.1.0.tar.gz +pip-6.1.1-py2.py3-none-any.whl +pip-6.1.1.tar.gz +pip-7.0.0-py2.py3-none-any.whl +pip-7.0.0.tar.gz +pip-7.0.1-py2.py3-none-any.whl +pip-7.0.1.tar.gz +pip-7.0.2-py2.py3-none-any.whl +pip-7.0.2.tar.gz +pip-7.0.3-py2.py3-none-any.whl +pip-7.0.3.tar.gz +pip-7.1.0-py2.py3-none-any.whl +pip-7.1.0.tar.gz +pip-7.1.1-py2.py3-none-any.whl +pip-7.1.1.tar.gz +pip-7.1.2-py2.py3-none-any.whl +pip-7.1.2.tar.gz +pip-8.0.0-py2.py3-none-any.whl +pip-8.0.0.tar.gz +pip-8.0.1-py2.py3-none-any.whl +pip-8.0.1.tar.gz +pip-8.0.2-py2.py3-none-any.whl +pip-8.0.2.tar.gz +pip-8.0.3-py2.py3-none-any.whl +pip-8.0.3.tar.gz +pip-8.1.0-py2.py3-none-any.whl +pip-8.1.0.tar.gz +pip-8.1.1-py2.py3-none-any.whl +pip-8.1.1.tar.gz +pip-8.1.2-py2.py3-none-any.whl +pip-8.1.2.tar.gz +pip-9.0.0-py2.py3-none-any.whl +pip-9.0.0.tar.gz +pip-9.0.1-py2.py3-none-any.whl +pip-9.0.1.tar.gz +pip-9.0.2-py2.py3-none-any.whl +pip-9.0.2.tar.gz +pip-9.0.3-py2.py3-none-any.whl +pip-9.0.3.tar.gz + \ No newline at end of file diff --git a/tests/testdata/projects/package-managers/python/pip/pip-curation/resources/ptyprocess-0.7.0-py2.py3-none-any.whl b/tests/testdata/projects/package-managers/python/pip/pip-curation/resources/ptyprocess-0.7.0-py2.py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..c70a0021937e53ae36600752729aa0f890da73f1 GIT binary patch literal 13993 zcmaKTb986TwryP_?MmtQcL?jk^>F0*-SmjZjwqb ze|F;7P^_eX%)MWz876{(a#t54Niv*pS8&S$e|EmUM}g0os79p26^Y{XEayqCmG$?G zu?>a)wQN*sNru*b^128V2#5fve3)UEeRMwHn*JeK{v95uM|o66)qvq#9YVL(>2Y*!c+qHf8*tGnwr36gzLBI zWKUjS`*t7|K>L23GR9_rZCn?a2+asy(X*gh)EHESBIAS#X$XMmc<>x^mM?8m5sdw= z>MzV`E8pDSen|Cd`D)*-CGV$pF5iB8=iP9c($Y;saFvfiTuqCKHRFY9 z;1B0C&7P-{V1W$eMMp>H3eJ8N*@IHV+7tTjGm90#eX1yvXBF3rpQnP|TV(F(=NvET zlNmjPV1y#5H@@IVYP_PBdY8#vX?EsNS>Oo9t-wLSsWKzZQyv*@0HJ>G{>hl_= zUoht6ZnB0J*yL+U9=sQC5(`JVCSSnz`RUNLB`#zeO_HXokkou=9$bjfCZd1M-_Z@zs0`Zg}UG;wszujAu`j@gVR zxV)chEF~&=XANW7D=H>$FT4+^fGMhpMcjWyg68kRwSf5Tu0{XDJKZ6mcI!8AMxZgt zNEm^cp|i|nOxc?MlgGxBEO$TX1$V2<>7IAMRFM!?=0ivRC;wP-;sq>OS-cuGQK?`U zDGc5r1z_LA#bxvBaB_j0_V;mXZ@?D{N9%nmc2I9!8+pwhya#zA>zbCddg19z+|sa5 zMS2&)PR*5TTrq2YFPCyk@==I1+4~k0$cg*;dG9>_sn6Sw3(FYivvWfp?Z^0}m1r0T zhX^?(oCk!fR-z#kW!TRmHzA+1qj1T1U>x@B;tY8O&W##x?dli~G%*y3B|j{t7s}{{ zOMLy-2fHB3)tpM1xDb#-WMF;9Dw}g-h%z-tNA%untL^5cGqaQ5D^>~iN4V0GeP!#~ zKUy`DF9tta5Z#9=E z_UE$lWIULDqO(rpa1hQM2rgk}9dxO>>VrzV2_J69s_>LFQa6D=^>Iu>@DShD=-gX` zUTE62)fw;gVDHs)qFQHt$dO*1^fXHYue|`%Ve7h&Xq}%?^sYG#Y6k zv+v*Qr3$RFF{K?si4h*B877zOp@LUFG_%D-mBQN+izJwGA&}thLtL-PePtNn1`Alw zJ(^;{S%uMrJK$TI$s{=`(L;riXI^Amh~O$q$6Sg zok(9EQ1?*)LDXPWX#u+*%Acj)hE_2m&VJC98689H!T+3b9b+GzG^)pI$T*hxk1 z@r8FJHQ5I)81tDBCFT#;Hv*Npe)A8wFKjgxQ)=1?`}5?)j959D-GRW_2Whyo+f}qe zR#=@WsA~^^}~D6~1Palhp&?V`HJ+ zRjwY%99mAEd3S6Lb_-E{;64x{5sspWMYcjh=%9O~&vCeOGKPzh0mPK^g&Wpm0v}=^ zEUhr9{nx2Ii0UmUf$!8ch22*Z*XA;3jJZ;Udwb?Zm-{?_gvah}{L} zy1 z+^4ipXIf@n4cI%lQL$&jDxwPpB2`(Gr;4i>$6eF(;>5S(`?7dj1YwZTV9RrAVLvU# z*d-%R$6siyc9ql|n@7^B>$L*1V&AF4nk%?uO=)5mB&Zpa8lajOZrsuW0JBh_9 zGGl-p;Ld&AVtz%TD1c#HAtMh$6e(NMvSTp@RRwU6h_5uPiFXkxW$ki(2UsR#+n@qX z0nT1FWlt2KH}Z{{O&lm6BO}S6tZ>CXDaDitybf_?l*Zpqe?T(q3kI9#ewj+ke1sCi zbg-$vS4|sX-w0%a3p2I7vZKg95?&uc#}Kr;zqFM`ah(9a9vvG$M&A49!+WlK8WjI%eR~2ZIwm{ot|!uP_G6?w zqT6%2(b7#ma#sk%$<5pO>Ew1?d>_?F;QjI7wW7hCm_ds=GZY1@SRg6rNI)syb+{Oz z)G~c~iy7y|8jEXY*<NH-wU39rP12ir73{;|zMGHz9v@090jd=!KIGs(LBVOt=Ce zoWoFwxOvSmx%pSnIjjUMf;<~&S9nq)`gbK8*u>!nob;;RcUAj*kSZCiexB>Fvt)9b zBwvj56G9_jOk5qonB2KcsWeK3Ae|%im21@0cmNrbWL{dLk)mqF-A)8O9k3iPy!q>0 zL{f*|sHW+s>{xyBA?7ndy`?3jX%!)>Q7VQfj&*6)M<=8{?}pFIp}MDV5Y)<)0f8U1 z4rto);lYn2o$P|#Wb1L?8bGzLQtuAdPl&67r~$h7Vcl3CY_iu% zDa&wLAwnU+`UV?YyavLYPjs=eBOeAp?Bzri%r6j3wfr(XELnIbi8&;fYK^4&Xs4=b zURWLf(jYdID&jkVm%!IU1ImiQUhepc>sIQHcxqXAj+<6N6qA&MGc!^@kb@qJQBv?242_9X+29uCJXL1&nnhlw?*4nYqXb-bMF+#!Wf?_L8fX06Hx5#jlWe}o{+WwTFWiEbJnGC7qdu@J;ogl z`t+S?ccdSicuNx_w8Kc?pnOn0f0COSPM7glD!z}?AQfO#m~rKh#Y8*x%1mOVk=?#5 zT!g_Gu9Q=3!~%li0BfCLeSf4+@L_7f?462<QMjXXajM(THYBa?~>oJ6XyPnG9FE*gd8{cu2V%89zV z^1S0Jvo~Edorf%1$0azowU;kFpQxGXe}Le$!6Q|T=)(D(S~7bW_(4d}>$#^os$dDm zu%8=6hpNGOP5O|cgd4pPtO{4aei!@Zm<0#-GuNHQ@WZ6eDFP|f6nj&|iIDga)cqpZ z6Hf!c^+jM{^jm2~q5T?n)OraX`mzW08?%Z)D~jLl(k;=jqXFreoq+^_5euxECeiFb z&IEJUv0oVQGMuo^p<(Tp0ZG>?I00gSXo3e^O7PSg7`#I;FxMQJ1VDv(oRm&-PAWU@ zii*mRH3?YkH{BE|S1zUC0MrI%Xer@lLP25>xR66UlyMZX*2hHXjmFW-0U#}XmRW6( zE6Z=7Swz4~W2r(lox-D2%{J^#f2DaeuGxS#7ej8QppND|M)_3{>Zf=+HK_)}C@#E} zDB(DW@f;#CcG8e%V0AW(a=_Ba9vX3FkEQv3Ny4yvcENO%{;<4!rXVjHbIAUXt-1lD zW`jWNXz&M;)T4?qPXtrX8dH&A@P ztT;lu9 z4C+Z=y^vPp?+QoI>rOD!usIxG)$a?NBAh2VyjbV9!F2oF6*xAMY(su-v0(|XJZVI_ zsY~`@>h$7y@eAni{D-X3V3Pp=uM?Qd^5l?;36U@Eez1clit~&~7uD=o;Kv3Xr%4WWc-;0ZBj)K*n zDC9zW3ND3q?96aBFR&?-A)l8@tWal))rC0`LaaniP@#m5)WdB9i{sUp z%Nu*wEF~3ljM~u^BF{lz-%;MP6Tj~dq&EOQ{H_sNfn#FB)Y&tuC7u3E3b44x$!&Z5 zT4n3^*}~V+NGk$sYHRSUTkK%%J!H`otu032j6*Bs7EU3&T*}JRBopSgVdN;0AR!L$ zra*C*Lm-F1SPb%e=`dw*^MasoIfo$@AELD*vI*Jh+bqBy1b4=9L)eO^rnf!Q?&kSk4pEaa_kFff$V*TO?g7UfB!jE5(%!Uh zpvJE%Z@teiXdynlo#H5k#;+zByNOBuyNvlvn=8KXHja+n&dI80VzJW>{asH1{gqFr zr_c3y2nDi2H3}ek5vpX2m(^WfS~kdre)LJ`mX}&m<(AaZkiQAP)h);-vy(4FM1nvSOhmsZG3_g3O?17)q-r4Toy{ zuod-@a3YAmsK|<&FN2g!Pdd8Ibui$Zpk=WJ6blFhAndr-Y(^!&hFyQv4q26)Z(+VW zF}MWylB_d_@25%ZNDPC^%S})FoQ(wU4u`F-Is8Dr5J|fj3RUs5>8YHO5|5&4L_)Wo zy{btNNtYP_<%`0WGY=5*lO%YhEYOSp$Tm4A%_670QchH2WK0XB39&yUrk64>ZvvBw zxy8cZtxGWHrit#oWFcc1g9ayh$Od7my6aUmG+R_-Fj};s{duNdxO=cWlBqE^|hqo`3{+U;$6F?VM)(O!%=Ns^_ z80@mr8bZm8xfl3WOA?wHK*dWK zy4e{5@Qj_>VFCASN9=mNJFgMLz^wIv72-P)vxAcE8_r)y?C;Tv9&!Wfz&@!EzGc77 zGv?xB(}1UF4M%ZC8LSzla&Pw`;%liDmt)U~XyFNshz>fQhb02(%ejt%dRgy`fRN4) zYKh*Y3b&1o;GhJtSoPj;YkbRlfMJ6rJV`Ph4(?X8cj=E)(Q%NN4HQ7=9|fbAAKWvip6pXs{XWYM0v_cI5n`3% z+XkftK#84;cP}#g!pq2Cu+N~zPv)2;k8h;9?a~|a+mm;ZS7@B0P5R~9lPW5ROg3bI zzfRVf{-VUgS$%N+Yy7*`_of(Jw6%J6>4qoXTWk@iYG)|9b7$`$;Xa*)RXKF?)Xwg)8i>d*IM5jPH*fkw`Lh@n#0 zI}Nij!mOdAr%c*T$U%k<00G1stK#8@0HFq^tr?78h~qD?A(Z0bm*~gdE_`bjV)Tpv zt>*4qk3ROElSr&hd>{xnt_nl#&BXzT9PlpDQinFrT3=S3Bc7KeX5_-vq|Ox%(t>?a z5Jm*BgN6hdaQB{hThL*#Yiw&txvk^0iDRs$?v0lVAQwv;x{y>-TA2l-!%bN)=-neh zz%tT|SqBX8BITe5$R(!dOuuVd5-yg7W05kmNuOW{$EG^{e3rBOK*;`0-k$NaoN;Rz z0=;p=sd|Jr>w-G3;gW|kj{rBrRgER-ww_GV_LjOvTr^h!X)gkFLi>c4`qUqBbi!%g zE{SYuVb}^uQ#VR+|zf1yY9ctgr6zE=foef6VaU9eSLvg9`t##tlC~^ z4#3@M2bs&v)pJ;@6zYoUr6g^!(&f?lH1-&vu&nC2qt&PwXpyj^3Hg46-}f|Pry1Yc zq8XYJ6DaE92Xxul*3TEH!T)UZe6aX^N$6eL5yb%w`%JKd|iKGag9&Ac=NOeLba<()t3EkjM z8i4S92(Oz~E&p{bvD(5G1`a}8B_OKS8ZTZKB!F(uki^iu#;YGKXZDFR##)XyTOVdb zd7;6oX-5~RCPXUqOv$uzb5s=_rD*rapxudWv!{39UM?LdqdwYl#3q>#I{~akXmf*1 z;6dmFg+#K9DgC7TcFqjXn6VEZs(^^cJ;`7eag8mr#-ZM_B#qFp-?Q%iY_il?Ha~G*6~kgxG^qS}_E0ENtyHR^G`!z;woU5U z{4R!=o}E+ZdD4ukj@yi{L_<35GYU(w4?QuK8E46;0A+`wtUj~N)KL3YZn%F)kDu3J zW$Q6`QosQd%sg0vP}K?{kb4s%o%=GeUpv1b8kH~SErtQGx#SAM7UnlZt^QalJq*Q( zAK_nWm1SjGRw|Xpx=uPN#KNj_0INy`xzRZVs{m3nmd-?^sQt_S65}#_VaGsZ3Te4K zvjli6k#%MZFH-pz9-l*DaYuBlxwtJlXbSZS&}p;xjRB762@0;T?o6O=dM7*|aRC$% z3W^`_VF2j%>#&kVQ;5JX{D68Lq^WJn#M-qyqHVRje!WTU6J!HRm)ee505>uuiwrtB z3N_bLK^17hN~Dok%-6zix71~3dh`QtJNPX0IkAvR%NsyPbIfHcST^qp-pwsjaUFW&C1vq$GC>O0HC(3>8yV=Al524ovyx6aX zOA^K(-xYh&fm~+`+*CA9b8Q=dIIXIsRXH&g1>^K+YN;-SENZxcRCl8}JE;}^4J*7iDKdRE{P|u~p5D?Tl zBr7e>wF)kRX=)Hmg`e6BT3RbblYj%zHR70Klrpn;J2sxscKFDe)m@5Q<)9qZW<(Tg zH4*Jk6ZZ7hM9dPyBi4A9gQNnXuS`+pY&P&%z$!a|xj=`1*zC>1w}q~@9OJn#tgLr` z`DS}osZ(Ft64QZkLrp5#&D#Lj3~!ZtwIN*mYIBjfosKj$LPSou`JUw=d z^?OwGziB(^l;!=Uig?`kD8M}P{XzN9o^*^C!Jk-w4u3Kb( z)z%AHgAVZm++q*Z_ZxU#RcjB3^7uC9$ z*fr1PiArpEf4`+8PQ;_}ib$PnOdiMp6ow)O*JSfRXlFQ$1_z${I5jx9@(-w1tPM?N zDj`>JrYZ@NOSj%l$gPFAqwv!ggz|^{TfYdI(n$U2Z4J-L9@(WimGg2AcS%181m27Z z>tupIS8=^_<+WQWyiDZYPduY%=W%7vWa!QPvp;yX&ya6FR*fOGx#}RC2ji96 zPXyl0$~-J+JR5@i?ogNzrh#JSFd4z3xd(^IdFyy&$98Q zl?UOc0xn;n2jGIH$sMCjAp_&@AafPzdxsWc)frWcW66XGry@X1BJs*ZifV&7W109Lwn9h48j?59I7^W`fD!Mei+WD7e-5 z32#lL9BUhpQ_x#$ncm=b6zyq$>WCYxWFHlY_S=~S_*!Vh$l%3b#Rh9Hw{{EhlhK^^ z%rlM)HPYhTai_m#Fb8p$?R|B9+YU^<)=cqYCIA!=`l9pl3OE3FDpx7!D~W)v-Hbg4 z$Y{$f{RI4E=jg#}3vhd=fwZ{6sJ;Z1gd}*0@OeJYxjkdIf3tfZ?Dq1*Bf3jBmcWp}5~vJmX95n0EJ_?WL=B zfU~)gJ(XVs59g8bT|}bbI_ii-iN?{hgIHzf3fpGUaz9n#MFzV`7HiIMIgE|cYG4bh z;nIeE-AP~=2tW6k2_;J>aZBsZ;qh_RT`z*(uelr$4+G4e^2#puz*w-aqWSO-m==aT z(}Arucy&iLbR?QHgV+Z3fWMj84e+L+*CY#L(P98+sJT^eaqP?ugxUfu{pZQEas*w0rO zEhj(qbz6)5H7h~tq=(E~WCZUV=eMr9oD+FZzS9Zq_Wp1!aVD$jx^=@&+H?j&$@i%D zI+c3akR<9nfs^Gu?87NEQ^{DT%VcW)H`&M=_@@aQ{=b@_jp!2o=rBA2LDO47a{(RmuwV z6l%f@NsOV`rn5+I`;jrA0;p83Zk<>bM~O+Qe&A{l*p!5(lCucaM0E-64pWLE02D}5 zad3*C57e@0`M7fmex$T$x2M`>?9$rp*6D6^cT2-DtM{WI+8|fB2TR&2TO72LES5R> z5V{?F-pqXb+&<2qXEZzK*_a`;L-Mtgx5i%G!t+?6QFK1aEf{CP#G5k2*Akp|zeX0| zfJ`1n3y!<@k=h(A2_wC72)f^ae_RtBY~Jwmd)@X4<^<_F|B6JY1@lhUA2rJ>;VQ7q zMc5|PWH9M(b{W7r1ggLlv6~3P(;s?F(ko9G!X3eedY*9un(E11$pe=?B5N>ks34AU z588$h8@EMM_L(fv z;8J2#!yoel5U16zp z7$kSgqXVB-#Gs(461RstI^1V|R=`52Ks_D3mZ8H;s^+-DNKOW1%}vdETq&;+Sf$gX z?<0{WJsWlFzJ^^O;53COu0sT7qEoAZ?$3rRWht9j;1j(qtXA%$5fXqKxNBY%&MMu} zDWbX^ITcB|&Hk#KEKs}IM9fc%D5O#=vdSt?ve?p?fO(ee+7qf0u4@-8YSbdT{XLu+xS#5F2l|W zNF97|jEXY%gy;C`vcXE}*w!^}D(|moc*o$B?1`-W2?rOVgeY_wfh{9T`Y;BtngEM`c&N*8@eNseen>dIM;ga&v|9rLS zq&BFK+Jf|Rf16(}%ix|LE-<4FeNHMIm-+d3llzI0?_w5~S#o?l7 zl@sJst|Bz|gN{0k&_+-8Wp&5LEens;EVv)*8-cNUEG+an^>NlU8Vws1Re?aWMoyle zm4I82Tm{(gj8vX%kLyBFhncB!HZ;3QZSCM8QOBx)b?o4|WLZSTgEYnHMAH7vq{;g% z0qH1Fs)}S7V`igZ2e8iVI+^lxs&E_zf9&bu(z8gpOO-=*v7qxCpC*dC-;TRivK9;e zkJZlURKt+nx@-?kpDD15yn*~HL2VHs_uAm8^*~y-evUCeyGt-7YJ?YdpC3S8O+reM zh4&;%s4m(coW9JJELHi$wDCSJNV_(pa;MA^BIOv^AVa#iu)2Xau_^1xl784NGEpkK zEJ=%*)hDdfbtO8jT)mOF^lP4VDyocBIjG`84%YS;N#+Iq>Sx#H-eZ0B6>irCAVeTb080NN{kScxegvKLLee3F| zS3{LQ%;h*{20kw^pR%$t>CyG+Y66(Ssht|?w+N*MGSJ4e54i8de^2aEJFN2HgT2X5kWH%Im;(H9y} zH1aQLDL6yX9*Xrsw=UIl5w<*XQ~B9gp2;MZVmmoM|GvE4L*KTKQ{;^Uu6EO#rU=ua zjDJ)8w66D)iRoH{lcfZCR@$Dt5wn~J*Fe2t%!`(Ikk5Z<;TDVBoh|CD)I}|w3u~xj zUexCir*(7QVdbf0J62wi$~gmt9g%FcGFNs?vD_B2EyXZ5_&~^+8OFl`?#%qFgY371NL~k|E&0NakjMnqxJDpvx)d4GeED74jLI zg*cIMp#0S8Rkne85pQ2INYi;teI7&{bepXlpz@e_tlQ|BO=@*fdB1zVb$dxsjF5kC z_EQV`@fOn1DyMfiT7RK_?~j4j&ViqFIc?{w?{RzkF>fc(i_^aCJ3Qk7n>Q4i^fOvv zZFTIGSkQjfgunqDPKS%c0LLCb5G+*+gICIZ3D^a-a*Rbo$2r!VT!K4H)kQW`4}%e- z7S(83ZEuzMwwpM);s9(7b3cpr32-OVYI;5*M-7{k_5l0CZY(~411mZi{ft7QT=5lJ z{gz{YtUUUf`sJ6HmjN~vgP4-&9?m;ORa|HSPA8Q1WrsLkzUv^^iI--&L8hv#6NH!G zCiDi~DFvSt@o*Nu6Nco#*Z9g@mIVlFDu4vRm*Tgn>>m-8w*xKsQ)YAazM9^u3VDK3 zd`V4gW?VkaixmZuc8xJ_6aw9GO*CT6iWCKCB+(Hy#rgLv^&3BGg6-G5H5L6-O8o+a zj|+E25NDh2L8c$1q8x%IqO`+`l6RMSFceQcBDML~QWY8119~)dk1M4;Jip5` ze9xkpt@>G*H7;N&x`-}iJsJXA1fsp*sx^0P=0dHDyj$!5m?6oz6<8UaquA-r2fm3v|CWbe&BU- z&7{m)5n!K<=-6xAN65QOEbM(UyB;^q+IBdOf?5mt$XH$0kxG{f*O2G7MMYl?RyEnf z?pKFTAW8(zAwwCXMHuSqt#fwl+g>EPQO54eWxEYy2l&zCDKF1QBiefVs#a#Mn_FAl z0#ERLftx=*KmC2k%it*9y7BlkzSe^nXgF}8F{K6ZHpuVXCyf!z37@|r4q|YaxSUtY z0xAd)P}m;@@wb19IJ69O>~sutCYDalw3fDJcJwlm!lH7@qNYl+F&PX0BCEg3=vMTu=cP<=+Icz=WRFZc(#gfCdNG1#@U;ZH6JrJAFpsB%sO)1 z{5`GD0&GFY{YyDhe14Ny&6`u8cKCYl2;T6U$k<=)&+86kzBwNpPx{CA)#d_jSk2x` z@bdT6y&hB6LWR0tT)5d!#2xEhA;U2_d_Ft7(vHSPzcIfy9>smI4IeW0ze0z>R9aR8 zF6sO24Jx+fI^L0k314(WA!KEj;?;Io8Zl5MTpHwZm#_|bq?0C_{30uv8!hnssYm@6 ze`!kw#NHRHe7De=w}>zjtp@wVG~47FLFUAznB6gg4~iQz9O4CZ7Q!)`B}7Al{hE=E z+zSq$^P7uSLaXGb+~Uk?7GQ&y0xc9!Qx!m2(>(ZnPPbDnwI`^h$KNwBa+8cVG&Lus zWJ3&F;4f+xj_2Delul!EUu+{tRkaqWf~=^*j_$zhW|1WvMMmpEiK!B&ExwzXDKyST z*$CV!au`SpI+|4iE+p!x_z|(pmKtA)()^vl1$y63UnkDW^wxY_M~9e6bNrh|YHrJ6 zctUeJI=q|d`WX20uUS@=HL?EN7v@tFUq@T(B{Cz`Z`v7-N9-=UP_5~rS#iC7efjwy z=sD1!E|sop_J-eUfVw1tX{f{PDFIc)L{1c$0OJiJP*>!~S^N8@$jaCC7jvQaza=ps zAR-AOR+rzZf2d^qrxX5XN~ueTips!FOUO>n!b{UqO-)QUC^0NB?>Nd&O4CZwjMCRD zNsN!uC^9TD1BF8z9;clAz&M9U#3(v4A=9KxL`y1m3?eDkrbJW0K0P`-E;=JSQtcCo zME(Z!7t8-)(%v|IZonUw6M_Q){SnmuO}+pBuv}JDMNmXgMKDQ4`};Zrn)h-Ihn=M6 zr8D05UIq;dNLbl~+QM~%^#G%smHZ{}Q;M=LuNcSEbOg0@NaNF=DFxHwzeMAcNYcE- z2@+YsGmozaMXYFg6puS-8NqHN)tgNQj|3Spl%lgbq@}1K;_`9OSl&$7R!Xt>?Ybpoj6MvDz!9@9Vb)qyCIj29 ztyIlkw=gd#HG$7Gzr1>@Pp}@{>ggC$bUF@fnyW7%VUJ;C8Qb(hV)K~r#kN|RLRl5X z(pEb}Rk@`5F_23#3Mlr7j~E=;ppO@_?DW+S5^rHPsBNXw#z0@7U%-w~tg37DGN{4G zhC#PJ@B8WxPqhn_8frLHQ^ls3=+8WjlHNG;`RDCRELZlO&%U3PZ5$829E5TnVvtZO z6X>Ke8As*Rj)+Z2Qi_NS;m=QRrPVCXL3Z<_T5ppUHg2~~^Y}#BRr1luC>`}eg~YD5;24w{ z4n2GJ4V2>4N@3|(2wYpHn3fHWl8YPelV^izU=p9&Fjq@E)jM^WR)(J|1(}n^PgSc$ zhHokezHOjruV=4ib@!zzuss75WWYC_F5u~ zzJ5-dr(-jY(z{i-RnT>M)yBH#SEIu}XcTF^a4mt?n~>fR?0&G0J32BLRdGg-(;g;| zyH(19h$@2|k|Neq76TO4^mmMZSMl@T2tYuEe(C>$@wfV)zk~d{ z;)1_Goc|m^{$|Ku!#~H5zoY!S1Iu41W)%N~@;}Fyzpwvy+mFB2uh9H`{r?lD|7SDu zcf5a>d;S*>n411S;{8`9`d74nO&foqb^T|w|C>bq74Tnu_%A?zrvGT(e|xloG&saR RmO%Y^WdDQ#F_wSK{tq?%unPbH literal 0 HcmV?d00001 diff --git a/tests/testdata/projects/package-managers/python/pip/pip-curation/resources/ptyprocess-resp b/tests/testdata/projects/package-managers/python/pip/pip-curation/resources/ptyprocess-resp new file mode 100644 index 00000000..ca4b7042 --- /dev/null +++ b/tests/testdata/projects/package-managers/python/pip/pip-curation/resources/ptyprocess-resp @@ -0,0 +1,40 @@ + + + + + Simple Index + + + + + ptyprocess-0.1.tar.gz + ptyprocess-0.2.tar.gz + ptyprocess-0.3.1.tar.gz + ptyprocess-0.3.tar.gz + ptyprocess-0.4.tar.gz + ptyprocess-0.5.1-py2.py3-none-any.whl + ptyprocess-0.5.1.tar.gz + ptyprocess-0.5.2-py2.py3-none-any.whl + ptyprocess-0.5.2.tar.gz + ptyprocess-0.5.tar.gz + ptyprocess-0.6.0-py2.py3-none-any.whl + ptyprocess-0.6.0.tar.gz + ptyprocess-0.7.0-py2.py3-none-any.whl + ptyprocess-0.7.0.tar.gz + + + \ No newline at end of file diff --git a/utils/auditbasicparams.go b/utils/auditbasicparams.go index e66a618f..37e3275e 100644 --- a/utils/auditbasicparams.go +++ b/utils/auditbasicparams.go @@ -40,6 +40,8 @@ type AuditParams interface { Exclusions() []string SetIsRecursiveScan(isRecursiveScan bool) *AuditBasicParams IsRecursiveScan() bool + SetDownloadUrls(urlsMap map[string]string) + GetDownloadUrls() map[string]string } type AuditBasicParams struct { @@ -61,6 +63,7 @@ type AuditBasicParams struct { dependenciesForApplicabilityScan []string exclusions []string isRecursiveScan bool + downloadUrls map[string]string } func (abp *AuditBasicParams) DirectDependencies() []string { @@ -228,3 +231,10 @@ func (abp *AuditBasicParams) SetIsRecursiveScan(isRecursiveScan bool) *AuditBasi func (abp *AuditBasicParams) IsRecursiveScan() bool { return abp.isRecursiveScan } + +func (abp *AuditBasicParams) SetDownloadUrls(urlsMap map[string]string) { + abp.downloadUrls = urlsMap +} +func (abp *AuditBasicParams) GetDownloadUrls() map[string]string { + return abp.downloadUrls +} diff --git a/utils/paths.go b/utils/paths.go index 10f4fd97..05a58542 100644 --- a/utils/paths.go +++ b/utils/paths.go @@ -42,3 +42,11 @@ func GetCurationMavenCacheFolder() (string, error) { } return filepath.Join(curationFolder, "maven"), nil } + +func GetCurationPipCacheFolder() (string, error) { + curationFolder, err := GetCurationCacheFolder() + if err != nil { + return "", err + } + return filepath.Join(curationFolder, "pip"), nil +} From 1ae700e4fc94baca553e439f6e4bfd38eea4fba7 Mon Sep 17 00:00:00 2001 From: asafambar Date: Wed, 13 Mar 2024 22:12:39 +0200 Subject: [PATCH 02/19] Fix --- commands/audit/sca/python/python.go | 5 +++-- commands/curation/curationaudit_test.go | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/commands/audit/sca/python/python.go b/commands/audit/sca/python/python.go index 41dc69ed..0323ee47 100644 --- a/commands/audit/sca/python/python.go +++ b/commands/audit/sca/python/python.go @@ -117,8 +117,9 @@ func getDependencies(serverDetails *config.ServerDetails, tech coreutils.Technol sca.LogExecutableVersion(string(auditPython.Tool)) } if auditPython.IsCurationCmd { - pipUrls, err := processPipDownloadsUrlsFromReportFile() - if err != nil { + pipUrls, errProcessed := processPipDownloadsUrlsFromReportFile() + if errProcessed != nil { + err = errProcessed return } params.SetDownloadUrls(pipUrls) diff --git a/commands/curation/curationaudit_test.go b/commands/curation/curationaudit_test.go index 3892fce8..9af0fd47 100644 --- a/commands/curation/curationaudit_test.go +++ b/commands/curation/curationaudit_test.go @@ -670,7 +670,8 @@ func curationServer(t *testing.T, expectedBuildRequest map[string]bool, expected f, err := fileutils.ReadFile(pathToRes) require.NoError(t, err) w.Header().Add("content-type", "text/html") - w.Write(f) + _, err = w.Write(f) + require.NoError(t, err) return } } From 5a341da7b776957ee697d9708ba9124659b0bd38 Mon Sep 17 00:00:00 2001 From: asafambar Date: Wed, 13 Mar 2024 22:29:51 +0200 Subject: [PATCH 03/19] Fix --- commands/audit/sca/python/python.go | 1 - commands/audit/sca/python/python_test.go | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/commands/audit/sca/python/python.go b/commands/audit/sca/python/python.go index 0323ee47..6b6c83df 100644 --- a/commands/audit/sca/python/python.go +++ b/commands/audit/sca/python/python.go @@ -34,7 +34,6 @@ type AuditPython struct { RemotePypiRepo string PipRequirementsFile string IsCurationCmd bool - reportFilePath string } func BuildDependencyTree(serverDetails *config.ServerDetails, tech coreutils.Technology, params xrayutils2.AuditParams) (dependencyTree []*xrayUtils.GraphNode, uniqueDeps []string, err error) { diff --git a/commands/audit/sca/python/python_test.go b/commands/audit/sca/python/python_test.go index 91301521..f103f92d 100644 --- a/commands/audit/sca/python/python_test.go +++ b/commands/audit/sca/python/python_test.go @@ -63,11 +63,11 @@ func TestBuildPipDependencyListSetuppyForCuration(t *testing.T) { assert.NotEmpty(t, downloadUrls) url, exist := downloadUrls[PythonPackageTypeIdentifier+"ptyprocess:0.7.0"] assert.True(t, exist) - strings.HasSuffix(url, "packages/packages/22/a6/858897256d0deac81") + assert.True(t, strings.HasSuffix(url, "packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl")) url, exist = downloadUrls[PythonPackageTypeIdentifier+"pexpect:4.8.0"] assert.True(t, exist) - strings.HasSuffix(url, "packages/packages/39/7b/88dbb785881c28a10") + assert.True(t, strings.HasSuffix(url, "packages/39/7b/88dbb785881c28a102619d46423cb853b46dbccc70d3ac362d99773a78ce/pexpect-4.8.0-py2.py3-none-any.whl")) } } From 7a645ce4c81f81fbde4892f7fa1b995ec96a0d95 Mon Sep 17 00:00:00 2001 From: asafambar Date: Thu, 14 Mar 2024 00:41:06 +0200 Subject: [PATCH 04/19] Fix --- commands/audit/sca/python/python_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/commands/audit/sca/python/python_test.go b/commands/audit/sca/python/python_test.go index f103f92d..e59cd28f 100644 --- a/commands/audit/sca/python/python_test.go +++ b/commands/audit/sca/python/python_test.go @@ -99,7 +99,7 @@ func TestBuildPipDependencyListRequirements(t *testing.T) { // Run getModulesDependencyTrees params := &xrayutils.AuditBasicParams{} params.SetPipRequirementsFile("requirements.txt") - rootNode, uniqueDeps, err := BuildDependencyTree(nil, coreutils.Pypi, params) + rootNode, uniqueDeps, err := BuildDependencyTree(nil, coreutils.Pip, params) assert.NoError(t, err) assert.Contains(t, uniqueDeps, PythonPackageTypeIdentifier+"pexpect:4.7.0") assert.Contains(t, uniqueDeps, PythonPackageTypeIdentifier+"ptyprocess:0.7.0") @@ -125,7 +125,7 @@ func TestBuildPipenvDependencyList(t *testing.T) { PythonPackageTypeIdentifier + "ptyprocess:0.7.0", } // Run getModulesDependencyTrees - rootNode, uniqueDeps, err := BuildDependencyTree(nil, coreutils.Pypi, &xrayutils.AuditBasicParams{}) + rootNode, uniqueDeps, err := BuildDependencyTree(nil, coreutils.Pipenv, &xrayutils.AuditBasicParams{}) if err != nil { t.Fatal(err) } @@ -160,7 +160,7 @@ func TestBuildPoetryDependencyList(t *testing.T) { PythonPackageTypeIdentifier + "pytest:5.4.3", } // Run getModulesDependencyTrees - rootNode, uniqueDeps, err := BuildDependencyTree(nil, coreutils.Pypi, &xrayutils.AuditBasicParams{}) + rootNode, uniqueDeps, err := BuildDependencyTree(nil, coreutils.Poetry, &xrayutils.AuditBasicParams{}) if err != nil { t.Fatal(err) } From 1e7c97d0886971267079847b15a6be9a1947bf1b Mon Sep 17 00:00:00 2001 From: asafambar Date: Thu, 14 Mar 2024 09:11:10 +0200 Subject: [PATCH 05/19] Fix --- cli/docs/flags.go | 2 +- commands/audit/sca/python/python.go | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/cli/docs/flags.go b/cli/docs/flags.go index 8c285c48..d58b8a93 100644 --- a/cli/docs/flags.go +++ b/cli/docs/flags.go @@ -128,7 +128,7 @@ var commandFlags = map[string][]string{ useWrapperAudit, DepType, RequirementsFile, Fail, ExtendedTable, WorkingDirs, ExclusionsAudit, Mvn, Gradle, Npm, Pnpm, Yarn, Go, Nuget, Pip, Pipenv, Poetry, MinSeverity, FixableOnly, ThirdPartyContextualAnalysis, }, CurationAudit: { - CurationOutput, WorkingDirs, CurationThreads, + CurationOutput, WorkingDirs, CurationThreads, RequirementsFile, }, // TODO: Deprecated commands (remove at next CLI major version) AuditMvn: { diff --git a/commands/audit/sca/python/python.go b/commands/audit/sca/python/python.go index 6b6c83df..47b34e43 100644 --- a/commands/audit/sca/python/python.go +++ b/commands/audit/sca/python/python.go @@ -272,7 +272,7 @@ func executeCommand(executable string, args ...string) error { } func getPipInstallArgs(requirementsFile string, remoteUrl string, cacheFolder string, reportFileName string) []string { - args := []string{"-m", "pip", "install", "--ignore-installed"} + args := []string{"-m", "pip", "install"} if requirementsFile == "" { // Run 'pip install .' args = append(args, ".") @@ -287,7 +287,10 @@ func getPipInstallArgs(requirementsFile string, remoteUrl string, cacheFolder st args = append(args, "--cache-dir", cacheFolder) } if reportFileName != "" { + // For report to include download urls, pip should ignore installed packages. + args = append(args, "--ignore-installed") args = append(args, "--report", reportFileName) + } return args } From 2f50678d3775522c1845cf5f0328e759fe820cfd Mon Sep 17 00:00:00 2001 From: asafambar Date: Thu, 14 Mar 2024 18:51:10 +0200 Subject: [PATCH 06/19] Add error handling for curation pass through disabled on curated repo. --- commands/audit/sca/python/python.go | 14 ++++++++- commands/audit/sca/python/python_test.go | 39 ++++++++++++++++++++++++ commands/curation/curationaudit.go | 4 ++- 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/commands/audit/sca/python/python.go b/commands/audit/sca/python/python.go index 47b34e43..c7e3991f 100644 --- a/commands/audit/sca/python/python.go +++ b/commands/audit/sca/python/python.go @@ -245,10 +245,11 @@ func installPipDeps(auditPython *AuditPython) (restoreEnv func() error, err erro } pipInstallArgs := getPipInstallArgs(auditPython.PipRequirementsFile, remoteUrl, curationCachePip, reportFileName) + var reqErr error err = executeCommand("python", pipInstallArgs...) if err != nil && auditPython.PipRequirementsFile == "" { pipInstallArgs = getPipInstallArgs("requirements.txt", remoteUrl, curationCachePip, reportFileName) - reqErr := executeCommand("python", pipInstallArgs...) + reqErr = executeCommand("python", pipInstallArgs...) if reqErr != nil { // Return Pip install error and log the requirements fallback error. log.Debug(reqErr.Error()) @@ -256,9 +257,20 @@ func installPipDeps(auditPython *AuditPython) (restoreEnv func() error, err erro err = nil } } + err = errors.Join(err, curationPassThroughError(auditPython, errors.Join(err, reqErr))) return } +// If its curation command, we want to inform user that it can be resulted of pass-through disabled on curated repos. +func curationPassThroughError(auditPython *AuditPython, errFromPip error) (err error) { + if !auditPython.IsCurationCmd { + return + } + if strings.Contains(strings.ToLower(errFromPip.Error()), "http error 403") { + err = errors.New("Failed to get dependencies tree for python project, Please verify pass-through enabled on the curated repos") + } + return +} func executeCommand(executable string, args ...string) error { installCmd := exec.Command(executable, args...) maskedCmdString := coreutils.GetMaskedCommandString(installCmd) diff --git a/commands/audit/sca/python/python_test.go b/commands/audit/sca/python/python_test.go index e59cd28f..1f4ee72a 100644 --- a/commands/audit/sca/python/python_test.go +++ b/commands/audit/sca/python/python_test.go @@ -1,8 +1,10 @@ package python import ( + "errors" "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" xrayutils "github.com/jfrog/jfrog-cli-security/utils" + "github.com/stretchr/testify/require" "path/filepath" "strings" "testing" @@ -184,3 +186,40 @@ func TestGetPipInstallArgs(t *testing.T) { assert.Equal(t, []string{"-m", "pip", "install", ".", "-i", "https://user@pass:remote.url/repo"}, getPipInstallArgs("", "https://user@pass:remote.url/repo", "", "")) assert.Equal(t, []string{"-m", "pip", "install", "-r", "requirements.txt", "-i", "https://user@pass:remote.url/repo"}, getPipInstallArgs("requirements.txt", "https://user@pass:remote.url/repo", "", "")) } + +func Test_curationPassThroughError(t *testing.T) { + tests := []struct { + name string + isCurationCommand bool + errFromPip error + expected string + }{ + { + name: "curation command and error include 403", + isCurationCommand: true, + errFromPip: errors.New("tes error from pip HTTP error 403"), + expected: "Failed to get dependencies tree for python project, Please verify pass-through enabled on the curated repos", + }, + { + name: "not curation cmd", + isCurationCommand: false, + errFromPip: errors.New("tes error from pip HTTP error 403"), + }, + { + name: "curation cmd, not 403 error", + isCurationCommand: true, + errFromPip: errors.New("tes error from pip HTTP error 500"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := curationPassThroughError(&AuditPython{IsCurationCmd: tt.isCurationCommand}, tt.errFromPip) + if tt.expected != "" { + require.NotNil(t, err) + strings.Contains(err.Error(), tt.expected) + } else { + require.Nil(t, err) + } + }) + } +} diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index b1158330..43f66f12 100644 --- a/commands/curation/curationaudit.go +++ b/commands/curation/curationaudit.go @@ -281,7 +281,9 @@ func (ca *CurationAuditCommand) getAuditParamsByTech(tech coreutils.Technology) case coreutils.Maven: ca.AuditParams.SetIsMavenDepTreeInstalled(true) case coreutils.Pip: - ca.AuditParams.SetIsMavenDepTreeInstalled(true) + if ca.PipRequirementsFile() == "" { + ca.SetPipRequirementsFile("requirements.txt") + } } return ca.AuditParams From 30036b884a769fa6dd5dd2040a39aca1da01671f Mon Sep 17 00:00:00 2001 From: asafambar Date: Sun, 17 Mar 2024 13:28:46 +0200 Subject: [PATCH 07/19] Fix test. --- commands/audit/sca/python/python.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commands/audit/sca/python/python.go b/commands/audit/sca/python/python.go index c7e3991f..01d9e173 100644 --- a/commands/audit/sca/python/python.go +++ b/commands/audit/sca/python/python.go @@ -266,7 +266,7 @@ func curationPassThroughError(auditPython *AuditPython, errFromPip error) (err e if !auditPython.IsCurationCmd { return } - if strings.Contains(strings.ToLower(errFromPip.Error()), "http error 403") { + if errFromPip != nil && strings.Contains(strings.ToLower(errFromPip.Error()), "http error 403") { err = errors.New("Failed to get dependencies tree for python project, Please verify pass-through enabled on the curated repos") } return From ff7553ee55a541e3d42d7f8c0947158cf0de97c1 Mon Sep 17 00:00:00 2001 From: asafambar Date: Sun, 17 Mar 2024 14:45:41 +0200 Subject: [PATCH 08/19] add curation pip env variable --- commands/curation/curationaudit.go | 10 ++++++---- commands/curation/curationaudit_test.go | 6 ++++-- utils/paths.go | 1 + 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index 43f66f12..7826009b 100644 --- a/commands/curation/curationaudit.go +++ b/commands/curation/curationaudit.go @@ -53,17 +53,19 @@ const ( TotalConcurrentRequests = 10 - MinArtiMavenSupport = "7.82.0" - MinArtiXraySupport = "3.92.0" + MinArtiPassThroughSupport = "7.82.0" + MinXrayPassTHroughSupport = "3.92.0" ) var CurationOutputFormats = []string{string(outFormat.Table), string(outFormat.Json)} var supportedTech = map[coreutils.Technology]func(ca *CurationAuditCommand) (bool, error){ coreutils.Npm: func(ca *CurationAuditCommand) (bool, error) { return true, nil }, - coreutils.Pip: func(ca *CurationAuditCommand) (bool, error) { return true, nil }, + coreutils.Pip: func(ca *CurationAuditCommand) (bool, error) { + return ca.checkSupportByVersionOrEnv(coreutils.Pip, MinArtiPassThroughSupport, MinXrayPassTHroughSupport, utils.CurationPipSupport) + }, coreutils.Maven: func(ca *CurationAuditCommand) (bool, error) { - return ca.checkSupportByVersionOrEnv(coreutils.Maven, MinArtiMavenSupport, MinArtiXraySupport, utils.CurationMavenSupport) + return ca.checkSupportByVersionOrEnv(coreutils.Maven, MinArtiPassThroughSupport, MinXrayPassTHroughSupport, utils.CurationMavenSupport) }, } diff --git a/commands/curation/curationaudit_test.go b/commands/curation/curationaudit_test.go index 9af0fd47..b29c854c 100644 --- a/commands/curation/curationaudit_test.go +++ b/commands/curation/curationaudit_test.go @@ -411,8 +411,10 @@ func TestDoCurationAudit(t *testing.T) { configurationDir := tt.pathToTest callback := clienttestutils.SetEnvWithCallbackAndAssert(t, coreutils.HomeDir, filepath.Join(currentDir, configurationDir)) defer callback() - callback2 := clienttestutils.SetEnvWithCallbackAndAssert(t, "JFROG_CLI_CURATION_MAVEN", "true") - defer callback2() + callbackMaven := clienttestutils.SetEnvWithCallbackAndAssert(t, "JFROG_CLI_CURATION_MAVEN", "true") + defer callbackMaven() + callbackPip := clienttestutils.SetEnvWithCallbackAndAssert(t, "JFROG_CLI_CURATION_PIP", "true") + defer callbackPip() mockServer, config := curationServer(t, tt.expectedBuildRequest, tt.expectedRequest, tt.requestToFail, tt.requestToError, tt.serveResources) defer mockServer.Close() configFilePath := WriteServerDetailsConfigFileBytes(t, config.ArtifactoryUrl, configurationDir) diff --git a/utils/paths.go b/utils/paths.go index 05a58542..51738194 100644 --- a/utils/paths.go +++ b/utils/paths.go @@ -13,6 +13,7 @@ const ( // #nosec G101 -- Not credentials. CurationMavenSupport = "JFROG_CLI_CURATION_MAVEN" + CurationPipSupport = "JFROG_CLI_CURATION_PIP" ) func getJfrogCurationFolder() (string, error) { From 229195a3e8a0834db4024d569f50878850c576a1 Mon Sep 17 00:00:00 2001 From: asafambar Date: Wed, 27 Mar 2024 18:27:25 +0200 Subject: [PATCH 09/19] Fix cache folder and cache message --- commands/audit/scarunner.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/commands/audit/scarunner.go b/commands/audit/scarunner.go index 0df739d3..c1c69f03 100644 --- a/commands/audit/scarunner.go +++ b/commands/audit/scarunner.go @@ -246,7 +246,7 @@ func getCurationCacheFolderAndLogMsg(params xrayutils.AuditParams, tech coreutil if !params.IsCurationCmd() { return } - if curationCacheFolder, err = getCurationCacheByTech(tech); err != nil { + if curationCacheFolder, err = getCurationCacheByTech(tech); err != nil || curationCacheFolder == "" { return } @@ -262,7 +262,7 @@ func getCurationCacheFolderAndLogMsg(params xrayutils.AuditParams, tech coreutil } } - logMessage = ". Project's cache is currently empty, so this run may take longer to complete" + logMessage = ". Quick note: we're running our first scan on the project with curation-audit. Expect this one to take a bit longer. Subsequent scans will be faster. Thanks for your patience." return logMessage, curationCacheFolder, err } From a8e145612c30490ebcb70ce578488d7769042699 Mon Sep 17 00:00:00 2001 From: asafambar Date: Wed, 27 Mar 2024 18:39:03 +0200 Subject: [PATCH 10/19] Fix go mod --- go.sum | 8 -------- 1 file changed, 8 deletions(-) diff --git a/go.sum b/go.sum index 36757fa8..a71b146c 100644 --- a/go.sum +++ b/go.sum @@ -98,22 +98,14 @@ github.com/jedib0t/go-pretty/v6 v6.5.5 h1:PpIU8lOjxvVYGGKule0QxxJfNysUSbC9lggQU2 github.com/jedib0t/go-pretty/v6 v6.5.5/go.mod h1:5LQIxa52oJ/DlDSLv0HEkWOFMDGoWkJb9ss5KqPpJBg= github.com/jfrog/archiver/v3 v3.6.0 h1:OVZ50vudkIQmKMgA8mmFF9S0gA47lcag22N13iV3F1w= github.com/jfrog/archiver/v3 v3.6.0/go.mod h1:fCAof46C3rAXgZurS8kNRNdSVMKBbZs+bNNhPYxLldI= -github.com/jfrog/build-info-go v1.8.9-0.20240225113943-096bf22ca54c h1:M1QiuCYGCYN1IiGyxogrLzfetYGkkhE2pgDh5K4Wo9A= -github.com/jfrog/build-info-go v1.8.9-0.20240225113943-096bf22ca54c/go.mod h1:QHcKuesY4MrBVBuEwwBz4uIsX6mwYuMEDV09ng4AvAU= -github.com/jfrog/gofrog v1.6.3 h1:F7He0+75HcgCe6SGTSHLFCBDxiE2Ja0tekvvcktW6wc= -github.com/jfrog/gofrog v1.6.3/go.mod h1:SZ1EPJUruxrVGndOzHd+LTiwWYKMlHqhKD+eu+v5Hqg= github.com/jfrog/build-info-go v1.9.24 h1:MjT+4bYecbNQ+dbLczg0lkE5DoLAhdyrF0cRXtnEJqI= github.com/jfrog/build-info-go v1.9.24/go.mod h1:CaCKqcg3V2W9/ZysE4ZvXZMgsvunclhjrTTQQGp3CzM= github.com/jfrog/gofrog v1.6.3 h1:F7He0+75HcgCe6SGTSHLFCBDxiE2Ja0tekvvcktW6wc= github.com/jfrog/gofrog v1.6.3/go.mod h1:SZ1EPJUruxrVGndOzHd+LTiwWYKMlHqhKD+eu+v5Hqg= github.com/jfrog/jfrog-apps-config v1.0.1 h1:mtv6k7g8A8BVhlHGlSveapqf4mJfonwvXYLipdsOFMY= github.com/jfrog/jfrog-apps-config v1.0.1/go.mod h1:8AIIr1oY9JuH5dylz2S6f8Ym2MaadPLR6noCBO4C22w= -github.com/jfrog/jfrog-cli-core/v2 v2.31.1-0.20240321095315-72b008905aa2 h1:wJ9Tn8D+koRVNuVdX5f0+FBxuEmVuY6hgCQZsCIWV0U= -github.com/jfrog/jfrog-cli-core/v2 v2.31.1-0.20240321095315-72b008905aa2/go.mod h1:XZP7fmNBBoieQTUE2p2mvA8h/CFO5z4PE7KW1s2cdNk= github.com/jfrog/jfrog-client-go v1.38.0 h1:0QP4/dSmJe0oYUrAqzoPDpGdJHcrOeq9mycnb0pSxqQ= github.com/jfrog/jfrog-client-go v1.38.0/go.mod h1:EHRLxpu0pIT7+ulYDNQ7IeieYBHMQeEPr8CoBHoJzQY= -github.com/jfrog/jfrog-client-go v1.28.1-0.20240222155638-e55c7d7acbee h1:IrM+wE8WmsSm95vpYSEYle2mPAOVn1FrRTeScSNxgrw= -github.com/jfrog/jfrog-client-go v1.28.1-0.20240222155638-e55c7d7acbee/go.mod h1:jcZYTyo9H4GtZ6eAYIfKm1ulxeTbshcBBA+YUbWlHNc= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= From aaae60e7b5de6a906881a99f9cc197706f7ee17d Mon Sep 17 00:00:00 2001 From: asafambar Date: Thu, 28 Mar 2024 15:53:23 +0200 Subject: [PATCH 11/19] Fix CR --- commands/audit/sca/python/python.go | 59 ++++++++++++++---------- commands/audit/sca/python/python_test.go | 34 ++++++-------- commands/curation/curationaudit.go | 46 +++++++++--------- 3 files changed, 70 insertions(+), 69 deletions(-) diff --git a/commands/audit/sca/python/python.go b/commands/audit/sca/python/python.go index 01d9e173..d2c3a10d 100644 --- a/commands/audit/sca/python/python.go +++ b/commands/audit/sca/python/python.go @@ -26,6 +26,7 @@ import ( const ( PythonPackageTypeIdentifier = "pypi://" + pythonReportFile = "report.json" ) type AuditPython struct { @@ -115,35 +116,24 @@ func getDependencies(serverDetails *config.ServerDetails, tech coreutils.Technol sca.LogExecutableVersion("python") sca.LogExecutableVersion(string(auditPython.Tool)) } - if auditPython.IsCurationCmd { - pipUrls, errProcessed := processPipDownloadsUrlsFromReportFile() - if errProcessed != nil { - err = errProcessed - return - } + if !auditPython.IsCurationCmd { + return + } + pipUrls, errProcessed := processPipDownloadsUrlsFromReportFile() + if errProcessed != nil { + err = errProcessed + + } else { params.SetDownloadUrls(pipUrls) } return } func processPipDownloadsUrlsFromReportFile() (map[string]string, error) { - exist, err := fileutils.IsFileExists("report.json", false) + pipReport, err := readPipReportIfExists() if err != nil { return nil, err } - if !exist { - err = errors.New("process failed, report file wasn't found, cant processed with curation command") - return nil, err - } - var reportBytes []byte - reportBytes, err = fileutils.ReadFile("report.json") - if err != nil { - return nil, err - } - pipReport := &pypiReport{} - if err = json.Unmarshal(reportBytes, pipReport); err != nil { - return nil, err - } pipUrls := map[string]string{} for _, dep := range pipReport.Install { if dep.MetaData.Name != "" { @@ -154,16 +144,36 @@ func processPipDownloadsUrlsFromReportFile() (map[string]string, error) { return pipUrls, nil } +func readPipReportIfExists() (pipReport *pypiReport, err error) { + if exist, existErr := fileutils.IsFileExists(pythonReportFile, false); existErr != nil { + err = existErr + return + } else if !exist { + err = errors.New("process failed, report file wasn't found, cant processed with curation command") + return + } + + var reportBytes []byte + if reportBytes, err = fileutils.ReadFile(pythonReportFile); err != nil { + return + } + pipReport = &pypiReport{} + if err = json.Unmarshal(reportBytes, pipReport); err != nil { + return + } + return +} + type pypiReport struct { Install []pypiReportInfo } type pypiReportInfo struct { - DownloadInfo pypiDwonloadInfo `json:"download_info"` + DownloadInfo pypiDownloadInfo `json:"download_info"` MetaData pypiMetaData `json:"metadata"` } -type pypiDwonloadInfo struct { +type pypiDownloadInfo struct { Url string `json:"url"` } @@ -237,11 +247,10 @@ func installPipDeps(auditPython *AuditPython) (restoreEnv func() error, err erro var curationCachePip string var reportFileName string if auditPython.IsCurationCmd { - curationCachePip, err = xrayutils2.GetCurationPipCacheFolder() - if err != nil { + if curationCachePip, err = xrayutils2.GetCurationPipCacheFolder(); err != nil { return } - reportFileName = "report.json" + reportFileName = pythonReportFile } pipInstallArgs := getPipInstallArgs(auditPython.PipRequirementsFile, remoteUrl, curationCachePip, reportFileName) diff --git a/commands/audit/sca/python/python_test.go b/commands/audit/sca/python/python_test.go index 1f4ee72a..e57d2607 100644 --- a/commands/audit/sca/python/python_test.go +++ b/commands/audit/sca/python/python_test.go @@ -51,27 +51,23 @@ func TestBuildPipDependencyListSetuppyForCuration(t *testing.T) { assert.Contains(t, uniqueDeps, PythonPackageTypeIdentifier+"ptyprocess:0.7.0") assert.Contains(t, uniqueDeps, PythonPackageTypeIdentifier+"pip-example:1.2.3") assert.Len(t, rootNode, 1) - if len(rootNode) > 0 { - assert.NotEmpty(t, rootNode[0].Nodes) - if rootNode[0].Nodes != nil { - // Test direct dependency - directDepNode := tests.GetAndAssertNode(t, rootNode[0].Nodes, "pip-example:1.2.3") - // Test child module - childNode := tests.GetAndAssertNode(t, directDepNode.Nodes, "pexpect:4.8.0") - // Test sub child module - tests.GetAndAssertNode(t, childNode.Nodes, "ptyprocess:0.7.0") - - downloadUrls := params.GetDownloadUrls() - assert.NotEmpty(t, downloadUrls) - url, exist := downloadUrls[PythonPackageTypeIdentifier+"ptyprocess:0.7.0"] - assert.True(t, exist) - assert.True(t, strings.HasSuffix(url, "packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl")) + if assert.NotNil(t, rootNode[0].Nodes) && assert.NotEmpty(t, rootNode[0].Nodes) { + // Test direct dependency + directDepNode := tests.GetAndAssertNode(t, rootNode[0].Nodes, "pip-example:1.2.3") + // Test child module + childNode := tests.GetAndAssertNode(t, directDepNode.Nodes, "pexpect:4.8.0") + // Test sub child module + tests.GetAndAssertNode(t, childNode.Nodes, "ptyprocess:0.7.0") - url, exist = downloadUrls[PythonPackageTypeIdentifier+"pexpect:4.8.0"] - assert.True(t, exist) - assert.True(t, strings.HasSuffix(url, "packages/39/7b/88dbb785881c28a102619d46423cb853b46dbccc70d3ac362d99773a78ce/pexpect-4.8.0-py2.py3-none-any.whl")) + downloadUrls := params.GetDownloadUrls() + assert.NotEmpty(t, downloadUrls) + url, exist := downloadUrls[PythonPackageTypeIdentifier+"ptyprocess:0.7.0"] + assert.True(t, exist) + assert.True(t, strings.HasSuffix(url, "packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl")) - } + url, exist = downloadUrls[PythonPackageTypeIdentifier+"pexpect:4.8.0"] + assert.True(t, exist) + assert.True(t, strings.HasSuffix(url, "packages/39/7b/88dbb785881c28a102619d46423cb853b46dbccc70d3ac362d99773a78ce/pexpect-4.8.0-py2.py3-none-any.whl")) } } diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index 7826009b..c69a70df 100644 --- a/commands/curation/curationaudit.go +++ b/commands/curation/curationaudit.go @@ -4,24 +4,15 @@ import ( "encoding/json" "errors" "fmt" - config "github.com/jfrog/jfrog-cli-core/v2/utils/config" - "github.com/jfrog/jfrog-cli-security/commands/audit/sca/python" - "net/http" - "os" - "path/filepath" - "regexp" - "sort" - "strings" - "sync" - "time" - "github.com/jfrog/gofrog/datastructures" "github.com/jfrog/gofrog/parallel" rtUtils "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils" outFormat "github.com/jfrog/jfrog-cli-core/v2/common/format" "github.com/jfrog/jfrog-cli-core/v2/common/project" + config "github.com/jfrog/jfrog-cli-core/v2/utils/config" "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" "github.com/jfrog/jfrog-cli-security/commands/audit" + "github.com/jfrog/jfrog-cli-security/commands/audit/sca/python" "github.com/jfrog/jfrog-cli-security/utils" "github.com/jfrog/jfrog-client-go/artifactory" "github.com/jfrog/jfrog-client-go/auth" @@ -30,6 +21,13 @@ import ( "github.com/jfrog/jfrog-client-go/utils/io/httputils" "github.com/jfrog/jfrog-client-go/utils/log" xrayUtils "github.com/jfrog/jfrog-client-go/xray/services/utils" + "net/http" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" ) const ( @@ -62,14 +60,14 @@ var CurationOutputFormats = []string{string(outFormat.Table), string(outFormat.J var supportedTech = map[coreutils.Technology]func(ca *CurationAuditCommand) (bool, error){ coreutils.Npm: func(ca *CurationAuditCommand) (bool, error) { return true, nil }, coreutils.Pip: func(ca *CurationAuditCommand) (bool, error) { - return ca.checkSupportByVersionOrEnv(coreutils.Pip, MinArtiPassThroughSupport, MinXrayPassTHroughSupport, utils.CurationPipSupport) + return ca.checkSupportByVersionOrEnv(coreutils.Pip, utils.CurationPipSupport) }, coreutils.Maven: func(ca *CurationAuditCommand) (bool, error) { - return ca.checkSupportByVersionOrEnv(coreutils.Maven, MinArtiPassThroughSupport, MinXrayPassTHroughSupport, utils.CurationMavenSupport) + return ca.checkSupportByVersionOrEnv(coreutils.Maven, utils.CurationMavenSupport) }, } -func (ca *CurationAuditCommand) checkSupportByVersionOrEnv(tech coreutils.Technology, minRtVersion, minXrayVersion, envName string) (bool, error) { +func (ca *CurationAuditCommand) checkSupportByVersionOrEnv(tech coreutils.Technology, envName string) (bool, error) { if flag, err := clientutils.GetBoolEnvValue(envName, false); flag { return true, nil } else if err != nil { @@ -85,10 +83,9 @@ func (ca *CurationAuditCommand) checkSupportByVersionOrEnv(tech coreutils.Techno return false, err } - xrayVersionErr := clientutils.ValidateMinimumVersion(clientutils.Xray, xrayVersion, minXrayVersion) - rtVersionErr := clientutils.ValidateMinimumVersion(clientutils.Artifactory, rtVersion, minRtVersion) + xrayVersionErr := clientutils.ValidateMinimumVersion(clientutils.Xray, xrayVersion, MinXrayPassTHroughSupport) + rtVersionErr := clientutils.ValidateMinimumVersion(clientutils.Artifactory, rtVersion, MinArtiPassThroughSupport) if xrayVersionErr != nil || rtVersionErr != nil { - // though artifactory or xray is not in the required version, the feature can be enabled with env variable. return false, errors.Join(xrayVersionErr, rtVersionErr) } return true, nil @@ -292,7 +289,6 @@ func (ca *CurationAuditCommand) getAuditParamsByTech(tech coreutils.Technology) } func (ca *CurationAuditCommand) auditTree(tech coreutils.Technology, results map[string][]*PackageStatus) error { - start := time.Now() flattenGraph, fullDependenciesTrees, err := audit.GetTechDependencyTree(ca.getAuditParamsByTech(tech), tech) if err != nil { return err @@ -313,10 +309,12 @@ func (ca *CurationAuditCommand) auditTree(tech coreutils.Technology, results map _, projectName, projectScope, projectVersion := getUrlNameAndVersionByTech(tech, rootNode, nil, "", "") if projectName == "" { workPath, err := os.Getwd() - if err == nil { - projectName = filepath.Base(workPath) + if err != nil { + return err } + projectName = filepath.Base(workPath) } + if ca.Progress() != nil { ca.Progress().SetHeadlineMsg(fmt.Sprintf("Fetch curation status for %s graph with %v nodes project name: %s:%s", tech.ToFormal(), len(flattenGraph.Nodes)-1, projectName, projectVersion)) } @@ -353,7 +351,6 @@ func (ca *CurationAuditCommand) auditTree(tech coreutils.Technology, results map return packagesStatus[i].ParentName < packagesStatus[j].ParentName }) results[strings.TrimSuffix(fmt.Sprintf("%s:%s", projectName, projectVersion), ":")] = packagesStatus - log.Info(fmt.Sprintf("total time: %v", time.Since(start))) return err } @@ -646,11 +643,10 @@ func getPythonNameVersion(id string, downloadUrlsMap map[string]string) (downloa } id = strings.TrimPrefix(id, python.PythonPackageTypeIdentifier) allParts := strings.Split(id, ":") - if len(allParts) < 2 { - return + if len(allParts) >= 2 { + name = allParts[0] + version = allParts[1] } - name = allParts[0] - version = allParts[1] return } From b851e9de6ae093455211841744dba1a92fbabcb2 Mon Sep 17 00:00:00 2001 From: asafambar Date: Thu, 28 Mar 2024 19:53:22 +0200 Subject: [PATCH 12/19] Refactor depTreeBuilder to return struct with download urls, added tests --- commands/audit/sca/python/python.go | 20 ++++------- commands/audit/sca/python/python_test.go | 41 +++++++++++++++------- commands/audit/scarunner.go | 44 ++++++++++++++++-------- commands/curation/curationaudit.go | 16 ++++----- utils/auditbasicparams.go | 9 ----- 5 files changed, 72 insertions(+), 58 deletions(-) diff --git a/commands/audit/sca/python/python.go b/commands/audit/sca/python/python.go index d2c3a10d..2c6026e8 100644 --- a/commands/audit/sca/python/python.go +++ b/commands/audit/sca/python/python.go @@ -37,11 +37,13 @@ type AuditPython struct { IsCurationCmd bool } -func BuildDependencyTree(serverDetails *config.ServerDetails, tech coreutils.Technology, params xrayutils2.AuditParams) (dependencyTree []*xrayUtils.GraphNode, uniqueDeps []string, err error) { - dependenciesGraph, directDependenciesList, err := getDependencies(serverDetails, tech, params) - if err != nil { +func BuildDependencyTree(auditPython *AuditPython) (dependencyTree []*xrayUtils.GraphNode, uniqueDeps []string, downloadUrls map[string]string, err error) { + dependenciesGraph, directDependenciesList, pipUrls, errGetTree := getDependencies(auditPython) + if errGetTree != nil { + err = errGetTree return } + downloadUrls = pipUrls directDependencies := []*xrayUtils.GraphNode{} uniqueDepsSet := datastructures.MakeSet[string]() for _, rootDep := range directDependenciesList { @@ -61,15 +63,7 @@ func BuildDependencyTree(serverDetails *config.ServerDetails, tech coreutils.Tec return } -func getDependencies(serverDetails *config.ServerDetails, tech coreutils.Technology, - params xrayutils2.AuditParams) (dependenciesGraph map[string][]string, directDependencies []string, err error) { - auditPython := &AuditPython{ - Server: serverDetails, - Tool: pythonutils.PythonTool(tech), - RemotePypiRepo: params.DepsRepo(), - PipRequirementsFile: params.PipRequirementsFile(), - IsCurationCmd: params.IsCurationCmd(), - } +func getDependencies(auditPython *AuditPython) (dependenciesGraph map[string][]string, directDependencies []string, pipUrls map[string]string, err error) { wd, err := os.Getwd() if errorutils.CheckError(err) != nil { return @@ -123,8 +117,6 @@ func getDependencies(serverDetails *config.ServerDetails, tech coreutils.Technol if errProcessed != nil { err = errProcessed - } else { - params.SetDownloadUrls(pipUrls) } return } diff --git a/commands/audit/sca/python/python_test.go b/commands/audit/sca/python/python_test.go index e57d2607..b23e59a7 100644 --- a/commands/audit/sca/python/python_test.go +++ b/commands/audit/sca/python/python_test.go @@ -2,8 +2,8 @@ package python import ( "errors" + "github.com/jfrog/build-info-go/utils/pythonutils" "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" - xrayutils "github.com/jfrog/jfrog-cli-security/utils" "github.com/stretchr/testify/require" "path/filepath" "strings" @@ -19,7 +19,10 @@ func TestBuildPipDependencyListSetuppy(t *testing.T) { _, cleanUp := sca.CreateTestWorkspace(t, filepath.Join("projects", "package-managers", "python", "pip", "pip", "setuppyproject")) defer cleanUp() // Run getModulesDependencyTrees - rootNode, uniqueDeps, err := BuildDependencyTree(nil, coreutils.Pip, &xrayutils.AuditBasicParams{}) + rootNode, uniqueDeps, _, err := BuildDependencyTree(&AuditPython{ + Server: nil, + Tool: pythonutils.PythonTool(coreutils.Pip), + }) assert.NoError(t, err) assert.Contains(t, uniqueDeps, PythonPackageTypeIdentifier+"pexpect:4.8.0") assert.Contains(t, uniqueDeps, PythonPackageTypeIdentifier+"ptyprocess:0.7.0") @@ -43,9 +46,11 @@ func TestBuildPipDependencyListSetuppyForCuration(t *testing.T) { _, cleanUp := sca.CreateTestWorkspace(t, filepath.Join("projects", "package-managers", "python", "pip", "pip", "setuppyproject")) defer cleanUp() // Run getModulesDependencyTrees - params := &xrayutils.AuditBasicParams{} - params.SetIsCurationCmd(true) - rootNode, uniqueDeps, err := BuildDependencyTree(nil, coreutils.Pip, params) + rootNode, uniqueDeps, downloadUrls, err := BuildDependencyTree(&AuditPython{ + Server: nil, + Tool: pythonutils.PythonTool(coreutils.Pip), + IsCurationCmd: true, + }) assert.NoError(t, err) assert.Contains(t, uniqueDeps, PythonPackageTypeIdentifier+"pexpect:4.8.0") assert.Contains(t, uniqueDeps, PythonPackageTypeIdentifier+"ptyprocess:0.7.0") @@ -59,7 +64,6 @@ func TestBuildPipDependencyListSetuppyForCuration(t *testing.T) { // Test sub child module tests.GetAndAssertNode(t, childNode.Nodes, "ptyprocess:0.7.0") - downloadUrls := params.GetDownloadUrls() assert.NotEmpty(t, downloadUrls) url, exist := downloadUrls[PythonPackageTypeIdentifier+"ptyprocess:0.7.0"] assert.True(t, exist) @@ -76,7 +80,9 @@ func TestPipDependencyListRequirementsFallback(t *testing.T) { _, cleanUp := sca.CreateTestWorkspace(t, filepath.Join("projects", "package-managers", "python", "pip", "pip", "requirementsproject")) defer cleanUp() // No requirements file field specified, expect the command to use the fallback 'pip install -r requirements.txt' command - rootNode, uniqueDeps, err := BuildDependencyTree(nil, coreutils.Pip, &xrayutils.AuditBasicParams{}) + rootNode, uniqueDeps, _, err := BuildDependencyTree(&AuditPython{ + Tool: pythonutils.PythonTool(coreutils.Pip), + }) assert.NoError(t, err) assert.Contains(t, uniqueDeps, PythonPackageTypeIdentifier+"pexpect:4.7.0") assert.Contains(t, uniqueDeps, PythonPackageTypeIdentifier+"ptyprocess:0.7.0") @@ -95,9 +101,11 @@ func TestBuildPipDependencyListRequirements(t *testing.T) { _, cleanUp := sca.CreateTestWorkspace(t, filepath.Join("projects", "package-managers", "python", "pip", "pip", "requirementsproject")) defer cleanUp() // Run getModulesDependencyTrees - params := &xrayutils.AuditBasicParams{} - params.SetPipRequirementsFile("requirements.txt") - rootNode, uniqueDeps, err := BuildDependencyTree(nil, coreutils.Pip, params) + rootNode, uniqueDeps, _, err := BuildDependencyTree(&AuditPython{ + Server: nil, + Tool: pythonutils.PythonTool(coreutils.Pip), + PipRequirementsFile: "requirements.txt", + }) assert.NoError(t, err) assert.Contains(t, uniqueDeps, PythonPackageTypeIdentifier+"pexpect:4.7.0") assert.Contains(t, uniqueDeps, PythonPackageTypeIdentifier+"ptyprocess:0.7.0") @@ -123,7 +131,10 @@ func TestBuildPipenvDependencyList(t *testing.T) { PythonPackageTypeIdentifier + "ptyprocess:0.7.0", } // Run getModulesDependencyTrees - rootNode, uniqueDeps, err := BuildDependencyTree(nil, coreutils.Pipenv, &xrayutils.AuditBasicParams{}) + rootNode, uniqueDeps, _, err := BuildDependencyTree(&AuditPython{ + Server: nil, + Tool: pythonutils.PythonTool(coreutils.Pipenv), + }) if err != nil { t.Fatal(err) } @@ -158,7 +169,11 @@ func TestBuildPoetryDependencyList(t *testing.T) { PythonPackageTypeIdentifier + "pytest:5.4.3", } // Run getModulesDependencyTrees - rootNode, uniqueDeps, err := BuildDependencyTree(nil, coreutils.Poetry, &xrayutils.AuditBasicParams{}) + rootNode, uniqueDeps, _, err := BuildDependencyTree(&AuditPython{ + Server: nil, + Tool: pythonutils.PythonTool(coreutils.Poetry), + IsCurationCmd: true, + }) if err != nil { t.Fatal(err) } @@ -181,6 +196,8 @@ func TestGetPipInstallArgs(t *testing.T) { assert.Equal(t, []string{"-m", "pip", "install", ".", "-i", "https://user@pass:remote.url/repo"}, getPipInstallArgs("", "https://user@pass:remote.url/repo", "", "")) assert.Equal(t, []string{"-m", "pip", "install", "-r", "requirements.txt", "-i", "https://user@pass:remote.url/repo"}, getPipInstallArgs("requirements.txt", "https://user@pass:remote.url/repo", "", "")) + assert.Equal(t, []string{"-m", "pip", "install", ".", "--cache-dir", filepath.Join("test", "path"), "--ignore-installed", "--report", "report.json"}, getPipInstallArgs("", "", filepath.Join("test", "path"), "report.json")) + } func Test_curationPassThroughError(t *testing.T) { diff --git a/commands/audit/scarunner.go b/commands/audit/scarunner.go index 23d3035f..c70ef3da 100644 --- a/commands/audit/scarunner.go +++ b/commands/audit/scarunner.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/jfrog/build-info-go/utils/pythonutils" "github.com/jfrog/jfrog-client-go/utils/io/fileutils" "os" "time" @@ -112,20 +113,20 @@ func executeScaScan(serverDetails *config.ServerDetails, params *AuditParams, sc if err = os.Chdir(scan.WorkingDirectory); err != nil { return errorutils.CheckError(err) } - flattenTree, fullDependencyTrees, techErr := GetTechDependencyTree(params.AuditBasicParams, scan.Technology) + treeResult, techErr := GetTechDependencyTree(params.AuditBasicParams, scan.Technology) if techErr != nil { return fmt.Errorf("failed while building '%s' dependency tree:\n%s", scan.Technology, techErr.Error()) } - if flattenTree == nil || len(flattenTree.Nodes) == 0 { + if treeResult.FlatTree == nil || len(treeResult.FlatTree.Nodes) == 0 { return errorutils.CheckErrorf("no dependencies were found. Please try to build your project and re-run the audit command") } // Scan the dependency tree. - scanResults, xrayErr := runScaWithTech(scan.Technology, params, serverDetails, flattenTree, fullDependencyTrees) + scanResults, xrayErr := runScaWithTech(scan.Technology, params, serverDetails, treeResult.FlatTree, treeResult.FullDepTrees) if xrayErr != nil { return fmt.Errorf("'%s' Xray dependency tree scan request failed:\n%s", scan.Technology, xrayErr.Error()) } - scan.IsMultipleRootProject = clientutils.Pointer(len(fullDependencyTrees) > 1) - addThirdPartyDependenciesToParams(params, scan.Technology, flattenTree, fullDependencyTrees) + scan.IsMultipleRootProject = clientutils.Pointer(len(treeResult.FullDepTrees) > 1) + addThirdPartyDependenciesToParams(params, scan.Technology, treeResult.FlatTree, treeResult.FullDepTrees) scan.XrayResults = append(scan.XrayResults, scanResults...) return } @@ -181,7 +182,13 @@ func getCurationCacheByTech(tech coreutils.Technology) (string, error) { return "", nil } -func GetTechDependencyTree(params xrayutils.AuditParams, tech coreutils.Technology) (flatTree *xrayCmdUtils.GraphNode, fullDependencyTrees []*xrayCmdUtils.GraphNode, err error) { +type DependencyTreeResult struct { + FlatTree *xrayCmdUtils.GraphNode + FullDepTrees []*xrayCmdUtils.GraphNode + DownloadUrls map[string]string +} + +func GetTechDependencyTree(params xrayutils.AuditParams, tech coreutils.Technology) (depTreeResult DependencyTreeResult, err error) { logMessage := fmt.Sprintf("Calculating %s dependencies", tech.ToFormal()) curationLogMsg, curationCacheFolder, err := getCurationCacheFolderAndLogMsg(params, tech) if err != nil { @@ -208,7 +215,7 @@ func GetTechDependencyTree(params xrayutils.AuditParams, tech coreutils.Technolo switch tech { case coreutils.Maven, coreutils.Gradle: - fullDependencyTrees, uniqDepsWithTypes, err = java.BuildDependencyTree(java.DepTreeParams{ + depTreeResult.FullDepTrees, uniqDepsWithTypes, err = java.BuildDependencyTree(java.DepTreeParams{ Server: serverDetails, DepsRepo: params.DepsRepo(), IsMavenDepTreeInstalled: params.IsMavenDepTreeInstalled(), @@ -217,17 +224,24 @@ func GetTechDependencyTree(params xrayutils.AuditParams, tech coreutils.Technolo CurationCacheFolder: curationCacheFolder, }, tech) case coreutils.Npm: - fullDependencyTrees, uniqueDeps, err = npm.BuildDependencyTree(params) + depTreeResult.FullDepTrees, uniqueDeps, err = npm.BuildDependencyTree(params) case coreutils.Pnpm: - fullDependencyTrees, uniqueDeps, err = pnpm.BuildDependencyTree(params) + depTreeResult.FullDepTrees, uniqueDeps, err = pnpm.BuildDependencyTree(params) case coreutils.Yarn: - fullDependencyTrees, uniqueDeps, err = yarn.BuildDependencyTree(params) + depTreeResult.FullDepTrees, uniqueDeps, err = yarn.BuildDependencyTree(params) case coreutils.Go: - fullDependencyTrees, uniqueDeps, err = _go.BuildDependencyTree(params) + depTreeResult.FullDepTrees, uniqueDeps, err = _go.BuildDependencyTree(params) case coreutils.Pipenv, coreutils.Pip, coreutils.Poetry: - fullDependencyTrees, uniqueDeps, err = python.BuildDependencyTree(serverDetails, tech, params) + depTreeResult.FullDepTrees, uniqueDeps, + depTreeResult.DownloadUrls, err = python.BuildDependencyTree(&python.AuditPython{ + Server: serverDetails, + Tool: pythonutils.PythonTool(tech), + RemotePypiRepo: params.DepsRepo(), + PipRequirementsFile: params.PipRequirementsFile(), + IsCurationCmd: params.IsCurationCmd(), + }) case coreutils.Nuget: - fullDependencyTrees, uniqueDeps, err = nuget.BuildDependencyTree(params) + depTreeResult.FullDepTrees, uniqueDeps, err = nuget.BuildDependencyTree(params) default: err = errorutils.CheckErrorf("%s is currently not supported", string(tech)) } @@ -236,10 +250,10 @@ func GetTechDependencyTree(params xrayutils.AuditParams, tech coreutils.Technolo } log.Debug(fmt.Sprintf("Created '%s' dependency tree with %d nodes. Elapsed time: %.1f seconds.", tech.ToFormal(), len(uniqueDeps), time.Since(startTime).Seconds())) if len(uniqDepsWithTypes) > 0 { - flatTree, err = createFlatTreeWithTypes(uniqDepsWithTypes) + depTreeResult.FlatTree, err = createFlatTreeWithTypes(uniqDepsWithTypes) return } - flatTree, err = createFlatTree(uniqueDeps) + depTreeResult.FlatTree, err = createFlatTree(uniqueDeps) return } diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index c69a70df..15b8f6d2 100644 --- a/commands/curation/curationaudit.go +++ b/commands/curation/curationaudit.go @@ -289,12 +289,12 @@ func (ca *CurationAuditCommand) getAuditParamsByTech(tech coreutils.Technology) } func (ca *CurationAuditCommand) auditTree(tech coreutils.Technology, results map[string][]*PackageStatus) error { - flattenGraph, fullDependenciesTrees, err := audit.GetTechDependencyTree(ca.getAuditParamsByTech(tech), tech) + depTreeResult, err := audit.GetTechDependencyTree(ca.getAuditParamsByTech(tech), tech) if err != nil { return err } // Validate the graph isn't empty. - if len(fullDependenciesTrees) == 0 { + if len(depTreeResult.FullDepTrees) == 0 { return errorutils.CheckErrorf("found no dependencies for the audited project using '%v' as the package manager", tech.String()) } rtManager, serverDetails, err := ca.getRtManagerAndAuth(tech) @@ -305,7 +305,7 @@ func (ca *CurationAuditCommand) auditTree(tech coreutils.Technology, results map if err != nil { return err } - rootNode := fullDependenciesTrees[0] + rootNode := depTreeResult.FullDepTrees[0] _, projectName, projectScope, projectVersion := getUrlNameAndVersionByTech(tech, rootNode, nil, "", "") if projectName == "" { workPath, err := os.Getwd() @@ -316,7 +316,7 @@ func (ca *CurationAuditCommand) auditTree(tech coreutils.Technology, results map } if ca.Progress() != nil { - ca.Progress().SetHeadlineMsg(fmt.Sprintf("Fetch curation status for %s graph with %v nodes project name: %s:%s", tech.ToFormal(), len(flattenGraph.Nodes)-1, projectName, projectVersion)) + ca.Progress().SetHeadlineMsg(fmt.Sprintf("Fetch curation status for %s graph with %v nodes project name: %s:%s", tech.ToFormal(), len(depTreeResult.FlatTree.Nodes)-1, projectName, projectVersion)) } if projectScope != "" { projectName = projectScope + "/" + projectName @@ -334,18 +334,18 @@ func (ca *CurationAuditCommand) auditTree(tech coreutils.Technology, results map repo: ca.PackageManagerConfig.TargetRepo(), tech: tech, parallelRequests: ca.parallelRequests, - downloadUrls: ca.GetDownloadUrls(), + downloadUrls: depTreeResult.DownloadUrls, } rootNodes := map[string]struct{}{} - for _, tree := range fullDependenciesTrees { + for _, tree := range depTreeResult.FullDepTrees { rootNodes[tree.Id] = struct{}{} } // Fetch status for each node from a flatten graph which, has no duplicate nodes. packagesStatusMap := sync.Map{} // if error returned we still want to produce a report, so we don't fail the next step - err = analyzer.fetchNodesStatus(flattenGraph, &packagesStatusMap, rootNodes) - analyzer.GraphsRelations(fullDependenciesTrees, &packagesStatusMap, + err = analyzer.fetchNodesStatus(depTreeResult.FlatTree, &packagesStatusMap, rootNodes) + analyzer.GraphsRelations(depTreeResult.FullDepTrees, &packagesStatusMap, &packagesStatus) sort.Slice(packagesStatus, func(i, j int) bool { return packagesStatus[i].ParentName < packagesStatus[j].ParentName diff --git a/utils/auditbasicparams.go b/utils/auditbasicparams.go index 37e3275e..11e4f262 100644 --- a/utils/auditbasicparams.go +++ b/utils/auditbasicparams.go @@ -40,8 +40,6 @@ type AuditParams interface { Exclusions() []string SetIsRecursiveScan(isRecursiveScan bool) *AuditBasicParams IsRecursiveScan() bool - SetDownloadUrls(urlsMap map[string]string) - GetDownloadUrls() map[string]string } type AuditBasicParams struct { @@ -231,10 +229,3 @@ func (abp *AuditBasicParams) SetIsRecursiveScan(isRecursiveScan bool) *AuditBasi func (abp *AuditBasicParams) IsRecursiveScan() bool { return abp.isRecursiveScan } - -func (abp *AuditBasicParams) SetDownloadUrls(urlsMap map[string]string) { - abp.downloadUrls = urlsMap -} -func (abp *AuditBasicParams) GetDownloadUrls() map[string]string { - return abp.downloadUrls -} From f8e15f2812b11f81f224f9321d2bcf91828cb80c Mon Sep 17 00:00:00 2001 From: asafambar Date: Thu, 28 Mar 2024 19:56:48 +0200 Subject: [PATCH 13/19] Fix test. --- commands/curation/curationaudit_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/commands/curation/curationaudit_test.go b/commands/curation/curationaudit_test.go index 86ba8800..ff7723fe 100644 --- a/commands/curation/curationaudit_test.go +++ b/commands/curation/curationaudit_test.go @@ -411,9 +411,9 @@ func TestDoCurationAudit(t *testing.T) { configurationDir := tt.pathToTest callback := clienttestutils.SetEnvWithCallbackAndAssert(t, coreutils.HomeDir, filepath.Join(currentDir, configurationDir)) defer callback() - callbackMaven := clienttestutils.SetEnvWithCallbackAndAssert(t, "JFROG_CLI_CURATION_MAVEN", "true") + callbackMaven := clienttestutils.SetEnvWithCallbackAndAssert(t, utils.CurationMavenSupport, "true") defer callbackMaven() - callbackPip := clienttestutils.SetEnvWithCallbackAndAssert(t, "JFROG_CLI_CURATION_PIP", "true") + callbackPip := clienttestutils.SetEnvWithCallbackAndAssert(t, utils.CurationPipSupport, "true") defer callbackPip() mockServer, config := curationServer(t, tt.expectedBuildRequest, tt.expectedRequest, tt.requestToFail, tt.requestToError, tt.serveResources) defer mockServer.Close() From a04e64e848088f8b088d93abb8bc0bd7595ba95a Mon Sep 17 00:00:00 2001 From: asafambar Date: Thu, 28 Mar 2024 21:54:41 +0200 Subject: [PATCH 14/19] Fix test. --- commands/audit/sca/python/python_test.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/commands/audit/sca/python/python_test.go b/commands/audit/sca/python/python_test.go index b23e59a7..917a251b 100644 --- a/commands/audit/sca/python/python_test.go +++ b/commands/audit/sca/python/python_test.go @@ -170,9 +170,7 @@ func TestBuildPoetryDependencyList(t *testing.T) { } // Run getModulesDependencyTrees rootNode, uniqueDeps, _, err := BuildDependencyTree(&AuditPython{ - Server: nil, - Tool: pythonutils.PythonTool(coreutils.Poetry), - IsCurationCmd: true, + Tool: pythonutils.PythonTool(coreutils.Poetry), }) if err != nil { t.Fatal(err) From 18b6ab068e91059ef1f9e6026490fd01dd62caaa Mon Sep 17 00:00:00 2001 From: asafambar Date: Thu, 28 Mar 2024 22:03:31 +0200 Subject: [PATCH 15/19] Fix go mod. --- go.sum | 4 ---- 1 file changed, 4 deletions(-) diff --git a/go.sum b/go.sum index 9005a382..dc4907a7 100644 --- a/go.sum +++ b/go.sum @@ -104,10 +104,6 @@ github.com/jfrog/gofrog v1.6.3 h1:F7He0+75HcgCe6SGTSHLFCBDxiE2Ja0tekvvcktW6wc= github.com/jfrog/gofrog v1.6.3/go.mod h1:SZ1EPJUruxrVGndOzHd+LTiwWYKMlHqhKD+eu+v5Hqg= github.com/jfrog/jfrog-apps-config v1.0.1 h1:mtv6k7g8A8BVhlHGlSveapqf4mJfonwvXYLipdsOFMY= github.com/jfrog/jfrog-apps-config v1.0.1/go.mod h1:8AIIr1oY9JuH5dylz2S6f8Ym2MaadPLR6noCBO4C22w= -github.com/jfrog/jfrog-client-go v1.38.0 h1:0QP4/dSmJe0oYUrAqzoPDpGdJHcrOeq9mycnb0pSxqQ= -github.com/jfrog/jfrog-client-go v1.38.0/go.mod h1:EHRLxpu0pIT7+ulYDNQ7IeieYBHMQeEPr8CoBHoJzQY= -github.com/jfrog/jfrog-cli-core/v2 v2.50.0 h1:QmjSIktMKAbNH7OGY+eVZKx9husqgMANSI5kB8MlvlA= -github.com/jfrog/jfrog-cli-core/v2 v2.50.0/go.mod h1:95AsjwlMLNWU0v71/3dS715e1RAQfvPO47RRHz2xKh8= github.com/jfrog/jfrog-client-go v1.39.0 h1:GZ1qbpUDzYz8ZEycYicDkbVMN2H0VSCuz8mUNTyf7tc= github.com/jfrog/jfrog-client-go v1.39.0/go.mod h1:tUyEmxznphh0nwAGo6xz9Sps7RRW/TBMxIJZteo+j2k= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= From 0e3341fb3b8fa589f6ee4d472dcb2ed827546987 Mon Sep 17 00:00:00 2001 From: asafambar Date: Sun, 31 Mar 2024 10:21:26 +0300 Subject: [PATCH 16/19] Fixed sca. --- utils/auditbasicparams.go | 1 - 1 file changed, 1 deletion(-) diff --git a/utils/auditbasicparams.go b/utils/auditbasicparams.go index 11e4f262..e66a618f 100644 --- a/utils/auditbasicparams.go +++ b/utils/auditbasicparams.go @@ -61,7 +61,6 @@ type AuditBasicParams struct { dependenciesForApplicabilityScan []string exclusions []string isRecursiveScan bool - downloadUrls map[string]string } func (abp *AuditBasicParams) DirectDependencies() []string { From 66505e5fa6be8c687fff37f41f8d822e43624dde Mon Sep 17 00:00:00 2001 From: asafambar Date: Sun, 31 Mar 2024 17:06:29 +0300 Subject: [PATCH 17/19] Fixed CR. --- commands/audit/sca/common.go | 20 ++++++++ commands/audit/sca/common_test.go | 61 ++++++++++++++++++++++++ commands/audit/sca/java/mvn.go | 8 ++-- commands/audit/sca/java/mvn_test.go | 34 ------------- commands/audit/sca/python/python.go | 14 ++---- commands/audit/sca/python/python_test.go | 39 --------------- commands/audit/scarunner.go | 2 +- commands/curation/curationaudit.go | 4 -- 8 files changed, 90 insertions(+), 92 deletions(-) diff --git a/commands/audit/sca/common.go b/commands/audit/sca/common.go index ff93b280..566ac82c 100644 --- a/commands/audit/sca/common.go +++ b/commands/audit/sca/common.go @@ -22,6 +22,9 @@ import ( var DefaultExcludePatterns = []string{"*.git*", "*node_modules*", "*target*", "*venv*", "*test*"} +var curationErrorMsgToUserTemplate = "Failed to retrieve the dependencies tree for the %s project. Please contact your " + + "Artifactory administrator to verify pass-through for Curation audit is enabled for your project" + func GetExcludePattern(params utils.AuditParams) string { exclusions := params.Exclusions() if len(exclusions) == 0 { @@ -168,3 +171,20 @@ func setPathsForIssues(dependency *xrayUtils.GraphNode, issuesImpactPathsMap map setPathsForIssues(depChild, issuesImpactPathsMap, pathFromRoot) } } + +func SuspectCurationBlockedError(isCurationCmd bool, tech coreutils.Technology, cmdOutput string) (msgToUser string) { + if !isCurationCmd { + return + } + switch tech { + case coreutils.Maven: + if strings.Contains(cmdOutput, "status code: 403") || strings.Contains(cmdOutput, "status code: 500") { + msgToUser = fmt.Sprintf(curationErrorMsgToUserTemplate, coreutils.Maven) + } + case coreutils.Pip: + if strings.Contains(strings.ToLower(cmdOutput), "http error 403") { + msgToUser = fmt.Sprintf(curationErrorMsgToUserTemplate, coreutils.Pip) + } + } + return +} diff --git a/commands/audit/sca/common_test.go b/commands/audit/sca/common_test.go index 22a65a4e..a76361b4 100644 --- a/commands/audit/sca/common_test.go +++ b/commands/audit/sca/common_test.go @@ -1,6 +1,8 @@ package sca import ( + "fmt" + "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" "reflect" "testing" @@ -272,3 +274,62 @@ func TestBuildImpactPaths(t *testing.T) { expectedImpactPaths = [][]services.ImpactPathNode{{{ComponentId: "dep1"}, {ComponentId: "dep2"}, {ComponentId: "dep3"}}} reflect.DeepEqual(expectedImpactPaths, scanResult[0].Licenses[0].Components["dep3"].ImpactPaths) } + +func TestSuspectCurationBlockedError(t *testing.T) { + mvnOutput1 := "status code: 403, reason phrase: Forbidden (403)" + mvnOutput2 := "status code: 500, reason phrase: Server Error (500)" + pipOutput := "because of HTTP error 403 Client Error: Forbidden for url" + + tests := []struct { + name string + isCurationCmd bool + tech coreutils.Technology + output string + expect string + }{ + { + name: "mvn 403 error", + isCurationCmd: true, + tech: coreutils.Maven, + output: mvnOutput1, + expect: fmt.Sprintf(curationErrorMsgToUserTemplate, coreutils.Maven), + }, + { + name: "mvn 500 error", + isCurationCmd: true, + tech: coreutils.Maven, + output: mvnOutput2, + expect: fmt.Sprintf(curationErrorMsgToUserTemplate, coreutils.Maven), + }, + { + name: "pip 403 error", + isCurationCmd: true, + tech: coreutils.Maven, + output: pipOutput, + expect: fmt.Sprintf(curationErrorMsgToUserTemplate, coreutils.Pip), + }, + { + name: "pip not pass through error", + isCurationCmd: true, + tech: coreutils.Pip, + output: "http error 401", + }, + { + name: "maven not pass through error", + isCurationCmd: true, + tech: coreutils.Maven, + output: "http error 401", + }, + { + name: "nota supported tech", + isCurationCmd: true, + tech: coreutils.CI, + output: pipOutput, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + SuspectCurationBlockedError(tt.isCurationCmd, tt.tech, tt.output) + }) + } +} diff --git a/commands/audit/sca/java/mvn.go b/commands/audit/sca/java/mvn.go index 76514fb1..3979b384 100644 --- a/commands/audit/sca/java/mvn.go +++ b/commands/audit/sca/java/mvn.go @@ -4,6 +4,8 @@ import ( _ "embed" "errors" "fmt" + "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" + "github.com/jfrog/jfrog-cli-security/commands/audit/sca" "net/url" "os" "os/exec" @@ -174,7 +176,7 @@ func (mdt *MavenDepTreeManager) RunMvnCmd(goals []string) (cmdOutput []byte, err if len(cmdOutput) > 0 { log.Info(stringOutput) } - if msg := mdt.suspectCurationBlockedError(stringOutput); msg != "" { + if msg := sca.SuspectCurationBlockedError(mdt.isCurationCmd, coreutils.Maven, stringOutput); msg != "" { err = fmt.Errorf("failed running command 'mvn %s\n\n%s", strings.Join(goals, " "), msg) } else { err = fmt.Errorf("failed running command 'mvn %s': %s", strings.Join(goals, " "), err.Error()) @@ -254,8 +256,8 @@ func (mdt *MavenDepTreeManager) CreateTempDirWithSettingsXmlIfNeeded() (tempDirP // In case mvn tree fails on 403 or 500 it can be related to packages blocked by curation. // For this use case to succeed, pass through should be enabled in the curated repos -func (mdt *MavenDepTreeManager) suspectCurationBlockedError(cmdOutput string) (msgToUser string) { - if !mdt.isCurationCmd { +func (mdt *MavenDepTreeManager) suspectCurationBlockedError(isCurationCmd bool, cmdOutput string) (msgToUser string) { + if isCurationCmd { return } if strings.Contains(cmdOutput, "status code: 403") || strings.Contains(cmdOutput, "status code: 500") { diff --git a/commands/audit/sca/java/mvn_test.go b/commands/audit/sca/java/mvn_test.go index f211879e..3c614bc6 100644 --- a/commands/audit/sca/java/mvn_test.go +++ b/commands/audit/sca/java/mvn_test.go @@ -344,37 +344,3 @@ func TestRemoveMavenConfig(t *testing.T) { assert.NoError(t, err) assert.FileExists(t, mavenConfigPath) } - -func TestMavenDepTreeManager_suspectCurationBlockedError(t *testing.T) { - errPrefix := "[ERROR] Failed to execute goal on project my-app: Could not resolve dependencies for project com.mycompany.app:my-app:jar:1.0-SNAPSHOT: Failed to " + - "collect dependencies at junit:junit:jar:3.8.1: Failed to read artifact descriptor for junit:junit:jar:3.8.1: " + - "The following artifacts could not be resolved: junit:junit:pom:3.8.1 (absent): Could not transfer artifact junit:junit:pom:3.8.1 " + - "from/to artifactory (http://test:8046/artifactory/api/curation/audit/maven-remote):" - tests := []struct { - name string - wantMsgToUser string - input string - }{ - { - name: "failed on 403", - wantMsgToUser: "Please verify pass-through enabled on the curated repos", - input: errPrefix + "status code: 403, reason phrase: Forbidden (403)", - }, - { - name: "failed on 500", - wantMsgToUser: "Please verify pass-through enabled on the curated repos", - input: errPrefix + " status code: 500, reason phrase: Internal Server Error (500)", - }, - { - name: "not 403 or 500", - wantMsgToUser: "", - input: errPrefix + " status code: 400, reason phrase: Forbidden (400)", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mdt := &MavenDepTreeManager{} - assert.Contains(t, tt.wantMsgToUser, mdt.suspectCurationBlockedError(tt.input)) - }) - } -} diff --git a/commands/audit/sca/python/python.go b/commands/audit/sca/python/python.go index 2c6026e8..3c8d3fca 100644 --- a/commands/audit/sca/python/python.go +++ b/commands/audit/sca/python/python.go @@ -258,20 +258,12 @@ func installPipDeps(auditPython *AuditPython) (restoreEnv func() error, err erro err = nil } } - err = errors.Join(err, curationPassThroughError(auditPython, errors.Join(err, reqErr))) - return -} - -// If its curation command, we want to inform user that it can be resulted of pass-through disabled on curated repos. -func curationPassThroughError(auditPython *AuditPython, errFromPip error) (err error) { - if !auditPython.IsCurationCmd { - return - } - if errFromPip != nil && strings.Contains(strings.ToLower(errFromPip.Error()), "http error 403") { - err = errors.New("Failed to get dependencies tree for python project, Please verify pass-through enabled on the curated repos") + if msgToUser := sca.SuspectCurationBlockedError(auditPython.IsCurationCmd, coreutils.Pip, errors.Join(err, reqErr).Error()); msgToUser != "" { + err = errors.Join(err, errors.New(msgToUser)) } return } + func executeCommand(executable string, args ...string) error { installCmd := exec.Command(executable, args...) maskedCmdString := coreutils.GetMaskedCommandString(installCmd) diff --git a/commands/audit/sca/python/python_test.go b/commands/audit/sca/python/python_test.go index 917a251b..25b0118a 100644 --- a/commands/audit/sca/python/python_test.go +++ b/commands/audit/sca/python/python_test.go @@ -1,10 +1,8 @@ package python import ( - "errors" "github.com/jfrog/build-info-go/utils/pythonutils" "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" - "github.com/stretchr/testify/require" "path/filepath" "strings" "testing" @@ -197,40 +195,3 @@ func TestGetPipInstallArgs(t *testing.T) { assert.Equal(t, []string{"-m", "pip", "install", ".", "--cache-dir", filepath.Join("test", "path"), "--ignore-installed", "--report", "report.json"}, getPipInstallArgs("", "", filepath.Join("test", "path"), "report.json")) } - -func Test_curationPassThroughError(t *testing.T) { - tests := []struct { - name string - isCurationCommand bool - errFromPip error - expected string - }{ - { - name: "curation command and error include 403", - isCurationCommand: true, - errFromPip: errors.New("tes error from pip HTTP error 403"), - expected: "Failed to get dependencies tree for python project, Please verify pass-through enabled on the curated repos", - }, - { - name: "not curation cmd", - isCurationCommand: false, - errFromPip: errors.New("tes error from pip HTTP error 403"), - }, - { - name: "curation cmd, not 403 error", - isCurationCommand: true, - errFromPip: errors.New("tes error from pip HTTP error 500"), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := curationPassThroughError(&AuditPython{IsCurationCmd: tt.isCurationCommand}, tt.errFromPip) - if tt.expected != "" { - require.NotNil(t, err) - strings.Contains(err.Error(), tt.expected) - } else { - require.Nil(t, err) - } - }) - } -} diff --git a/commands/audit/scarunner.go b/commands/audit/scarunner.go index c70ef3da..2472701c 100644 --- a/commands/audit/scarunner.go +++ b/commands/audit/scarunner.go @@ -277,7 +277,7 @@ func getCurationCacheFolderAndLogMsg(params xrayutils.AuditParams, tech coreutil } } - logMessage = ". Quick note: we're running our first scan on the project with curation-audit. Expect this one to take a bit longer. Subsequent scans will be faster. Thanks for your patience." + logMessage = ". Quick note: we're running our first scan on the project with curation-audit. Expect this one to take a bit longer. Subsequent scans will be faster. Thanks for your patience" return logMessage, curationCacheFolder, err } diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index 15b8f6d2..1b4e78d5 100644 --- a/commands/curation/curationaudit.go +++ b/commands/curation/curationaudit.go @@ -279,10 +279,6 @@ func (ca *CurationAuditCommand) getAuditParamsByTech(tech coreutils.Technology) SetNpmOverwritePackageLock(true) case coreutils.Maven: ca.AuditParams.SetIsMavenDepTreeInstalled(true) - case coreutils.Pip: - if ca.PipRequirementsFile() == "" { - ca.SetPipRequirementsFile("requirements.txt") - } } return ca.AuditParams From bf4a3d5de6624b6aced16662551d418c7f05736d Mon Sep 17 00:00:00 2001 From: asafambar Date: Mon, 1 Apr 2024 10:56:23 +0300 Subject: [PATCH 18/19] Fixed tests. --- commands/audit/sca/java/mvn.go | 12 ------------ commands/audit/sca/python/python.go | 6 ++++-- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/commands/audit/sca/java/mvn.go b/commands/audit/sca/java/mvn.go index 3979b384..0b616985 100644 --- a/commands/audit/sca/java/mvn.go +++ b/commands/audit/sca/java/mvn.go @@ -253,15 +253,3 @@ func (mdt *MavenDepTreeManager) CreateTempDirWithSettingsXmlIfNeeded() (tempDirP } return } - -// In case mvn tree fails on 403 or 500 it can be related to packages blocked by curation. -// For this use case to succeed, pass through should be enabled in the curated repos -func (mdt *MavenDepTreeManager) suspectCurationBlockedError(isCurationCmd bool, cmdOutput string) (msgToUser string) { - if isCurationCmd { - return - } - if strings.Contains(cmdOutput, "status code: 403") || strings.Contains(cmdOutput, "status code: 500") { - msgToUser = "Failed to get dependencies tree for maven project, Please verify pass-through enabled on the curated repos" - } - return msgToUser -} diff --git a/commands/audit/sca/python/python.go b/commands/audit/sca/python/python.go index 3c8d3fca..b29fdbd5 100644 --- a/commands/audit/sca/python/python.go +++ b/commands/audit/sca/python/python.go @@ -258,8 +258,10 @@ func installPipDeps(auditPython *AuditPython) (restoreEnv func() error, err erro err = nil } } - if msgToUser := sca.SuspectCurationBlockedError(auditPython.IsCurationCmd, coreutils.Pip, errors.Join(err, reqErr).Error()); msgToUser != "" { - err = errors.Join(err, errors.New(msgToUser)) + if err != nil || reqErr != nil { + if msgToUser := sca.SuspectCurationBlockedError(auditPython.IsCurationCmd, coreutils.Pip, errors.Join(err, reqErr).Error()); msgToUser != "" { + err = errors.Join(err, errors.New(msgToUser)) + } } return } From b8f41aac79a0309d68484b1e3410dc059c25f81e Mon Sep 17 00:00:00 2001 From: asafambar Date: Sun, 7 Apr 2024 16:08:37 +0300 Subject: [PATCH 19/19] Fix go mod. --- go.sum | 4 ---- 1 file changed, 4 deletions(-) diff --git a/go.sum b/go.sum index 6602a1e6..f23e834c 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,6 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuW github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= -github.com/asafambar/jfrog-cli-core/v2 v2.0.0-20240312152209-88de746b7631 h1:vWetc60TB2szGBsyYAqjScZ6vgh7GAHEtMnA7Vjk9G4= -github.com/asafambar/jfrog-cli-core/v2 v2.0.0-20240312152209-88de746b7631/go.mod h1:fTnA9KjwuMEWnqAFPPoLc6IzvYxD8SorqawESk74fP8= github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= @@ -104,8 +102,6 @@ github.com/jfrog/gofrog v1.6.3 h1:F7He0+75HcgCe6SGTSHLFCBDxiE2Ja0tekvvcktW6wc= github.com/jfrog/gofrog v1.6.3/go.mod h1:SZ1EPJUruxrVGndOzHd+LTiwWYKMlHqhKD+eu+v5Hqg= github.com/jfrog/jfrog-apps-config v1.0.1 h1:mtv6k7g8A8BVhlHGlSveapqf4mJfonwvXYLipdsOFMY= github.com/jfrog/jfrog-apps-config v1.0.1/go.mod h1:8AIIr1oY9JuH5dylz2S6f8Ym2MaadPLR6noCBO4C22w= -github.com/jfrog/jfrog-client-go v1.39.0 h1:GZ1qbpUDzYz8ZEycYicDkbVMN2H0VSCuz8mUNTyf7tc= -github.com/jfrog/jfrog-client-go v1.39.0/go.mod h1:tUyEmxznphh0nwAGo6xz9Sps7RRW/TBMxIJZteo+j2k= github.com/jfrog/jfrog-cli-core/v2 v2.31.1-0.20240404075604-3df49e9a9d64 h1:eCAqJ8hqJ6bqgmjswjpqhInJMG80MT5D2r465s/fXzg= github.com/jfrog/jfrog-cli-core/v2 v2.31.1-0.20240404075604-3df49e9a9d64/go.mod h1:iQoYSsjLWF8x//rtQCwNPE2ycle2X2x6VFQM0LQE2n0= github.com/jfrog/jfrog-client-go v1.28.1-0.20240403100335-8292671b7cc4 h1:A67yoFRYjRzg+xhLYhH0QN7b4/wggRa/lSQKSjzOwNQ=