diff --git a/cli/docs/flags.go b/cli/docs/flags.go index 07a4149ed..b3c0bacdb 100644 --- a/cli/docs/flags.go +++ b/cli/docs/flags.go @@ -160,6 +160,8 @@ const ( DockerImageName = "image" SolutionPath = "solution-path" IncludeCachedPackages = "include-cached-packages" + LegacyPeerDeps = "legacy-peer-deps" + RunNative = "run-native" // Unique git flags InputFile = "input-file" @@ -218,7 +220,7 @@ var commandFlags = map[string][]string{ StaticSca, XrayLibPluginBinaryCustomPath, AnalyzerManagerCustomPath, AddSastRules, }, CurationAudit: { - CurationOutput, WorkingDirs, Threads, RequirementsFile, InsecureTls, useWrapperAudit, UseIncludedBuilds, SolutionPath, DockerImageName, IncludeCachedPackages, + CurationOutput, WorkingDirs, Threads, RequirementsFile, InsecureTls, useWrapperAudit, UseIncludedBuilds, SolutionPath, DockerImageName, IncludeCachedPackages, LegacyPeerDeps, RunNative, }, GitCountContributors: { InputFile, ScmType, ScmApiUrl, Token, Owner, RepoName, Months, DetailedSummary, InsecureTls, @@ -340,6 +342,8 @@ var flagsMap = map[string]components.Flag{ CurationOutput: components.NewStringFlag(OutputFormat, "Defines the output format of the command. Acceptable values are: table, json.", components.WithStrDefaultValue("table")), SolutionPath: components.NewStringFlag(SolutionPath, "Path to the .NET solution file (.sln) to use when multiple solution files are present in the directory."), IncludeCachedPackages: components.NewBoolFlag(IncludeCachedPackages, "When set to true, the system will audit cached packages. This configuration is mandatory for Curation on-demand workflows, which rely on package caching."), + LegacyPeerDeps: components.NewBoolFlag(LegacyPeerDeps, "[npm] Pass --legacy-peer-deps to npm install to bypass peer-dependency version conflicts."), + RunNative: components.NewBoolFlag(RunNative, "[npm] Use the native npm client for dependency resolution. Reads Artifactory URL and repository from the project's .npmrc registry — no 'jf npm-config' required. Respects .npmrc and Volta configuration."), binarySca: components.NewBoolFlag(Sca, fmt.Sprintf("Selective scanners mode: Execute SCA (Software Composition Analysis) sub-scan. Use --%s to run both SCA and Contextual Analysis. Use --%s --%s to to run SCA. Can be combined with --%s.", Sca, Sca, WithoutCA, Secrets)), binarySecrets: components.NewBoolFlag(Secrets, fmt.Sprintf("Selective scanners mode: Execute Secrets sub-scan. Can be combined with --%s.", Sca)), binaryWithoutCA: components.NewBoolFlag(WithoutCA, fmt.Sprintf("Selective scanners mode: Disable Contextual Analysis scanner after SCA. Relevant only with --%s flag.", Sca)), diff --git a/cli/scancommands.go b/cli/scancommands.go index dd6b2e568..1a58235cc 100644 --- a/cli/scancommands.go +++ b/cli/scancommands.go @@ -739,6 +739,10 @@ func getCurationCommand(c *components.Context) (*curation.CurationAuditCommand, SetSolutionFilePath(c.GetStringFlagValue(flags.SolutionPath)) curationAuditCommand.SetDockerImageName(c.GetStringFlagValue(flags.DockerImageName)) curationAuditCommand.SetIncludeCachedPackages(c.GetBoolFlagValue(flags.IncludeCachedPackages)) + if c.GetBoolFlagValue(flags.LegacyPeerDeps) { + curationAuditCommand.SetNpmScope("legacyPeerDeps") + } + curationAuditCommand.SetRunNative(c.GetBoolFlagValue(flags.RunNative)) return curationAuditCommand, nil } diff --git a/commands/audit/auditbasicparams.go b/commands/audit/auditbasicparams.go index 7c08e7cfb..6927e576e 100644 --- a/commands/audit/auditbasicparams.go +++ b/commands/audit/auditbasicparams.go @@ -29,6 +29,8 @@ type AuditParamsInterface interface { InstallCommandName() string InstallCommandArgs() []string SetNpmScope(depType string) *AuditBasicParams + SetRunNative(runNative bool) *AuditBasicParams + RunNative() bool SetMaxTreeDepth(maxTreeDepth string) *AuditBasicParams MaxTreeDepth() string OutputFormat() format.OutputFormat @@ -78,6 +80,7 @@ type AuditBasicParams struct { isRecursiveScan bool skipAutoInstall bool allowPartialResults bool + runNative bool xrayVersion string xscVersion string configProfile *xscservices.ConfigProfile @@ -221,10 +224,21 @@ func (abp *AuditBasicParams) SetNpmScope(depType string) *AuditBasicParams { abp.args = []string{"--dev"} case "prodOnly": abp.args = []string{"--prod"} + case "legacyPeerDeps": + abp.installCommandArgs = append(abp.installCommandArgs, "--legacy-peer-deps") } return abp } +func (abp *AuditBasicParams) SetRunNative(runNative bool) *AuditBasicParams { + abp.runNative = runNative + return abp +} + +func (abp *AuditBasicParams) RunNative() bool { + return abp.runNative +} + func (abp *AuditBasicParams) SetConanProfile(file string) *AuditBasicParams { abp.args = append(abp.args, "--profile:build", file) return abp diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index 642793b4b..ed6f7cbb4 100644 --- a/commands/curation/curationaudit.go +++ b/commands/curation/curationaudit.go @@ -12,6 +12,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "github.com/jfrog/gofrog/datastructures" "github.com/jfrog/gofrog/parallel" @@ -38,6 +39,7 @@ 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" "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" @@ -214,6 +216,13 @@ type treeAnalyzer struct { parallelRequests int downloadUrls map[string]string includeCachedPackages bool + // cancelled is set to true when an unrecoverable error (e.g. 401) occurs so that + // the producer goroutine stops queuing new tasks and in-flight tasks bail out early. + cancelled atomic.Bool + // authErr holds the first authentication error encountered during HEAD requests. + // Stored via atomic.Value so it can be retrieved after the parallel runner finishes + // and returned once — avoiding double-printing via errorsQueue.AddError. + authErr atomic.Value } type CurationAuditCommand struct { @@ -449,6 +458,7 @@ func (ca *CurationAuditCommand) getBuildInfoParamsByTech() (technologies.BuildIn // Npm params NpmIgnoreNodeModules: true, NpmOverwritePackageLock: true, + NpmRunNative: ca.RunNative(), // Python params PipRequirementsFile: ca.PipRequirementsFile(), // Docker params @@ -463,6 +473,11 @@ 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 { + params.IgnoreConfigFile = true + } serverDetails, err := buildinfo.SetResolutionRepoInParamsIfExists(¶ms, tech) if err != nil { return err @@ -529,6 +544,10 @@ func (ca *CurationAuditCommand) auditTree(tech techutils.Technology, results map packagesStatusMap := sync.Map{} // if error returned we still want to produce a report, so we don't fail the next step err = analyzer.fetchNodesStatus(depTreeResult.FlatTree, &packagesStatusMap, rootNodes) + // Auth errors are unrecoverable — skip building a misleading partial report. + if analyzer.cancelled.Load() { + return err + } analyzer.GraphsRelations(depTreeResult.FullDepTrees, &packagesStatusMap, &packagesStatus) sort.Slice(packagesStatus, func(i, j int) bool { @@ -756,6 +775,12 @@ func (ca *CurationAuditCommand) SetRepo(tech techutils.Technology) error { return nil } + // When --run-native is set for npm, read the Artifactory URL and repo name from the + // project's .npmrc via native npm config — no jf npm-config/npm.yaml required. + if ca.RunNative() && tech == techutils.Npm { + return ca.setRepoFromNpmrc() + } + resolverParams, err := ca.getRepoParams(tech.GetProjectType()) if err != nil { return err @@ -764,6 +789,40 @@ func (ca *CurationAuditCommand) SetRepo(tech techutils.Technology) error { return nil } +// setRepoFromNpmrc builds PackageManagerConfig by reading the npm registry URL from the +// native npm configuration (respecting .npmrc and Volta), then parsing the Artifactory +// base URL and repository name from it. +// Authentication is taken from the jfrog-cli.conf server entry (via ca.ServerDetails()) — +// the same credentials the user configured with 'jf c'. Only the Artifactory URL and +// repository name are sourced from .npmrc, so 'jf npm-config' is not required. +func (ca *CurationAuditCommand) setRepoFromNpmrc() error { + registryConfig, err := npmtech.GetNativeNpmRegistryConfig() + if err != nil { + return fmt.Errorf("--run-native: failed to read Artifactory details from .npmrc: %w", err) + } + + // Use auth from the jfrog server config (jfrog-cli.conf) — it holds properly stored + // credentials. Only override the ArtifactoryUrl with what .npmrc reports so the + // Curation HEAD requests go to the right repository. + serverDetails, err := ca.ServerDetails() + if err != nil || serverDetails == nil { + // No server configured — fall back to whatever auth .npmrc provides. + serverDetails = &config.ServerDetails{ + ArtifactoryUrl: registryConfig.ArtifactoryUrl, + AccessToken: registryConfig.AuthToken, + } + } else { + serverDetails.ArtifactoryUrl = registryConfig.ArtifactoryUrl + } + + repoConfig := (&project.RepositoryConfig{}). + SetTargetRepo(registryConfig.RepoName). + SetServerDetails(serverDetails) + ca.setPackageManagerConfig(repoConfig) + log.Info(fmt.Sprintf("--run-native: 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 { @@ -831,6 +890,9 @@ func (nc *treeAnalyzer) fetchNodesStatus(graph *xrayUtils.GraphNode, p *sync.Map go func() { defer consumerProducer.Done() for _, node := range graph.Nodes { + if nc.cancelled.Load() { + break + } if _, ok := rootNodeIds[node.Id]; ok { continue } @@ -848,6 +910,11 @@ func (nc *treeAnalyzer) fetchNodesStatus(graph *xrayUtils.GraphNode, p *sync.Map if err := errorsQueue.GetError(); err != nil { multiErrors = errors.Join(err, multiErrors) } + // Auth errors are stored silently (not via errorsQueue) to avoid double-printing. + // Surface them here so they propagate as a single error to the caller. + if authErr, ok := nc.authErr.Load().(error); ok { + multiErrors = errors.Join(authErr, multiErrors) + } return multiErrors } @@ -860,8 +927,22 @@ func (nc *treeAnalyzer) fetchNodeStatus(node xrayUtils.GraphNode, p *sync.Map) e name = scope + "/" + name } for _, packageUrl := range packageUrls { + if nc.cancelled.Load() { + return nil + } requestDetails := nc.httpClientDetails.Clone() resp, _, err := nc.rtManager.Client().SendHead(packageUrl, requestDetails) + if resp != nil && resp.StatusCode == http.StatusUnauthorized { + // Store the error silently (not returned) so errorsQueue.AddError is never + // called and the message is not logged here. fetchNodesStatus picks it up + // after the runner finishes and returns it once to the caller. + if nc.cancelled.CompareAndSwap(false, true) { + nc.authErr.Store(fmt.Errorf("authentication failed (401 Unauthorized) for Curation request to %s (package %s:%s).\n"+ + "The credentials configured via 'jf c' may not be valid for the Artifactory instance at that URL.\n"+ + "Run 'jf c' to update your server configuration, or verify that the correct server-id is configured", packageUrl, name, version)) + } + return nil + } if err != nil { if resp != nil && resp.StatusCode >= 400 { return errorutils.CheckErrorf(errorTemplateHeadRequest, packageUrl, name, version, resp.StatusCode, err) diff --git a/sca/bom/buildinfo/technologies/common.go b/sca/bom/buildinfo/technologies/common.go index 28180c75c..8c1fca4b2 100644 --- a/sca/bom/buildinfo/technologies/common.go +++ b/sca/bom/buildinfo/technologies/common.go @@ -58,6 +58,7 @@ type BuildInfoBomGeneratorParams struct { // Npm params NpmIgnoreNodeModules bool NpmOverwritePackageLock bool + NpmRunNative bool // Pnpm params MaxTreeDepth string // Docker params diff --git a/sca/bom/buildinfo/technologies/npm/npm.go b/sca/bom/buildinfo/technologies/npm/npm.go index 65af75b4e..d0e665db5 100644 --- a/sca/bom/buildinfo/technologies/npm/npm.go +++ b/sca/bom/buildinfo/technologies/npm/npm.go @@ -3,6 +3,7 @@ package npm import ( "errors" "fmt" + "strings" biutils "github.com/jfrog/build-info-go/build/utils" buildinfo "github.com/jfrog/build-info-go/entities" @@ -18,9 +19,17 @@ import ( ) const ( - IgnoreScriptsFlag = "--ignore-scripts" + IgnoreScriptsFlag = "--ignore-scripts" + artifactoryApiNpmPath = "/api/npm/" ) +// NpmrcRegistryConfig holds Artifactory connection details parsed from the native npm registry config. +type NpmrcRegistryConfig struct { + ArtifactoryUrl string + RepoName string + AuthToken string +} + func BuildDependencyTree(params technologies.BuildInfoBomGeneratorParams) (dependencyTrees []*xrayUtils.GraphNode, uniqueDeps []string, err error) { currentDir, err := coreutils.GetWorkingDirectory() if err != nil { @@ -65,16 +74,73 @@ func BuildDependencyTree(params technologies.BuildInfoBomGeneratorParams) (depen } // Generates a .npmrc file to configure an Artifactory server as the resolver server. +// Skipped when NpmRunNative is set — the project's existing .npmrc is used as-is for dependency resolution. func configNpmResolutionServerIfNeeded(params *technologies.BuildInfoBomGeneratorParams) (clearResolutionServerFunc func() error, err error) { - // If we don't have an artifactory repo's name we don't need to configure any Artifactory server as resolution server - if params.DependenciesRepository == "" { + if params.DependenciesRepository == "" || params.NpmRunNative { return } - clearResolutionServerFunc, err = npm.SetArtifactoryAsResolutionServer(params.ServerDetails, params.DependenciesRepository) return } +// GetNativeNpmRegistryConfig reads the npm registry URL from the native npm configuration +// (respecting .npmrc, Volta, and other environment settings) and parses it as an +// Artifactory npm repository URL to extract the RT base URL, repo name, and auth token. +func GetNativeNpmRegistryConfig() (*NpmrcRegistryConfig, error) { + _, npmExecPath, err := biutils.GetNpmVersionAndExecPath(log.Logger) + if err != nil { + return nil, fmt.Errorf("failed to locate npm executable: %w", err) + } + + registryData, _, err := biutils.RunNpmCmd(npmExecPath, "", []string{"config", "get", "registry"}, log.Logger) + if err != nil { + return nil, fmt.Errorf("failed to read npm registry from native config: %w", err) + } + registryUrl := strings.TrimSpace(string(registryData)) + + rtBaseUrl, repoName, err := parseArtifactoryNpmRegistryUrl(registryUrl) + if err != nil { + return nil, err + } + + // Derive the auth key from the registry URL, e.g. //myrt.jfrog.io/artifactory/api/npm/my-repo/:_authToken + registryWithoutProtocol := registryUrl[strings.Index(registryUrl, "//"):] + authKey := registryWithoutProtocol + ":_authToken" + tokenData, _, _ := biutils.RunNpmCmd(npmExecPath, "", []string{"config", "get", authKey}, log.Logger) + authToken := strings.TrimSpace(string(tokenData)) + if authToken == "undefined" || authToken == "null" { + authToken = "" + } + + return &NpmrcRegistryConfig{ + ArtifactoryUrl: rtBaseUrl, + RepoName: repoName, + AuthToken: authToken, + }, nil +} + +// 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) { + 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) + } + rtBaseUrl = registryUrl[:apiNpmIdx] + "/" + afterApiNpm := registryUrl[apiNpmIdx+len(artifactoryApiNpmPath):] + repoName = strings.TrimSuffix(afterApiNpm, "/") + if slashIdx := strings.Index(repoName, "/"); slashIdx != -1 { + repoName = repoName[:slashIdx] + } + if repoName == "" { + return "", "", fmt.Errorf("could not extract repository name from npm registry URL %q", registryUrl) + } + return rtBaseUrl, repoName, nil +} + func createTreeDepsParam(params *technologies.BuildInfoBomGeneratorParams) biutils.NpmTreeDepListParam { if params == nil { return biutils.NpmTreeDepListParam{