From f4e3c3fcaf175cdb19b202b754298de1e1f5c85c Mon Sep 17 00:00:00 2001 From: Gauri Yadav Date: Wed, 27 May 2026 15:22:59 +0530 Subject: [PATCH 1/4] XRAY-140503 - Implementing support for pnpm in jf ca --- commands/curation/curationaudit.go | 111 ++++++++- sca/bom/buildinfo/technologies/npm/npm.go | 15 +- .../buildinfo/technologies/npm/npm_test.go | 4 +- sca/bom/buildinfo/technologies/pnpm/pnpm.go | 182 +++++++++------ .../buildinfo/technologies/pnpm/pnpm_test.go | 67 ++---- .../buildinfo/technologies/pnpm/pnpmlock.go | 213 ++++++++++++++++++ .../technologies/pnpm/pnpmlock_test.go | 125 ++++++++++ .../pnpm/pnpm-project/package.json | 17 ++ .../pnpm/pnpm-project/pnpm-lock-complex.yaml | 56 +++++ .../pnpm/pnpm-project/pnpm-lock.yaml | 33 +++ utils/techutils/techutils.go | 6 +- utils/techutils/techutils_test.go | 29 +++ 12 files changed, 734 insertions(+), 124 deletions(-) create mode 100644 sca/bom/buildinfo/technologies/pnpm/pnpmlock.go create mode 100644 sca/bom/buildinfo/technologies/pnpm/pnpmlock_test.go create mode 100644 tests/testdata/projects/package-managers/pnpm/pnpm-project/package.json create mode 100644 tests/testdata/projects/package-managers/pnpm/pnpm-project/pnpm-lock-complex.yaml create mode 100644 tests/testdata/projects/package-managers/pnpm/pnpm-project/pnpm-lock.yaml diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index d12fcc5b3..0c12d01aa 100644 --- a/commands/curation/curationaudit.go +++ b/commands/curation/curationaudit.go @@ -39,7 +39,8 @@ import ( "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo" "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies" "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/docker" - npmtech "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/npm" + npmtech "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/npm" + pnpmtech "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/pnpm" "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/python" "github.com/jfrog/jfrog-cli-security/utils" "github.com/jfrog/jfrog-cli-security/utils/formats" @@ -88,7 +89,8 @@ const ( var CurationOutputFormats = []string{string(outFormat.Table), string(outFormat.Json)} var supportedTech = map[techutils.Technology]func(ca *CurationAuditCommand) (bool, error){ - techutils.Npm: func(ca *CurationAuditCommand) (bool, error) { return true, nil }, + techutils.Npm: func(ca *CurationAuditCommand) (bool, error) { return true, nil }, + techutils.Pnpm: func(ca *CurationAuditCommand) (bool, error) { return true, nil }, techutils.Pip: func(ca *CurationAuditCommand) (bool, error) { return ca.checkSupportByVersionOrEnv(techutils.Pip, MinArtiPassThroughSupport) }, @@ -378,8 +380,55 @@ func getPolicyAndConditionId(policy, condition string) string { return fmt.Sprintf("%s:%s", policy, condition) } +// promotePnpmWorkspaceMember replaces "npm" with "pnpm" in the detected +// technologies list when the current directory is a pnpm workspace member +// (i.e., it has no pnpm indicators itself but an ancestor directory contains +// pnpm-workspace.yaml or pnpm-lock.yaml). +func promotePnpmWorkspaceMember(techs []string) []string { + hasPnpm := false + hasNpm := false + for _, t := range techs { + switch t { + case techutils.Pnpm.String(): + hasPnpm = true + case techutils.Npm.String(): + hasNpm = true + } + } + if hasPnpm || !hasNpm { + return techs + } + // Walk up to find a pnpm workspace root. + dir, err := os.Getwd() + if err != nil { + return techs + } + for { + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + for _, indicator := range []string{"pnpm-workspace.yaml", "pnpm-lock.yaml"} { + if _, statErr := os.Stat(filepath.Join(dir, indicator)); statErr == nil { + log.Debug(fmt.Sprintf("Detected pnpm workspace root at %s via %s; promoting current directory from npm to pnpm.", dir, indicator)) + result := make([]string, 0, len(techs)) + for _, t := range techs { + if t == techutils.Npm.String() { + result = append(result, techutils.Pnpm.String()) + } else { + result = append(result, t) + } + } + return result + } + } + } + return techs +} + func (ca *CurationAuditCommand) doCurateAudit(results map[string]*CurationReport) error { - techs := techutils.DetectedTechnologiesList() + techs := promotePnpmWorkspaceMember(techutils.DetectedTechnologiesList()) if ca.DockerImageName() != "" { log.Debug(fmt.Sprintf("Docker image name '%s' was provided, running Docker curation audit.", ca.DockerImageName())) techs = []string{techutils.Docker.String()} @@ -460,6 +509,8 @@ func (ca *CurationAuditCommand) getBuildInfoParamsByTech() (technologies.BuildIn NpmOverwritePackageLock: true, NpmRunNative: ca.RunNative(), NpmLegacyPeerDeps: ca.LegacyPeerDeps(), + // Pnpm params + MaxTreeDepth: ca.MaxTreeDepth(), // Python params PipRequirementsFile: ca.PipRequirementsFile(), // Docker params @@ -474,9 +525,9 @@ func (ca *CurationAuditCommand) auditTree(tech techutils.Technology, results map if err != nil { return errorutils.CheckErrorf("failed to get build info params for %s: %v", tech.String(), err) } - // When --run-native is set for npm, the Artifactory details are already populated from .npmrc. - // Skip the npm.yaml config file lookup to avoid requiring 'jf npm-config'. - if ca.RunNative() && tech == techutils.Npm { + // When --run-native is set for npm, or for pnpm (always .npmrc-based), the Artifactory + // details are already populated from .npmrc. Skip the yaml config file lookup. + if (ca.RunNative() && tech == techutils.Npm) || tech == techutils.Pnpm { params.IgnoreConfigFile = true } serverDetails, err := buildinfo.SetResolutionRepoInParamsIfExists(¶ms, tech) @@ -782,6 +833,12 @@ func (ca *CurationAuditCommand) SetRepo(tech techutils.Technology) error { return ca.setRepoFromNpmrc() } + // Pnpm always reads from .npmrc — there is no 'jf pnpm-config' command. + // pnpm shares the npm registry protocol, so the same .npmrc key/URL format applies. + if tech == techutils.Pnpm { + return ca.setRepoFromNpmrcForPnpm() + } + resolverParams, err := ca.getRepoParams(tech.GetProjectType()) if err != nil { return err @@ -824,6 +881,45 @@ func (ca *CurationAuditCommand) setRepoFromNpmrc() error { return nil } +// setRepoFromNpmrcForPnpm reads Artifactory connection details from the project's .npmrc +// via the pnpm CLI. pnpm uses the same .npmrc format and registry protocol as npm, so the +// URL parsing logic is identical. This is always called for pnpm — there is no 'jf pnpm-config'. +// +// Auth priority: +// 1. Token from .npmrc — preferred, because it is scoped to the exact registry URL. +// 2. Token from 'jf c' server config — used as fallback when .npmrc carries no token +// (e.g. user relies on a jf-managed credential store). +func (ca *CurationAuditCommand) setRepoFromNpmrcForPnpm() error { + registryConfig, err := pnpmtech.GetNativePnpmRegistryConfig() + if err != nil { + return fmt.Errorf("pnpm: failed to read Artifactory details from .npmrc: %w\nEnsure the registry is configured in .npmrc (e.g. registry=https:///artifactory/api/npm//)", err) + } + + var serverDetails *config.ServerDetails + if registryConfig.AuthToken != "" { + // .npmrc has an auth token that matches the registry — use it directly. + serverDetails = &config.ServerDetails{ + ArtifactoryUrl: registryConfig.ArtifactoryUrl, + AccessToken: registryConfig.AuthToken, + } + } else { + // No token in .npmrc — fall back to whatever 'jf c' has stored, overriding + // only the Artifactory URL so requests go to the correct registry. + serverDetails, err = ca.ServerDetails() + if err != nil || serverDetails == nil { + return fmt.Errorf("pnpm: no auth token found in .npmrc and no 'jf c' server configured: %w", err) + } + serverDetails.ArtifactoryUrl = registryConfig.ArtifactoryUrl + } + + repoConfig := (&project.RepositoryConfig{}). + SetTargetRepo(registryConfig.RepoName). + SetServerDetails(serverDetails) + ca.setPackageManagerConfig(repoConfig) + log.Info(fmt.Sprintf("pnpm: using Artifactory URL %q and repository %q from .npmrc", registryConfig.ArtifactoryUrl, registryConfig.RepoName)) + return nil +} + func (ca *CurationAuditCommand) getRepoParams(projectType project.ProjectType) (*project.RepositoryConfig, error) { configFilePath, exists, err := project.GetProjectConfFilePath(projectType) if err != nil { @@ -1066,7 +1162,8 @@ func makeLegiblePolicyDetails(explanation, recommendation string) (string, strin func getUrlNameAndVersionByTech(tech techutils.Technology, node *xrayUtils.GraphNode, downloadUrlsMap map[string]string, artiUrl, repo string) (downloadUrls []string, name string, scope string, version string) { switch tech { - case techutils.Npm: + case techutils.Npm, techutils.Pnpm: + // pnpm uses npm:// Xray IDs and the same Artifactory /api/npm/ endpoint as npm. return getNpmNameScopeAndVersion(node.Id, artiUrl, repo, techutils.Npm.String()) case techutils.Maven: return getMavenNameScopeAndVersion(node.Id, artiUrl, repo, node) diff --git a/sca/bom/buildinfo/technologies/npm/npm.go b/sca/bom/buildinfo/technologies/npm/npm.go index b0538e386..f3f531464 100644 --- a/sca/bom/buildinfo/technologies/npm/npm.go +++ b/sca/bom/buildinfo/technologies/npm/npm.go @@ -102,12 +102,12 @@ func GetNativeNpmRegistryConfig() (*NpmrcRegistryConfig, error) { } registryUrl := strings.TrimSpace(string(registryData)) - rtBaseUrl, repoName, err := parseArtifactoryNpmRegistryUrl(registryUrl) + rtBaseUrl, repoName, err := ParseArtifactoryNpmRegistryUrl(registryUrl) if err != nil { return nil, err } - authKey, err := buildNpmAuthTokenKey(registryUrl) + authKey, err := BuildNpmAuthTokenKey(registryUrl) if err != nil { return nil, err } @@ -124,7 +124,7 @@ func GetNativeNpmRegistryConfig() (*NpmrcRegistryConfig, error) { }, nil } -// buildNpmAuthTokenKey returns the npm config key used to look up the auth token for a +// BuildNpmAuthTokenKey returns the npm config key used to look up the auth token for a // given registry URL — the registry URL with its scheme stripped and ":_authToken" appended, // e.g. https://myrt.jfrog.io/artifactory/api/npm/my-repo/ → //myrt.jfrog.io/artifactory/api/npm/my-repo/:_authToken // @@ -132,7 +132,8 @@ func GetNativeNpmRegistryConfig() (*NpmrcRegistryConfig, error) { // the "://" separator, so callers see an actionable message instead of a runtime panic. // The original URL is preserved verbatim (including any trailing slash) so the lookup // matches exactly what npm stored in .npmrc. -func buildNpmAuthTokenKey(registryUrl string) (string, error) { +// Exported so that other package-manager clients (e.g. pnpm) can reuse the same logic. +func BuildNpmAuthTokenKey(registryUrl string) (string, error) { _, schemeRelative, ok := strings.Cut(registryUrl, "://") if !ok { return "", fmt.Errorf("npm registry %q is malformed: expected a scheme-prefixed URL (e.g. https://...)", registryUrl) @@ -143,12 +144,14 @@ func buildNpmAuthTokenKey(registryUrl string) (string, error) { return "//" + schemeRelative + npmAuthTokenSuffix, nil } -// parseArtifactoryNpmRegistryUrl extracts the Artifactory base URL and repository name from +// ParseArtifactoryNpmRegistryUrl extracts the Artifactory base URL and repository name from // a registry URL containing "/api/npm//". // Supports both standard URLs (https:///artifactory/api/npm//) and // reverse-proxy URLs where the "/artifactory" context root is stripped // (e.g. https://npm.company.com/api/npm//). -func parseArtifactoryNpmRegistryUrl(registryUrl string) (rtBaseUrl, repoName string, err error) { +// Exported so that other package-manager clients (e.g. pnpm) can reuse the same +// Artifactory URL parsing without duplicating logic. +func ParseArtifactoryNpmRegistryUrl(registryUrl string) (rtBaseUrl, repoName string, err error) { apiNpmIdx := strings.Index(registryUrl, artifactoryApiNpmPath) if apiNpmIdx == -1 { return "", "", fmt.Errorf("npm registry %q does not appear to be an Artifactory npm registry (expected %q in URL)", registryUrl, artifactoryApiNpmPath) diff --git a/sca/bom/buildinfo/technologies/npm/npm_test.go b/sca/bom/buildinfo/technologies/npm/npm_test.go index 63cf54da8..2b0537f48 100644 --- a/sca/bom/buildinfo/technologies/npm/npm_test.go +++ b/sca/bom/buildinfo/technologies/npm/npm_test.go @@ -238,7 +238,7 @@ func TestBuildNpmAuthTokenKey(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - authKey, err := buildNpmAuthTokenKey(tc.registryUrl) + authKey, err := BuildNpmAuthTokenKey(tc.registryUrl) if tc.expectErr { assert.Error(t, err) assert.Contains(t, err.Error(), tc.errContains) @@ -300,7 +300,7 @@ func TestParseArtifactoryNpmRegistryUrl(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - rtUrl, repo, err := parseArtifactoryNpmRegistryUrl(tc.registryUrl) + rtUrl, repo, err := ParseArtifactoryNpmRegistryUrl(tc.registryUrl) if tc.expectErr { assert.Error(t, err) assert.Contains(t, err.Error(), tc.errContains) diff --git a/sca/bom/buildinfo/technologies/pnpm/pnpm.go b/sca/bom/buildinfo/technologies/pnpm/pnpm.go index 191afbd99..821c4b458 100644 --- a/sca/bom/buildinfo/technologies/pnpm/pnpm.go +++ b/sca/bom/buildinfo/technologies/pnpm/pnpm.go @@ -1,12 +1,14 @@ package pnpm import ( - "encoding/json" "errors" "fmt" + "os" "os/exec" "path/filepath" + "strings" + biutils "github.com/jfrog/build-info-go/utils" "github.com/jfrog/gofrog/datastructures" "github.com/jfrog/gofrog/io" "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" @@ -19,10 +21,11 @@ import ( "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" ) +const lockfileOnlyFlag = "--lockfile-only" + type pnpmLsDependency struct { From string `json:"from"` Version string `json:"version"` @@ -37,7 +40,6 @@ type pnpmLsProject struct { } func BuildDependencyTree(params technologies.BuildInfoBomGeneratorParams) (dependencyTrees []*xrayUtils.GraphNode, uniqueDeps []string, err error) { - // Prepare currentDir, err := coreutils.GetWorkingDirectory() if err != nil { return @@ -46,22 +48,89 @@ func BuildDependencyTree(params technologies.BuildInfoBomGeneratorParams) (depen if err != nil { return } - // Build - var dirForDependenciesCalculation string - if dirForDependenciesCalculation, err = installProjectIfNeeded(pnpmExecPath, currentDir); errorutils.CheckError(err) != nil { + + if err = ensureLockfile(pnpmExecPath, currentDir); err != nil { return } - if dirForDependenciesCalculation == "" { - // If we didn't execute 'install' dirForDependenciesCalculation contains an empty value and the dependencies calculation should be performed on the original cloned dir - dirForDependenciesCalculation = currentDir - } else { - // If tempDirForDependenciesCalculation contains a non-empty value, it means we created a temporary directory during the execution of 'install' command, and it needs to removed at the end - defer func() { - err = errors.Join(err, biutils.RemoveTempDir(dirForDependenciesCalculation)) - }() + projects, err := parsePnpmLockFile(currentDir) + if err != nil { + return + } + // Apply scope filter (dev-only / prod-only) to the raw project list if requested. + if len(params.Args) > 0 { + projects = filterProjectsByScope(projects, params.Args) + } + dependencyTrees, uniqueDeps = parsePnpmLSContent(projects) + return +} + +// filterProjectsByScope removes dev or prod dependencies from each project +// based on the --dev or --prod flags passed via params.Args (set by SetNpmScope). +func filterProjectsByScope(projects []pnpmLsProject, args []string) []pnpmLsProject { + devOnly, prodOnly := false, false + for _, arg := range args { + switch arg { + case "--dev": + devOnly = true + case "--prod": + prodOnly = true + } + } + if !devOnly && !prodOnly { + return projects } - return calculateDependencies(pnpmExecPath, dirForDependenciesCalculation, params) + for i := range projects { + if devOnly { + projects[i].Dependencies = nil + } + if prodOnly { + projects[i].DevDependencies = nil + } + } + return projects +} + +// GetNativePnpmRegistryConfig reads the Artifactory registry URL and auth token +// from the project's .npmrc via the pnpm CLI. pnpm reads .npmrc with the same +// hierarchy and semantics as npm, so this mirrors GetNativeNpmRegistryConfig +// without requiring an npm binary on pnpm-only machines. +func GetNativePnpmRegistryConfig() (*npm.NpmrcRegistryConfig, error) { + pnpmExecPath, err := getPnpmExecPath() + if err != nil { + return nil, fmt.Errorf("failed to locate pnpm executable: %w", err) + } + + registryData, err := getPnpmCmd(pnpmExecPath, "", "config", "get", "registry").RunWithOutput() + if err != nil { + return nil, fmt.Errorf("failed to read registry from pnpm config: %w", err) + } + registryUrl := strings.TrimSpace(string(registryData)) + + rtBaseUrl, repoName, err := npm.ParseArtifactoryNpmRegistryUrl(registryUrl) + if err != nil { + return nil, err + } + + authKey, err := npm.BuildNpmAuthTokenKey(registryUrl) + if err != nil { + return nil, err + } + + tokenData, tokenErr := getPnpmCmd(pnpmExecPath, "", "config", "get", authKey).RunWithOutput() + authToken := "" + if tokenErr == nil { + authToken = strings.TrimSpace(string(tokenData)) + if authToken == "undefined" || authToken == "null" { + authToken = "" + } + } + + return &npm.NpmrcRegistryConfig{ + ArtifactoryUrl: rtBaseUrl, + RepoName: repoName, + AuthToken: authToken, + }, nil } func getPnpmExecPath() (pnpmExecPath string, err error) { @@ -73,7 +142,6 @@ func getPnpmExecPath() (pnpmExecPath string, err error) { return } log.Debug("Using Pnpm executable:", pnpmExecPath) - // Validate pnpm version command version, err := getPnpmCmd(pnpmExecPath, "", "--version").RunWithOutput() if errorutils.CheckError(err) != nil { return @@ -88,68 +156,59 @@ func getPnpmCmd(pnpmExecPath, workingDir, cmd string, args ...string) *io.Comman return command } -// Installation is necessary when either the "pnpm-lock.yaml" lock file or the "node_modules/.pnpm" directory does not exist. -// If install is needed, we duplicate the project to a temporary directory and conduct the 'install' operation on the duplicate, to ensure that the original clone does not retain the node_modules directory if it didn't exist previously. -// Upon 'install' the path to the duplicate directory will be returned. -func installProjectIfNeeded(pnpmExecPath, workingDir string) (dirForDependenciesCalculation string, err error) { - lockFileExists, err := fileutils.IsFileExists(filepath.Join(workingDir, "pnpm-lock.yaml"), false) +// ensureLockfile guarantees that pnpm-lock.yaml exists in workingDir. +// If it is already present, nothing is done — the existing lockfile is used as-is. +// If it is absent, `pnpm install --lockfile-only --ignore-scripts` is run in a +// temporary copy of workingDir so the original directory is not mutated. +// +// This mirrors the npm path (--package-lock-only) and yarn V3 path (--mode=update-lockfile): +// no tarballs are downloaded, only resolution metadata is written. +func ensureLockfile(pnpmExecPath, workingDir string) error { + lockExists, err := fileutils.IsFileExists(filepath.Join(workingDir, "pnpm-lock.yaml"), false) if err != nil { - return + return err } - pnpmDirExists, err := fileutils.IsDirExists(filepath.Join(workingDir, "node_modules", ".pnpm"), false) - if err != nil || (lockFileExists && pnpmDirExists) { - return + if lockExists { + return nil } - // Install is needed and will be performed on a copy of the cloned dir - log.Debug("Installing Pnpm project:", workingDir) - dirForDependenciesCalculation, err = fileutils.CreateTempDir() + + log.Debug("pnpm-lock.yaml not found — running 'pnpm install --lockfile-only' in a temporary directory") + tmpDir, err := fileutils.CreateTempDir() if err != nil { - err = fmt.Errorf("failed to create a temporary dir: %w", err) - return + return fmt.Errorf("failed to create a temporary dir: %w", err) } defer func() { - // If an error occurs for any reason, we proceed to delete the temporary directory. - if err != nil { - err = errors.Join(err, fileutils.RemoveTempDir(dirForDependenciesCalculation)) - } + err = errors.Join(err, fileutils.RemoveTempDir(tmpDir)) }() - // Exclude Visual Studio inner directory since it is not necessary for the scan process and may cause race condition. - err = biutils.CopyDir(workingDir, dirForDependenciesCalculation, true, []string{technologies.DotVsRepoSuffix}) - if err != nil { - err = fmt.Errorf("failed copying project to temp dir: %w", err) - return + if copyErr := copyProjectToDir(workingDir, tmpDir); copyErr != nil { + return copyErr } - 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)) + + out, runErr := getPnpmCmd(pnpmExecPath, tmpDir, "install", lockfileOnlyFlag, npm.IgnoreScriptsFlag).GetCmd().CombinedOutput() + if runErr != nil { + return fmt.Errorf("'pnpm install --lockfile-only' failed: %w\n%s", runErr, string(out)) } - 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 technologies.BuildInfoBomGeneratorParams) (dependencyTrees []*xrayUtils.GraphNode, uniqueDeps []string, err error) { - 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 + // Copy the generated lockfile back so parsePnpmLockFile can read it from workingDir. + generatedLock, readErr := fileutils.ReadFile(filepath.Join(tmpDir, "pnpm-lock.yaml")) + if readErr != nil { + return fmt.Errorf("lockfile not produced after 'pnpm install --lockfile-only': %w", readErr) } - log.Verbose("Pnpm ls command output:\n", string(npmLsCmdContent)) - output := &[]pnpmLsProject{} - if err = json.Unmarshal(npmLsCmdContent, output); err != nil { - return + return os.WriteFile(filepath.Join(workingDir, "pnpm-lock.yaml"), generatedLock, 0644) +} + +func copyProjectToDir(src, dst string) error { + if err := biutils.CopyDir(src, dst, true, []string{technologies.DotVsRepoSuffix}); err != nil { + return fmt.Errorf("failed copying project to temp dir: %w", err) } - dependencyTrees, uniqueDeps = parsePnpmLSContent(*output) - return + return nil } func parsePnpmLSContent(projectInfo []pnpmLsProject) (dependencyTrees []*xrayUtils.GraphNode, uniqueDeps []string) { uniqueDepsSet := datastructures.MakeSet[string]() for _, project := range projectInfo { - // Parse the dependencies into Xray dependency tree format dependencyTree, uniqueProjectDeps := xray.BuildXrayDependencyTree(createProjectDependenciesTree(project), getDependencyId(project.Name, project.Version)) - // Add results dependencyTrees = append(dependencyTrees, dependencyTree) uniqueDepsSet.AddElements(maps.Keys(uniqueProjectDeps)...) } @@ -159,14 +218,12 @@ func parsePnpmLSContent(projectInfo []pnpmLsProject) (dependencyTrees []*xrayUti func createProjectDependenciesTree(project pnpmLsProject) map[string]xray.DepTreeNode { treeMap := make(map[string]xray.DepTreeNode) - directDependencies := []string{} - // Handle production-dependencies + var directDependencies []string for depName, dependency := range project.Dependencies { directDependency := getDependencyId(depName, dependency.Version) directDependencies = append(directDependencies, directDependency) appendTransitiveDependencies(directDependency, dependency.Dependencies, &treeMap) } - // Handle dev-dependencies for depName, dependency := range project.DevDependencies { directDependency := getDependencyId(depName, dependency.Version) directDependencies = append(directDependencies, directDependency) @@ -178,7 +235,6 @@ func createProjectDependenciesTree(project pnpmLsProject) map[string]xray.DepTre return treeMap } -// Return npm://: of a dependency func getDependencyId(depName, version string) string { return techutils.Npm.GetXrayPackageTypeId() + depName + ":" + version } diff --git a/sca/bom/buildinfo/technologies/pnpm/pnpm_test.go b/sca/bom/buildinfo/technologies/pnpm/pnpm_test.go index 96d5adbd4..f98ef7463 100644 --- a/sca/bom/buildinfo/technologies/pnpm/pnpm_test.go +++ b/sca/bom/buildinfo/technologies/pnpm/pnpm_test.go @@ -5,9 +5,6 @@ import ( "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" @@ -18,7 +15,6 @@ import ( ) func TestBuildDependencyTreeLimitedDepth(t *testing.T) { - // Create and change directory to test workspace _, cleanUp := technologies.CreateTestWorkspace(t, filepath.Join("projects", "package-managers", "npm", "npm-big-tree")) defer cleanUp() testCases := []struct { @@ -62,14 +58,12 @@ func TestBuildDependencyTreeLimitedDepth(t *testing.T) { for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { - // Build dependency tree params := technologies.BuildInfoBomGeneratorParams{MaxTreeDepth: testCase.treeDepth} rootNode, uniqueDeps, err := BuildDependencyTree(params) 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) @@ -81,27 +75,26 @@ func TestBuildDependencyTreeLimitedDepth(t *testing.T) { } } -func TestBuildDependencyTree(t *testing.T) { - // Create and change directory to test workspace - _, cleanUp := technologies.CreateTestWorkspace(t, filepath.Join("projects", "package-managers", "npm", "npm-no-lock")) +func TestBuildDependencyTreePnpmLockfile(t *testing.T) { + _, cleanUp := technologies.CreateTestWorkspace(t, filepath.Join("projects", "package-managers", "pnpm", "pnpm-project")) defer cleanUp() testCases := []struct { name string - depType string + depScope string expectedUniqueDeps []string expectedTree *xrayUtils.GraphNode }{ { - name: "All", - depType: "all", + name: "All dependencies", + depScope: "all", expectedUniqueDeps: []string{ - "npm://jfrog-cli-tests:v1.0.0", + "npm://pnpm-example:1.0.0", "npm://xml:1.0.1", "npm://json:9.0.6", }, expectedTree: &xrayUtils.GraphNode{ - Id: "npm://jfrog-cli-tests:v1.0.0", + Id: "npm://pnpm-example:1.0.0", Nodes: []*xrayUtils.GraphNode{ {Id: "npm://xml:1.0.1"}, {Id: "npm://json:9.0.6"}, @@ -109,26 +102,26 @@ func TestBuildDependencyTree(t *testing.T) { }, }, { - name: "Prod", - depType: "prodOnly", + name: "Prod only", + depScope: "prodOnly", expectedUniqueDeps: []string{ - "npm://jfrog-cli-tests:v1.0.0", + "npm://pnpm-example:1.0.0", "npm://xml:1.0.1", }, expectedTree: &xrayUtils.GraphNode{ - Id: "npm://jfrog-cli-tests:v1.0.0", + Id: "npm://pnpm-example:1.0.0", Nodes: []*xrayUtils.GraphNode{{Id: "npm://xml:1.0.1"}}, }, }, { - name: "Dev", - depType: "devOnly", + name: "Dev only", + depScope: "devOnly", expectedUniqueDeps: []string{ - "npm://jfrog-cli-tests:v1.0.0", + "npm://pnpm-example:1.0.0", "npm://json:9.0.6", }, expectedTree: &xrayUtils.GraphNode{ - Id: "npm://jfrog-cli-tests:v1.0.0", + Id: "npm://pnpm-example:1.0.0", Nodes: []*xrayUtils.GraphNode{{Id: "npm://json:9.0.6"}}, }, }, @@ -136,14 +129,13 @@ func TestBuildDependencyTree(t *testing.T) { for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { - // Build dependency tree params := technologies.BuildInfoBomGeneratorParams{} - rootNode, uniqueDeps, err := BuildDependencyTree(*params.SetNpmScope(testCase.depType)) + rootNode, uniqueDeps, err := BuildDependencyTree(*params.SetNpmScope(testCase.depScope)) require.NoError(t, err) - // Validations - assert.ElementsMatch(t, uniqueDeps, testCase.expectedUniqueDeps, "First is actual, Second is Expected") + sort.Slice(uniqueDeps, func(i, j int) bool { return uniqueDeps[i] < uniqueDeps[j] }) + assert.ElementsMatch(t, uniqueDeps, testCase.expectedUniqueDeps) if assert.Len(t, rootNode, 1) { - assert.Equal(t, rootNode[0].Id, testCase.expectedTree.Id) + assert.Equal(t, testCase.expectedTree.Id, rootNode[0].Id) if !tests.CompareTree(testCase.expectedTree, rootNode[0]) { t.Error("expected:", testCase.expectedTree.Nodes, "got:", rootNode[0].Nodes) } @@ -152,25 +144,14 @@ func TestBuildDependencyTree(t *testing.T) { } } -func TestInstallProjectIfNeeded(t *testing.T) { - _, cleanUp := technologies.CreateTestWorkspace(t, filepath.Join("projects", "package-managers", "npm", "npm-no-lock")) +func TestEnsureLockfileExisting(t *testing.T) { + _, cleanUp := technologies.CreateTestWorkspace(t, filepath.Join("projects", "package-managers", "pnpm", "pnpm-project")) defer cleanUp() - currentDir, err := coreutils.GetWorkingDirectory() - assert.NoError(t, err) - pnpmExecPath, err := getPnpmExecPath() - assert.NoError(t, err) - - dirForDependenciesCalculation, err := installProjectIfNeeded(pnpmExecPath, currentDir) - assert.NoError(t, err) - assert.NotEmpty(t, dirForDependenciesCalculation) - - nodeModulesExist, err := fileutils.IsDirExists(filepath.Join(dirForDependenciesCalculation, "node_modules"), false) - assert.NoError(t, err) - assert.True(t, nodeModulesExist) + require.NoError(t, err) - nodeModulesExist, err = fileutils.IsDirExists(filepath.Join(currentDir, "node_modules"), false) + // Workspace already contains pnpm-lock.yaml — ensureLockfile should be a no-op. + err = ensureLockfile(pnpmExecPath, ".") assert.NoError(t, err) - assert.False(t, nodeModulesExist) } diff --git a/sca/bom/buildinfo/technologies/pnpm/pnpmlock.go b/sca/bom/buildinfo/technologies/pnpm/pnpmlock.go new file mode 100644 index 000000000..cf293d08e --- /dev/null +++ b/sca/bom/buildinfo/technologies/pnpm/pnpmlock.go @@ -0,0 +1,213 @@ +package pnpm + +import ( + "fmt" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +// Minimum supported pnpm lockfile version. Versions below this use a different +// format (flat packages map with no snapshots block) that we do not support. +const minSupportedLockfileVersion = "6.0" + +// pnpmLockFile is the top-level structure of pnpm-lock.yaml. +type pnpmLockFile struct { + LockfileVersion string `yaml:"lockfileVersion"` + Importers map[string]pnpmLockImporter `yaml:"importers"` + Snapshots map[string]pnpmLockSnapshot `yaml:"snapshots"` +} + +// pnpmLockImporter represents a workspace member (or the root project at "."). +type pnpmLockImporter struct { + Dependencies map[string]pnpmLockDep `yaml:"dependencies"` + DevDependencies map[string]pnpmLockDep `yaml:"devDependencies"` +} + +// pnpmLockDep holds the resolved version for a direct dependency. +type pnpmLockDep struct { + Version string `yaml:"version"` +} + +// pnpmLockSnapshot is one entry in the snapshots block. +// The key format is "@(@)(@)..." but we +// strip the peer suffix when building Xray dependency IDs. +type pnpmLockSnapshot struct { + // Dependencies are keyed by bare package name; values are either a plain + // version string or a version+peer-suffix string (e.g. "3.0.5(@foo/bar@1.2)"). + Dependencies map[string]string `yaml:"dependencies"` +} + +// parsePnpmLockFile reads workingDir/pnpm-lock.yaml and converts it into the +// same []pnpmLsProject shape that the old `pnpm ls --json` path produced. +// The name and version for each importer entry are taken from +// workingDir//package.json when available; importer paths other +// than "." are treated as workspace members. +func parsePnpmLockFile(workingDir string) ([]pnpmLsProject, error) { + lockPath := workingDir + "/pnpm-lock.yaml" + data, err := os.ReadFile(lockPath) + if err != nil { + return nil, fmt.Errorf("reading pnpm-lock.yaml: %w", err) + } + + var lf pnpmLockFile + if err = yaml.Unmarshal(data, &lf); err != nil { + return nil, fmt.Errorf("parsing pnpm-lock.yaml: %w", err) + } + + if err = validateLockfileVersion(lf.LockfileVersion); err != nil { + return nil, err + } + + // Root-only project (no importers block) — treat the whole file as a single importer. + if len(lf.Importers) == 0 { + return nil, fmt.Errorf("pnpm-lock.yaml has no importers block; run 'pnpm install --lockfile-only' first") + } + + var projects []pnpmLsProject + for importerPath, importer := range lf.Importers { + name, version := readPackageNameVersion(workingDir, importerPath) + project := pnpmLsProject{ + Name: name, + Version: version, + } + + visited := map[string]bool{} + project.Dependencies = buildDepsMap(importer.Dependencies, lf.Snapshots, visited) + project.DevDependencies = buildDepsMap(importer.DevDependencies, lf.Snapshots, visited) + projects = append(projects, project) + } + return projects, nil +} + +// buildDepsMap converts a direct-dependency map from the importers block into +// the nested pnpmLsDependency tree, walking the snapshots block for transitive deps. +func buildDepsMap(deps map[string]pnpmLockDep, snapshots map[string]pnpmLockSnapshot, visited map[string]bool) map[string]pnpmLsDependency { + if len(deps) == 0 { + return nil + } + result := make(map[string]pnpmLsDependency) + for name, dep := range deps { + // dep.Version may contain a peer-dep suffix: "2.0.0(@peer/dep@1.0.0)" + // Strip it for the Xray ID; keep the raw form for snapshot lookup. + rawRef := dep.Version + _, cleanVersion := splitPnpmRef(rawRef) + depKey := buildSnapshotKey(name, rawRef) + + entry := pnpmLsDependency{ + From: name, + Version: cleanVersion, + } + if !visited[depKey] { + visited[depKey] = true + entry.Dependencies = walkSnapshot(depKey, snapshots, visited) + visited[depKey] = false // allow same package at different depths + } + result[name] = entry + } + return result +} + +// walkSnapshot recursively resolves transitive dependencies from the snapshots block. +func walkSnapshot(snapshotKey string, snapshots map[string]pnpmLockSnapshot, visited map[string]bool) map[string]pnpmLsDependency { + snap, ok := snapshots[snapshotKey] + if !ok || len(snap.Dependencies) == 0 { + return nil + } + result := make(map[string]pnpmLsDependency) + for name, rawRef := range snap.Dependencies { + _, cleanVersion := splitPnpmRef(rawRef) + childKey := buildSnapshotKey(name, rawRef) + entry := pnpmLsDependency{ + From: name, + Version: cleanVersion, + } + if !visited[childKey] { + visited[childKey] = true + entry.Dependencies = walkSnapshot(childKey, snapshots, visited) + visited[childKey] = false + } + result[name] = entry + } + return result +} + +// splitPnpmRef splits a pnpm lockfile reference into (name, version). +// The reference may be either a snapshot key like "@scope/pkg@2.0.0(@peer@1.0)" +// or a plain version string like "2.0.0(@peer@1.0)". +// In both cases the peer-dep suffix (everything from the first '(' onward) is stripped. +// The split is always on the LAST '@' so scoped names like "@scope/pkg" are handled correctly. +func splitPnpmRef(ref string) (name, version string) { + // Strip peer-dep suffix. + if i := strings.IndexByte(ref, '('); i >= 0 { + ref = ref[:i] + } + i := strings.LastIndexByte(ref, '@') + if i <= 0 { + // No '@' or starts with '@' but has no version — treat the whole thing as a version. + return "", ref + } + return ref[:i], ref[i+1:] +} + +// buildSnapshotKey constructs the key used to look up an entry in the snapshots map. +// pnpm stores snapshots under the full "@()" key, so we need +// to combine the package name with its raw (possibly peer-suffixed) version ref. +// For plain version strings (no name in the ref) the name is prepended. +func buildSnapshotKey(name, rawRef string) string { + // If rawRef already contains an '@' after the first character (i.e. it's a full + // "@..." key rather than a bare version), use it as-is. + if strings.Count(rawRef, "@") >= 1 && !strings.HasPrefix(rawRef, "@") { + return name + "@" + rawRef + } + // Scoped packages (@scope/pkg) always start with '@'; their version refs look like + // "2.0.0" or "2.0.0(@peer@1.0)" — never "@". + if strings.HasPrefix(rawRef, "@") { + // rawRef is itself a full scoped key e.g. "@scope/pkg@2.0.0(@peer@1.0)" + return rawRef + } + return name + "@" + rawRef +} + +// readPackageNameVersion reads name and version from the package.json at +// workingDir//package.json. Falls back to the importer path as +// the name and "0.0.0" as the version if the file is absent or unreadable. +func readPackageNameVersion(workingDir, importerPath string) (name, version string) { + dir := workingDir + if importerPath != "." { + dir = workingDir + "/" + importerPath + } + data, err := os.ReadFile(dir + "/package.json") + if err != nil { + return importerPath, "0.0.0" + } + // Minimal JSON extraction — avoid a full unmarshal dependency just for two fields. + var pkg struct { + Name string `json:"name"` + Version string `json:"version"` + } + // Use yaml decoder as a light JSON superset parser (json is valid yaml). + if err = yaml.Unmarshal(data, &pkg); err != nil || pkg.Name == "" { + return importerPath, "0.0.0" + } + if pkg.Version == "" { + pkg.Version = "0.0.0" + } + return pkg.Name, pkg.Version +} + +// validateLockfileVersion rejects lockfile versions older than minSupportedLockfileVersion. +// pnpm v5 used "5.x" and had a different flat format; v6+ uses the current structure. +func validateLockfileVersion(v string) error { + // Strip surrounding quotes if present (pnpm 9 writes lockfileVersion: '9.0'). + v = strings.Trim(v, "'\"") + if v == "" { + return fmt.Errorf("pnpm-lock.yaml is missing lockfileVersion; run 'pnpm install --lockfile-only' to regenerate") + } + // Only reject clearly old formats (5.x). Anything >= 6.0 shares the same structure. + if strings.HasPrefix(v, "5.") { + return fmt.Errorf("pnpm-lock.yaml lockfileVersion %q requires pnpm v6 or later to parse; please upgrade pnpm and re-run 'pnpm install --lockfile-only'", v) + } + return nil +} diff --git a/sca/bom/buildinfo/technologies/pnpm/pnpmlock_test.go b/sca/bom/buildinfo/technologies/pnpm/pnpmlock_test.go new file mode 100644 index 000000000..a3e233f72 --- /dev/null +++ b/sca/bom/buildinfo/technologies/pnpm/pnpmlock_test.go @@ -0,0 +1,125 @@ +package pnpm + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fixtureDir is relative to the repository root; in tests we use +// technologies.CreateTestWorkspace to cd into the fixture, but for pure parser +// tests we want to read files without changing the working directory. +func testdataDir(t *testing.T) string { + t.Helper() + // Walk up until we find the go.mod root, then descend into testdata. + // Assumes tests run from within the module tree. + dir, err := filepath.Abs("../../../../..") + require.NoError(t, err) + return filepath.Join(dir, "tests", "testdata", "projects", "package-managers", "pnpm", "pnpm-project") +} + +// TestParsePnpmLockFileSimple exercises the simple fixture (two top-level deps, no transitives). +func TestParsePnpmLockFileSimple(t *testing.T) { + dir := testdataDir(t) + projects, err := parsePnpmLockFile(dir) + require.NoError(t, err) + require.Len(t, projects, 1) + + p := projects[0] + assert.Equal(t, "pnpm-example", p.Name) + assert.Equal(t, "1.0.0", p.Version) + + require.Contains(t, p.Dependencies, "xml") + assert.Equal(t, "1.0.1", p.Dependencies["xml"].Version) + + require.Contains(t, p.DevDependencies, "json") + assert.Equal(t, "9.0.6", p.DevDependencies["json"].Version) +} + +// TestParsePnpmLockFileComplex tests scoped packages, peer-dep suffixes, and transitives. +func TestParsePnpmLockFileComplex(t *testing.T) { + dir := testdataDir(t) + // Swap the simple lockfile for the complex one temporarily. + simpleLock := filepath.Join(dir, "pnpm-lock.yaml") + complexLock := filepath.Join(dir, "pnpm-lock-complex.yaml") + origContent, readErr := os.ReadFile(simpleLock) + require.NoError(t, readErr) + complexContent, readErr := os.ReadFile(complexLock) + require.NoError(t, readErr) + + require.NoError(t, os.WriteFile(simpleLock, complexContent, 0644)) + t.Cleanup(func() { _ = os.WriteFile(simpleLock, origContent, 0644) }) + + projects, err := parsePnpmLockFile(dir) + require.NoError(t, err) + require.Len(t, projects, 1) + + p := projects[0] + assert.Equal(t, "pnpm-example", p.Name) + assert.Equal(t, "1.0.0", p.Version) + + // prod: xml (no transitives) + require.Contains(t, p.Dependencies, "xml") + assert.Equal(t, "1.0.1", p.Dependencies["xml"].Version) + assert.Empty(t, p.Dependencies["xml"].Dependencies) + + // prod: @scope/pkg — peer suffix stripped from version + require.Contains(t, p.Dependencies, "@scope/pkg") + scopedPkg := p.Dependencies["@scope/pkg"] + assert.Equal(t, "2.0.0", scopedPkg.Version, "peer-dep suffix must be stripped from version") + + // @scope/pkg has two transitive deps: @peer/dep and transitive-a + require.Contains(t, scopedPkg.Dependencies, "@peer/dep") + assert.Equal(t, "1.0.0", scopedPkg.Dependencies["@peer/dep"].Version) + require.Contains(t, scopedPkg.Dependencies, "transitive-a") + assert.Equal(t, "1.2.3", scopedPkg.Dependencies["transitive-a"].Version) + + // dev: json + require.Contains(t, p.DevDependencies, "json") + assert.Equal(t, "9.0.6", p.DevDependencies["json"].Version) +} + +// TestSplitPnpmRef checks peer-suffix stripping and scoped name handling. +func TestSplitPnpmRef(t *testing.T) { + tests := []struct { + input string + wantName string + wantVersion string + }{ + {"1.0.1", "", "1.0.1"}, + {"2.0.0(@peer/dep@1.0.0)", "", "2.0.0"}, + {"@scope/pkg@2.0.0(@peer@1.0)", "@scope/pkg", "2.0.0"}, + {"xml@1.0.1", "xml", "1.0.1"}, + } + for _, tc := range tests { + name, version := splitPnpmRef(tc.input) + assert.Equal(t, tc.wantName, name, "name for %q", tc.input) + assert.Equal(t, tc.wantVersion, version, "version for %q", tc.input) + } +} + +// TestValidateLockfileVersion ensures old lockfile versions are rejected and modern ones accepted. +func TestValidateLockfileVersion(t *testing.T) { + assert.NoError(t, validateLockfileVersion("6.0")) + assert.NoError(t, validateLockfileVersion("'6.0'")) + assert.NoError(t, validateLockfileVersion("9.0")) + assert.NoError(t, validateLockfileVersion("'9.0'")) + assert.Error(t, validateLockfileVersion("5.4")) + assert.Error(t, validateLockfileVersion("")) +} + +// TestBuildSnapshotKey checks that scoped and non-scoped package references +// produce the correct snapshot lookup key. +func TestBuildSnapshotKey(t *testing.T) { + // Plain version (most common in importers block). + assert.Equal(t, "xml@1.0.1", buildSnapshotKey("xml", "1.0.1")) + // Scoped package with plain version. + assert.Equal(t, "@scope/pkg@2.0.0", buildSnapshotKey("@scope/pkg", "2.0.0")) + // Scoped package with peer-dep suffix (common for peer-requiring packages). + assert.Equal(t, "@scope/pkg@2.0.0(@peer/dep@1.0.0)", buildSnapshotKey("@scope/pkg", "2.0.0(@peer/dep@1.0.0)")) + // Non-scoped package with peer-dep suffix from a snapshot's dependencies map. + assert.Equal(t, "transitive-a@1.2.3", buildSnapshotKey("transitive-a", "1.2.3")) +} diff --git a/tests/testdata/projects/package-managers/pnpm/pnpm-project/package.json b/tests/testdata/projects/package-managers/pnpm/pnpm-project/package.json new file mode 100644 index 000000000..1b79de2d8 --- /dev/null +++ b/tests/testdata/projects/package-managers/pnpm/pnpm-project/package.json @@ -0,0 +1,17 @@ +{ + "name": "pnpm-example", + "version": "1.0.0", + "description": "pnpm test project for jf ca / jf audit", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "dependencies": { + "xml": "1.0.1" + }, + "devDependencies": { + "json": "9.0.6" + } +} diff --git a/tests/testdata/projects/package-managers/pnpm/pnpm-project/pnpm-lock-complex.yaml b/tests/testdata/projects/package-managers/pnpm/pnpm-project/pnpm-lock-complex.yaml new file mode 100644 index 000000000..b5a67660f --- /dev/null +++ b/tests/testdata/projects/package-managers/pnpm/pnpm-project/pnpm-lock-complex.yaml @@ -0,0 +1,56 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + xml: + specifier: 1.0.1 + version: 1.0.1 + '@scope/pkg': + specifier: 2.0.0 + version: 2.0.0(@peer/dep@1.0.0) + devDependencies: + json: + specifier: 9.0.6 + version: 9.0.6 + +packages: + + '@peer/dep@1.0.0': + resolution: {integrity: sha512-aaa==} + + '@scope/pkg@2.0.0': + resolution: {integrity: sha512-bbb==} + peerDependencies: + '@peer/dep': '>=1.0.0' + + json@9.0.6: + resolution: {integrity: sha512-Nx+4WwMM1xadgqjjteOVEyjoIVq7fGH1hAlRDoxoq2tFzYsBYZDIKwYbyxolkTYwxsSOgAZD2ACLkeGjhFW2Jw==} + engines: {node: '>=0.10.0'} + hasBin: true + + transitive-a@1.2.3: + resolution: {integrity: sha512-ccc==} + + xml@1.0.1: + resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==} + +snapshots: + + '@peer/dep@1.0.0': {} + + '@scope/pkg@2.0.0(@peer/dep@1.0.0)': + dependencies: + '@peer/dep': 1.0.0 + transitive-a: 1.2.3 + + json@9.0.6: {} + + transitive-a@1.2.3: {} + + xml@1.0.1: {} diff --git a/tests/testdata/projects/package-managers/pnpm/pnpm-project/pnpm-lock.yaml b/tests/testdata/projects/package-managers/pnpm/pnpm-project/pnpm-lock.yaml new file mode 100644 index 000000000..0b133c59b --- /dev/null +++ b/tests/testdata/projects/package-managers/pnpm/pnpm-project/pnpm-lock.yaml @@ -0,0 +1,33 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + xml: + specifier: 1.0.1 + version: 1.0.1 + devDependencies: + json: + specifier: 9.0.6 + version: 9.0.6 + +packages: + + json@9.0.6: + resolution: {integrity: sha512-Nx+4WwMM1xadgqjjteOVEyjoIVq7fGH1hAlRDoxoq2tFzYsBYZDIKwYbyxolkTYwxsSOgAZD2ACLkeGjhFW2Jw==} + engines: {node: '>=0.10.0'} + hasBin: true + + xml@1.0.1: + resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==} + +snapshots: + + json@9.0.6: {} + + xml@1.0.1: {} diff --git a/utils/techutils/techutils.go b/utils/techutils/techutils.go index f49024673..59cc17948 100644 --- a/utils/techutils/techutils.go +++ b/utils/techutils/techutils.go @@ -174,7 +174,7 @@ var technologiesData = map[Technology]TechData{ }, Npm: { indicators: []string{"package.json", "package-lock.json", "npm-shrinkwrap.json"}, - exclude: []string{"pnpm-lock.yaml", ".yarnrc.yml", "yarn.lock", ".yarn"}, + exclude: []string{"pnpm-lock.yaml", "pnpm-workspace.yaml", ".pnpmfile.cjs", ".yarnrc.yml", "yarn.lock", ".yarn"}, packageDescriptors: []string{"package.json"}, formal: string(Npm), packageVersionOperator: "@", @@ -185,7 +185,7 @@ var technologiesData = map[Technology]TechData{ Pnpm: { packageType: "npm", xrayPackageType: "npm", - indicators: []string{"pnpm-lock.yaml"}, + indicators: []string{"pnpm-lock.yaml", "pnpm-workspace.yaml", ".pnpmfile.cjs"}, exclude: []string{".yarnrc.yml", "yarn.lock", ".yarn"}, packageDescriptors: []string{"package.json"}, packageVersionOperator: "@", @@ -195,7 +195,7 @@ var technologiesData = map[Technology]TechData{ }, Yarn: { indicators: []string{".yarnrc.yml", "yarn.lock", ".yarn", ".yarnrc"}, - exclude: []string{"pnpm-lock.yaml"}, + exclude: []string{"pnpm-lock.yaml", "pnpm-workspace.yaml", ".pnpmfile.cjs"}, packageDescriptors: []string{"package.json"}, packageVersionOperator: "@", projectType: project.Yarn, diff --git a/utils/techutils/techutils_test.go b/utils/techutils/techutils_test.go index 87f9d3c0a..2dbcd5313 100644 --- a/utils/techutils/techutils_test.go +++ b/utils/techutils/techutils_test.go @@ -58,6 +58,35 @@ func TestMapFilesToRelevantWorkingDirectories(t *testing.T) { expectedWorkingDir: map[string][]string{"dir": {filepath.Join("dir", "package.json"), filepath.Join("dir", "pnpm-lock.yaml")}}, expectedExcluded: map[string][]Technology{"dir": {Npm, Yarn}}, }, + { + name: "pnpmWorkspaceTest", + paths: []string{filepath.Join("dir", "package.json"), filepath.Join("dir", "pnpm-workspace.yaml")}, + requestedDescriptors: noRequest, + expectedWorkingDir: map[string][]string{"dir": {filepath.Join("dir", "package.json"), filepath.Join("dir", "pnpm-workspace.yaml")}}, + expectedExcluded: map[string][]Technology{"dir": {Npm, Yarn}}, + }, + { + name: "pnpmfileTest", + paths: []string{filepath.Join("dir", "package.json"), filepath.Join("dir", ".pnpmfile.cjs")}, + requestedDescriptors: noRequest, + expectedWorkingDir: map[string][]string{"dir": {filepath.Join("dir", "package.json"), filepath.Join("dir", ".pnpmfile.cjs")}}, + expectedExcluded: map[string][]Technology{"dir": {Npm, Yarn}}, + }, + { + // pnpm-workspace.yaml + pnpm-lock.yaml both present: only pnpm should be detected, + // npm and yarn excluded. + name: "pnpmWorkspaceAndLockfileTest", + paths: []string{ + filepath.Join("dir", "package.json"), + filepath.Join("dir", "pnpm-workspace.yaml"), + filepath.Join("dir", "pnpm-lock.yaml"), + }, + requestedDescriptors: noRequest, + expectedWorkingDir: map[string][]string{ + "dir": {filepath.Join("dir", "package.json"), filepath.Join("dir", "pnpm-workspace.yaml"), filepath.Join("dir", "pnpm-lock.yaml")}, + }, + expectedExcluded: map[string][]Technology{"dir": {Npm, Yarn}}, + }, { name: "yarnTest", paths: []string{filepath.Join("dir", "package.json"), filepath.Join("dir", ".yarn")}, From 8a6d199b6f14715444ac7954cfec2d21f13a628a Mon Sep 17 00:00:00 2001 From: Gauri Yadav Date: Thu, 28 May 2026 15:39:49 +0530 Subject: [PATCH 2/4] XRAY-140503 - Add pnpm v10 min version check, stale lockfile refresh, and improved install logging - Reject pnpm versions below 10.x with a clear error message - Refresh pnpm-lock.yaml when package.json is newer (stale lockfile) - Log full install command including --lockfile-only and --ignore-scripts flags - Add unit tests for validatePnpmMinVersion Co-authored-by: Cursor --- sca/bom/buildinfo/technologies/pnpm/pnpm.go | 44 ++++++++++++++++--- .../buildinfo/technologies/pnpm/pnpm_test.go | 24 ++++++++++ 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/sca/bom/buildinfo/technologies/pnpm/pnpm.go b/sca/bom/buildinfo/technologies/pnpm/pnpm.go index 821c4b458..b18268c2e 100644 --- a/sca/bom/buildinfo/technologies/pnpm/pnpm.go +++ b/sca/bom/buildinfo/technologies/pnpm/pnpm.go @@ -133,6 +133,8 @@ func GetNativePnpmRegistryConfig() (*npm.NpmrcRegistryConfig, error) { }, nil } +const minPnpmMajorVersion = 10 + func getPnpmExecPath() (pnpmExecPath string, err error) { if pnpmExecPath, err = exec.LookPath("pnpm"); errorutils.CheckError(err) != nil { return @@ -142,14 +144,34 @@ func getPnpmExecPath() (pnpmExecPath string, err error) { return } log.Debug("Using Pnpm executable:", pnpmExecPath) - version, err := getPnpmCmd(pnpmExecPath, "", "--version").RunWithOutput() - if errorutils.CheckError(err) != nil { + versionOut, versionErr := getPnpmCmd(pnpmExecPath, "", "--version").RunWithOutput() + if errorutils.CheckError(versionErr) != nil { + err = versionErr + return + } + versionStr := strings.TrimSpace(string(versionOut)) + log.Debug("Pnpm version:", versionStr) + if err = validatePnpmMinVersion(versionStr); err != nil { return } - log.Debug("Pnpm version:", string(version)) return } +// validatePnpmMinVersion returns an error if the installed pnpm major version is below minPnpmMajorVersion. +func validatePnpmMinVersion(versionStr string) error { + // Version string may include extra lines (warnings on incompatible Node); take first token. + firstLine := strings.SplitN(versionStr, "\n", 2)[0] + parts := strings.SplitN(strings.TrimSpace(firstLine), ".", 2) + var major int + if _, scanErr := fmt.Sscanf(parts[0], "%d", &major); scanErr != nil { + return fmt.Errorf("could not parse pnpm version %q: %w", versionStr, scanErr) + } + if major < minPnpmMajorVersion { + return fmt.Errorf("pnpm version %s is not supported. Minimum required version is %d.x", versionStr, minPnpmMajorVersion) + } + return nil +} + func getPnpmCmd(pnpmExecPath, workingDir, cmd string, args ...string) *io.Command { command := io.NewCommand(pnpmExecPath, cmd, args) command.Dir = workingDir @@ -164,15 +186,23 @@ func getPnpmCmd(pnpmExecPath, workingDir, cmd string, args ...string) *io.Comman // This mirrors the npm path (--package-lock-only) and yarn V3 path (--mode=update-lockfile): // no tarballs are downloaded, only resolution metadata is written. func ensureLockfile(pnpmExecPath, workingDir string) error { - lockExists, err := fileutils.IsFileExists(filepath.Join(workingDir, "pnpm-lock.yaml"), false) + lockPath := filepath.Join(workingDir, "pnpm-lock.yaml") + lockExists, err := fileutils.IsFileExists(lockPath, false) if err != nil { return err } if lockExists { - return nil + // Re-run install if package.json is newer than pnpm-lock.yaml (stale lockfile). + pkgStat, pkgErr := os.Stat(filepath.Join(workingDir, "package.json")) + lockStat, lockErr := os.Stat(lockPath) + if pkgErr == nil && lockErr == nil && pkgStat.ModTime().After(lockStat.ModTime()) { + log.Debug(fmt.Sprintf("package.json is newer than pnpm-lock.yaml — running '%s install %s %s' to refresh it", pnpmExecPath, lockfileOnlyFlag, npm.IgnoreScriptsFlag)) + } else { + return nil + } + } else { + log.Debug(fmt.Sprintf("pnpm-lock.yaml not found — running '%s install %s %s' in a temporary directory", pnpmExecPath, lockfileOnlyFlag, npm.IgnoreScriptsFlag)) } - - log.Debug("pnpm-lock.yaml not found — running 'pnpm install --lockfile-only' in a temporary directory") tmpDir, err := fileutils.CreateTempDir() if err != nil { return fmt.Errorf("failed to create a temporary dir: %w", err) diff --git a/sca/bom/buildinfo/technologies/pnpm/pnpm_test.go b/sca/bom/buildinfo/technologies/pnpm/pnpm_test.go index f98ef7463..8fed0861f 100644 --- a/sca/bom/buildinfo/technologies/pnpm/pnpm_test.go +++ b/sca/bom/buildinfo/technologies/pnpm/pnpm_test.go @@ -155,3 +155,27 @@ func TestEnsureLockfileExisting(t *testing.T) { err = ensureLockfile(pnpmExecPath, ".") assert.NoError(t, err) } + +func TestValidatePnpmMinVersion(t *testing.T) { + testCases := []struct { + name string + version string + expectError bool + }{ + {name: "v10 accepted", version: "10.0.0", expectError: false}, + {name: "v10 minor accepted", version: "10.27.0", expectError: false}, + {name: "v9 rejected", version: "9.15.0", expectError: true}, + {name: "v8 rejected", version: "8.15.9", expectError: true}, + {name: "v11 accepted", version: "11.0.0", expectError: false}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := validatePnpmMinVersion(tc.version) + if tc.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} From 69b4e253c8475c19d4986cf3dd48b4f28bbce90c Mon Sep 17 00:00:00 2001 From: Gauri Yadav Date: Fri, 29 May 2026 12:07:01 +0530 Subject: [PATCH 3/4] XRAY-140503 - Polish pnpm version check, rejection message, and --run-native warning - Enforce pnpm 10.x only (reject <10 and >10); add maxPnpmMajorVersion=10 const - Align version rejection message with yarn's wording pattern - Fix test: v11 correctly expected to be rejected - Warn via log.Warn when --run-native is passed for pnpm (no-op, always native) Co-authored-by: Cursor --- commands/curation/curationaudit.go | 4 ++++ sca/bom/buildinfo/technologies/pnpm/pnpm.go | 11 +++++++---- sca/bom/buildinfo/technologies/pnpm/pnpm_test.go | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index 0c12d01aa..59ac19955 100644 --- a/commands/curation/curationaudit.go +++ b/commands/curation/curationaudit.go @@ -530,6 +530,10 @@ func (ca *CurationAuditCommand) auditTree(tech techutils.Technology, results map if (ca.RunNative() && tech == techutils.Npm) || tech == techutils.Pnpm { params.IgnoreConfigFile = true } + // Pnpm always resolves natively from .npmrc — --run-native is redundant and has no effect. + if ca.RunNative() && tech == techutils.Pnpm { + log.Warn("--run-native has no effect for pnpm; pnpm always resolves natively from .npmrc") + } serverDetails, err := buildinfo.SetResolutionRepoInParamsIfExists(¶ms, tech) if err != nil { return err diff --git a/sca/bom/buildinfo/technologies/pnpm/pnpm.go b/sca/bom/buildinfo/technologies/pnpm/pnpm.go index b18268c2e..9e5200a3d 100644 --- a/sca/bom/buildinfo/technologies/pnpm/pnpm.go +++ b/sca/bom/buildinfo/technologies/pnpm/pnpm.go @@ -133,7 +133,10 @@ func GetNativePnpmRegistryConfig() (*npm.NpmrcRegistryConfig, error) { }, nil } -const minPnpmMajorVersion = 10 +const ( + minPnpmMajorVersion = 10 + maxPnpmMajorVersion = 10 +) func getPnpmExecPath() (pnpmExecPath string, err error) { if pnpmExecPath, err = exec.LookPath("pnpm"); errorutils.CheckError(err) != nil { @@ -157,7 +160,7 @@ func getPnpmExecPath() (pnpmExecPath string, err error) { return } -// validatePnpmMinVersion returns an error if the installed pnpm major version is below minPnpmMajorVersion. +// validatePnpmMinVersion returns an error if the installed pnpm major version is outside the supported range [minPnpmMajorVersion, maxPnpmMajorVersion]. func validatePnpmMinVersion(versionStr string) error { // Version string may include extra lines (warnings on incompatible Node); take first token. firstLine := strings.SplitN(versionStr, "\n", 2)[0] @@ -166,8 +169,8 @@ func validatePnpmMinVersion(versionStr string) error { if _, scanErr := fmt.Sscanf(parts[0], "%d", &major); scanErr != nil { return fmt.Errorf("could not parse pnpm version %q: %w", versionStr, scanErr) } - if major < minPnpmMajorVersion { - return fmt.Errorf("pnpm version %s is not supported. Minimum required version is %d.x", versionStr, minPnpmMajorVersion) + if major < minPnpmMajorVersion || major > maxPnpmMajorVersion { + return fmt.Errorf("Resolving Pnpm dependencies from Artifactory is currently not supported for Pnpm versions outside the %d.x range. The current Pnpm version is: %s", minPnpmMajorVersion, versionStr) } return nil } diff --git a/sca/bom/buildinfo/technologies/pnpm/pnpm_test.go b/sca/bom/buildinfo/technologies/pnpm/pnpm_test.go index 8fed0861f..c59327e53 100644 --- a/sca/bom/buildinfo/technologies/pnpm/pnpm_test.go +++ b/sca/bom/buildinfo/technologies/pnpm/pnpm_test.go @@ -166,7 +166,7 @@ func TestValidatePnpmMinVersion(t *testing.T) { {name: "v10 minor accepted", version: "10.27.0", expectError: false}, {name: "v9 rejected", version: "9.15.0", expectError: true}, {name: "v8 rejected", version: "8.15.9", expectError: true}, - {name: "v11 accepted", version: "11.0.0", expectError: false}, + {name: "v11 rejected", version: "11.0.0", expectError: true}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { From 8bee66c6cbd92b5942f3ba7b6438ca86ba42cc7f Mon Sep 17 00:00:00 2001 From: Gauri Yadav Date: Fri, 29 May 2026 12:24:55 +0530 Subject: [PATCH 4/4] XRAY-140503 - Fix gosec G703 path traversal warnings in pnpmlock_test.go Co-authored-by: Cursor --- commands/curation/curationaudit_test.go | 90 +++++++++++++ sca/bom/buildinfo/technologies/pnpm/pnpm.go | 127 +++++++++++++++--- .../buildinfo/technologies/pnpm/pnpm_test.go | 101 +++++++++++++- .../buildinfo/technologies/pnpm/pnpmlock.go | 55 +++----- .../technologies/pnpm/pnpmlock_test.go | 4 +- 5 files changed, 313 insertions(+), 64 deletions(-) diff --git a/commands/curation/curationaudit_test.go b/commands/curation/curationaudit_test.go index b50d1a4fb..e87eca742 100644 --- a/commands/curation/curationaudit_test.go +++ b/commands/curation/curationaudit_test.go @@ -1693,3 +1693,93 @@ func TestFetchNodesStatusConcurrentMapWrite(t *testing.T) { }) assert.Equal(t, numNodes, count, "expected all %d packages to be recorded as blocked", numNodes) } + +func TestPromotePnpmWorkspaceMember(t *testing.T) { + npm := "npm" + pnpm := "pnpm" + other := "maven" + + tests := []struct { + name string + techs []string + ancestorFile string // file to create in the ancestor dir ("" = none) + expectedHasPnpm bool + }{ + { + name: "already has pnpm — no change", + techs: []string{pnpm, npm}, + expectedHasPnpm: true, + }, + { + name: "no npm — no change", + techs: []string{other}, + expectedHasPnpm: false, + }, + { + name: "npm only, no ancestor indicator — no promotion", + techs: []string{npm}, + ancestorFile: "", + expectedHasPnpm: false, + }, + { + name: "npm only, ancestor has pnpm-workspace.yaml — promote", + techs: []string{npm}, + ancestorFile: "pnpm-workspace.yaml", + expectedHasPnpm: true, + }, + { + name: "npm only, ancestor has pnpm-lock.yaml — promote", + techs: []string{npm}, + ancestorFile: "pnpm-lock.yaml", + expectedHasPnpm: true, + }, + { + name: "npm + other, ancestor has pnpm-workspace.yaml — npm promoted, other kept", + techs: []string{npm, other}, + ancestorFile: "pnpm-workspace.yaml", + expectedHasPnpm: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Build a two-level temp dir: root/sub — we run from sub so the walk finds root. + root := t.TempDir() + sub := filepath.Join(root, "sub") + require_noError(t, os.MkdirAll(sub, 0o755)) + + if tc.ancestorFile != "" { + require_noError(t, os.WriteFile(filepath.Join(root, tc.ancestorFile), []byte{}, 0o644)) + } + + origDir, err := os.Getwd() + require_noError(t, err) + require_noError(t, os.Chdir(sub)) + defer func() { _ = os.Chdir(origDir) }() + + result := promotePnpmWorkspaceMember(tc.techs) + + hasPnpm := false + hasNpm := false + for _, tech := range result { + switch tech { + case pnpm: + hasPnpm = true + case npm: + hasNpm = true + } + } + assert.Equal(t, tc.expectedHasPnpm, hasPnpm, "pnpm presence mismatch") + if tc.expectedHasPnpm { + assert.False(t, hasNpm, "npm should have been replaced by pnpm") + } + }) + } +} + +func require_noError(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/sca/bom/buildinfo/technologies/pnpm/pnpm.go b/sca/bom/buildinfo/technologies/pnpm/pnpm.go index 9e5200a3d..ab61127ff 100644 --- a/sca/bom/buildinfo/technologies/pnpm/pnpm.go +++ b/sca/bom/buildinfo/technologies/pnpm/pnpm.go @@ -1,6 +1,7 @@ package pnpm import ( + "encoding/json" "errors" "fmt" "os" @@ -48,16 +49,28 @@ func BuildDependencyTree(params technologies.BuildInfoBomGeneratorParams) (depen if err != nil { return } + if params.IsCurationCmd { + return buildDependencyTreeFromLockfile(pnpmExecPath, currentDir, params) + } + return buildDependencyTreeFromPnpmLs(pnpmExecPath, currentDir, params) +} +// buildDependencyTreeFromLockfile is the curation-audit path: parses pnpm-lock.yaml +// directly without running pnpm ls or downloading tarballs. +func buildDependencyTreeFromLockfile(pnpmExecPath, currentDir string, params technologies.BuildInfoBomGeneratorParams) (dependencyTrees []*xrayUtils.GraphNode, uniqueDeps []string, err error) { + if err = validatePnpmVersionForCuration(pnpmExecPath); err != nil { + return + } + if params.MaxTreeDepth != "" && params.MaxTreeDepth != "Infinity" { + log.Warn("The --max-tree-depth flag is not supported for pnpm curation audit (lockfile-based resolution always produces the full tree). The flag will be ignored.") + } if err = ensureLockfile(pnpmExecPath, currentDir); err != nil { return } - projects, err := parsePnpmLockFile(currentDir) if err != nil { return } - // Apply scope filter (dev-only / prod-only) to the raw project list if requested. if len(params.Args) > 0 { projects = filterProjectsByScope(projects, params.Args) } @@ -65,6 +78,78 @@ func BuildDependencyTree(params technologies.BuildInfoBomGeneratorParams) (depen return } +// buildDependencyTreeFromPnpmLs is the audit/scan path: installs into a temp dir +// if needed, then calls `pnpm ls --json` to obtain the full dependency tree. +func buildDependencyTreeFromPnpmLs(pnpmExecPath, currentDir string, params technologies.BuildInfoBomGeneratorParams) (dependencyTrees []*xrayUtils.GraphNode, uniqueDeps []string, err error) { + var dirForDependenciesCalculation string + if dirForDependenciesCalculation, err = installProjectIfNeeded(pnpmExecPath, currentDir); errorutils.CheckError(err) != nil { + return + } + if dirForDependenciesCalculation == "" { + // Lockfile and node_modules already present — run ls in the original dir. + dirForDependenciesCalculation = currentDir + } else { + defer func() { + err = errors.Join(err, biutils.RemoveTempDir(dirForDependenciesCalculation)) + }() + } + return calculateDependencies(pnpmExecPath, dirForDependenciesCalculation, params) +} + +// installProjectIfNeeded runs `pnpm install --ignore-scripts` in a temp copy of the +// project when pnpm-lock.yaml or node_modules/.pnpm is missing. +// Returns the temp dir path, or "" if no install was needed. +func installProjectIfNeeded(pnpmExecPath, workingDir string) (dirForDependenciesCalculation string, err error) { + lockFileExists, err := fileutils.IsFileExists(filepath.Join(workingDir, "pnpm-lock.yaml"), false) + if err != nil { + return + } + pnpmDirExists, err := fileutils.IsDirExists(filepath.Join(workingDir, "node_modules", ".pnpm"), false) + if err != nil || (lockFileExists && pnpmDirExists) { + return + } + log.Debug("Installing Pnpm project:", workingDir) + dirForDependenciesCalculation, err = fileutils.CreateTempDir() + if err != nil { + err = fmt.Errorf("failed to create a temporary dir: %w", err) + return + } + defer func() { + if err != nil { + err = errors.Join(err, fileutils.RemoveTempDir(dirForDependenciesCalculation)) + } + }() + // Exclude Visual Studio inner directory — not needed for scanning and may cause race conditions. + err = biutils.CopyDir(workingDir, dirForDependenciesCalculation, true, []string{technologies.DotVsRepoSuffix}) + if err != nil { + err = fmt.Errorf("failed copying project to temp dir: %w", err) + return + } + 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 +} + +// calculateDependencies runs `pnpm ls --json` in workingDir (which must already be +// installed) and converts the output into an Xray dependency tree. +func calculateDependencies(executablePath, workingDir string, params technologies.BuildInfoBomGeneratorParams) (dependencyTrees []*xrayUtils.GraphNode, uniqueDeps []string, err error) { + 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 + } + log.Verbose("Pnpm ls command output:\n", string(npmLsCmdContent)) + output := &[]pnpmLsProject{} + if err = json.Unmarshal(npmLsCmdContent, output); err != nil { + return + } + dependencyTrees, uniqueDeps = parsePnpmLSContent(*output) + return +} + // filterProjectsByScope removes dev or prod dependencies from each project // based on the --dev or --prod flags passed via params.Args (set by SetNpmScope). func filterProjectsByScope(projects []pnpmLsProject, args []string) []pnpmLsProject { @@ -133,10 +218,7 @@ func GetNativePnpmRegistryConfig() (*npm.NpmrcRegistryConfig, error) { }, nil } -const ( - minPnpmMajorVersion = 10 - maxPnpmMajorVersion = 10 -) +const supportedPnpmMajorVersion = 10 func getPnpmExecPath() (pnpmExecPath string, err error) { if pnpmExecPath, err = exec.LookPath("pnpm"); errorutils.CheckError(err) != nil { @@ -152,15 +234,21 @@ func getPnpmExecPath() (pnpmExecPath string, err error) { err = versionErr return } - versionStr := strings.TrimSpace(string(versionOut)) - log.Debug("Pnpm version:", versionStr) - if err = validatePnpmMinVersion(versionStr); err != nil { - return - } + log.Debug("Pnpm version:", strings.TrimSpace(string(versionOut))) return } -// validatePnpmMinVersion returns an error if the installed pnpm major version is outside the supported range [minPnpmMajorVersion, maxPnpmMajorVersion]. +// validatePnpmVersionForCuration checks pnpm version compatibility for the curation path. +// The audit/scan path accepts any pnpm version. +func validatePnpmVersionForCuration(pnpmExecPath string) error { + versionOut, err := getPnpmCmd(pnpmExecPath, "", "--version").RunWithOutput() + if err != nil { + return fmt.Errorf("failed to determine pnpm version: %w", err) + } + return validatePnpmMinVersion(strings.TrimSpace(string(versionOut))) +} + +// validatePnpmMinVersion returns an error if the installed pnpm major version is not supportedPnpmMajorVersion. func validatePnpmMinVersion(versionStr string) error { // Version string may include extra lines (warnings on incompatible Node); take first token. firstLine := strings.SplitN(versionStr, "\n", 2)[0] @@ -169,8 +257,8 @@ func validatePnpmMinVersion(versionStr string) error { if _, scanErr := fmt.Sscanf(parts[0], "%d", &major); scanErr != nil { return fmt.Errorf("could not parse pnpm version %q: %w", versionStr, scanErr) } - if major < minPnpmMajorVersion || major > maxPnpmMajorVersion { - return fmt.Errorf("Resolving Pnpm dependencies from Artifactory is currently not supported for Pnpm versions outside the %d.x range. The current Pnpm version is: %s", minPnpmMajorVersion, versionStr) + if major != supportedPnpmMajorVersion { + return fmt.Errorf("resolving pnpm dependencies from Artifactory is currently not supported for pnpm versions other than %d.x. The current pnpm version is: %s", supportedPnpmMajorVersion, versionStr) } return nil } @@ -181,14 +269,9 @@ func getPnpmCmd(pnpmExecPath, workingDir, cmd string, args ...string) *io.Comman return command } -// ensureLockfile guarantees that pnpm-lock.yaml exists in workingDir. -// If it is already present, nothing is done — the existing lockfile is used as-is. -// If it is absent, `pnpm install --lockfile-only --ignore-scripts` is run in a -// temporary copy of workingDir so the original directory is not mutated. -// -// This mirrors the npm path (--package-lock-only) and yarn V3 path (--mode=update-lockfile): -// no tarballs are downloaded, only resolution metadata is written. -func ensureLockfile(pnpmExecPath, workingDir string) error { +// ensureLockfile guarantees pnpm-lock.yaml exists in workingDir. +// Runs `pnpm install --lockfile-only --ignore-scripts` in a temp copy when absent or stale. +func ensureLockfile(pnpmExecPath, workingDir string) (err error) { lockPath := filepath.Join(workingDir, "pnpm-lock.yaml") lockExists, err := fileutils.IsFileExists(lockPath, false) if err != nil { diff --git a/sca/bom/buildinfo/technologies/pnpm/pnpm_test.go b/sca/bom/buildinfo/technologies/pnpm/pnpm_test.go index c59327e53..552093b9d 100644 --- a/sca/bom/buildinfo/technologies/pnpm/pnpm_test.go +++ b/sca/bom/buildinfo/technologies/pnpm/pnpm_test.go @@ -8,7 +8,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" "github.com/jfrog/jfrog-cli-core/v2/utils/tests" + "github.com/jfrog/jfrog-client-go/utils/io/fileutils" xrayUtils "github.com/jfrog/jfrog-client-go/xray/services/utils" "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies" @@ -129,7 +131,7 @@ func TestBuildDependencyTreePnpmLockfile(t *testing.T) { for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { - params := technologies.BuildInfoBomGeneratorParams{} + params := technologies.BuildInfoBomGeneratorParams{IsCurationCmd: true} rootNode, uniqueDeps, err := BuildDependencyTree(*params.SetNpmScope(testCase.depScope)) require.NoError(t, err) sort.Slice(uniqueDeps, func(i, j int) bool { return uniqueDeps[i] < uniqueDeps[j] }) @@ -156,6 +158,103 @@ func TestEnsureLockfileExisting(t *testing.T) { assert.NoError(t, err) } +// TestBuildDependencyTree exercises the audit/scan path (pnpm ls --json) which is +// the pre-existing behaviour unmodified by the curation-audit feature. +func TestBuildDependencyTree(t *testing.T) { + _, cleanUp := technologies.CreateTestWorkspace(t, filepath.Join("projects", "package-managers", "npm", "npm-no-lock")) + defer cleanUp() + + testCases := []struct { + name string + depScope string + expectedUniqueDeps []string + expectedTree *xrayUtils.GraphNode + }{ + { + name: "All", + depScope: "all", + expectedUniqueDeps: []string{ + "npm://jfrog-cli-tests:v1.0.0", + "npm://xml:1.0.1", + "npm://json:9.0.6", + }, + expectedTree: &xrayUtils.GraphNode{ + Id: "npm://jfrog-cli-tests:v1.0.0", + Nodes: []*xrayUtils.GraphNode{ + {Id: "npm://xml:1.0.1"}, + {Id: "npm://json:9.0.6"}, + }, + }, + }, + { + name: "Prod", + depScope: "prodOnly", + expectedUniqueDeps: []string{ + "npm://jfrog-cli-tests:v1.0.0", + "npm://xml:1.0.1", + }, + expectedTree: &xrayUtils.GraphNode{ + Id: "npm://jfrog-cli-tests:v1.0.0", + Nodes: []*xrayUtils.GraphNode{{Id: "npm://xml:1.0.1"}}, + }, + }, + { + name: "Dev", + depScope: "devOnly", + expectedUniqueDeps: []string{ + "npm://jfrog-cli-tests:v1.0.0", + "npm://json:9.0.6", + }, + expectedTree: &xrayUtils.GraphNode{ + Id: "npm://jfrog-cli-tests:v1.0.0", + Nodes: []*xrayUtils.GraphNode{{Id: "npm://json:9.0.6"}}, + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + // IsCurationCmd is false (default) — exercises the pnpm ls audit path. + params := technologies.BuildInfoBomGeneratorParams{} + rootNode, uniqueDeps, err := BuildDependencyTree(*params.SetNpmScope(testCase.depScope)) + require.NoError(t, err) + 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) + } + } + }) + } +} + +// TestInstallProjectIfNeeded verifies that installProjectIfNeeded creates a temp dir +// with node_modules installed without touching the original project directory. +func TestInstallProjectIfNeeded(t *testing.T) { + _, cleanUp := technologies.CreateTestWorkspace(t, filepath.Join("projects", "package-managers", "npm", "npm-no-lock")) + defer cleanUp() + + currentDir, err := coreutils.GetWorkingDirectory() + assert.NoError(t, err) + + pnpmExecPath, err := getPnpmExecPath() + assert.NoError(t, err) + + dirForDependenciesCalculation, err := installProjectIfNeeded(pnpmExecPath, currentDir) + assert.NoError(t, err) + assert.NotEmpty(t, dirForDependenciesCalculation) + + nodeModulesExist, err := fileutils.IsDirExists(filepath.Join(dirForDependenciesCalculation, "node_modules"), false) + assert.NoError(t, err) + assert.True(t, nodeModulesExist) + + // Original directory must NOT have node_modules created. + nodeModulesExist, err = fileutils.IsDirExists(filepath.Join(currentDir, "node_modules"), false) + assert.NoError(t, err) + assert.False(t, nodeModulesExist) +} + func TestValidatePnpmMinVersion(t *testing.T) { testCases := []struct { name string diff --git a/sca/bom/buildinfo/technologies/pnpm/pnpmlock.go b/sca/bom/buildinfo/technologies/pnpm/pnpmlock.go index cf293d08e..85cb7b365 100644 --- a/sca/bom/buildinfo/technologies/pnpm/pnpmlock.go +++ b/sca/bom/buildinfo/technologies/pnpm/pnpmlock.go @@ -1,22 +1,20 @@ package pnpm import ( + "encoding/json" "fmt" "os" + "path/filepath" "strings" "gopkg.in/yaml.v3" ) -// Minimum supported pnpm lockfile version. Versions below this use a different -// format (flat packages map with no snapshots block) that we do not support. -const minSupportedLockfileVersion = "6.0" - // pnpmLockFile is the top-level structure of pnpm-lock.yaml. type pnpmLockFile struct { - LockfileVersion string `yaml:"lockfileVersion"` - Importers map[string]pnpmLockImporter `yaml:"importers"` - Snapshots map[string]pnpmLockSnapshot `yaml:"snapshots"` + LockfileVersion string `yaml:"lockfileVersion"` + Importers map[string]pnpmLockImporter `yaml:"importers"` + Snapshots map[string]pnpmLockSnapshot `yaml:"snapshots"` } // pnpmLockImporter represents a workspace member (or the root project at "."). @@ -39,13 +37,10 @@ type pnpmLockSnapshot struct { Dependencies map[string]string `yaml:"dependencies"` } -// parsePnpmLockFile reads workingDir/pnpm-lock.yaml and converts it into the -// same []pnpmLsProject shape that the old `pnpm ls --json` path produced. -// The name and version for each importer entry are taken from -// workingDir//package.json when available; importer paths other -// than "." are treated as workspace members. +// parsePnpmLockFile reads workingDir/pnpm-lock.yaml and converts it into []pnpmLsProject. +// Name and version are read from each importer's package.json when available. func parsePnpmLockFile(workingDir string) ([]pnpmLsProject, error) { - lockPath := workingDir + "/pnpm-lock.yaml" + lockPath := filepath.Join(workingDir, "pnpm-lock.yaml") data, err := os.ReadFile(lockPath) if err != nil { return nil, fmt.Errorf("reading pnpm-lock.yaml: %w", err) @@ -60,7 +55,6 @@ func parsePnpmLockFile(workingDir string) ([]pnpmLsProject, error) { return nil, err } - // Root-only project (no importers block) — treat the whole file as a single importer. if len(lf.Importers) == 0 { return nil, fmt.Errorf("pnpm-lock.yaml has no importers block; run 'pnpm install --lockfile-only' first") } @@ -133,38 +127,24 @@ func walkSnapshot(snapshotKey string, snapshots map[string]pnpmLockSnapshot, vis return result } -// splitPnpmRef splits a pnpm lockfile reference into (name, version). -// The reference may be either a snapshot key like "@scope/pkg@2.0.0(@peer@1.0)" -// or a plain version string like "2.0.0(@peer@1.0)". -// In both cases the peer-dep suffix (everything from the first '(' onward) is stripped. -// The split is always on the LAST '@' so scoped names like "@scope/pkg" are handled correctly. +// splitPnpmRef splits a pnpm ref into (name, version), stripping any peer-dep suffix. +// Splits on the last '@' so scoped names like "@scope/pkg" are handled correctly. func splitPnpmRef(ref string) (name, version string) { - // Strip peer-dep suffix. if i := strings.IndexByte(ref, '('); i >= 0 { ref = ref[:i] } i := strings.LastIndexByte(ref, '@') if i <= 0 { - // No '@' or starts with '@' but has no version — treat the whole thing as a version. return "", ref } return ref[:i], ref[i+1:] } // buildSnapshotKey constructs the key used to look up an entry in the snapshots map. -// pnpm stores snapshots under the full "@()" key, so we need -// to combine the package name with its raw (possibly peer-suffixed) version ref. -// For plain version strings (no name in the ref) the name is prepended. +// For scoped packages (@scope/pkg), rawRef is already a full key like "@scope/pkg@2.0.0(@peer@1.0)" +// and is returned as-is. For unscoped packages, name and rawRef are joined as "name@rawRef". func buildSnapshotKey(name, rawRef string) string { - // If rawRef already contains an '@' after the first character (i.e. it's a full - // "@..." key rather than a bare version), use it as-is. - if strings.Count(rawRef, "@") >= 1 && !strings.HasPrefix(rawRef, "@") { - return name + "@" + rawRef - } - // Scoped packages (@scope/pkg) always start with '@'; their version refs look like - // "2.0.0" or "2.0.0(@peer@1.0)" — never "@". if strings.HasPrefix(rawRef, "@") { - // rawRef is itself a full scoped key e.g. "@scope/pkg@2.0.0(@peer@1.0)" return rawRef } return name + "@" + rawRef @@ -176,19 +156,17 @@ func buildSnapshotKey(name, rawRef string) string { func readPackageNameVersion(workingDir, importerPath string) (name, version string) { dir := workingDir if importerPath != "." { - dir = workingDir + "/" + importerPath + dir = filepath.Join(workingDir, importerPath) } - data, err := os.ReadFile(dir + "/package.json") + data, err := os.ReadFile(filepath.Join(dir, "package.json")) if err != nil { return importerPath, "0.0.0" } - // Minimal JSON extraction — avoid a full unmarshal dependency just for two fields. var pkg struct { Name string `json:"name"` Version string `json:"version"` } - // Use yaml decoder as a light JSON superset parser (json is valid yaml). - if err = yaml.Unmarshal(data, &pkg); err != nil || pkg.Name == "" { + if err = json.Unmarshal(data, &pkg); err != nil || pkg.Name == "" { return importerPath, "0.0.0" } if pkg.Version == "" { @@ -197,8 +175,7 @@ func readPackageNameVersion(workingDir, importerPath string) (name, version stri return pkg.Name, pkg.Version } -// validateLockfileVersion rejects lockfile versions older than minSupportedLockfileVersion. -// pnpm v5 used "5.x" and had a different flat format; v6+ uses the current structure. +// validateLockfileVersion rejects pnpm v5 lockfile format (5.x), which uses a different flat structure. func validateLockfileVersion(v string) error { // Strip surrounding quotes if present (pnpm 9 writes lockfileVersion: '9.0'). v = strings.Trim(v, "'\"") diff --git a/sca/bom/buildinfo/technologies/pnpm/pnpmlock_test.go b/sca/bom/buildinfo/technologies/pnpm/pnpmlock_test.go index a3e233f72..91657fc8f 100644 --- a/sca/bom/buildinfo/technologies/pnpm/pnpmlock_test.go +++ b/sca/bom/buildinfo/technologies/pnpm/pnpmlock_test.go @@ -50,8 +50,8 @@ func TestParsePnpmLockFileComplex(t *testing.T) { complexContent, readErr := os.ReadFile(complexLock) require.NoError(t, readErr) - require.NoError(t, os.WriteFile(simpleLock, complexContent, 0644)) - t.Cleanup(func() { _ = os.WriteFile(simpleLock, origContent, 0644) }) + require.NoError(t, os.WriteFile(filepath.Clean(simpleLock), complexContent, 0644)) // #nosec G703 -- simpleLock is constructed from a test-controlled directory, not user input + t.Cleanup(func() { _ = os.WriteFile(filepath.Clean(simpleLock), origContent, 0644) }) // #nosec G703 -- simpleLock is constructed from a test-controlled directory, not user input projects, err := parsePnpmLockFile(dir) require.NoError(t, err)