diff --git a/cli/docs/flags.go b/cli/docs/flags.go index 6ac7f619..a2ed7866 100644 --- a/cli/docs/flags.go +++ b/cli/docs/flags.go @@ -2,9 +2,10 @@ package docs import ( "fmt" - "github.com/jfrog/jfrog-cli-security/commands/git" "strings" + "github.com/jfrog/jfrog-cli-security/commands/git" + "github.com/jfrog/jfrog-cli-core/v2/common/cliutils" pluginsCommon "github.com/jfrog/jfrog-cli-core/v2/plugins/common" "github.com/jfrog/jfrog-cli-core/v2/plugins/components" @@ -114,6 +115,7 @@ const ( useWrapperAudit = auditPrefix + UseWrapper ExcludeTestDeps = "exclude-test-deps" DepType = "dep-type" + MaxTreeDepth = "max-tree-depth" ThirdPartyContextualAnalysis = "third-party-contextual-analysis" RequirementsFile = "requirements-file" WorkingDirs = "working-dirs" @@ -240,17 +242,18 @@ var flagsMap = map[string]components.Flag{ "List of exclusions separated by semicolons, utilized to skip sub-projects from undergoing an audit. These exclusions may incorporate the * and ? wildcards.", components.WithStrDefaultValue(strings.Join(utils.DefaultScaExcludePatterns, ";")), ), - Mvn: components.NewBoolFlag(Mvn, "Set to true to request audit for a Maven project."), - Gradle: components.NewBoolFlag(Gradle, "Set to true to request audit for a Gradle project."), - Npm: components.NewBoolFlag(Npm, "Set to true to request audit for a npm project."), - Pnpm: components.NewBoolFlag(Pnpm, "Set to true to request audit for a Pnpm project."), - Yarn: components.NewBoolFlag(Yarn, "Set to true to request audit for a Yarn project."), - Nuget: components.NewBoolFlag(Nuget, "Set to true to request audit for a .NET project."), - Pip: components.NewBoolFlag(Pip, "Set to true to request audit for a Pip project."), - Pipenv: components.NewBoolFlag(Pipenv, "Set to true to request audit for a Pipenv project."), - Poetry: components.NewBoolFlag(Poetry, "Set to true to request audit for a Poetry project."), - Go: components.NewBoolFlag(Go, "Set to true to request audit for a Go project."), - DepType: components.NewStringFlag(DepType, "[npm] Defines npm dependencies type. Possible values are: all, devOnly and prodOnly."), + Mvn: components.NewBoolFlag(Mvn, "Set to true to request audit for a Maven project."), + Gradle: components.NewBoolFlag(Gradle, "Set to true to request audit for a Gradle project."), + Npm: components.NewBoolFlag(Npm, "Set to true to request audit for a npm project."), + Pnpm: components.NewBoolFlag(Pnpm, "Set to true to request audit for a Pnpm project."), + Yarn: components.NewBoolFlag(Yarn, "Set to true to request audit for a Yarn project."), + Nuget: components.NewBoolFlag(Nuget, "Set to true to request audit for a .NET project."), + Pip: components.NewBoolFlag(Pip, "Set to true to request audit for a Pip project."), + Pipenv: components.NewBoolFlag(Pipenv, "Set to true to request audit for a Pipenv project."), + Poetry: components.NewBoolFlag(Poetry, "Set to true to request audit for a Poetry project."), + Go: components.NewBoolFlag(Go, "Set to true to request audit for a Go project."), + DepType: components.NewStringFlag(DepType, "[npm] Defines npm dependencies type. Possible values are: all, devOnly and prodOnly."), + MaxTreeDepth: components.NewStringFlag(MaxTreeDepth, "[pnpm] Max depth of the generated dependencies tree for SCA scan.", components.WithStrDefaultValue("Infinity")), ThirdPartyContextualAnalysis: components.NewBoolFlag( ThirdPartyContextualAnalysis, "[npm] when set, the Contextual Analysis scan also uses the code of the project dependencies to determine the applicability of the vulnerability.", diff --git a/cli/scancommands.go b/cli/scancommands.go index 65569d63..25a9f6c7 100644 --- a/cli/scancommands.go +++ b/cli/scancommands.go @@ -500,6 +500,7 @@ func CreateAuditCmd(c *components.Context) (string, string, *coreConfig.ServerDe SetInsecureTls(c.GetBoolFlagValue(flags.InsecureTls)). SetNpmScope(c.GetStringFlagValue(flags.DepType)). SetPipRequirementsFile(c.GetStringFlagValue(flags.RequirementsFile)). + SetMaxTreeDepth(c.GetStringFlagValue(flags.MaxTreeDepth)). SetExclusions(pluginsCommon.GetStringsArrFlagValue(c, flags.Exclusions)) return xrayVersion, xscVersion, serverDetails, auditCmd, err } diff --git a/commands/audit/sca/pnpm/pnpm.go b/commands/audit/sca/pnpm/pnpm.go index a22c5cdd..b6edca23 100644 --- a/commands/audit/sca/pnpm/pnpm.go +++ b/commands/audit/sca/pnpm/pnpm.go @@ -10,9 +10,6 @@ import ( "github.com/jfrog/gofrog/datastructures" "github.com/jfrog/gofrog/io" "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" - "golang.org/x/exp/maps" - "golang.org/x/exp/slices" - "github.com/jfrog/jfrog-cli-security/commands/audit/sca" "github.com/jfrog/jfrog-cli-security/commands/audit/sca/npm" "github.com/jfrog/jfrog-cli-security/utils" @@ -21,6 +18,7 @@ import ( "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" + "golang.org/x/exp/maps" biutils "github.com/jfrog/build-info-go/utils" xrayUtils "github.com/jfrog/jfrog-client-go/xray/services/utils" @@ -123,13 +121,17 @@ func installProjectIfNeeded(pnpmExecPath, workingDir string) (dirForDependencies err = fmt.Errorf("failed copying project to temp dir: %w", err) return } - err = getPnpmCmd(pnpmExecPath, dirForDependenciesCalculation, "install", npm.IgnoreScriptsFlag).GetCmd().Run() + output, err := getPnpmCmd(pnpmExecPath, dirForDependenciesCalculation, "install", npm.IgnoreScriptsFlag).GetCmd().CombinedOutput() + if err != nil { + err = fmt.Errorf("failed to install project: %w\n%s", err, string(output)) + } return } // Run 'pnpm ls ...' command (project must be installed) and parse the returned result to create a dependencies trees for the projects. func calculateDependencies(executablePath, workingDir string, params utils.AuditParams) (dependencyTrees []*xrayUtils.GraphNode, uniqueDeps []string, err error) { - lsArgs := append([]string{"--depth", "Infinity", "--json", "--long"}, params.Args()...) + lsArgs := append([]string{"--depth", params.MaxTreeDepth(), "--json", "--long"}, params.Args()...) + log.Debug("Running Pnpm ls command with args:", lsArgs) npmLsCmdContent, err := getPnpmCmd(executablePath, workingDir, "ls", lsArgs...).RunWithOutput() if err != nil { return @@ -163,13 +165,13 @@ func createProjectDependenciesTree(project pnpmLsProject) map[string]xray.DepTre for depName, dependency := range project.Dependencies { directDependency := getDependencyId(depName, dependency.Version) directDependencies = append(directDependencies, directDependency) - appendTransitiveDependencies(directDependency, dependency.Dependencies, treeMap) + appendTransitiveDependencies(directDependency, dependency.Dependencies, &treeMap) } // Handle dev-dependencies for depName, dependency := range project.DevDependencies { directDependency := getDependencyId(depName, dependency.Version) directDependencies = append(directDependencies, directDependency) - appendTransitiveDependencies(directDependency, dependency.Dependencies, treeMap) + appendTransitiveDependencies(directDependency, dependency.Dependencies, &treeMap) } if len(directDependencies) > 0 { treeMap[getDependencyId(project.Name, project.Version)] = xray.DepTreeNode{Children: directDependencies} @@ -182,21 +184,15 @@ func getDependencyId(depName, version string) string { return techutils.Npm.GetPackageTypeId() + depName + ":" + version } -func appendTransitiveDependencies(parent string, dependencies map[string]pnpmLsDependency, result map[string]xray.DepTreeNode) { +func appendTransitiveDependencies(parent string, dependencies map[string]pnpmLsDependency, result *map[string]xray.DepTreeNode) { for depName, dependency := range dependencies { dependencyId := getDependencyId(depName, dependency.Version) - if node, ok := result[parent]; ok { - node.Children = appendUniqueChild(node.Children, dependencyId) + if node, ok := (*result)[parent]; ok { + node.Children = append(node.Children, dependencyId) + (*result)[parent] = node } else { - result[parent] = xray.DepTreeNode{Children: []string{dependencyId}} + (*result)[parent] = xray.DepTreeNode{Children: []string{dependencyId}} } appendTransitiveDependencies(dependencyId, dependency.Dependencies, result) } } - -func appendUniqueChild(children []string, candidateDependency string) []string { - if slices.Contains(children, candidateDependency) { - return children - } - return append(children, candidateDependency) -} diff --git a/commands/audit/sca/pnpm/pnpm_test.go b/commands/audit/sca/pnpm/pnpm_test.go index e04ba84f..dad31f7e 100644 --- a/commands/audit/sca/pnpm/pnpm_test.go +++ b/commands/audit/sca/pnpm/pnpm_test.go @@ -1,11 +1,13 @@ package pnpm import ( - "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" - "github.com/jfrog/jfrog-client-go/utils/io/fileutils" "path/filepath" + "sort" "testing" + "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" + "github.com/jfrog/jfrog-client-go/utils/io/fileutils" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -16,6 +18,70 @@ import ( "github.com/jfrog/jfrog-cli-security/utils" ) +func TestBuildDependencyTreeLimitedDepth(t *testing.T) { + // Create and change directory to test workspace + _, cleanUp := sca.CreateTestWorkspace(t, filepath.Join("projects", "package-managers", "npm", "npm-big-tree")) + defer cleanUp() + testCases := []struct { + name string + treeDepth string + expectedUniqueDeps []string + expectedTree *xrayUtils.GraphNode + }{ + { + name: "Only direct dependencies", + treeDepth: "0", + expectedUniqueDeps: []string{ + "npm://zen-website:1.0.0", + "npm://balaganjs:1.0.0", + }, + expectedTree: &xrayUtils.GraphNode{ + Id: "npm://zen-website:1.0.0", + Nodes: []*xrayUtils.GraphNode{{Id: "npm://balaganjs:1.0.0"}}, + }, + }, + { + name: "With transitive dependencies", + treeDepth: "1", + expectedUniqueDeps: []string{ + "npm://axios:1.7.9", + "npm://balaganjs:1.0.0", + "npm://yargs:13.3.0", + "npm://zen-website:1.0.0", + }, + expectedTree: &xrayUtils.GraphNode{ + Id: "npm://zen-website:1.0.0", + Nodes: []*xrayUtils.GraphNode{ + { + Id: "npm://balaganjs:1.0.0", + Nodes: []*xrayUtils.GraphNode{{Id: "npm://axios:1.7.9"}, {Id: "npm://yargs:13.3.0"}}, + }, + }, + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + // Build dependency tree + params := &utils.AuditBasicParams{} + rootNode, uniqueDeps, err := BuildDependencyTree(params.SetMaxTreeDepth(testCase.treeDepth)) + require.NoError(t, err) + sort.Slice(uniqueDeps, func(i, j int) bool { + return uniqueDeps[i] < uniqueDeps[j] + }) + // Validations + assert.ElementsMatch(t, uniqueDeps, testCase.expectedUniqueDeps, "First is actual, Second is Expected") + if assert.Len(t, rootNode, 1) { + assert.Equal(t, rootNode[0].Id, testCase.expectedTree.Id) + if !tests.CompareTree(testCase.expectedTree, rootNode[0]) { + t.Error("expected:", testCase.expectedTree.Nodes, "got:", rootNode[0].Nodes) + } + } + }) + } +} + func TestBuildDependencyTree(t *testing.T) { // Create and change directory to test workspace _, cleanUp := sca.CreateTestWorkspace(t, filepath.Join("projects", "package-managers", "npm", "npm-no-lock")) diff --git a/tests/testdata/projects/package-managers/npm/npm-big-tree/package.json b/tests/testdata/projects/package-managers/npm/npm-big-tree/package.json new file mode 100644 index 00000000..f632961f --- /dev/null +++ b/tests/testdata/projects/package-managers/npm/npm-big-tree/package.json @@ -0,0 +1,20 @@ +{ + "name": "zen-website", + "version": "1.0.0", + "description": "", + "main": "index.js", + "publishConfig": { + "registry": "http://artifactory-unified.soleng-us.jfrog.team/artifactory/api/npm/npm/" + }, + "scripts": { + "dev": "nodemon ./index.js", + "ui": "browser-sync start --config bs-config.js" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "balaganjs": "1.0.0" + } + } + \ No newline at end of file diff --git a/utils/auditbasicparams.go b/utils/auditbasicparams.go index 5e2b7727..57d11b78 100644 --- a/utils/auditbasicparams.go +++ b/utils/auditbasicparams.go @@ -27,6 +27,8 @@ type AuditParams interface { InstallCommandName() string InstallCommandArgs() []string SetNpmScope(depType string) *AuditBasicParams + SetMaxTreeDepth(maxTreeDepth string) *AuditBasicParams + MaxTreeDepth() string OutputFormat() format.OutputFormat DepsRepo() string SetDepsRepo(depsRepo string) *AuditBasicParams @@ -55,6 +57,7 @@ type AuditBasicParams struct { ignoreConfigFile bool isMavenDepTreeInstalled bool isCurationCmd bool + maxTreeDepth string pipRequirementsFile string depsRepo string installCommandName string @@ -118,6 +121,15 @@ func (abp *AuditBasicParams) UseJas() bool { return abp.useJas } +func (abp *AuditBasicParams) MaxTreeDepth() string { + return abp.maxTreeDepth +} + +func (abp *AuditBasicParams) SetMaxTreeDepth(maxTreeDepth string) *AuditBasicParams { + abp.maxTreeDepth = maxTreeDepth + return abp +} + func (abp *AuditBasicParams) PipRequirementsFile() string { return abp.pipRequirementsFile }