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
176 changes: 172 additions & 4 deletions commands/curation/curationaudit.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ import (
"github.com/jfrog/jfrog-cli-security/utils/xray"

"github.com/jfrog/build-info-go/build/utils/dotnet/dependencies"

bibuildutils "github.com/jfrog/build-info-go/build/utils"
"github.com/jfrog/gofrog/version"
yarntech "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/yarn"
)

const (
Expand Down Expand Up @@ -250,6 +254,9 @@ type CurationAuditCommand struct {
dockerImageName string
includeCachedPackages bool
mvnIncludePluginDeps bool
// pendingWarnings collects log.Warn messages that must be emitted after the
// progress spinner stops; otherwise the spinner's ANSI clear codes overwrite them.
pendingWarnings []string
audit.AuditParamsInterface
}

Expand Down Expand Up @@ -347,7 +354,10 @@ func (ca *CurationAuditCommand) Run() (err error) {
if ca.Progress() != nil {
err = errors.Join(err, ca.Progress().Quit())
}
// Print after the spinner has stopped so the messages appear on the terminal.
// Print after the spinner has stopped so messages are not overwritten by ANSI clear codes.
for _, w := range ca.pendingWarnings {
log.Warn(w)
}
// Don't include scanErr.Error() here — it is in the returned err and the CLI framework
// prints it once; printing it here too would duplicate the full error message.
if scanErr != nil {
Expand Down Expand Up @@ -463,8 +473,58 @@ func promotePnpmWorkspaceMember(techs []string) []string {
}
}

// promoteYarnWorkspaceMember replaces "npm" with "yarn" in the detected technologies
// list when the current directory is a yarn workspace member — it has no yarn marker
// itself, but an ancestor directory contains .yarnrc.yml or yarn.lock.
// This lets `jf ca --working-dirs=<member>` audit the member as part of its yarn
// workspace, consistently with how pnpm workspace members are promoted via
// promotePnpmWorkspaceMember.
func promoteYarnWorkspaceMember(techs []string) []string {
hasYarn, hasNpm := false, false
for _, t := range techs {
switch t {
case techutils.Yarn.String():
hasYarn = true
case techutils.Npm.String():
hasNpm = true
}
}
if hasYarn || !hasNpm {
return techs
}
dir, err := os.Getwd()
if err != nil {
return techs
}
// Stop at $HOME: a personal ~/.yarnrc.yml (created by 'jf c'/yarn setup) must
// not misclassify every npm project under $HOME as a yarn workspace member.
home, _ := os.UserHomeDir()
for {
parent := filepath.Dir(dir)
if parent == dir {
return techs
}
dir = parent
if home != "" && dir == home {
return techs
}
if techutils.DirectoryHasYarnIndicator(dir) {
log.Debug(fmt.Sprintf("Detected yarn workspace root at %s; promoting current directory from npm to yarn.", dir))
promoted := make([]string, 0, len(techs))
for _, t := range techs {
if t == techutils.Npm.String() {
t = techutils.Yarn.String()
}
promoted = append(promoted, t)
}
return promoted
}
}
}

func (ca *CurationAuditCommand) doCurateAudit(results map[string]*CurationReport) error {
techs := promotePnpmWorkspaceMember(techutils.DetectedTechnologiesListForCurationAudit())
techs = promoteYarnWorkspaceMember(techs)
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 @@ -500,8 +560,10 @@ 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.
// 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),
// or when the project has a yarn indicator file (.yarnrc.yml / yarn.lock / .yarnrc / .yarn)
// without a yarn.yaml — which is the V4 native mode case where no jf yarn-config is needed.
func resolveNpmYarnTech(tech string) string {
if techutils.Technology(tech) != techutils.Npm {
return tech
Expand All @@ -515,6 +577,30 @@ func resolveNpmYarnTech(tech string) string {
log.Info("No npm.yaml config found but yarn.yaml detected — treating project as yarn.")
return techutils.Yarn.String()
}
// V4 native mode: no yarn.yaml, but project may have a local yarn indicator
// (.yarnrc.yml / yarn.lock / .yarnrc / .yarn) OR only a global ~/.yarnrc.yml
// (set via 'yarn config set --home', as the Artifactory "Set Up" page instructs).
// Guard against false-positives: if package-lock.json exists the project is npm.
workingDir, wdErr := coreutils.GetWorkingDirectory()
if wdErr == nil {
if _, err := os.Stat(filepath.Join(workingDir, "package-lock.json")); err == nil {
// package-lock.json present — this is an npm project.
return tech
}
if techutils.DirectoryHasYarnIndicator(workingDir) {
log.Info("No npm.yaml or yarn.yaml found but yarn indicator file detected (.yarnrc.yml / yarn.lock / .yarnrc / .yarn) — treating project as yarn.")
return techutils.Yarn.String()
}
// Check global ~/.yarnrc.yml — customers using 'yarn config set --home'
// (as shown in the Artifactory "Set Up" page for Yarn V4) have no project-level
// .yarnrc.yml but a global one that carries the registry and auth token.
if homeDir, err := os.UserHomeDir(); err == nil {
if _, err := os.Stat(filepath.Join(homeDir, ".yarnrc.yml")); err == nil {
log.Info("No npm.yaml or yarn.yaml found but global ~/.yarnrc.yml detected — treating project as yarn (V4 native mode).")
return techutils.Yarn.String()
}
}
}
return tech
}

Expand Down Expand Up @@ -616,8 +702,14 @@ func (ca *CurationAuditCommand) auditTree(tech techutils.Technology, results map
params.IgnoreConfigFile = true
}
// Pnpm always resolves natively from .npmrc — --run-native is redundant and has no effect.
// Deferred: emitted after the spinner stops so the message is not overwritten.
if ca.RunNative() && tech == techutils.Pnpm {
log.Warn("--run-native has no effect for pnpm; pnpm always resolves natively from .npmrc")
ca.pendingWarnings = append(ca.pendingWarnings, "--run-native has no effect for pnpm; pnpm always resolves natively from .npmrc")
}
// Yarn V4 always resolves natively from .yarnrc.yml — --run-native is redundant and has no effect.
// Deferred: emitted after the spinner stops so the message is not overwritten.
if ca.RunNative() && tech == techutils.Yarn {
ca.pendingWarnings = append(ca.pendingWarnings, "--run-native has no effect for yarn V4; yarn V4 always resolves natively from .yarnrc.yml")
}
// For yarn with no yarn.yaml, fall back to npm.yaml — npm and yarn share the same Artifactory npm API.
resolverTech := resolveResolverTechForCuration(tech)
Expand Down Expand Up @@ -937,6 +1029,30 @@ func (ca *CurationAuditCommand) SetRepo(tech techutils.Technology) error {
return ca.setRepoFromNpmrcForPnpm()
}

// Yarn V4 uses native mode: no jf yarn-config / yarn.yaml required.
// Detect the running yarn version and route to the appropriate path.
// Version detection failures are fatal — silently falling through to the
// V2/V3 path would use different flags and break the audit.
if tech == techutils.Yarn {
yarnExecPath, yarnExecErr := bibuildutils.GetYarnExecutable()
if yarnExecErr != nil {
return fmt.Errorf("could not locate the yarn executable: %w. Ensure yarn is installed and available on PATH before running 'jf ca'", yarnExecErr)
}
workingDir, wdErr := coreutils.GetWorkingDirectory()
if wdErr != nil {
return fmt.Errorf("could not determine working directory for yarn version detection: %w", wdErr)
}
versionStr, versionErr := bibuildutils.GetVersion(yarnExecPath, workingDir)
if versionErr != nil {
return fmt.Errorf("could not detect yarn version: %w. Ensure the yarn binary at %q is functional (try 'yarn --version') before running 'jf ca'", versionErr, yarnExecPath)
}
yarnVersion := version.NewVersion(versionStr)
if yarnVersion.Compare(yarntech.YarnV4Version) <= 0 {
return ca.setRepoFromYarnrcForYarnV4(yarnExecPath, workingDir)
}
// V2/V3: fall through to getRepoParams (yarn.yaml / npm.yaml).
}

resolverParams, err := ca.getRepoParams(tech.GetProjectType())
if err != nil {
// npm and yarn share the same Artifactory npm API for curation, so their
Expand Down Expand Up @@ -978,6 +1094,9 @@ func validateRunNativeForTech(tech techutils.Technology, runNative bool) error {
// pnpm always resolves from .npmrc, so --run-native is a redundant no-op
// rather than an error (a warning is emitted in auditTree).
techutils.Pnpm: {},
// Yarn V4 always uses native mode (.yarnrc.yml), so --run-native is a
// redundant no-op rather than an error (a warning is emitted in auditTree).
techutils.Yarn: {},
}
if _, ok := supported[tech]; ok {
return nil
Expand Down Expand Up @@ -1064,6 +1183,55 @@ func (ca *CurationAuditCommand) setRepoFromNpmrcForPnpm() error {
return nil
}

// setRepoFromYarnrcForYarnV4 reads Artifactory connection details from the
// project's .yarnrc.yml via the Yarn CLI. Yarn V4 uses native mode — no
// jf yarn-config step is required; the registry URL and auth token live in
// .yarnrc.yml already. This is always called for Yarn V4 curation.
//
// Auth priority:
// 1. Token from .yarnrc.yml — preferred, scoped to the exact registry URL.
// 2. Token from 'jf c' server config — fallback when .yarnrc.yml carries no token.
func (ca *CurationAuditCommand) setRepoFromYarnrcForYarnV4(yarnExecPath, workingDir string) error {
registryConfig, err := yarntech.GetNativeYarnV4RegistryConfig(yarnExecPath, workingDir)
if err != nil {
log.Warn("Ensure npmRegistryServer is configured in .yarnrc.yml (e.g. npmRegistryServer: \"https://<host>/artifactory/api/npm/<repo>/\")")
return fmt.Errorf("yarn V4: failed to read Artifactory details from .yarnrc.yml: %w", err)
}

var serverDetails *config.ServerDetails
if registryConfig.AuthToken != "" {
log.Debug("yarn V4: using auth token from .yarnrc.yml")
serverDetails = &config.ServerDetails{
ArtifactoryUrl: registryConfig.ArtifactoryUrl,
AccessToken: registryConfig.AuthToken,
}
} else {
log.Debug("yarn V4: no token in .yarnrc.yml — using 'jf c' server credentials")
base, sdErr := ca.ServerDetails()
if sdErr != nil || base == nil {
return fmt.Errorf("yarn V4: no auth token found in .yarnrc.yml and no 'jf c' server configured: %w", sdErr)
}
// Copy before mutating: ca.ServerDetails() returns the shared struct, and
// overwriting its URL would leak to other techs in a multi-tech audit.
copied := *base
copied.ArtifactoryUrl = registryConfig.ArtifactoryUrl
serverDetails = &copied
}

repoConfig := (&project.RepositoryConfig{}).
SetTargetRepo(registryConfig.RepoName).
SetServerDetails(serverDetails)
ca.setPackageManagerConfig(repoConfig)
// Populate depsRepo on the audit-params interface so getBuildInfoParamsByTech
// returns the correct repository name. For V4 native mode the user never passes
// --deps-repo, so ca.DepsRepo() would otherwise be "". The repo name is consumed
// downstream by the curation error messages and probeBlockedDirectDeps HEAD checks
// (V4 does not route installs through the curation endpoint).
ca.SetDepsRepo(registryConfig.RepoName)
log.Info(fmt.Sprintf("yarn V4: using Artifactory URL %q and repository %q from .yarnrc.yml", 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
Loading
Loading