diff --git a/packagehandlers/packagehandlers_test.go b/packagehandlers/packagehandlers_test.go index b52c3b0b9..75b911e4c 100644 --- a/packagehandlers/packagehandlers_test.go +++ b/packagehandlers/packagehandlers_test.go @@ -1,6 +1,7 @@ package packagehandlers import ( + "encoding/json" "fmt" "os" "path/filepath" @@ -12,6 +13,7 @@ import ( "github.com/jfrog/build-info-go/tests" biutils "github.com/jfrog/build-info-go/utils" "github.com/jfrog/frogbot/v2/utils" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/java" "github.com/jfrog/jfrog-cli-security/utils/formats" "github.com/jfrog/jfrog-cli-security/utils/techutils" @@ -915,6 +917,229 @@ func compareFixedFileToComparisonFile(t *testing.T, descriptorFileAbsPath string assert.ElementsMatch(t, expectedFileContent, fixedFileContent) } +func TestFindYarnWorkspaceRoot(t *testing.T) { + tests := []struct { + name string + rootPkgJson string + pkgAPkgJson string + expectWorkspace bool + expectErr bool + expectedPkgName string + }{ + { + name: "package inside workspace with array workspaces", + rootPkgJson: `{"name":"root","version":"1.0.0","workspaces":["packages/*"]}`, + pkgAPkgJson: `{"name":"pkg-a","version":"1.0.0","dependencies":{"minimist":"1.2.5"}}`, + expectWorkspace: true, + expectedPkgName: "pkg-a", + }, + { + name: "package inside workspace with object workspaces (nohoist)", + rootPkgJson: `{"name":"root","workspaces":{"packages":["packages/*"],"nohoist":["**/react"]}}`, + pkgAPkgJson: `{"name":"pkg-a","version":"1.0.0"}`, + expectWorkspace: true, + expectedPkgName: "pkg-a", + }, + { + name: "package missing name field", + rootPkgJson: `{"name":"root","workspaces":["packages/*"]}`, + pkgAPkgJson: `{"version":"1.0.0"}`, + expectWorkspace: false, + expectErr: true, + }, + { + name: "standalone project without workspaces", + rootPkgJson: "", + pkgAPkgJson: `{"name":"standalone","version":"1.0.0"}`, + expectWorkspace: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rootDir, err := os.MkdirTemp("", "yarn-ws-test-") + assert.NoError(t, err) + defer func() { assert.NoError(t, fileutils.RemoveTempDir(rootDir)) }() + + pkgADir := filepath.Join(rootDir, "packages", "pkg-a") + assert.NoError(t, os.MkdirAll(pkgADir, 0755)) + + if tc.rootPkgJson != "" { + assert.NoError(t, os.WriteFile(filepath.Join(rootDir, "package.json"), []byte(tc.rootPkgJson), 0644)) + } + assert.NoError(t, os.WriteFile(filepath.Join(pkgADir, "package.json"), []byte(tc.pkgAPkgJson), 0644)) + + ws, err := findYarnWorkspaceRoot(pkgADir) + if tc.expectErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + if !tc.expectWorkspace { + assert.Nil(t, ws) + return + } + assert.NotNil(t, ws) + assert.Equal(t, rootDir, ws.rootDir) + assert.Equal(t, tc.expectedPkgName, ws.packageName) + }) + } +} + +func TestGetYarnVersionFromYarnrc(t *testing.T) { + tests := []struct { + name string + fileContent string + fileExists bool + expectedVersion string + }{ + { + name: "v1 yarnPath", + fileContent: "nodeLinker: node-modules\nyarnPath: .yarn/releases/yarn-1.22.19.cjs\n", + fileExists: true, + expectedVersion: "1.22.19", + }, + { + name: "v3 yarnPath", + fileContent: "nodeLinker: node-modules\nyarnPath: .yarn/releases/yarn-3.4.1.cjs\n", + fileExists: true, + expectedVersion: "3.4.1", + }, + { + name: "no yarnPath entry", + fileContent: "nodeLinker: node-modules\n", + fileExists: true, + expectedVersion: "", + }, + { + name: "file does not exist", + fileExists: false, + expectedVersion: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + dir, err := os.MkdirTemp("", "yarn-version-test-") + assert.NoError(t, err) + defer func() { assert.NoError(t, fileutils.RemoveTempDir(dir)) }() + + if tc.fileExists { + assert.NoError(t, os.WriteFile(filepath.Join(dir, ".yarnrc.yml"), []byte(tc.fileContent), 0644)) + } + + got, err := getYarnVersionFromYarnrc(dir) + assert.NoError(t, err) + assert.Equal(t, tc.expectedVersion, got) + }) + } +} + +func TestYarnWorkspaceUpdateDependency(t *testing.T) { + testcases := []struct { + name string + yarnSourceDir string // existing test project supplying the yarn binary + yarnVersion string + yarnBinaryName string + }{ + { + name: "v1 workspace", + yarnSourceDir: "yarn1", + yarnVersion: "1.22.19", + yarnBinaryName: "yarn-1.22.19.cjs", + }, + { + name: "v2 workspace", + yarnSourceDir: "yarn2", + yarnVersion: "3.4.1", + yarnBinaryName: "yarn-3.4.1.cjs", + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + testdataDir, err := filepath.Abs(filepath.Join("..", "testdata", "projects")) + assert.NoError(t, err) + + // Build the workspace in a temp dir: + // /package.json — workspace root + // /.yarnrc.yml — yarnPath → packages/pkg-a binary + // /yarn.lock — copied from source project + // /packages/pkg-a/ — copy of existing yarn project + tmpRoot, err := os.MkdirTemp("", "yarn-ws-integ-") + assert.NoError(t, err) + defer func() { assert.NoError(t, fileutils.RemoveTempDir(tmpRoot)) }() + + pkgADir := filepath.Join(tmpRoot, "packages", "pkg-a") + assert.NoError(t, os.MkdirAll(pkgADir, 0755)) + + // Copy the existing yarn project into packages/pkg-a. + assert.NoError(t, biutils.CopyDir(filepath.Join(testdataDir, tc.yarnSourceDir), pkgADir, true, nil)) + + // Give the workspace package a stable name. + pkgAJson := filepath.Join(pkgADir, "package.json") + rawPkg, err := os.ReadFile(pkgAJson) + assert.NoError(t, err) + var pkg map[string]interface{} + assert.NoError(t, json.Unmarshal(rawPkg, &pkg)) + pkg["name"] = "pkg-a" + out, err := json.MarshalIndent(pkg, "", " ") + assert.NoError(t, err) + assert.NoError(t, os.WriteFile(pkgAJson, out, 0644)) + + // Create the workspace root package.json. + // "private": true is required by Yarn v1 to enable workspace mode. + rootPkg := map[string]interface{}{ + "name": "workspace-root", + "version": "1.0.0", + "private": true, + "workspaces": []string{"packages/*"}, + "packageManager": "yarn@" + tc.yarnVersion, + } + rootPkgOut, err := json.MarshalIndent(rootPkg, "", " ") + assert.NoError(t, err) + assert.NoError(t, os.WriteFile(filepath.Join(tmpRoot, "package.json"), rootPkgOut, 0644)) + + // Create the root .yarnrc.yml pointing to the binary we copied into pkg-a. + binaryRelPath := filepath.Join("packages", "pkg-a", ".yarn", "releases", tc.yarnBinaryName) + yarnrcContent := fmt.Sprintf("nodeLinker: node-modules\nyarnPath: %s\n", filepath.ToSlash(binaryRelPath)) + assert.NoError(t, os.WriteFile(filepath.Join(tmpRoot, ".yarnrc.yml"), []byte(yarnrcContent), 0644)) + + // Copy the yarn.lock from the source project to the workspace root. + lockData, err := os.ReadFile(filepath.Join(testdataDir, tc.yarnSourceDir, "yarn.lock")) + assert.NoError(t, err) + assert.NoError(t, os.WriteFile(filepath.Join(tmpRoot, "yarn.lock"), lockData, 0644)) + + // Simulate what scanrepository does: Chdir into the workspace package directory. + currDir, err := os.Getwd() + assert.NoError(t, err) + assert.NoError(t, os.Chdir(pkgADir)) + defer func() { assert.NoError(t, os.Chdir(currDir)) }() + + handler := &YarnPackageHandler{} + handler.SetCommonParams(&config.ServerDetails{}, "") + + vulnDetails := &utils.VulnerabilityDetails{ + SuggestedFixedVersion: "1.2.6", + IsDirectDependency: true, + VulnerabilityOrViolationRow: formats.VulnerabilityOrViolationRow{ + Technology: techutils.Yarn, + ImpactedDependencyDetails: formats.ImpactedDependencyDetails{ + ImpactedDependencyName: "minimist", + }, + }, + } + + assert.NoError(t, handler.UpdateDependency(vulnDetails)) + + // Verify the workspace package's descriptor was updated. + fixed, err := os.ReadFile(pkgAJson) + assert.NoError(t, err) + assert.Contains(t, string(fixed), "1.2.6") + }) + } +} + func TestGradleIsVersionSupportedForFix(t *testing.T) { var testcases = []struct { impactedVersion string diff --git a/packagehandlers/yarnpackagehandler.go b/packagehandlers/yarnpackagehandler.go index dc51d3853..0bfc42b24 100644 --- a/packagehandlers/yarnpackagehandler.go +++ b/packagehandlers/yarnpackagehandler.go @@ -1,8 +1,13 @@ package packagehandlers import ( + "encoding/json" "errors" "fmt" + "os" + "path/filepath" + "strings" + biUtils "github.com/jfrog/build-info-go/build/utils" "github.com/jfrog/frogbot/v2/utils" "github.com/jfrog/gofrog/version" @@ -15,8 +20,15 @@ const ( yarnV1PackageUpdateCmd = "upgrade" yarnV2PackageUpdateCmd = "up" modulesFolderFlag = "--modules-folder=" + yarnWorkspaceCmd = "workspace" ) +// workspaceInfo holds the workspace root directory and the name of the current workspace package. +type workspaceInfo struct { + rootDir string + packageName string +} + type YarnPackageHandler struct { CommonPackageHandler } @@ -24,59 +36,196 @@ type YarnPackageHandler struct { func (yarn *YarnPackageHandler) UpdateDependency(vulnDetails *utils.VulnerabilityDetails) error { if vulnDetails.IsDirectDependency { return yarn.updateDirectDependency(vulnDetails) - } else { - return &utils.ErrUnsupportedFix{ - PackageName: vulnDetails.ImpactedDependencyName, - FixedVersion: vulnDetails.SuggestedFixedVersion, - ErrorType: utils.IndirectDependencyFixNotSupported, - } + } + return &utils.ErrUnsupportedFix{ + PackageName: vulnDetails.ImpactedDependencyName, + FixedVersion: vulnDetails.SuggestedFixedVersion, + ErrorType: utils.IndirectDependencyFixNotSupported, } } func (yarn *YarnPackageHandler) updateDirectDependency(vulnDetails *utils.VulnerabilityDetails) (err error) { - isYarn1, executableYarnVersion, err := isYarnV1Project() + wd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current working directory: %w", err) + } + + workspace, err := findYarnWorkspaceRoot(wd) + if err != nil { + return fmt.Errorf("failed to find yarn workspace root: %w", err) + } + + // Version detection uses the workspace root's .yarnrc.yml when in a workspace, + // otherwise falls back to the current directory and then the global binary. + yarnrcSearchDir := wd + if workspace != nil { + yarnrcSearchDir = workspace.rootDir + } + isYarn1, executableYarnVersion, err := isYarnV1Project(yarnrcSearchDir) if err != nil { - return + return fmt.Errorf("failed to detect yarn version: %w", err) } var installationCommand string var extraArgs []string - if isYarn1 { - installationCommand = yarnV1PackageUpdateCmd - // This dir is created to store node_modules that are created during updating packages in Yarn V1. This dir is to be deleted and not pushed into the PR - var tmpNodeModulesDir string - tmpNodeModulesDir, err = fileutils.CreateTempDir() + if workspace != nil { + // Move to the workspace root so yarn can locate the lock file and workspace config. + if err = os.Chdir(workspace.rootDir); err != nil { + return fmt.Errorf("failed to change directory to workspace root '%s': %w", workspace.rootDir, err) + } defer func() { - err = errors.Join(err, fileutils.RemoveTempDir(tmpNodeModulesDir)) + err = errors.Join(err, os.Chdir(wd)) }() - if err != nil { - return + installationCommand = yarnWorkspaceCmd + extraArgs = append(extraArgs, workspace.packageName) + if isYarn1 { + extraArgs = append(extraArgs, yarnV1PackageUpdateCmd) + var tmpNodeModulesDir string + tmpNodeModulesDir, err = fileutils.CreateTempDir() + defer func() { + err = errors.Join(err, fileutils.RemoveTempDir(tmpNodeModulesDir)) + }() + if err != nil { + return + } + extraArgs = append(extraArgs, modulesFolderFlag+tmpNodeModulesDir) + } else { + extraArgs = append(extraArgs, yarnV2PackageUpdateCmd) } - extraArgs = append(extraArgs, modulesFolderFlag+tmpNodeModulesDir) } else { - installationCommand = yarnV2PackageUpdateCmd + if isYarn1 { + installationCommand = yarnV1PackageUpdateCmd + // This dir is created to store node_modules that are created during updating packages in Yarn V1. This dir is to be deleted and not pushed into the PR + var tmpNodeModulesDir string + tmpNodeModulesDir, err = fileutils.CreateTempDir() + defer func() { + err = errors.Join(err, fileutils.RemoveTempDir(tmpNodeModulesDir)) + }() + if err != nil { + return + } + extraArgs = append(extraArgs, modulesFolderFlag+tmpNodeModulesDir) + } else { + installationCommand = yarnV2PackageUpdateCmd + } } + err = yarn.CommonPackageHandler.UpdateDependency(vulnDetails, installationCommand, extraArgs...) if err != nil { - err = fmt.Errorf("running 'yarn %s for '%s' failed:\n%s\nHint: The Yarn version that was used is: %s. If your project was built with a different major version of Yarn, please configure your CI runner to include it", + err = fmt.Errorf("running 'yarn %s' for '%s' failed: %w\nHint: The Yarn version that was used is: %s. If your project was built with a different major version of Yarn, please configure your CI runner to include it", installationCommand, vulnDetails.ImpactedDependencyName, - err.Error(), + err, executableYarnVersion) } return } -// isYarnV1Project gets the current executed yarn version and returns whether the current yarn version is V1 or not -func isYarnV1Project() (isYarn1 bool, executableYarnVersion string, err error) { - // NOTICE: in case your global yarn version is 1.x this function will always return true even if the project is originally in higher yarn version - executableYarnVersion, err = biUtils.GetVersion("yarn", "") +// findYarnWorkspaceRoot walks up from startDir looking for a parent directory whose +// package.json declares a "workspaces" field. Returns nil if startDir is not inside +// a Yarn workspace. +func findYarnWorkspaceRoot(startDir string) (*workspaceInfo, error) { + currentDir := startDir + for { + parentDir := filepath.Dir(currentDir) + if parentDir == currentDir { + // Reached the filesystem root without finding a workspace. + return nil, nil + } + + pkgJsonPath := filepath.Join(parentDir, "package.json") + data, err := os.ReadFile(pkgJsonPath) + if os.IsNotExist(err) { + currentDir = parentDir + continue + } + if err != nil { + return nil, fmt.Errorf("failed to read '%s': %w", pkgJsonPath, err) + } + + var rootPkg struct { + Workspaces json.RawMessage `json:"workspaces"` + } + if jsonErr := json.Unmarshal(data, &rootPkg); jsonErr != nil || rootPkg.Workspaces == nil { + currentDir = parentDir + continue + } + + // Found a workspace root. Read the package name from startDir's own package.json. + wsPkgJsonPath := filepath.Join(startDir, "package.json") + wsPkgData, err := os.ReadFile(wsPkgJsonPath) + if err != nil { + return nil, fmt.Errorf("failed to read workspace package descriptor at '%s': %w", wsPkgJsonPath, err) + } + var wsPkg struct { + Name string `json:"name"` + } + if err = json.Unmarshal(wsPkgData, &wsPkg); err != nil { + return nil, fmt.Errorf("failed to parse workspace package descriptor at '%s': %w", wsPkgJsonPath, err) + } + if wsPkg.Name == "" { + return nil, fmt.Errorf("workspace package at '%s' is missing a 'name' field in package.json", startDir) + } + + return &workspaceInfo{rootDir: parentDir, packageName: wsPkg.Name}, nil + } +} + +// getYarnVersionFromYarnrc parses the yarnPath entry from a .yarnrc.yml file in dir +// and extracts the version string from the filename (e.g. "yarn-3.4.1.cjs" → "3.4.1"). +// Returns an empty string when no yarnPath entry is present or the file does not exist. +func getYarnVersionFromYarnrc(dir string) (string, error) { + yarnrcPath := filepath.Join(dir, ".yarnrc.yml") + data, err := os.ReadFile(yarnrcPath) + if os.IsNotExist(err) { + return "", nil + } if err != nil { - return + return "", fmt.Errorf("failed to read '%s': %w", yarnrcPath, err) } - log.Info("Using Yarn version: ", executableYarnVersion) - isYarn1 = version.NewVersion(executableYarnVersion).Compare(yarnV2Version) > 0 - return + + for _, line := range strings.Split(string(data), "\n") { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "yarnPath:") { + continue + } + parts := strings.SplitN(trimmed, ":", 2) + if len(parts) != 2 { + continue + } + // Extract the version from a path like ".yarn/releases/yarn-3.4.1.cjs". + base := strings.TrimSpace(parts[1]) + base = filepath.Base(base) + base = strings.TrimPrefix(base, "yarn-") + for _, suffix := range []string{".cjs", ".js"} { + base = strings.TrimSuffix(base, suffix) + } + if strings.ContainsRune(base, '.') { + return base, nil + } + } + return "", nil +} + +// isYarnV1Project reports whether the project uses Yarn v1. +// It first tries to read the version from .yarnrc.yml in searchDir, then falls back +// to the globally installed yarn binary. +func isYarnV1Project(searchDir string) (bool, string, error) { + yarnVersion, err := getYarnVersionFromYarnrc(searchDir) + if err != nil { + return false, "", fmt.Errorf("failed to read .yarnrc.yml: %w", err) + } + if yarnVersion == "" { + // NOTICE: if the global yarn version is 1.x this will always return true even + // if the project targets a higher version. Use .yarnrc.yml yarnPath to avoid this. + yarnVersion, err = biUtils.GetVersion("yarn", "") + if err != nil { + return false, "", fmt.Errorf("failed to get yarn version: %w", err) + } + } + log.Info("Using Yarn version: ", yarnVersion) + isYarn1 := version.NewVersion(yarnVersion).Compare(yarnV2Version) > 0 + return isYarn1, yarnVersion, nil } diff --git a/testdata/projects/yarn-workspace-v1/.yarnrc.yml b/testdata/projects/yarn-workspace-v1/.yarnrc.yml new file mode 100644 index 000000000..fdc343f9d --- /dev/null +++ b/testdata/projects/yarn-workspace-v1/.yarnrc.yml @@ -0,0 +1,3 @@ +nodeLinker: node-modules + +yarnPath: .yarn/releases/yarn-1.22.19.cjs diff --git a/testdata/projects/yarn-workspace-v1/package.json b/testdata/projects/yarn-workspace-v1/package.json new file mode 100644 index 000000000..f7e5d7ec3 --- /dev/null +++ b/testdata/projects/yarn-workspace-v1/package.json @@ -0,0 +1,9 @@ +{ + "name": "workspace-root", + "version": "1.0.0", + "private": true, + "workspaces": [ + "packages/*" + ], + "packageManager": "yarn@1.22.19" +} diff --git a/testdata/projects/yarn-workspace-v1/packages/pkg-a/package.json b/testdata/projects/yarn-workspace-v1/packages/pkg-a/package.json new file mode 100644 index 000000000..4435f9053 --- /dev/null +++ b/testdata/projects/yarn-workspace-v1/packages/pkg-a/package.json @@ -0,0 +1,7 @@ +{ + "name": "pkg-a", + "version": "1.0.0", + "dependencies": { + "minimist": "1.2.5" + } +} diff --git a/testdata/projects/yarn-workspace-v2/.yarnrc.yml b/testdata/projects/yarn-workspace-v2/.yarnrc.yml new file mode 100644 index 000000000..fc8ec93a4 --- /dev/null +++ b/testdata/projects/yarn-workspace-v2/.yarnrc.yml @@ -0,0 +1,3 @@ +nodeLinker: node-modules + +yarnPath: .yarn/releases/yarn-3.4.1.cjs diff --git a/testdata/projects/yarn-workspace-v2/package.json b/testdata/projects/yarn-workspace-v2/package.json new file mode 100644 index 000000000..be3f622d0 --- /dev/null +++ b/testdata/projects/yarn-workspace-v2/package.json @@ -0,0 +1,8 @@ +{ + "name": "workspace-root", + "version": "1.0.0", + "workspaces": [ + "packages/*" + ], + "packageManager": "yarn@3.4.1" +} diff --git a/testdata/projects/yarn-workspace-v2/packages/pkg-a/package.json b/testdata/projects/yarn-workspace-v2/packages/pkg-a/package.json new file mode 100644 index 000000000..4435f9053 --- /dev/null +++ b/testdata/projects/yarn-workspace-v2/packages/pkg-a/package.json @@ -0,0 +1,7 @@ +{ + "name": "pkg-a", + "version": "1.0.0", + "dependencies": { + "minimist": "1.2.5" + } +}