diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index d12fcc5b3..59ac19955 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,11 +525,15 @@ 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 } + // 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 @@ -782,6 +837,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 +885,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 +1166,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/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/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..ab61127ff 100644 --- a/sca/bom/buildinfo/technologies/pnpm/pnpm.go +++ b/sca/bom/buildinfo/technologies/pnpm/pnpm.go @@ -4,9 +4,12 @@ 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 +22,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 +41,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,51 +49,56 @@ func BuildDependencyTree(params technologies.BuildInfoBomGeneratorParams) (depen if err != nil { return } - // Build - var dirForDependenciesCalculation string - if dirForDependenciesCalculation, err = installProjectIfNeeded(pnpmExecPath, currentDir); errorutils.CheckError(err) != nil { - return + if params.IsCurationCmd { + return buildDependencyTreeFromLockfile(pnpmExecPath, currentDir, params) } - - 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)) - }() - } - return calculateDependencies(pnpmExecPath, dirForDependenciesCalculation, params) + return buildDependencyTreeFromPnpmLs(pnpmExecPath, currentDir, params) } -func getPnpmExecPath() (pnpmExecPath string, err error) { - if pnpmExecPath, err = exec.LookPath("pnpm"); errorutils.CheckError(err) != nil { +// 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 pnpmExecPath == "" { - err = errors.New("could not find the 'pnpm' executable in the system PATH") + 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 } - log.Debug("Using Pnpm executable:", pnpmExecPath) - // Validate pnpm version command - version, err := getPnpmCmd(pnpmExecPath, "", "--version").RunWithOutput() - if errorutils.CheckError(err) != nil { + projects, err := parsePnpmLockFile(currentDir) + if err != nil { return } - log.Debug("Pnpm version:", string(version)) + if len(params.Args) > 0 { + projects = filterProjectsByScope(projects, params.Args) + } + dependencyTrees, uniqueDeps = parsePnpmLSContent(projects) return } -func getPnpmCmd(pnpmExecPath, workingDir, cmd string, args ...string) *io.Command { - command := io.NewCommand(pnpmExecPath, cmd, args) - command.Dir = workingDir - return command +// 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) } -// 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. +// 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 { @@ -100,7 +108,6 @@ func installProjectIfNeeded(pnpmExecPath, workingDir string) (dirForDependencies if err != nil || (lockFileExists && pnpmDirExists) { return } - // Install is needed and will be performed on a copy of the cloned dir log.Debug("Installing Pnpm project:", workingDir) dirForDependenciesCalculation, err = fileutils.CreateTempDir() if err != nil { @@ -108,13 +115,11 @@ func installProjectIfNeeded(pnpmExecPath, workingDir string) (dirForDependencies return } 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)) } }() - - // Exclude Visual Studio inner directory since it is not necessary for the scan process and may cause race condition. + // 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) @@ -127,7 +132,8 @@ func installProjectIfNeeded(pnpmExecPath, workingDir string) (dirForDependencies return } -// Run 'pnpm ls ...' command (project must be installed) and parse the returned result to create a dependencies trees for the projects. +// 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) @@ -144,12 +150,181 @@ func calculateDependencies(executablePath, workingDir string, params technologie 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 + } + 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 +} + +const supportedPnpmMajorVersion = 10 + +func getPnpmExecPath() (pnpmExecPath string, err error) { + if pnpmExecPath, err = exec.LookPath("pnpm"); errorutils.CheckError(err) != nil { + return + } + if pnpmExecPath == "" { + err = errors.New("could not find the 'pnpm' executable in the system PATH") + return + } + log.Debug("Using Pnpm executable:", pnpmExecPath) + versionOut, versionErr := getPnpmCmd(pnpmExecPath, "", "--version").RunWithOutput() + if errorutils.CheckError(versionErr) != nil { + err = versionErr + return + } + log.Debug("Pnpm version:", strings.TrimSpace(string(versionOut))) + return +} + +// 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] + 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 != 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 +} + +func getPnpmCmd(pnpmExecPath, workingDir, cmd string, args ...string) *io.Command { + command := io.NewCommand(pnpmExecPath, cmd, args) + command.Dir = workingDir + return command +} + +// 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 { + return err + } + if lockExists { + // 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)) + } + tmpDir, err := fileutils.CreateTempDir() + if err != nil { + return fmt.Errorf("failed to create a temporary dir: %w", err) + } + defer func() { + err = errors.Join(err, fileutils.RemoveTempDir(tmpDir)) + }() + + if copyErr := copyProjectToDir(workingDir, tmpDir); copyErr != nil { + return copyErr + } + + 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)) + } + + // 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) + } + 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) + } + 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 +334,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 +351,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..552093b9d 100644 --- a/sca/bom/buildinfo/technologies/pnpm/pnpm_test.go +++ b/sca/bom/buildinfo/technologies/pnpm/pnpm_test.go @@ -5,20 +5,18 @@ 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" + "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" ) 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 +60,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,20 +77,102 @@ func TestBuildDependencyTreeLimitedDepth(t *testing.T) { } } +func TestBuildDependencyTreePnpmLockfile(t *testing.T) { + _, cleanUp := technologies.CreateTestWorkspace(t, filepath.Join("projects", "package-managers", "pnpm", "pnpm-project")) + defer cleanUp() + + testCases := []struct { + name string + depScope string + expectedUniqueDeps []string + expectedTree *xrayUtils.GraphNode + }{ + { + name: "All dependencies", + depScope: "all", + expectedUniqueDeps: []string{ + "npm://pnpm-example:1.0.0", + "npm://xml:1.0.1", + "npm://json:9.0.6", + }, + expectedTree: &xrayUtils.GraphNode{ + Id: "npm://pnpm-example:1.0.0", + Nodes: []*xrayUtils.GraphNode{ + {Id: "npm://xml:1.0.1"}, + {Id: "npm://json:9.0.6"}, + }, + }, + }, + { + name: "Prod only", + depScope: "prodOnly", + expectedUniqueDeps: []string{ + "npm://pnpm-example:1.0.0", + "npm://xml:1.0.1", + }, + expectedTree: &xrayUtils.GraphNode{ + Id: "npm://pnpm-example:1.0.0", + Nodes: []*xrayUtils.GraphNode{{Id: "npm://xml:1.0.1"}}, + }, + }, + { + name: "Dev only", + depScope: "devOnly", + expectedUniqueDeps: []string{ + "npm://pnpm-example:1.0.0", + "npm://json:9.0.6", + }, + expectedTree: &xrayUtils.GraphNode{ + Id: "npm://pnpm-example:1.0.0", + Nodes: []*xrayUtils.GraphNode{{Id: "npm://json:9.0.6"}}, + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + 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] }) + assert.ElementsMatch(t, uniqueDeps, testCase.expectedUniqueDeps) + if assert.Len(t, rootNode, 1) { + 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) + } + } + }) + } +} + +func TestEnsureLockfileExisting(t *testing.T) { + _, cleanUp := technologies.CreateTestWorkspace(t, filepath.Join("projects", "package-managers", "pnpm", "pnpm-project")) + defer cleanUp() + + pnpmExecPath, err := getPnpmExecPath() + require.NoError(t, err) + + // Workspace already contains pnpm-lock.yaml — ensureLockfile should be a no-op. + err = ensureLockfile(pnpmExecPath, ".") + 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) { - // Create and change directory to test workspace _, cleanUp := technologies.CreateTestWorkspace(t, filepath.Join("projects", "package-managers", "npm", "npm-no-lock")) defer cleanUp() testCases := []struct { name string - depType string + depScope string expectedUniqueDeps []string expectedTree *xrayUtils.GraphNode }{ { - name: "All", - depType: "all", + name: "All", + depScope: "all", expectedUniqueDeps: []string{ "npm://jfrog-cli-tests:v1.0.0", "npm://xml:1.0.1", @@ -109,8 +187,8 @@ func TestBuildDependencyTree(t *testing.T) { }, }, { - name: "Prod", - depType: "prodOnly", + name: "Prod", + depScope: "prodOnly", expectedUniqueDeps: []string{ "npm://jfrog-cli-tests:v1.0.0", "npm://xml:1.0.1", @@ -121,8 +199,8 @@ func TestBuildDependencyTree(t *testing.T) { }, }, { - name: "Dev", - depType: "devOnly", + name: "Dev", + depScope: "devOnly", expectedUniqueDeps: []string{ "npm://jfrog-cli-tests:v1.0.0", "npm://json:9.0.6", @@ -136,11 +214,10 @@ func TestBuildDependencyTree(t *testing.T) { for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { - // Build dependency tree + // IsCurationCmd is false (default) — exercises the pnpm ls audit path. 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") if assert.Len(t, rootNode, 1) { assert.Equal(t, rootNode[0].Id, testCase.expectedTree.Id) @@ -152,6 +229,8 @@ func TestBuildDependencyTree(t *testing.T) { } } +// 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() @@ -170,7 +249,32 @@ func TestInstallProjectIfNeeded(t *testing.T) { 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 + 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 rejected", version: "11.0.0", expectError: true}, + } + 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) + } + }) + } +} diff --git a/sca/bom/buildinfo/technologies/pnpm/pnpmlock.go b/sca/bom/buildinfo/technologies/pnpm/pnpmlock.go new file mode 100644 index 000000000..85cb7b365 --- /dev/null +++ b/sca/bom/buildinfo/technologies/pnpm/pnpmlock.go @@ -0,0 +1,190 @@ +package pnpm + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// 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 []pnpmLsProject. +// Name and version are read from each importer's package.json when available. +func parsePnpmLockFile(workingDir string) ([]pnpmLsProject, error) { + 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) + } + + 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 + } + + 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 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) { + if i := strings.IndexByte(ref, '('); i >= 0 { + ref = ref[:i] + } + i := strings.LastIndexByte(ref, '@') + if i <= 0 { + return "", ref + } + return ref[:i], ref[i+1:] +} + +// buildSnapshotKey constructs the key used to look up an entry in the snapshots map. +// 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 strings.HasPrefix(rawRef, "@") { + 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 = filepath.Join(workingDir, importerPath) + } + data, err := os.ReadFile(filepath.Join(dir, "package.json")) + if err != nil { + return importerPath, "0.0.0" + } + var pkg struct { + Name string `json:"name"` + Version string `json:"version"` + } + if err = json.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 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, "'\"") + 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..91657fc8f --- /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(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) + 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")},