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
6 changes: 5 additions & 1 deletion cli/docs/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)),
Expand Down
4 changes: 4 additions & 0 deletions cli/scancommands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
14 changes: 14 additions & 0 deletions commands/audit/auditbasicparams.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -78,6 +80,7 @@ type AuditBasicParams struct {
isRecursiveScan bool
skipAutoInstall bool
allowPartialResults bool
runNative bool
xrayVersion string
xscVersion string
configProfile *xscservices.ConfigProfile
Expand Down Expand Up @@ -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
Expand Down
81 changes: 81 additions & 0 deletions commands/curation/curationaudit.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"

"github.com/jfrog/gofrog/datastructures"
"github.com/jfrog/gofrog/parallel"
Expand All @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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(&params, tech)
if err != nil {
return err
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}

Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions sca/bom/buildinfo/technologies/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ type BuildInfoBomGeneratorParams struct {
// Npm params
NpmIgnoreNodeModules bool
NpmOverwritePackageLock bool
NpmRunNative bool
// Pnpm params
MaxTreeDepth string
// Docker params
Expand Down
74 changes: 70 additions & 4 deletions sca/bom/buildinfo/technologies/npm/npm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand Down Expand Up @@ -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/<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) {
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{
Expand Down
Loading