Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 108 additions & 7 deletions commands/curation/curationaudit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
},
Expand Down Expand Up @@ -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()}
Expand Down Expand Up @@ -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
Expand All @@ -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(&params, tech)
if err != nil {
return err
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
return nil
}

func (ca *CurationAuditCommand) getRepoParams(projectType project.ProjectType) (*project.RepositoryConfig, error) {
configFilePath, exists, err := project.GetProjectConfFilePath(projectType)
if err != nil {
Expand Down Expand Up @@ -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)
Expand Down
90 changes: 90 additions & 0 deletions commands/curation/curationaudit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
15 changes: 9 additions & 6 deletions sca/bom/buildinfo/technologies/npm/npm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -124,15 +124,16 @@ 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
//
// Returns a typed error (without slicing) when the registry value is malformed and lacks
// 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)
Expand All @@ -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/<repo>/".
// Supports both standard URLs (https://<host>/artifactory/api/npm/<repo>/) and
// reverse-proxy URLs where the "/artifactory" context root is stripped
// (e.g. https://npm.company.com/api/npm/<repo>/).
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)
Expand Down
4 changes: 2 additions & 2 deletions sca/bom/buildinfo/technologies/npm/npm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading