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
3 changes: 0 additions & 3 deletions cli/docs/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,9 +224,6 @@ var commandFlags = map[string][]string{
CurationAudit: {
CurationOutput, WorkingDirs, Threads, RequirementsFile, InsecureTls, useWrapperAudit, UseIncludedBuilds, SolutionPath, DockerImageName, IncludeCachedPackages, LegacyPeerDeps, RunNative,
},
GitCountContributors: {
InputFile, ScmType, ScmApiUrl, Token, Owner, RepoName, Months, DetailedSummary, InsecureTls,
},
SastServer: {
Port,
},
Expand Down
1 change: 1 addition & 0 deletions commands/audit/auditbasicparams.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type AuditParamsInterface interface {
Args() []string
InstallCommandName() string
InstallCommandArgs() []string
SetInstallCommandArgs(installCommandArgs []string) *AuditBasicParams
SetNpmScope(depType string) *AuditBasicParams
SetRunNative(runNative bool) *AuditBasicParams
RunNative() bool
Expand Down
173 changes: 159 additions & 14 deletions commands/curation/curationaudit.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"github.com/jfrog/jfrog-client-go/auth"
clientutils "github.com/jfrog/jfrog-client-go/utils"
"github.com/jfrog/jfrog-client-go/utils/errorutils"
clientio "github.com/jfrog/jfrog-client-go/utils/io"
"github.com/jfrog/jfrog-client-go/utils/io/httputils"
"github.com/jfrog/jfrog-client-go/utils/log"
xrayClient "github.com/jfrog/jfrog-client-go/xray"
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.Yarn: 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 @@ -379,11 +381,16 @@ func getPolicyAndConditionId(policy, condition string) string {
}

func (ca *CurationAuditCommand) doCurateAudit(results map[string]*CurationReport) error {
techs := techutils.DetectedTechnologiesList()
techs := techutils.DetectedTechnologiesListForCurationAudit()
if ca.DockerImageName() != "" {
log.Debug(fmt.Sprintf("Docker image name '%s' was provided, running Docker curation audit.", ca.DockerImageName()))
techs = []string{techutils.Docker.String()}
}
// Resolve npm→yarn when the project was configured with 'jf yarn-config' (yarn.yaml exists)
// but has no yarn.lock/.yarnrc.yml so the file-based detector picked npm instead.
for i, tech := range techs {
techs[i] = resolveNpmYarnTech(tech)
}
for _, tech := range techs {
supportedFunc, ok := supportedTech[techutils.Technology(tech)]
if !ok {
Expand All @@ -410,6 +417,41 @@ func (ca *CurationAuditCommand) doCurateAudit(results map[string]*CurationReport
return nil
}

// resolveNpmYarnTech upgrades npm→yarn when the project has yarn.yaml but no npm.yaml —
// the developer ran 'jf yarn-config' but the file-system detector fell back to npm.
func resolveNpmYarnTech(tech string) string {
if techutils.Technology(tech) != techutils.Npm {
return tech
}
_, npmConfigExists, _ := project.GetProjectConfFilePath(techutils.Npm.GetProjectType())
if npmConfigExists {
return tech
}
_, yarnConfigExists, _ := project.GetProjectConfFilePath(techutils.Yarn.GetProjectType())
if yarnConfigExists {
log.Info("No npm.yaml config found but yarn.yaml detected — treating project as yarn.")
return techutils.Yarn.String()
}
return tech
}

// resolveResolverTechForCuration returns the tech whose *.yaml config drives
// SetResolutionRepoInParamsIfExists. For yarn with no yarn.yaml, falls back to
// npm.yaml — npm and yarn share the same Artifactory npm API.
func resolveResolverTechForCuration(tech techutils.Technology) techutils.Technology {
if tech != techutils.Yarn {
return tech
}
if _, yarnConfigExists, _ := project.GetProjectConfFilePath(techutils.Yarn.GetProjectType()); yarnConfigExists {
return tech
}
if _, npmConfigExists, _ := project.GetProjectConfFilePath(techutils.Npm.GetProjectType()); !npmConfigExists {
return tech
}
log.Info("No yarn.yaml found; using npm.yaml for resolver configuration (npm and yarn share the same Artifactory npm API).")
return techutils.Npm
}

func (ca *CurationAuditCommand) getRtManagerAndAuth(tech techutils.Technology) (rtManager artifactory.ArtifactoryServicesManager, serverDetails *config.ServerDetails, err error) {
serverDetails, err = ca.GetAuth(tech)
if err != nil {
Expand Down Expand Up @@ -440,7 +482,15 @@ func (ca *CurationAuditCommand) getBuildInfoParamsByTech() (technologies.BuildIn
return technologies.BuildInfoBomGeneratorParams{
XrayVersion: ca.GetXrayVersion(),
ExclusionPattern: technologies.GetExcludePattern(ca.GetConfigProfile(), ca.IsRecursiveScan(), ca.Exclusions()...),
Progress: ca.Progress(),
// Suppress the spinner when emitting machine-readable output: the
// progress goroutine ticks on stdout with carriage-return sequences
// and can overwrite the last line of JSON output (e.g. the closing ']').
Progress: func() clientio.ProgressMgr {
if ca.OutputFormat() == outFormat.Json {
return nil
}
return ca.Progress()
}(),
// Artifactory Repository params
ServerDetails: serverDetails,
DependenciesRepository: ca.DepsRepo(),
Expand All @@ -452,6 +502,7 @@ func (ca *CurationAuditCommand) getBuildInfoParamsByTech() (technologies.BuildIn
InstallCommandArgs: ca.InstallCommandArgs(),
// Curation params
IsCurationCmd: true,
OutputFormat: string(ca.OutputFormat()),
// Java params
IsMavenDepTreeInstalled: true,
UseWrapper: ca.UseWrapper(),
Expand All @@ -460,6 +511,8 @@ func (ca *CurationAuditCommand) getBuildInfoParamsByTech() (technologies.BuildIn
NpmOverwritePackageLock: true,
NpmRunNative: ca.RunNative(),
NpmLegacyPeerDeps: ca.LegacyPeerDeps(),
// Yarn: always refresh yarn.lock when older than package.json (mirrors NpmOverwritePackageLock).
YarnOverwriteYarnLock: true,
// Python params
PipRequirementsFile: ca.PipRequirementsFile(),
// Docker params
Expand All @@ -470,6 +523,10 @@ func (ca *CurationAuditCommand) getBuildInfoParamsByTech() (technologies.BuildIn
}

func (ca *CurationAuditCommand) auditTree(tech techutils.Technology, results map[string]*CurationReport) error {
// --run-native is only supported for npm today; reject it early for all other techs.
if err := validateRunNativeForTech(tech, ca.RunNative()); err != nil {
return err
}
params, err := ca.getBuildInfoParamsByTech()
if err != nil {
return errorutils.CheckErrorf("failed to get build info params for %s: %v", tech.String(), err)
Expand All @@ -479,7 +536,9 @@ func (ca *CurationAuditCommand) auditTree(tech techutils.Technology, results map
if ca.RunNative() && tech == techutils.Npm {
params.IgnoreConfigFile = true
}
serverDetails, err := buildinfo.SetResolutionRepoInParamsIfExists(&params, tech)
// For yarn with no yarn.yaml, fall back to npm.yaml — npm and yarn share the same Artifactory npm API.
resolverTech := resolveResolverTechForCuration(tech)
serverDetails, err := buildinfo.SetResolutionRepoInParamsIfExists(&params, resolverTech)
if err != nil {
return err
}
Expand Down Expand Up @@ -543,10 +602,9 @@ func (ca *CurationAuditCommand) auditTree(tech techutils.Technology, results map
}
// Fetch status for each node from a flatten graph which, has no duplicate nodes.
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() {
// Any non-200/403 response from fetchNodesStatus means the walk is incomplete; bail out rather
// than rendering a misleading "0 blocked packages" report.
if err = analyzer.fetchNodesStatus(depTreeResult.FlatTree, &packagesStatusMap, rootNodes); err != nil {
return err
}
analyzer.GraphsRelations(depTreeResult.FullDepTrees, &packagesStatusMap,
Expand All @@ -559,7 +617,7 @@ func (ca *CurationAuditCommand) auditTree(tech techutils.Technology, results map
// We subtract 1 because the root node is not a package.
totalNumberOfPackages: len(depTreeResult.FlatTree.Nodes) - 1,
}
return err
return nil
}

func getSelectedPackages(requestedRows string, blockedPackages []*PackageStatus) (selectedPackages []*PackageStatus, ok bool) {
Expand Down Expand Up @@ -784,12 +842,52 @@ func (ca *CurationAuditCommand) SetRepo(tech techutils.Technology) error {

resolverParams, err := ca.getRepoParams(tech.GetProjectType())
if err != nil {
return err
// npm and yarn share the same Artifactory npm API for curation, so their
// repository configs are interchangeable. Fall back to the sibling tech's
// config when the primary one is missing (e.g. the project was configured
// with 'jf yarn-config' but is detected as npm because yarn.lock is absent).
primaryErr := err
switch tech {
case techutils.Npm:
resolverParams, err = ca.getRepoParams(techutils.Yarn.GetProjectType())
case techutils.Yarn:
resolverParams, err = ca.getRepoParams(techutils.Npm.GetProjectType())
}
if err != nil {
// Return the primary tech's error so the user sees the correct command.
// Yarn's CLI config command is 'jf yarn-config', not 'jf yarn c'.
if tech == techutils.Yarn {
return errorutils.CheckErrorf("no config file was found! Before running jf ca on a yarn" +
"project for the first time, the project should be configured using the 'jf yarn-config' command")
}
return primaryErr
}
}
ca.setPackageManagerConfig(resolverParams)
return nil
}

// validateRunNativeForTech rejects --run-native for techs that don't implement
// native-config semantics. Only npm is supported today; extend the allow-list
// below when a new tech adds the matching native-config flow.
func validateRunNativeForTech(tech techutils.Technology, runNative bool) error {
if !runNative {
return nil
}
// Extend this set when a new tech grows native-config semantics on
// both 'jf <tech>' and 'jf ca'.
supported := map[techutils.Technology]struct{}{
techutils.Npm: {},
}
if _, ok := supported[tech]; ok {
return nil
}
return errorutils.CheckErrorf(
"--run-native is not supported for '%s' projects. "+
"Run 'jf ca' without --run-native; configure the resolution repository using 'jf %s-config'.",
tech.String(), tech.String())
}

// 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.
Expand Down Expand Up @@ -994,11 +1092,29 @@ func (nc *treeAnalyzer) getBlockedPackageDetails(packageUrl string, name string,
if getResp.StatusCode == http.StatusForbidden {
respError := &ErrorsResp{}
if err := json.Unmarshal(respBody, respError); err != nil {
return nil, errorutils.CheckError(err)
// Body is not valid JSON (e.g. Artifactory returned an HTML error page).
// The 403 itself is authoritative — record the package as blocked with
// unknown policy rather than dropping it from results.
log.Debug(fmt.Sprintf("curation: could not parse 403 body for %s@%s as JSON (%s) — recording as blocked with unknown policy", name, version, err.Error()))
return &PackageStatus{
PackageName: name,
PackageVersion: version,
BlockedPackageUrl: packageUrl,
Action: blocked,
BlockingReason: BlockingReasonPolicy,
PkgType: string(nc.tech),
}, nil
}
if len(respError.Errors) == 0 {
return nil, errorutils.CheckErrorf("received 403 for unknown reason, no curation status will be presented for this package. "+
"package name: %s, version: %s, download url: %s ", name, version, packageUrl)
log.Debug(fmt.Sprintf("curation: received 403 with empty error list for %s@%s — recording as blocked with unknown policy", name, version))
return &PackageStatus{
PackageName: name,
PackageVersion: version,
BlockedPackageUrl: packageUrl,
Action: blocked,
BlockingReason: BlockingReasonPolicy,
PkgType: string(nc.tech),
}, nil
}
// if the error message contains the curation string key, then we can be sure it got blocked by Curation service.
if strings.Contains(strings.ToLower(respError.Errors[0].Message), BlockMessageKey) {
Expand Down Expand Up @@ -1066,7 +1182,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.Yarn:
// Yarn packages use npm:// node IDs and the same Artifactory npm API endpoint.
return getNpmNameScopeAndVersion(node.Id, artiUrl, repo, techutils.Npm.String())
case techutils.Maven:
return getMavenNameScopeAndVersion(node.Id, artiUrl, repo, node)
Expand Down Expand Up @@ -1251,6 +1368,12 @@ func getNpmNameScopeAndVersion(id, artiUrl, repo, tech string) (downloadUrl []st
if len(nameVersion) > 1 {
version = nameVersion[1]
}
// Skip local workspace members — they have no remote artifact.
// Yarn V1: version ends in "-use.local". Yarn V2+: name ends with a
// 6-char hex hash and version is "0.0.0" (e.g. "admin-ui-428bae:0.0.0").
if strings.HasSuffix(version, "-use.local") || isYarnBerryWorkspaceMember(name, version) {
return nil, name, "", version
}
scopeSplit := strings.Split(name, "/")
if len(scopeSplit) > 1 {
scope = scopeSplit[0]
Expand All @@ -1269,6 +1392,28 @@ func buildNpmDownloadUrl(url, repo, name, scope, version string) []string {
return []string{packageUrl}
}

// isYarnBerryWorkspaceMember reports whether a graph node is a Yarn V2/V3
// workspace member. Yarn Berry appends a 6-char lowercase hex hash to the
// package name (e.g. "admin-ui-428bae") and sets their version to "0.0.0".
func isYarnBerryWorkspaceMember(name, version string) bool {
if version != "0.0.0" {
return false
}
if len(name) < 8 {
return false
}
suffix := name[len(name)-7:]
if suffix[0] != '-' {
return false
}
for _, c := range suffix[1:] {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
return false
}
}
return true
}

func getDockerNameAndVersion(id, artiUrl, repo string) (downloadUrls []string, name, version string) {
if id == "" {
return
Expand Down
Loading
Loading