-
Notifications
You must be signed in to change notification settings - Fork 46
Feature/xray 138688 add pnpm support for jf ca #769
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
f4e3c3f
8a6d199
69b4e25
fdfac49
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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://<host>/artifactory/api/npm/<repo>/)", 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)) | ||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Consider logging the auth source so failures are self-diagnosable:
Suggested change
|
||||||||||||||||
| 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) | ||||||||||||||||
|
|
||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -1693,3 +1693,100 @@ 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 | ||||||
| expectedHasNpm bool | ||||||
| expectNpmRemoved bool // npm was present in input and should be replaced by pnpm | ||||||
| }{ | ||||||
| { | ||||||
| // pnpm already present: function returns early, npm is NOT replaced. | ||||||
| name: "already has pnpm — no change", | ||||||
| techs: []string{pnpm, npm}, | ||||||
| expectedHasPnpm: true, | ||||||
| expectedHasNpm: true, | ||||||
| }, | ||||||
| { | ||||||
| name: "no npm — no change", | ||||||
| techs: []string{other}, | ||||||
| expectedHasPnpm: false, | ||||||
| expectedHasNpm: false, | ||||||
| }, | ||||||
| { | ||||||
| name: "npm only, no ancestor indicator — no promotion", | ||||||
| techs: []string{npm}, | ||||||
| ancestorFile: "", | ||||||
| expectedHasPnpm: false, | ||||||
| expectedHasNpm: true, | ||||||
| }, | ||||||
| { | ||||||
| name: "npm only, ancestor has pnpm-workspace.yaml — promote", | ||||||
| techs: []string{npm}, | ||||||
| ancestorFile: "pnpm-workspace.yaml", | ||||||
| expectedHasPnpm: true, | ||||||
| expectedHasNpm: false, | ||||||
| expectNpmRemoved: true, | ||||||
| }, | ||||||
| { | ||||||
| name: "npm only, ancestor has pnpm-lock.yaml — promote", | ||||||
| techs: []string{npm}, | ||||||
| ancestorFile: "pnpm-lock.yaml", | ||||||
| expectedHasPnpm: true, | ||||||
| expectedHasNpm: false, | ||||||
| expectNpmRemoved: true, | ||||||
| }, | ||||||
| { | ||||||
| name: "npm + other, ancestor has pnpm-workspace.yaml — npm promoted, other kept", | ||||||
| techs: []string{npm, other}, | ||||||
| ancestorFile: "pnpm-workspace.yaml", | ||||||
| expectedHasPnpm: true, | ||||||
| expectedHasNpm: false, | ||||||
| expectNpmRemoved: 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)) | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Go 1.24 introduced This project is on Go 1.25 —
Suggested change
The |
||||||
| 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") | ||||||
| assert.Equal(t, tc.expectedHasNpm, hasNpm, "npm presence mismatch") | ||||||
| if tc.expectNpmRemoved { | ||||||
| assert.False(t, hasNpm, "npm should have been replaced by pnpm") | ||||||
| assert.True(t, hasPnpm, "pnpm should be present after promotion") | ||||||
| } | ||||||
| }) | ||||||
| } | ||||||
| } | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Error strings should not contain newlines — they compose in chains (e.g.
fmt.Errorf("outer: %w", err)) and an embedded\nbreaks the chain readability. The "Ensure the registry…" guidance belongs in alog.Warn, not in the error value.