From a705c70fc1bce626b547ad3f4682f7bb758da366 Mon Sep 17 00:00:00 2001 From: Gauri Yadav Date: Tue, 16 Jun 2026 15:10:14 +0530 Subject: [PATCH 1/4] XRAY-144147 - Onboard Yarn V4 for jf curation-audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V4 operates in native mode: registry URL and auth token come from .yarnrc.yml (local or ~/.yarnrc.yml written by yarn config set --home) instead of requiring jf yarn-config / yarn.yaml. Key changes: yarn.go - Remove V4 rejection from verifyYarnVersionSupportedForCuration and configureYarnResolutionServerAndRunInstall (only V1 is now rejected). - resolveCurationLockfileDir: copy project to temp dir before running install (mirrors pnpm), so the customer's checkout is never modified. - For V4 curation: yarnCurationRegistry() rewrites api/npm/ → api/curation/audit/, then runYarnConfigSet sets a global npmAuthToken in the temp .yarnrc.yml so the rewritten URL authenticates correctly (the original token is scoped to api/npm/ and does not match the curation endpoint). - GetNativeYarnV4RegistryConfig: reads npmRegistryServer via yarn config get; reads npmAuthToken via direct YAML parsing of .yarnrc.yml and ~/.yarnrc.yml (yarn config get is unreliable for nested keys). - runYarnCommandQuiet: capture stdout+stderr on failure and emit as Debug log so failed-install diagnosis is visible. curationaudit.go - setRepoFromYarnrcForYarnV4: calls SetDepsRepo(repoName) in addition to setPackageManagerConfig. Both are required — PackageManagerConfig drives auth; SetDepsRepo populates params.DependenciesRepository so the curation endpoint URL is constructed in configureYarnResolutionServerAndRunInstall (was always "" before, causing the V4 branch to be skipped and the install to hit api/npm/ instead of api/curation/audit/). - resolveNpmYarnTech: detect V4 projects that have .yarnrc.yml (created by yarn set version 4) without a pre-existing yarn.lock, and projects using a global ~/.yarnrc.yml (--home setup); guard with package-lock.json absence to avoid npm misidentification. - validateRunNativeForTech: accept --run-native for Yarn as a no-op (V4 is always native; flag has no effect but should not error). - SetRepo: detect V4 via version check and route to setRepoFromYarnrcForYarnV4; make version-detection failures explicit errors instead of silent fallbacks. Co-authored-by: Cursor --- commands/curation/curationaudit.go | 105 +++++++- sca/bom/buildinfo/technologies/yarn/yarn.go | 275 ++++++++++++++++---- 2 files changed, 332 insertions(+), 48 deletions(-) diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index 6ca0c64ec..b7ffd546c 100644 --- a/commands/curation/curationaudit.go +++ b/commands/curation/curationaudit.go @@ -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 ( @@ -475,8 +479,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 @@ -490,6 +496,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 } @@ -904,6 +934,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("4.0.0") <= 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 @@ -945,6 +999,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 @@ -1031,6 +1088,50 @@ 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:///artifactory/api/npm//\")") + 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") + serverDetails, err = ca.ServerDetails() + if err != nil || serverDetails == nil { + return fmt.Errorf("yarn V4: no auth token found in .yarnrc.yml and no 'jf c' server configured: %w", err) + } + serverDetails.ArtifactoryUrl = registryConfig.ArtifactoryUrl + } + + 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 "" and the curation endpoint URL would not be + // constructed in configureYarnResolutionServerAndRunInstall. + ca.AuditParamsInterface.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 { diff --git a/sca/bom/buildinfo/technologies/yarn/yarn.go b/sca/bom/buildinfo/technologies/yarn/yarn.go index 904044e58..d04d2d42f 100644 --- a/sca/bom/buildinfo/technologies/yarn/yarn.go +++ b/sca/bom/buildinfo/technologies/yarn/yarn.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "io" "maps" "net/http" "os" @@ -18,6 +17,7 @@ import ( "time" biutils "github.com/jfrog/build-info-go/utils" + "gopkg.in/yaml.v3" "github.com/jfrog/build-info-go/build" bibuildutils "github.com/jfrog/build-info-go/build/utils" @@ -29,6 +29,7 @@ import ( "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" "github.com/jfrog/jfrog-cli-core/v2/utils/ioutils" "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies" + "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/npm" "github.com/jfrog/jfrog-cli-security/utils/techutils" "github.com/jfrog/jfrog-cli-security/utils/xray" clientutils "github.com/jfrog/jfrog-client-go/utils" @@ -105,51 +106,47 @@ func BuildDependencyTree(params technologies.BuildInfoBomGeneratorParams) (depen return } - installRequired, err := isInstallRequired(currentDir, params.InstallCommandArgs, params.SkipAutoInstall, params.YarnOverwriteYarnLock) - if err != nil { - return - } - - // deferredInstallErr keeps the install failure around after - // handleCurationInstallError has decided we can keep going (yarn.lock - // was produced — the warn-and-continue path). Most curation runs - // succeed from here because 'yarn info' can enumerate a single-package - // project from the lockfile alone. Workspaces projects can't: - // 'yarn info' on a workspaces root needs a consistent install state on - // disk, and a curation 403 mid-install leaves it inconsistent. If - // GetYarnDependencies later fails we use this saved error to surface - // both halves of the story through enumerateAfterCurationInstallError. + // resolveDir is where we read yarn.lock and run GetYarnDependencies. + // For curation: a temp copy of the project so the customer's files are + // never modified. For non-curation: the project directory itself. + resolveDir := currentDir var deferredInstallErr error - if installRequired { - // Snapshot yarn.lock mtime before install so we can detect whether yarn - // wrote the lockfile or rolled it back entirely on a curation 403. - preInstallLockMtime := lockfileMtime(filepath.Join(currentDir, yarn.YarnLockFileName)) - installErr := configureYarnResolutionServerAndRunInstall(params, currentDir, executablePath) - if installErr != nil { - // A curation 403 causes yarn to exit non-zero, but Yarn V2/V3 still - // writes yarn.lock during resolution. When the lockfile exists we pass - // it to the HEAD-check walker to report all blocked packages. - if err = handleCurationInstallError(params, currentDir, executablePath, workspaceMemberRel, installErr, preInstallLockMtime); err != nil { + + if params.IsCurationCmd { + var lockfileCleanup func() error + resolveDir, lockfileCleanup, deferredInstallErr, err = resolveCurationLockfileDir(params, currentDir, executablePath, workspaceMemberRel) + if err != nil { + return + } + defer func() { err = errors.Join(err, lockfileCleanup()) }() + } else { + installRequired, installCheckErr := isInstallRequired(currentDir, params.InstallCommandArgs, params.SkipAutoInstall, params.YarnOverwriteYarnLock) + if installCheckErr != nil { + err = installCheckErr + return + } + if installRequired { + if installErr := configureYarnResolutionServerAndRunInstall(params, currentDir, executablePath); installErr != nil { + err = fmt.Errorf("failed to configure an Artifactory resolution server or running an install command: %w", installErr) return } - deferredInstallErr = installErr } } // Log the number of yarn.lock entries so debug output shows whether the // lockfile is complete or partial (some manifests blocked by curation). if params.IsCurationCmd { - logYarnLockEntryCount(filepath.Join(currentDir, yarn.YarnLockFileName)) + logYarnLockEntryCount(filepath.Join(resolveDir, yarn.YarnLockFileName)) } // Calculate Yarn dependencies - dependenciesMap, root, err := bibuildutils.GetYarnDependencies(executablePath, currentDir, packageInfo, log.Logger, params.AllowPartialResults) + dependenciesMap, root, err := bibuildutils.GetYarnDependencies(executablePath, resolveDir, packageInfo, log.Logger, params.AllowPartialResults) if err != nil { // On workspaces projects a prior curation 403 leaves yarn's install // state inconsistent; 'yarn info' then emits an opaque parse error. // Re-wrap with actionable context via enumerateAfterCurationInstallError. if params.IsCurationCmd && deferredInstallErr != nil { - err = enumerateAfterCurationInstallError(params, currentDir, workspaceMemberRel, deferredInstallErr, err) + err = enumerateAfterCurationInstallError(params, resolveDir, workspaceMemberRel, deferredInstallErr, err) } return } @@ -185,7 +182,7 @@ func BuildDependencyTree(params technologies.BuildInfoBomGeneratorParams) (depen // resolved map). Fixed versions only; semver ranges are skipped with a // warning. Skipped for jf audit/scan — those must use literal yarn.lock. if params.IsCurationCmd { - declared := collectDeclaredDirectDepsForMember(currentDir, workspaceMemberRel) + declared := collectDeclaredDirectDepsForMember(resolveDir, workspaceMemberRel) reconcileDeclaredDirectDepsAgainstTree(dependenciesMap, root, declared) } // Parse the dependencies into Xray dependency tree format @@ -224,16 +221,17 @@ func logYarnLockEntryCount(yarnLockPath string) { log.Debug(fmt.Sprintf("yarn curation: '%s' contains %d resolved package entries; the curation walker will HEAD-check this set", yarnLockPath, count)) } -// verifyYarnVersionSupportedForCuration returns an error for Yarn V1 and V4, +// verifyYarnVersionSupportedForCuration returns an error for Yarn V1, // which cannot be routed through Artifactory for curation. +// V2/V3 use configured-registry mode (jf yarn-config); V4 uses native mode (.yarnrc.yml). func verifyYarnVersionSupportedForCuration(yarnExecPath, curWd string) error { versionStr, err := bibuildutils.GetVersion(yarnExecPath, curWd) if err != nil { return err } yarnVersion := version.NewVersion(versionStr) - if yarnVersion.Compare(yarnV2Version) > 0 || yarnVersion.Compare(yarnV4Version) <= 0 { - return errorutils.CheckErrorf("'jf curation-audit' is not supported for Yarn V1 or Yarn V4 (detected: %s). Curation requires Artifactory-resolved installs, which the curation flow only routes through Artifactory for Yarn V2 and V3 — 'jf audit' and 'jf scan' continue to support Yarn V4.", versionStr) + if yarnVersion.Compare(yarnV2Version) > 0 { + return errorutils.CheckErrorf("'jf curation-audit' is not supported for Yarn V1 (detected: %s). Curation requires Artifactory-resolved installs, which the curation flow supports for Yarn V2, V3, and V4.", versionStr) } return nil } @@ -1015,25 +1013,81 @@ func printBlockedDirectDepsTable(blocked []blockedDirectDep, totalProbed int, fo return err } -// runYarnCommandQuiet runs yarn with both stdout and stderr discarded. -// Used when --format=json is active so yarn's install output (YN0013, YN0001, -// etc.) does not pollute the machine-readable JSON written to stdout. -// Mirrors build.RunYarnCommand exactly except for the output destination. +// runYarnCommandQuiet runs yarn with stdout and stderr captured internally. +// On failure the captured output is emitted as a Debug log and appended to the +// returned error so the caller (handleCurationInstallError / curationNoLockfileError) +// can surface it to the user. On success the output is discarded so machine-readable +// JSON written to the process's own stdout stays unpolluted. func runYarnCommandQuiet(executablePath, srcPath string, args ...string) error { command := exec.Command(executablePath, args...) command.Dir = srcPath - var stderr bytes.Buffer - command.Stdout = io.Discard - command.Stderr = &stderr + var combined bytes.Buffer + command.Stdout = &combined + command.Stderr = &combined if err := command.Run(); err != nil { - if msg := strings.TrimSpace(stderr.String()); msg != "" { - return fmt.Errorf("%w: %s", err, msg) + if msg := strings.TrimSpace(combined.String()); msg != "" { + log.Debug("yarn install output:\n" + msg) + return fmt.Errorf("%w\n%s", err, msg) } return err } return nil } +// resolveCurationLockfileDir prepares the directory from which the curation +// audit reads yarn.lock. When install is needed it copies the project to a +// temp dir, configures the curation registry there, and runs +// 'yarn install --mode=update-lockfile' — so the customer's project is never +// modified and read-only CI checkouts still work. +// +// Returns: +// - lockfileDir: where to read yarn.lock / run GetYarnDependencies from +// - cleanup: must always be called by the caller (no-op when using currentDir) +// - deferredInstallErr: non-nil when yarn install failed with a curation 403 +// but handleCurationInstallError determined we can continue (lockfile was +// partially written); the caller should surface it if enumeration also fails +func resolveCurationLockfileDir( + params technologies.BuildInfoBomGeneratorParams, + currentDir, yarnExecPath, workspaceMemberRel string, +) (lockfileDir string, cleanup func() error, deferredInstallErr error, err error) { + noop := func() error { return nil } + + installRequired, err := isInstallRequired(currentDir, params.InstallCommandArgs, params.SkipAutoInstall, params.YarnOverwriteYarnLock) + if err != nil { + return "", noop, nil, err + } + if !installRequired { + return currentDir, noop, nil, nil + } + + tmpDir, err := fileutils.CreateTempDir() + if err != nil { + return "", noop, nil, fmt.Errorf("failed to create a temporary dir: %w", err) + } + cleanup = func() error { return fileutils.RemoveTempDir(tmpDir) } + defer func() { + if err != nil { + err = errors.Join(err, cleanup()) + cleanup = noop + } + }() + + if err = biutils.CopyDir(currentDir, tmpDir, true, []string{technologies.DotVsRepoSuffix}); err != nil { + return "", cleanup, nil, fmt.Errorf("failed copying project to temp dir: %w", err) + } + + preInstallLockMtime := lockfileMtime(filepath.Join(tmpDir, yarn.YarnLockFileName)) + installErr := configureYarnResolutionServerAndRunInstall(params, tmpDir, yarnExecPath) + if installErr != nil { + if err = handleCurationInstallError(params, tmpDir, yarnExecPath, workspaceMemberRel, installErr, preInstallLockMtime); err != nil { + return "", cleanup, nil, err + } + deferredInstallErr = installErr + } + + return tmpDir, cleanup, deferredInstallErr, nil +} + // Sets up Artifactory server configurations for dependency resolution, if such were provided by the user. // Executes the user's 'install' command or a default 'install' command if none was specified. func configureYarnResolutionServerAndRunInstall(params technologies.BuildInfoBomGeneratorParams, curWd, yarnExecPath string) (err error) { @@ -1047,13 +1101,36 @@ func configureYarnResolutionServerAndRunInstall(params technologies.BuildInfoBom if err != nil { return err } - // Resolving through Artifactory is only supported for Yarn V2 and V3. yarnVersion := version.NewVersion(executableYarnVersion) - if yarnVersion.Compare(yarnV2Version) > 0 || yarnVersion.Compare(yarnV4Version) <= 0 { - return errors.New("resolving Yarn dependencies from Artifactory is currently not supported for Yarn V1 and Yarn V4. The current Yarn version is: " + executableYarnVersion) + + // Yarn V4 uses native mode: credentials are already stored in .yarnrc.yml (copied into curWd + // by resolveCurationLockfileDir). For curation we rewrite the registry URL to the audit + // endpoint and set a global npmAuthToken — no credential injection via ModifyYarnConfigurations. + if yarnVersion.Compare(yarnV4Version) <= 0 { + if params.IsCurationCmd { + artiURL := strings.TrimSuffix(params.ServerDetails.ArtifactoryUrl, "/") + registry := fmt.Sprintf("%s/api/npm/%s/", artiURL, depsRepo) + curationRegistry := yarnCurationRegistry(registry) + log.Debug(fmt.Sprintf("Yarn V4 native mode: rewriting npmRegistryServer to curation endpoint %s", curationRegistry)) + if err = setYarnConfigNpmRegistryServer(yarnExecPath, curWd, curationRegistry); err != nil { + return err + } + // The original auth token in .yarnrc.yml is scoped to api/npm//. After + // rewriting the URL to api/curation/audit//, Yarn can no longer match the + // scoped token. Setting a global npmAuthToken ensures the curation endpoint is + // authenticated with the same credential. + if params.ServerDetails != nil && params.ServerDetails.AccessToken != "" { + if setErr := runYarnConfigSet(yarnExecPath, curWd, "npmAuthToken", params.ServerDetails.AccessToken); setErr != nil { + log.Warn(fmt.Sprintf("yarn V4: could not set global npmAuthToken for curation endpoint: %v", setErr)) + } + } + } + return runYarnInstallAccordingToVersion(curWd, yarnExecPath, params.InstallCommandArgs, params.IsCurationCmd) } - // If an Artifactory resolution repository was provided we first configure to resolve from it and only then run the 'install' command + // V2/V3: inject Artifactory credentials via GetYarnAuthDetails + ModifyYarnConfigurations. + // V1 is rejected earlier by verifyYarnVersionSupportedForCuration (curation) or is unsupported + // by the jfrog-cli-artifactory yarn integration (non-curation). restoreYarnrcFunc, err := ioutils.BackupFile(filepath.Join(curWd, yarn.YarnrcFileName), yarn.YarnrcBackupFileName) if err != nil { return err @@ -1357,3 +1434,109 @@ func filterYarnDepMapToWorkspaceMember( func yarnCurationRegistry(registry string) string { return strings.Replace(registry, "/api/npm/", "/api/curation/audit/", 1) } + +// GetNativeYarnV4RegistryConfig reads the Artifactory registry URL and auth +// token from the project's .yarnrc.yml via the Yarn CLI. Yarn V4 uses native +// mode — credentials are already stored in .yarnrc.yml, no jf yarn-config step +// is required. The URL must contain /api/npm// so that ParseArtifactoryNpmRegistryUrl +// can extract the Artifactory base URL and repository name. +func GetNativeYarnV4RegistryConfig(yarnExecPath, workingDir string) (*npm.NpmrcRegistryConfig, error) { + registryURL, err := runYarnConfigGet(yarnExecPath, workingDir, "npmRegistryServer") + if err != nil { + return nil, fmt.Errorf("failed to read npmRegistryServer from .yarnrc.yml: %w", err) + } + if registryURL == "" || registryURL == "undefined" { + return nil, fmt.Errorf("npmRegistryServer is not set in .yarnrc.yml; configure it to point to your Artifactory npm repository (e.g. https:///artifactory/api/npm//)") + } + + rtBaseURL, repoName, err := npm.ParseArtifactoryNpmRegistryUrl(registryURL) + if err != nil { + return nil, err + } + + // Auth token lookup: parse .yarnrc.yml files directly rather than using + // 'yarn config get' with a composite key, which is unreliable across versions. + // Check order: project .yarnrc.yml → global ~/.yarnrc.yml. + // For each file, try the registry-scoped entry first, then the global npmAuthToken. + authToken := readNpmAuthTokenFromYarnrcFiles(registryURL, workingDir) + + return &npm.NpmrcRegistryConfig{ + ArtifactoryUrl: rtBaseURL, + RepoName: repoName, + AuthToken: authToken, + }, nil +} + +// runYarnConfigGet runs 'yarn config get ' in workingDir and returns the +// trimmed output. An empty or "undefined" response means the key is not set. +func runYarnConfigGet(yarnExecPath, workingDir, key string) (string, error) { + cmd := exec.Command(yarnExecPath, "config", "get", key) + cmd.Dir = workingDir + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("yarn config get %s: %w", key, err) + } + return strings.TrimSpace(string(out)), nil +} + +// runYarnConfigSet runs 'yarn config set ' in workingDir. +func runYarnConfigSet(yarnExecPath, workingDir, key, value string) error { + cmd := exec.Command(yarnExecPath, "config", "set", key, value) + cmd.Dir = workingDir + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("yarn config set %s failed: %w\n%s", key, err, strings.TrimSpace(string(out))) + } + return nil +} + +// setYarnConfigNpmRegistryServer runs 'yarn config set npmRegistryServer ' +// in workingDir. Used in V4 native curation mode to route installs through the +// curation audit endpoint without touching auth credentials. +func setYarnConfigNpmRegistryServer(yarnExecPath, workingDir, registryURL string) error { + return runYarnConfigSet(yarnExecPath, workingDir, "npmRegistryServer", registryURL) +} + +// yarnrcFile is the subset of .yarnrc.yml fields we need for curation. +type yarnrcFile struct { + NpmAuthToken string `yaml:"npmAuthToken"` + NpmRegistries map[string]yarnrcRegistryEntry `yaml:"npmRegistries"` +} + +type yarnrcRegistryEntry struct { + NpmAuthToken string `yaml:"npmAuthToken"` +} + +// readNpmAuthTokenFromYarnrcFiles returns the npm auth token for registryURL by +// parsing .yarnrc.yml files directly. It checks the project-level file first, +// then the global ~/.yarnrc.yml. For each file it tries the registry-scoped +// npmRegistries[""].npmAuthToken entry before falling back to the top-level +// npmAuthToken field. +func readNpmAuthTokenFromYarnrcFiles(registryURL, workingDir string) string { + candidates := []string{filepath.Join(workingDir, ".yarnrc.yml")} + if homeDir, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, filepath.Join(homeDir, ".yarnrc.yml")) + } + for _, path := range candidates { + data, err := os.ReadFile(path) + if err != nil { + continue + } + var rc yarnrcFile + if err := yaml.Unmarshal(data, &rc); err != nil { + log.Debug(fmt.Sprintf("yarn V4: could not parse %s: %s", path, err)) + continue + } + // Scoped registry entry takes priority. + if entry, ok := rc.NpmRegistries[registryURL]; ok && entry.NpmAuthToken != "" { + log.Debug(fmt.Sprintf("yarn V4: using auth token from scoped npmRegistries entry in %s", path)) + return entry.NpmAuthToken + } + // Fall back to top-level npmAuthToken in the same file. + if rc.NpmAuthToken != "" { + log.Debug(fmt.Sprintf("yarn V4: using top-level npmAuthToken from %s", path)) + return rc.NpmAuthToken + } + } + return "" +} From fd5de282d3d4ff50069513a924b87930e09aac12 Mon Sep 17 00:00:00 2001 From: Gauri Yadav Date: Tue, 16 Jun 2026 15:29:24 +0530 Subject: [PATCH 2/4] XRAY-144147 - Onboard Yarn V4 for jf curation-audit --- commands/curation/curationaudit.go | 62 ++++++- .../resources/jfrog-yarn-resolve-lockfile.cjs | 46 +++++ sca/bom/buildinfo/technologies/yarn/yarn.go | 163 ++++++++++++++++-- 3 files changed, 250 insertions(+), 21 deletions(-) create mode 100644 sca/bom/buildinfo/technologies/yarn/resources/jfrog-yarn-resolve-lockfile.cjs diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index b7ffd546c..a3a977d8c 100644 --- a/commands/curation/curationaudit.go +++ b/commands/curation/curationaudit.go @@ -242,6 +242,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 } @@ -334,7 +337,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 message appears 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) + } if scanErr != nil { log.Error("Curation audit encountered errors while checking some packages; the report below may be incomplete: " + scanErr.Error()) } @@ -442,8 +448,54 @@ 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=` 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 + } + for { + parent := filepath.Dir(dir) + if parent == dir { + return techs + } + dir = parent + for _, indicator := range []string{".yarnrc.yml", "yarn.lock", ".yarnrc"} { + if _, statErr := os.Stat(filepath.Join(dir, indicator)); statErr == nil { + log.Debug(fmt.Sprintf("Detected yarn workspace root at %s via %s; promoting current directory from npm to yarn.", dir, indicator)) + 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()} @@ -621,8 +673,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) diff --git a/sca/bom/buildinfo/technologies/yarn/resources/jfrog-yarn-resolve-lockfile.cjs b/sca/bom/buildinfo/technologies/yarn/resources/jfrog-yarn-resolve-lockfile.cjs new file mode 100644 index 000000000..25297dd49 --- /dev/null +++ b/sca/bom/buildinfo/technologies/yarn/resources/jfrog-yarn-resolve-lockfile.cjs @@ -0,0 +1,46 @@ +/* eslint-disable */ +// `yarn jfrog-yarn-resolve-lockfile`: resolves the full dependency graph from +// registry metadata and writes a complete yarn.lock WITHOUT fetching tarballs, +// so a curation 403 on a blocked tarball can't abort the lockfile build. +// Used only by `jf curation-audit` (mirrors npm's --package-lock-only). +module.exports = { + name: `plugin-jfrog-yarn-resolve-lockfile`, + factory: (require) => { + const { BaseCommand } = require(`@yarnpkg/cli`); + const { Cache, Configuration, Project, StreamReport } = require(`@yarnpkg/core`); + + class JfrogYarnResolveLockfileCommand extends BaseCommand { + static paths = [[`jfrog-yarn-resolve-lockfile`]]; + + async execute() { + const configuration = await Configuration.find( + this.context.cwd, + this.context.plugins, + ); + const { project } = await Project.find(configuration, this.context.cwd); + const cache = await Cache.find(configuration); + + const report = await StreamReport.start( + { + configuration, + stdout: this.context.stdout, + includeFooter: false, + }, + async (report) => { + // Resolve only (no fetchEverything = no tarball downloads). + // Don't pass lockfileOnly:true — it refuses to resolve packages + // absent from the lockfile (YN0020). + await project.resolveEverything({ cache, report }); + await project.persistLockfile(); + }, + ); + + return report.exitCode(); + } + } + + return { + commands: [JfrogYarnResolveLockfileCommand], + }; + }, +}; diff --git a/sca/bom/buildinfo/technologies/yarn/yarn.go b/sca/bom/buildinfo/technologies/yarn/yarn.go index d04d2d42f..c048eec24 100644 --- a/sca/bom/buildinfo/technologies/yarn/yarn.go +++ b/sca/bom/buildinfo/technologies/yarn/yarn.go @@ -2,6 +2,7 @@ package yarn import ( "bytes" + _ "embed" "encoding/json" "errors" "fmt" @@ -56,8 +57,21 @@ const ( yarnV3Version = "3.0.0" yarnV4Version = "4.0.0" nodeModulesRepoName = "node_modules" + + // Command registered by the embedded resolution-only plugin. + resolveLockfilePluginCommand = "jfrog-yarn-resolve-lockfile" + // Plugin path inside the curation temp dir (the layout yarn loads from). + resolveLockfilePluginRelPath = ".yarn/plugins/jfrog-yarn-resolve-lockfile.cjs" + // Spec recorded in .yarnrc.yml; only the path matters to yarn. + resolveLockfilePluginSpec = "@yarnpkg/plugin-jfrog-yarn-resolve-lockfile" ) +// Resolution-only Yarn V3/V4 plugin: builds a complete yarn.lock from registry +// metadata without fetching tarballs, so curation's 403s don't abort it. +// +//go:embed resources/jfrog-yarn-resolve-lockfile.cjs +var resolveLockfilePluginJS []byte + func BuildDependencyTree(params technologies.BuildInfoBomGeneratorParams) (dependencyTrees []*xrayUtils.GraphNode, uniqueDeps []string, err error) { currentDir, err := coreutils.GetWorkingDirectory() if err != nil { @@ -175,6 +189,11 @@ func BuildDependencyTree(params technologies.BuildInfoBomGeneratorParams) (depen log.Debug(fmt.Sprintf( "yarn workspace-member filter: scoped dependency map to '%s' — %d entries reachable from %s", workspaceMemberRel, len(dependenciesMap), root.Value)) + } else if params.IsCurationCmd { + // Workspace members are siblings of the root, not its deps, so their + // subgraphs would be orphaned and never probed. Attach each as a root + // child so 'jf ca' audits the whole workspace graph (matching npm/pnpm). + attachWorkspaceMembersToRoot(dependenciesMap, root) } // Inject synthetic dep-tree entries for any direct deps that curation // blocked during 'yarn install --mode=update-lockfile' (which aborts the @@ -1085,6 +1104,9 @@ func resolveCurationLockfileDir( deferredInstallErr = installErr } + // Mark yarn.lock as fresh so the next run skips re-resolution. + touchYarnLock(currentDir) + return tmpDir, cleanup, deferredInstallErr, nil } @@ -1193,7 +1215,6 @@ func isInstallRequired(currentDir string, installCommandArgs []string, skipAutoI } // isYarnLockStale reports whether package.json is newer than yarn.lock. -// Stat errors are treated as "not stale" to avoid unnecessary re-installs. func isYarnLockStale(curWd string) bool { pkgJsonStat, err := os.Stat(filepath.Join(curWd, "package.json")) if err != nil { @@ -1206,6 +1227,13 @@ func isYarnLockStale(curWd string) bool { return pkgJsonStat.ModTime().After(lockStat.ModTime()) } +// touchYarnLock bumps yarn.lock mtime to now so isYarnLockStale won't re-trigger. +func touchYarnLock(curWd string) { + lockPath := filepath.Join(curWd, yarn.YarnLockFileName) + now := time.Now() + _ = os.Chtimes(lockPath, now, now) +} + // runYarnInstallAccordingToVersion runs 'yarn install' (or the user-supplied // install command). Curation runs suppress yarn's own output; other commands // preserve it. @@ -1252,33 +1280,90 @@ func runYarnInstallAccordingToVersion(curWd, yarnExecPath string, installCommand installCommandArgs = append(installCommandArgs, v1IgnoreScriptsFlag, v1SilentFlag, v1NonInteractiveFlag) } else { if yarnVersion.Compare(yarnV3Version) > 0 { - // V2 — has no equivalent to V3's --mode=update-lockfile, so install - // always fetches tarballs. For curation this means any blocked package - // returns 403 during fetch and yarn aborts before yarn.lock is written; - // handleCurationInstallError then surfaces an actionable error. + // V2 has no lockfile-only mode, so install fetches tarballs; a + // curation 403 aborts it before yarn.lock is written (handled by + // handleCurationInstallError). installCommandArgs = append(installCommandArgs, v2SkipBuildFlag) } else { - // V3+ + // V3+ curation: resolve the full graph from metadata without + // fetching tarballs, so blocked (uncached) packages don't abort the + // lockfile. --mode=update-lockfile can't be used: it still fetches + // uncached tarballs to compute checksums. if isCurationCmd { - // --mode=update-lockfile skips fetch and link entirely — yarn just - // resolves manifests and writes yarn.lock. The curation HEAD-check - // walker enumerates blocked packages from the lockfile afterwards, - // so we don't need yarn to download tarballs (which curation would - // 403 anyway). - // Note: yarn berry's clipanion takes the LAST --mode value, so - // passing both --mode=update-lockfile and --mode=skip-build would - // silently reduce to --mode=skip-build (a full install). For - // curation we MUST pass only --mode=update-lockfile. - installCommandArgs = append(installCommandArgs, v3UpdateLockfileFlag) - } else { - installCommandArgs = append(installCommandArgs, v3UpdateLockfileFlag, v3SkipBuildFlag) + return runYarnResolveOnlyLockfile(yarnExecPath, curWd) } + installCommandArgs = append(installCommandArgs, v3UpdateLockfileFlag, v3SkipBuildFlag) } } log.Info(fmt.Sprintf("Running 'yarn %s' command.", strings.Join(installCommandArgs, " "))) return runYarn(yarnExecPath, curWd, installCommandArgs...) } +// runYarnResolveOnlyLockfile installs the embedded plugin and runs it to write +// a complete yarn.lock from registry metadata (no tarball fetch). Output is +// captured quietly; on failure it's surfaced via handleCurationInstallError. +func runYarnResolveOnlyLockfile(yarnExecPath, curWd string) error { + if err := installResolveLockfilePlugin(curWd); err != nil { + return fmt.Errorf("failed to install the resolution-only yarn plugin: %w", err) + } + log.Info("Running 'yarn jfrog-yarn-resolve-lockfile' command (resolving the dependency graph from registry metadata without downloading tarballs).") + return runYarnCommandQuiet(yarnExecPath, curWd, resolveLockfilePluginCommand) +} + +// installResolveLockfilePlugin writes the embedded plugin into curWd/.yarn/plugins/ +// and registers it in curWd/.yarnrc.yml (preserving existing config). Idempotent. +func installResolveLockfilePlugin(curWd string) error { + pluginPath := filepath.Join(curWd, filepath.FromSlash(resolveLockfilePluginRelPath)) + if err := os.MkdirAll(filepath.Dir(pluginPath), 0700); err != nil { + return fmt.Errorf("creating yarn plugins dir: %w", err) + } + if err := os.WriteFile(pluginPath, resolveLockfilePluginJS, 0600); err != nil { + return fmt.Errorf("writing yarn plugin file: %w", err) + } + return registerYarnPluginInYarnrc(curWd, resolveLockfilePluginRelPath, resolveLockfilePluginSpec) +} + +// registerYarnPluginInYarnrc adds a {path, spec} entry to the "plugins" list of +// curWd/.yarnrc.yml, creating the file if absent and preserving every other +// setting. If an entry with the same path already exists it is left untouched. +func registerYarnPluginInYarnrc(curWd, pluginRelPath, pluginSpec string) error { + yarnrcPath := filepath.Join(curWd, yarn.YarnrcFileName) + rc := map[string]interface{}{} + if data, err := os.ReadFile(yarnrcPath); err == nil { + if unmarshalErr := yaml.Unmarshal(data, &rc); unmarshalErr != nil { + log.Debug(fmt.Sprintf("yarn curation: could not parse existing %s (%v); recreating it for the resolution-only plugin", yarn.YarnrcFileName, unmarshalErr)) + rc = map[string]interface{}{} + } + } + if rc == nil { + rc = map[string]interface{}{} + } + + // Normalize the existing "plugins" value into a slice we can append to. + var plugins []interface{} + if existing, ok := rc["plugins"].([]interface{}); ok { + plugins = existing + } + for _, p := range plugins { + if entry, ok := p.(map[string]interface{}); ok { + if path, _ := entry["path"].(string); path == pluginRelPath { + return nil // already registered + } + } + } + plugins = append(plugins, map[string]interface{}{ + "path": pluginRelPath, + "spec": pluginSpec, + }) + rc["plugins"] = plugins + + updated, err := yaml.Marshal(rc) + if err != nil { + return fmt.Errorf("marshalling %s: %w", yarn.YarnrcFileName, err) + } + return os.WriteFile(yarnrcPath, updated, 0600) +} + // Parse the dependencies into a Xray dependency tree format func parseYarnDependenciesMap(dependencies map[string]*bibuildutils.YarnDependency, rootXrayId string) (*xrayUtils.GraphNode, []string, error) { treeMap := make(map[string]xray.DepTreeNode) @@ -1382,6 +1467,46 @@ func findClaimingYarnWorkspaceRoot(targetDir string) (rootDir, memberRel string) } } +// attachWorkspaceMembersToRoot makes every workspace member a direct child of +// the root node so the tree walk reaches each member's subgraph. Yarn only links +// a member under the root when the root explicitly depends on it; otherwise +// members are siblings whose deps would be orphaned. Root curation audits only; +// already-linked members are deduped. +func attachWorkspaceMembersToRoot(dependenciesMap map[string]*bibuildutils.YarnDependency, root *bibuildutils.YarnDependency) { + const workspaceMarker = "@workspace:" + const rootWorkspaceSuffix = "@workspace:." + if root == nil { + return + } + linked := map[string]struct{}{} + for _, ptr := range root.Details.Dependencies { + linked[bibuildutils.GetYarnDependencyKeyFromLocator(ptr.Locator)] = struct{}{} + } + var attached []string + for _, dep := range dependenciesMap { + if dep == nil || dep == root { + continue + } + // Only member workspaces; skip non-workspace packages and the root itself. + if !strings.Contains(dep.Value, workspaceMarker) || strings.HasSuffix(dep.Value, rootWorkspaceSuffix) { + continue + } + key := bibuildutils.GetYarnDependencyKeyFromLocator(dep.Value) + if _, already := linked[key]; already { + continue + } + root.Details.Dependencies = append(root.Details.Dependencies, bibuildutils.YarnDependencyPointer{Locator: dep.Value}) + linked[key] = struct{}{} + attached = append(attached, dep.Value) + } + if len(attached) > 0 { + slices.Sort(attached) + log.Debug(fmt.Sprintf( + "yarn curation: attached %d workspace member(s) to the root so their dependencies are audited: %s", + len(attached), strings.Join(attached, ", "))) + } +} + // filterYarnDepMapToWorkspaceMember returns the subgraph of dependenciesMap // reachable from the workspace entry whose Value ends in "@workspace:", // along with that entry as memberRoot. Returns an error when no matching entry @@ -1499,7 +1624,7 @@ func setYarnConfigNpmRegistryServer(yarnExecPath, workingDir, registryURL string // yarnrcFile is the subset of .yarnrc.yml fields we need for curation. type yarnrcFile struct { - NpmAuthToken string `yaml:"npmAuthToken"` + NpmAuthToken string `yaml:"npmAuthToken"` NpmRegistries map[string]yarnrcRegistryEntry `yaml:"npmRegistries"` } From e29283f77fcbeb408dbdd605401e23d402d5caac Mon Sep 17 00:00:00 2001 From: Gauri Yadav Date: Thu, 18 Jun 2026 11:26:10 +0530 Subject: [PATCH 3/4] XRAY-144147 - Onboarding yarn v4 --- commands/curation/curationaudit.go | 22 ++- commands/curation/curationaudit_test.go | 137 +++++++++++-- sca/bom/buildinfo/technologies/yarn/yarn.go | 69 ++----- .../buildinfo/technologies/yarn/yarn_test.go | 184 ++++++++++++++++-- 4 files changed, 325 insertions(+), 87 deletions(-) diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index a3a977d8c..00ad4f63b 100644 --- a/commands/curation/curationaudit.go +++ b/commands/curation/curationaudit.go @@ -471,12 +471,18 @@ func promoteYarnWorkspaceMember(techs []string) []string { 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 + } for _, indicator := range []string{".yarnrc.yml", "yarn.lock", ".yarnrc"} { if _, statErr := os.Stat(filepath.Join(dir, indicator)); statErr == nil { log.Debug(fmt.Sprintf("Detected yarn workspace root at %s via %s; promoting current directory from npm to yarn.", dir, indicator)) @@ -1010,7 +1016,7 @@ func (ca *CurationAuditCommand) SetRepo(tech techutils.Technology) error { 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("4.0.0") <= 0 { + if yarnVersion.Compare(yarntech.YarnV4Version) <= 0 { return ca.setRepoFromYarnrcForYarnV4(yarnExecPath, workingDir) } // V2/V3: fall through to getRepoParams (yarn.yaml / npm.yaml). @@ -1170,11 +1176,15 @@ func (ca *CurationAuditCommand) setRepoFromYarnrcForYarnV4(yarnExecPath, working } } else { log.Debug("yarn V4: no token in .yarnrc.yml — using 'jf c' server credentials") - serverDetails, err = ca.ServerDetails() - if err != nil || serverDetails == nil { - return fmt.Errorf("yarn V4: no auth token found in .yarnrc.yml and no 'jf c' server configured: %w", err) + 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) } - serverDetails.ArtifactoryUrl = registryConfig.ArtifactoryUrl + // 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{}). @@ -1185,7 +1195,7 @@ func (ca *CurationAuditCommand) setRepoFromYarnrcForYarnV4(yarnExecPath, working // the correct repository name. For V4 native mode the user never passes --deps-repo, // so ca.DepsRepo() would otherwise be "" and the curation endpoint URL would not be // constructed in configureYarnResolutionServerAndRunInstall. - ca.AuditParamsInterface.SetDepsRepo(registryConfig.RepoName) + 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 } diff --git a/commands/curation/curationaudit_test.go b/commands/curation/curationaudit_test.go index 010b64953..551d19006 100644 --- a/commands/curation/curationaudit_test.go +++ b/commands/curation/curationaudit_test.go @@ -1801,31 +1801,20 @@ func TestFetchNodesStatusConcurrentMapWrite(t *testing.T) { assert.Equal(t, numNodes, count, "expected all %d packages to be recorded as blocked", numNodes) } -// TestValidateRunNativeForTech checks that --run-native is accepted for npm and -// pnpm (both .npmrc-based) and rejected for all other techs with an error that -// names the offending tech. +// TestValidateRunNativeForTech checks that --run-native is accepted for the +// allow-listed native-config techs (npm, pnpm, yarn) and rejected for all other +// techs with an error that names the offending tech. func TestValidateRunNativeForTech(t *testing.T) { - // Sanity: npm and pnpm are the allow-listed techs. Both flag states pass. + // Sanity: npm and pnpm are allow-listed techs. Both flag states pass. assert.NoError(t, validateRunNativeForTech(techutils.Npm, true)) assert.NoError(t, validateRunNativeForTech(techutils.Npm, false)) assert.NoError(t, validateRunNativeForTech(techutils.Pnpm, true)) assert.NoError(t, validateRunNativeForTech(techutils.Pnpm, false)) - // The failing-test scenario from the bug report: yarn + --run-native - // must exit non-zero with a yarn-named error that points the user at - // the supported config flow. - t.Run("yarn rejects --run-native with actionable message", func(t *testing.T) { - err := validateRunNativeForTech(techutils.Yarn, true) - if assert.Error(t, err) { - msg := err.Error() - // Tech-neutral phrasing — the message must not hard-code - // "only supported for npm", because the allow-list is the - // source of truth and may grow over time. - assert.Contains(t, msg, "--run-native is not supported for 'yarn' projects") - assert.Contains(t, msg, "jf yarn-config", "the error must point the user at the supported config flow") - } - // Without the flag, yarn must pass validation cleanly — the - // guard is strictly conditional on --run-native being on. + // 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). + t.Run("yarn accepts --run-native as a redundant no-op", func(t *testing.T) { + assert.NoError(t, validateRunNativeForTech(techutils.Yarn, true)) assert.NoError(t, validateRunNativeForTech(techutils.Yarn, false)) }) @@ -1949,6 +1938,11 @@ func TestResolveResolverTechForCuration(t *testing.T) { // when nothing matches walking up from CWD). restoreHome := clienttestutils.SetEnvWithCallbackAndAssert(t, coreutils.HomeDir, t.TempDir()) defer restoreHome() + // Defensive: isolate the OS home too so a real ~/.yarnrc.yml can't leak + // in if this code path ever starts probing os.UserHomeDir(). + dummyHome := t.TempDir() + t.Setenv("HOME", dummyHome) + t.Setenv("USERPROFILE", dummyHome) restoreCwd := changeDirForTest(t, tempProjectDir) defer restoreCwd() @@ -2019,6 +2013,13 @@ func TestResolveNpmYarnTech(t *testing.T) { } restoreHome := clienttestutils.SetEnvWithCallbackAndAssert(t, coreutils.HomeDir, t.TempDir()) defer restoreHome() + // resolveNpmYarnTech also probes the OS home (~/.yarnrc.yml) via + // os.UserHomeDir(); point it at an empty dir so a real one on the + // developer's machine can't leak in and flip "neither yaml" to yarn. + // HOME (unix) and USERPROFILE (windows) cover os.UserHomeDir on all OSes. + dummyHome := t.TempDir() + t.Setenv("HOME", dummyHome) + t.Setenv("USERPROFILE", dummyHome) restoreCwd := changeDirForTest(t, tempProjectDir) defer restoreCwd() @@ -2132,3 +2133,101 @@ func TestPromotePnpmWorkspaceMember(t *testing.T) { }) } } + +func TestPromoteYarnWorkspaceMember(t *testing.T) { + npm := techutils.Npm.String() + yarn := techutils.Yarn.String() + other := "maven" + + tests := []struct { + name string + techs []string + ancestorFile string // indicator file created in the ancestor dir ("" = none) + expectedHasYarn bool + expectedHasNpm bool + }{ + { + name: "already has yarn — no change", + techs: []string{yarn, npm}, + expectedHasYarn: true, + expectedHasNpm: true, + }, + { + name: "no npm — no change", + techs: []string{other}, + expectedHasYarn: false, + expectedHasNpm: false, + }, + { + name: "npm only, no ancestor indicator — no promotion", + techs: []string{npm}, + expectedHasYarn: false, + expectedHasNpm: true, + }, + { + name: "npm only, ancestor has .yarnrc.yml — promote", + techs: []string{npm}, + ancestorFile: ".yarnrc.yml", + expectedHasYarn: true, + expectedHasNpm: false, + }, + { + name: "npm only, ancestor has yarn.lock — promote", + techs: []string{npm}, + ancestorFile: "yarn.lock", + expectedHasYarn: true, + expectedHasNpm: false, + }, + { + name: "npm + other, ancestor has .yarnrc.yml — npm promoted, other kept", + techs: []string{npm, other}, + ancestorFile: ".yarnrc.yml", + expectedHasYarn: true, + expectedHasNpm: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + 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)) + } + t.Chdir(sub) + + result := promoteYarnWorkspaceMember(tc.techs) + + hasYarn, hasNpm := false, false + for _, tech := range result { + switch tech { + case yarn: + hasYarn = true + case npm: + hasNpm = true + } + } + assert.Equal(t, tc.expectedHasYarn, hasYarn, "yarn presence mismatch") + assert.Equal(t, tc.expectedHasNpm, hasNpm, "npm presence mismatch") + }) + } + + // A personal ~/.yarnrc.yml must not misclassify an npm project under $HOME as a + // yarn workspace member: the walk stops at $HOME before statting it. + t.Run("indicator at $HOME — no promotion", func(t *testing.T) { + home := t.TempDir() + sub := filepath.Join(home, "project") + require.NoError(t, os.MkdirAll(sub, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(home, ".yarnrc.yml"), []byte{}, 0o644)) + // HOME (unix) and USERPROFILE (windows) cover os.UserHomeDir on all OSes. + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Chdir(sub) + + result := promoteYarnWorkspaceMember([]string{npm}) + + assert.Contains(t, result, npm, "npm should be kept when the only indicator is at $HOME") + assert.NotContains(t, result, yarn, "npm must not be promoted to yarn from a $HOME-level indicator") + }) +} diff --git a/sca/bom/buildinfo/technologies/yarn/yarn.go b/sca/bom/buildinfo/technologies/yarn/yarn.go index c048eec24..ccababd30 100644 --- a/sca/bom/buildinfo/technologies/yarn/yarn.go +++ b/sca/bom/buildinfo/technologies/yarn/yarn.go @@ -53,9 +53,10 @@ const ( v3UpdateLockfileFlag = "--mode=update-lockfile" // Ignores any build scripts v3SkipBuildFlag = "--mode=skip-build" - yarnV2Version = "2.0.0" - yarnV3Version = "3.0.0" - yarnV4Version = "4.0.0" + yarnV2Version = "2.0.0" + yarnV3Version = "3.0.0" + // YarnV4Version is the lowest version treated as Yarn V4 (native .yarnrc.yml mode). + YarnV4Version = "4.0.0" nodeModulesRepoName = "node_modules" // Command registered by the embedded resolution-only plugin. @@ -1056,8 +1057,11 @@ func runYarnCommandQuiet(executablePath, srcPath string, args ...string) error { // resolveCurationLockfileDir prepares the directory from which the curation // audit reads yarn.lock. When install is needed it copies the project to a // temp dir, configures the curation registry there, and runs -// 'yarn install --mode=update-lockfile' — so the customer's project is never -// modified and read-only CI checkouts still work. +// 'yarn install --mode=update-lockfile' — so the customer's project content is +// never modified and read-only CI checkouts still work. +// +// Exception: it bumps the original yarn.lock's mtime (touchYarnLock) so the +// next run skips re-resolution — mtime only, not content; failures are ignored. // // Returns: // - lockfileDir: where to read yarn.lock / run GetYarnDependencies from @@ -1125,28 +1129,9 @@ func configureYarnResolutionServerAndRunInstall(params technologies.BuildInfoBom } yarnVersion := version.NewVersion(executableYarnVersion) - // Yarn V4 uses native mode: credentials are already stored in .yarnrc.yml (copied into curWd - // by resolveCurationLockfileDir). For curation we rewrite the registry URL to the audit - // endpoint and set a global npmAuthToken — no credential injection via ModifyYarnConfigurations. - if yarnVersion.Compare(yarnV4Version) <= 0 { - if params.IsCurationCmd { - artiURL := strings.TrimSuffix(params.ServerDetails.ArtifactoryUrl, "/") - registry := fmt.Sprintf("%s/api/npm/%s/", artiURL, depsRepo) - curationRegistry := yarnCurationRegistry(registry) - log.Debug(fmt.Sprintf("Yarn V4 native mode: rewriting npmRegistryServer to curation endpoint %s", curationRegistry)) - if err = setYarnConfigNpmRegistryServer(yarnExecPath, curWd, curationRegistry); err != nil { - return err - } - // The original auth token in .yarnrc.yml is scoped to api/npm//. After - // rewriting the URL to api/curation/audit//, Yarn can no longer match the - // scoped token. Setting a global npmAuthToken ensures the curation endpoint is - // authenticated with the same credential. - if params.ServerDetails != nil && params.ServerDetails.AccessToken != "" { - if setErr := runYarnConfigSet(yarnExecPath, curWd, "npmAuthToken", params.ServerDetails.AccessToken); setErr != nil { - log.Warn(fmt.Sprintf("yarn V4: could not set global npmAuthToken for curation endpoint: %v", setErr)) - } - } - } + // V4 always uses native mode (.yarnrc.yml); --deps-repo / yarn.yaml are not applicable. + // If depsRepo is somehow non-empty for V4, skip credential injection and install as-is. + if yarnVersion.Compare(YarnV4Version) <= 0 { return runYarnInstallAccordingToVersion(curWd, yarnExecPath, params.InstallCommandArgs, params.IsCurationCmd) } @@ -1482,8 +1467,11 @@ func attachWorkspaceMembersToRoot(dependenciesMap map[string]*bibuildutils.YarnD for _, ptr := range root.Details.Dependencies { linked[bibuildutils.GetYarnDependencyKeyFromLocator(ptr.Locator)] = struct{}{} } + // Iterate in sorted key order so the appended root.Details.Dependencies (which + // feeds the tree walk) is deterministic across runs, not in map-random order. var attached []string - for _, dep := range dependenciesMap { + for _, key := range slices.Sorted(maps.Keys(dependenciesMap)) { + dep := dependenciesMap[key] if dep == nil || dep == root { continue } @@ -1491,16 +1479,15 @@ func attachWorkspaceMembersToRoot(dependenciesMap map[string]*bibuildutils.YarnD if !strings.Contains(dep.Value, workspaceMarker) || strings.HasSuffix(dep.Value, rootWorkspaceSuffix) { continue } - key := bibuildutils.GetYarnDependencyKeyFromLocator(dep.Value) - if _, already := linked[key]; already { + depKey := bibuildutils.GetYarnDependencyKeyFromLocator(dep.Value) + if _, already := linked[depKey]; already { continue } root.Details.Dependencies = append(root.Details.Dependencies, bibuildutils.YarnDependencyPointer{Locator: dep.Value}) - linked[key] = struct{}{} + linked[depKey] = struct{}{} attached = append(attached, dep.Value) } if len(attached) > 0 { - slices.Sort(attached) log.Debug(fmt.Sprintf( "yarn curation: attached %d workspace member(s) to the root so their dependencies are audited: %s", len(attached), strings.Join(attached, ", "))) @@ -1604,24 +1591,6 @@ func runYarnConfigGet(yarnExecPath, workingDir, key string) (string, error) { return strings.TrimSpace(string(out)), nil } -// runYarnConfigSet runs 'yarn config set ' in workingDir. -func runYarnConfigSet(yarnExecPath, workingDir, key, value string) error { - cmd := exec.Command(yarnExecPath, "config", "set", key, value) - cmd.Dir = workingDir - out, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("yarn config set %s failed: %w\n%s", key, err, strings.TrimSpace(string(out))) - } - return nil -} - -// setYarnConfigNpmRegistryServer runs 'yarn config set npmRegistryServer ' -// in workingDir. Used in V4 native curation mode to route installs through the -// curation audit endpoint without touching auth credentials. -func setYarnConfigNpmRegistryServer(yarnExecPath, workingDir, registryURL string) error { - return runYarnConfigSet(yarnExecPath, workingDir, "npmRegistryServer", registryURL) -} - // yarnrcFile is the subset of .yarnrc.yml fields we need for curation. type yarnrcFile struct { NpmAuthToken string `yaml:"npmAuthToken"` diff --git a/sca/bom/buildinfo/technologies/yarn/yarn_test.go b/sca/bom/buildinfo/technologies/yarn/yarn_test.go index 70401effd..71abef4b8 100644 --- a/sca/bom/buildinfo/technologies/yarn/yarn_test.go +++ b/sca/bom/buildinfo/technologies/yarn/yarn_test.go @@ -1008,23 +1008,24 @@ func TestBuildDependencyTreeWorkspaceRerouteIsCurationOnly(t *testing.T) { // The actual scope contract: the re-routing block in // BuildDependencyTree wraps the helper call in 'if params.IsCurationCmd'. - // Read the source and assert the gate is present so a future - // refactor that drops the guard fails this test loudly. + // Assert the guard sits directly before this specific call so a future + // refactor that drops it fails loudly. src, err := os.ReadFile("yarn.go") if assert.NoError(t, err, "must be able to read yarn.go to verify the curation-only gate") { - // Look for the exact gate pattern. Two things together: the - // IsCurationCmd predicate AND the helper call inside it. A weaker - // substring check would pass if either drifted to a different - // site, so we anchor on both. + // Anchor on the helper call and scan only the lines immediately before + // it for the gate. A plain strings.Index for the gate would match the + // *first* of several 'if params.IsCurationCmd {' blocks in this file and + // pass even if this specific call lost its guard. txt := string(src) - gateIdx := strings.Index(txt, "if params.IsCurationCmd {") helperIdx := strings.Index(txt, "findClaimingYarnWorkspaceRoot(currentDir)") - assert.NotEqual(t, -1, gateIdx, "BuildDependencyTree must contain 'if params.IsCurationCmd' guard for the workspace re-route") - assert.NotEqual(t, -1, helperIdx, "BuildDependencyTree must call findClaimingYarnWorkspaceRoot") - if gateIdx != -1 && helperIdx != -1 { - assert.Less(t, gateIdx, helperIdx, - "the IsCurationCmd guard must come BEFORE findClaimingYarnWorkspaceRoot — otherwise the re-routing fires for non-curation flows too") + require.NotEqual(t, -1, helperIdx, "BuildDependencyTree must call findClaimingYarnWorkspaceRoot") + windowStart := helperIdx - 200 + if windowStart < 0 { + windowStart = 0 } + context := txt[windowStart:helperIdx] + assert.Contains(t, context, "if params.IsCurationCmd {", + "findClaimingYarnWorkspaceRoot must be wrapped in an 'if params.IsCurationCmd' guard — otherwise the re-routing fires for non-curation flows too") } } @@ -1644,3 +1645,162 @@ func TestProbeBlockedDirectDeps(t *testing.T) { }) } } + +func TestRegisterYarnPluginInYarnrc(t *testing.T) { + const relPath = ".yarn/plugins/jfrog-yarn-resolve-lockfile.cjs" + const spec = "@yarnpkg/plugin-jfrog-yarn-resolve-lockfile" + const yarnrcName = ".yarnrc.yml" + + t.Run("creates yarnrc when absent", func(t *testing.T) { + curWd := t.TempDir() + require.NoError(t, registerYarnPluginInYarnrc(curWd, relPath, spec)) + data, err := os.ReadFile(filepath.Join(curWd, yarnrcName)) + require.NoError(t, err) + assert.Contains(t, string(data), relPath) + assert.Contains(t, string(data), spec) + }) + + t.Run("idempotent - no duplicate entry", func(t *testing.T) { + curWd := t.TempDir() + require.NoError(t, registerYarnPluginInYarnrc(curWd, relPath, spec)) + require.NoError(t, registerYarnPluginInYarnrc(curWd, relPath, spec)) + data, err := os.ReadFile(filepath.Join(curWd, yarnrcName)) + require.NoError(t, err) + assert.Equal(t, 1, strings.Count(string(data), relPath)) + }) + + t.Run("preserves unrelated settings", func(t *testing.T) { + curWd := t.TempDir() + yarnrc := "npmRegistryServer: \"https://example.com/artifactory/api/npm/repo/\"\nnpmAuthToken: secret-token\n" + require.NoError(t, os.WriteFile(filepath.Join(curWd, yarnrcName), []byte(yarnrc), 0o600)) + require.NoError(t, registerYarnPluginInYarnrc(curWd, relPath, spec)) + data, err := os.ReadFile(filepath.Join(curWd, yarnrcName)) + require.NoError(t, err) + assert.Contains(t, string(data), "npmRegistryServer") + assert.Contains(t, string(data), "secret-token") + assert.Contains(t, string(data), relPath) + }) + + t.Run("recovers from malformed yaml", func(t *testing.T) { + curWd := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(curWd, yarnrcName), []byte("{ not : valid : yaml ["), 0o600)) + require.NoError(t, registerYarnPluginInYarnrc(curWd, relPath, spec)) + data, err := os.ReadFile(filepath.Join(curWd, yarnrcName)) + require.NoError(t, err) + assert.Contains(t, string(data), relPath) + }) +} + +func TestAttachWorkspaceMembersToRoot(t *testing.T) { + newDep := func(value string, childLocators ...string) *bibuildutils.YarnDependency { + ptrs := make([]bibuildutils.YarnDependencyPointer, 0, len(childLocators)) + for _, locator := range childLocators { + ptrs = append(ptrs, bibuildutils.YarnDependencyPointer{Locator: locator}) + } + return &bibuildutils.YarnDependency{Value: value, Details: bibuildutils.YarnDepDetails{Dependencies: ptrs}} + } + rootChildLocators := func(root *bibuildutils.YarnDependency) []string { + var locs []string + for _, p := range root.Details.Dependencies { + locs = append(locs, p.Locator) + } + return locs + } + + t.Run("attaches unlinked workspace members in deterministic (sorted-key) order", func(t *testing.T) { + root := newDep("root@workspace:.") + depMap := map[string]*bibuildutils.YarnDependency{ + "root@workspace:.": root, + "ui@workspace:packages/ui": newDep("ui@workspace:packages/ui"), + "api@workspace:packages/api": newDep("api@workspace:packages/api"), + "lodash@npm:4.17.21": newDep("lodash@npm:4.17.21"), + } + attachWorkspaceMembersToRoot(depMap, root) + // Keys "api@workspace:packages/api" < "ui@workspace:packages/ui" so api comes first. + assert.Equal(t, []string{"api@workspace:packages/api", "ui@workspace:packages/ui"}, rootChildLocators(root)) + }) + + t.Run("dedups already-linked members", func(t *testing.T) { + root := newDep("root@workspace:.", "ui@workspace:packages/ui") + depMap := map[string]*bibuildutils.YarnDependency{ + "root@workspace:.": root, + "ui@workspace:packages/ui": newDep("ui@workspace:packages/ui"), + } + attachWorkspaceMembersToRoot(depMap, root) + assert.Equal(t, []string{"ui@workspace:packages/ui"}, rootChildLocators(root)) + }) + + t.Run("skips non-workspace deps and the root itself", func(t *testing.T) { + root := newDep("root@workspace:.") + depMap := map[string]*bibuildutils.YarnDependency{ + "root@workspace:.": root, + "lodash@npm:4.17.21": newDep("lodash@npm:4.17.21"), + } + attachWorkspaceMembersToRoot(depMap, root) + assert.Empty(t, rootChildLocators(root)) + }) + + t.Run("nil root is a no-op", func(t *testing.T) { + assert.NotPanics(t, func() { + attachWorkspaceMembersToRoot(map[string]*bibuildutils.YarnDependency{}, nil) + }) + }) +} + +func TestReadNpmAuthTokenFromYarnrcFiles(t *testing.T) { + const registryURL = "https://example.com/artifactory/api/npm/repo/" + scopedYarnrc := "npmRegistries:\n \"" + registryURL + "\":\n npmAuthToken: scoped-token\nnpmAuthToken: top-level-token\n" + + // setHome points os.UserHomeDir() at dir on every OS (HOME on unix, + // USERPROFILE on windows) so a real ~/.yarnrc.yml can't leak in. + setHome := func(t *testing.T, dir string) { + t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) + } + + t.Run("scoped registry entry wins over top-level", func(t *testing.T) { + wd := t.TempDir() + setHome(t, t.TempDir()) + require.NoError(t, os.WriteFile(filepath.Join(wd, ".yarnrc.yml"), []byte(scopedYarnrc), 0o600)) + assert.Equal(t, "scoped-token", readNpmAuthTokenFromYarnrcFiles(registryURL, wd)) + }) + + t.Run("falls back to top-level npmAuthToken", func(t *testing.T) { + wd := t.TempDir() + setHome(t, t.TempDir()) + require.NoError(t, os.WriteFile(filepath.Join(wd, ".yarnrc.yml"), []byte("npmAuthToken: top-level-token\n"), 0o600)) + assert.Equal(t, "top-level-token", readNpmAuthTokenFromYarnrcFiles(registryURL, wd)) + }) + + t.Run("global ~/.yarnrc.yml used when project file absent", func(t *testing.T) { + wd := t.TempDir() + home := t.TempDir() + setHome(t, home) + require.NoError(t, os.WriteFile(filepath.Join(home, ".yarnrc.yml"), []byte("npmAuthToken: global-token\n"), 0o600)) + assert.Equal(t, "global-token", readNpmAuthTokenFromYarnrcFiles(registryURL, wd)) + }) + + t.Run("project file takes priority over global", func(t *testing.T) { + wd := t.TempDir() + home := t.TempDir() + setHome(t, home) + require.NoError(t, os.WriteFile(filepath.Join(wd, ".yarnrc.yml"), []byte("npmAuthToken: project-token\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(home, ".yarnrc.yml"), []byte("npmAuthToken: global-token\n"), 0o600)) + assert.Equal(t, "project-token", readNpmAuthTokenFromYarnrcFiles(registryURL, wd)) + }) + + t.Run("malformed project yaml falls through to global", func(t *testing.T) { + wd := t.TempDir() + home := t.TempDir() + setHome(t, home) + require.NoError(t, os.WriteFile(filepath.Join(wd, ".yarnrc.yml"), []byte("{ not : valid : yaml ["), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(home, ".yarnrc.yml"), []byte("npmAuthToken: global-token\n"), 0o600)) + assert.Equal(t, "global-token", readNpmAuthTokenFromYarnrcFiles(registryURL, wd)) + }) + + t.Run("no token anywhere returns empty", func(t *testing.T) { + wd := t.TempDir() + setHome(t, t.TempDir()) + assert.Empty(t, readNpmAuthTokenFromYarnrcFiles(registryURL, wd)) + }) +} From 1261c7f86d83306caa7d07ffd68706691f1a0140 Mon Sep 17 00:00:00 2001 From: Gauri Yadav Date: Mon, 22 Jun 2026 11:09:45 +0530 Subject: [PATCH 4/4] XRAY-144147 - Fix registerYarnPluginInYarnrc test call sites after unparam refactor Co-authored-by: Cursor --- sca/bom/buildinfo/technologies/yarn/yarn_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/sca/bom/buildinfo/technologies/yarn/yarn_test.go b/sca/bom/buildinfo/technologies/yarn/yarn_test.go index 747965d51..02811da6e 100644 --- a/sca/bom/buildinfo/technologies/yarn/yarn_test.go +++ b/sca/bom/buildinfo/technologies/yarn/yarn_test.go @@ -1709,7 +1709,7 @@ func TestRegisterYarnPluginInYarnrc(t *testing.T) { t.Run("creates yarnrc when absent", func(t *testing.T) { curWd := t.TempDir() - require.NoError(t, registerYarnPluginInYarnrc(curWd, spec)) + require.NoError(t, registerYarnPluginInYarnrc(curWd)) data, err := os.ReadFile(filepath.Join(curWd, yarnrcName)) require.NoError(t, err) assert.Contains(t, string(data), resolveLockfilePluginRelPath) @@ -1718,8 +1718,8 @@ func TestRegisterYarnPluginInYarnrc(t *testing.T) { t.Run("idempotent - no duplicate entry", func(t *testing.T) { curWd := t.TempDir() - require.NoError(t, registerYarnPluginInYarnrc(curWd, spec)) - require.NoError(t, registerYarnPluginInYarnrc(curWd, spec)) + require.NoError(t, registerYarnPluginInYarnrc(curWd)) + require.NoError(t, registerYarnPluginInYarnrc(curWd)) data, err := os.ReadFile(filepath.Join(curWd, yarnrcName)) require.NoError(t, err) assert.Equal(t, 1, strings.Count(string(data), resolveLockfilePluginRelPath)) @@ -1729,7 +1729,7 @@ func TestRegisterYarnPluginInYarnrc(t *testing.T) { curWd := t.TempDir() yarnrc := "npmRegistryServer: \"https://example.com/artifactory/api/npm/repo/\"\nnpmAuthToken: secret-token\n" require.NoError(t, os.WriteFile(filepath.Join(curWd, yarnrcName), []byte(yarnrc), 0o600)) - require.NoError(t, registerYarnPluginInYarnrc(curWd, spec)) + require.NoError(t, registerYarnPluginInYarnrc(curWd)) data, err := os.ReadFile(filepath.Join(curWd, yarnrcName)) require.NoError(t, err) assert.Contains(t, string(data), "npmRegistryServer") @@ -1740,7 +1740,7 @@ func TestRegisterYarnPluginInYarnrc(t *testing.T) { t.Run("recovers from malformed yaml", func(t *testing.T) { curWd := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(curWd, yarnrcName), []byte("{ not : valid : yaml ["), 0o600)) - require.NoError(t, registerYarnPluginInYarnrc(curWd, spec)) + require.NoError(t, registerYarnPluginInYarnrc(curWd)) data, err := os.ReadFile(filepath.Join(curWd, yarnrcName)) require.NoError(t, err) assert.Contains(t, string(data), resolveLockfilePluginRelPath)