diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index a33b50501..0435064a5 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 ( @@ -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 } @@ -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 { @@ -469,8 +479,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=` 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()} @@ -506,8 +566,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 @@ -521,6 +583,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 } @@ -622,8 +708,15 @@ 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") + } + // --run-native has no effect for yarn regardless of version; the registry is + // always read from the yarn-specific config (yarn.yaml for V2/V3, .yarnrc.yml for V4). + // 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") } // For yarn with no yarn.yaml, fall back to npm.yaml — npm and yarn share the same Artifactory npm API. resolverTech := resolveResolverTechForCuration(tech) @@ -943,6 +1036,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 @@ -984,6 +1101,8 @@ 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: {}, + // --run-native has no effect for yarn regardless of version; a warning is emitted in auditTree. + techutils.Yarn: {}, } if _, ok := supported[tech]; ok { return nil @@ -1070,6 +1189,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:///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") + 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 { diff --git a/commands/curation/curationaudit_test.go b/commands/curation/curationaudit_test.go index 4c8061045..3909e5dfb 100644 --- a/commands/curation/curationaudit_test.go +++ b/commands/curation/curationaudit_test.go @@ -2405,28 +2405,19 @@ func TestEffectiveParentVersion(t *testing.T) { } } +// 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. + // --run-native has no effect for yarn regardless of version; 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)) }) @@ -2550,6 +2541,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() @@ -2561,8 +2557,11 @@ func TestResolveResolverTechForCuration(t *testing.T) { func TestResolveNpmYarnTech(t *testing.T) { type setup struct { - writeYarnYaml bool - writeNpmYaml bool + writeYarnYaml bool + writeNpmYaml bool + writeLocalYarnrc bool // write .yarnrc.yml into the project dir (V4 native, local) + writeGlobalYarnrc bool // write .yarnrc.yml into dummyHome (V4 native, global) + writePackageLockJSON bool // write package-lock.json — marks project as npm, blocks promotion } testCases := []struct { name string @@ -2606,6 +2605,25 @@ func TestResolveNpmYarnTech(t *testing.T) { setup: setup{writeYarnYaml: true}, want: techutils.Maven.String(), }, + // V4 native-mode paths: no yarn.yaml / npm.yaml, detection via yarn indicator files. + { + name: "npm, local .yarnrc.yml present — promoted to yarn (V4 native, local indicator)", + tech: techutils.Npm.String(), + setup: setup{writeLocalYarnrc: true}, + want: techutils.Yarn.String(), + }, + { + name: "npm, global ~/.yarnrc.yml only — promoted to yarn (V4 native, global indicator)", + tech: techutils.Npm.String(), + setup: setup{writeGlobalYarnrc: true}, + want: techutils.Yarn.String(), + }, + { + name: "npm, yarn indicator present but package-lock.json exists — NOT promoted", + tech: techutils.Npm.String(), + setup: setup{writeLocalYarnrc: true, writePackageLockJSON: true}, + want: techutils.Npm.String(), + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { @@ -2618,8 +2636,24 @@ func TestResolveNpmYarnTech(t *testing.T) { if tc.setup.writeNpmYaml { require.NoError(t, os.WriteFile(filepath.Join(projectsDir, "npm.yaml"), []byte("resolver:\n serverId: test\n repo: irrelevant-npm-repo\n"), 0o644)) } + if tc.setup.writeLocalYarnrc { + require.NoError(t, os.WriteFile(filepath.Join(tempProjectDir, ".yarnrc.yml"), []byte("npmRegistryServer: https://example.com\n"), 0o644)) + } + if tc.setup.writePackageLockJSON { + require.NoError(t, os.WriteFile(filepath.Join(tempProjectDir, "package-lock.json"), []byte("{}"), 0o644)) + } 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) + if tc.setup.writeGlobalYarnrc { + require.NoError(t, os.WriteFile(filepath.Join(dummyHome, ".yarnrc.yml"), []byte("npmRegistryServer: https://example.com\n"), 0o644)) + } restoreCwd := changeDirForTest(t, tempProjectDir) defer restoreCwd() @@ -2733,3 +2767,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/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 904044e58..983b70c04 100644 --- a/sca/bom/buildinfo/technologies/yarn/yarn.go +++ b/sca/bom/buildinfo/technologies/yarn/yarn.go @@ -2,10 +2,10 @@ package yarn import ( "bytes" + _ "embed" "encoding/json" "errors" "fmt" - "io" "maps" "net/http" "os" @@ -18,6 +18,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 +30,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" @@ -50,13 +52,27 @@ const ( // Skips linking and fetch only packages that are missing from yarn.lock file v3UpdateLockfileFlag = "--mode=update-lockfile" // Ignores any build scripts - v3SkipBuildFlag = "--mode=skip-build" - yarnV2Version = "2.0.0" - yarnV3Version = "3.0.0" - yarnV4Version = "4.0.0" + v3SkipBuildFlag = "--mode=skip-build" + 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. + 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 { @@ -91,9 +107,9 @@ func BuildDependencyTree(params technologies.BuildInfoBomGeneratorParams) (depen // Curation issues per-package HEAD requests to Artifactory, which only // return meaningful curation JSON for packages Artifactory has resolved. - // The jfrog-cli yarn integration only resolves through Artifactory for - // Yarn V2/V3, so V1 and V4 would silently bypass Artifactory and produce - // unreliable curation results. Reject those versions up front. + // The jfrog-cli yarn integration resolves through Artifactory for Yarn + // V2/V3/V4; only V1 (classic) silently bypasses it and produces unreliable + // curation results, so reject V1 up front. if params.IsCurationCmd { if err = verifyYarnVersionSupportedForCuration(executablePath, currentDir); err != nil { return @@ -105,51 +121,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 } @@ -163,6 +175,8 @@ func BuildDependencyTree(params technologies.BuildInfoBomGeneratorParams) (depen err = errorutils.CheckErrorf("could not identify the root workspace from yarn dependency output") return } + // Normalize workspace versions for display (drop Yarn's "-use.local"). + stripWorkspaceUseLocalSuffix(dependenciesMap) // When --working-dirs targets a workspace member, prune dependenciesMap // to the subgraph reachable from that member and reset root accordingly. // This keeps the dependency tree and the uniqueDeps list @@ -178,6 +192,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 @@ -185,7 +204,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 +243,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 +1035,97 @@ 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 jfrog-yarn-resolve-lockfile' (V3/V4) or 'yarn install' (V2) — +// 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 +// - 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 + } + + // Mark yarn.lock as fresh so the next run skips re-resolution. + touchYarnLock(currentDir) + + return tmpDir, cleanup, deferredInstallErr, nil +} + +// shouldRouteThroughCurationEndpoint reports whether 'yarn install' should hit +// the api/curation/audit endpoint instead of the plain Artifactory npm repo. +// Only Yarn V2 needs this: it has no lockfile-only mode, so blocked packages +// must 403 at the curation endpoint. V3/V4 resolve from the plain repo (the +// curation endpoint returns 403 HTML the plugin can't parse) and enforce +// curation afterwards via the HEAD-walker. +func shouldRouteThroughCurationEndpoint(yarnVersion *version.Version, isCurationCmd bool) bool { + return isCurationCmd && yarnVersion.Compare(yarnV3Version) > 0 +} + // 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 +1139,17 @@ 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) + + // 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) } - // 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 @@ -1064,8 +1160,7 @@ func configureYarnResolutionServerAndRunInstall(params technologies.BuildInfoBom return errors.Join(err, restoreYarnrcFunc()) } - // For curation, route installs through the api/curation/audit endpoint. - if params.IsCurationCmd { + if shouldRouteThroughCurationEndpoint(yarnVersion, params.IsCurationCmd) { registry = yarnCurationRegistry(registry) } log.Debug(fmt.Sprintf("Yarn npmRegistryServer set to: %s", registry)) @@ -1115,18 +1210,64 @@ func isInstallRequired(currentDir string, installCommandArgs []string, skipAutoI return false, nil } -// isYarnLockStale reports whether package.json is newer than yarn.lock. -// Stat errors are treated as "not stale" to avoid unnecessary re-installs. +// isYarnLockStale reports whether yarn.lock needs regeneration. +// If package.json is newer by mtime it does a specifier-coverage check: if +// every declared direct dep already has an entry in yarn.lock the lockfile is +// still fresh (handles yarn V4 stamping packageManager in package.json after +// writing yarn.lock, which would otherwise always trigger re-resolution). func isYarnLockStale(curWd string) bool { pkgJsonStat, err := os.Stat(filepath.Join(curWd, "package.json")) if err != nil { return false } - lockStat, err := os.Stat(filepath.Join(curWd, yarn.YarnLockFileName)) + lockPath := filepath.Join(curWd, yarn.YarnLockFileName) + lockStat, err := os.Stat(lockPath) if err != nil { return false } - return pkgJsonStat.ModTime().After(lockStat.ModTime()) + if !pkgJsonStat.ModTime().After(lockStat.ModTime()) { + return false + } + return yarnLockMissesDeclaredDeps(curWd, lockPath) +} + +// yarnLockMissesDeclaredDeps returns true if any direct dep declared in +// package.json has no entry in yarn.lock (Berry quoted format: "dep@...). +func yarnLockMissesDeclaredDeps(curWd, lockPath string) bool { + pkgData, err := os.ReadFile(filepath.Join(curWd, "package.json")) + if err != nil { + return true + } + var pkg struct { + Dependencies map[string]string `json:"dependencies"` + DevDependencies map[string]string `json:"devDependencies"` + } + if err = json.Unmarshal(pkgData, &pkg); err != nil { + return true + } + lockData, err := os.ReadFile(lockPath) + if err != nil { + return true + } + lockContent := string(lockData) + for dep := range pkg.Dependencies { + if !strings.Contains(lockContent, `"`+dep+`@`) { + return true + } + } + for dep := range pkg.DevDependencies { + if !strings.Contains(lockContent, `"`+dep+`@`) { + return true + } + } + return false +} + +// 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 @@ -1175,41 +1316,115 @@ 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) +} + +// 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 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 == resolveLockfilePluginRelPath { + return nil // already registered + } + } + } + plugins = append(plugins, map[string]interface{}{ + "path": resolveLockfilePluginRelPath, + "spec": resolveLockfilePluginSpec, + }) + 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 +// stripWorkspaceUseLocalSuffix drops Yarn's "-use.local" marker so workspace +// versions display as declared (e.g. 0.0.0), consistent across lockfile/plugin +// paths. Display-only; members are excluded from the HEAD-check regardless. +func stripWorkspaceUseLocalSuffix(dependencies map[string]*bibuildutils.YarnDependency) { + for _, dep := range dependencies { + if dep != nil && strings.Contains(dep.Value, "@workspace:") { + dep.Details.Version = strings.TrimSuffix(dep.Details.Version, "-use.local") + } + } +} + func parseYarnDependenciesMap(dependencies map[string]*bibuildutils.YarnDependency, rootXrayId string) (*xrayUtils.GraphNode, []string, error) { treeMap := make(map[string]xray.DepTreeNode) + workspaceMemberIds := make(map[string]bool) for _, dependency := range dependencies { xrayDepId, err := getXrayDependencyId(dependency) if err != nil { return nil, nil, err } + // Workspace members are local packages, not registry artifacts (the root + // is exempt). Track them so they're skipped by the curation HEAD-check. + if strings.Contains(dependency.Value, "@workspace:") && xrayDepId != rootXrayId { + workspaceMemberIds[xrayDepId] = true + } var subDeps []string for _, subDepPtr := range dependency.Details.Dependencies { subDep := dependencies[bibuildutils.GetYarnDependencyKeyFromLocator(subDepPtr.Locator)] @@ -1224,7 +1439,17 @@ func parseYarnDependenciesMap(dependencies map[string]*bibuildutils.YarnDependen } } graph, uniqDeps := xray.BuildXrayDependencyTree(treeMap, rootXrayId) - return graph, slices.Collect(maps.Keys(uniqDeps)), nil + // Drop workspace members from the flat list curation HEAD-checks: a local + // package coincidentally matching a public one would be a false positive. + // They stay in the graph so their dependencies remain attributed to them. + uniqueDepsList := make([]string, 0, len(uniqDeps)) + for id := range uniqDeps { + if workspaceMemberIds[id] { + continue + } + uniqueDepsList = append(uniqueDepsList, id) + } + return graph, uniqueDepsList, nil } func getXrayDependencyId(yarnDependency *bibuildutils.YarnDependency) (string, error) { @@ -1305,6 +1530,48 @@ 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{}{} + } + // 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 _, key := range slices.Sorted(maps.Keys(dependenciesMap)) { + dep := dependenciesMap[key] + 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 + } + depKey := bibuildutils.GetYarnDependencyKeyFromLocator(dep.Value) + if _, already := linked[depKey]; already { + continue + } + root.Details.Dependencies = append(root.Details.Dependencies, bibuildutils.YarnDependencyPointer{Locator: dep.Value}) + linked[depKey] = struct{}{} + attached = append(attached, dep.Value) + } + if len(attached) > 0 { + 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 @@ -1357,3 +1624,91 @@ 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 +} + +// 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 "" +} diff --git a/sca/bom/buildinfo/technologies/yarn/yarn_test.go b/sca/bom/buildinfo/technologies/yarn/yarn_test.go index 70401effd..36ac4b324 100644 --- a/sca/bom/buildinfo/technologies/yarn/yarn_test.go +++ b/sca/bom/buildinfo/technologies/yarn/yarn_test.go @@ -15,6 +15,7 @@ import ( bibuildutils "github.com/jfrog/build-info-go/build/utils" biutils "github.com/jfrog/build-info-go/utils" coreCommonTests "github.com/jfrog/jfrog-cli-core/v2/common/tests" + "github.com/jfrog/gofrog/version" "github.com/jfrog/jfrog-cli-core/v2/utils/tests" "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies" "github.com/jfrog/jfrog-cli-security/utils/techutils" @@ -65,6 +66,32 @@ func TestParseYarnDependenciesMap(t *testing.T) { expectedUniqueDeps: []string{npmId + "pack1:1.0.0", npmId + "pack2:2.0.0", npmId + "pack4:4.0.0", npmId + "pack5:5.0.0", npmId + "@jfrog/pack3:3.0.0"}, errorExpected: false, }, + { + // Workspace members are local packages, not registry artifacts: they must + // stay in the graph (so their deps attribute to them) but be dropped from + // the flat uniqueDeps list curation HEAD-checks, otherwise a coincidental + // public package of the same name/version is reported as a false positive. + name: "Workspace member excluded from uniqueDeps but kept in tree", + yarnDependencies: map[string]*bibuildutils.YarnDependency{ + "root@workspace:.": {Value: "root@workspace:.", Details: bibuildutils.YarnDepDetails{Version: "1.0.0", Dependencies: []bibuildutils.YarnDependencyPointer{{Locator: "ui@workspace:packages/ui"}}}}, + "ui@workspace:packages/ui": {Value: "ui@workspace:packages/ui", Details: bibuildutils.YarnDepDetails{Version: "0.0.0", Dependencies: []bibuildutils.YarnDependencyPointer{{Locator: "express@npm:3.0.1"}}}}, + "express@npm:3.0.1": {Value: "express@npm:3.0.1", Details: bibuildutils.YarnDepDetails{Version: "3.0.1"}}, + }, + rootXrayId: npmId + "root:1.0.0", + expectedTree: &xrayUtils.GraphNode{ + Id: npmId + "root:1.0.0", + Nodes: []*xrayUtils.GraphNode{ + {Id: npmId + "ui:0.0.0", + Nodes: []*xrayUtils.GraphNode{ + {Id: npmId + "express:3.0.1", + Nodes: []*xrayUtils.GraphNode{}}, + }}, + }, + }, + // ui (workspace member) is absent; root and express remain. + expectedUniqueDeps: []string{npmId + "root:1.0.0", npmId + "express:3.0.1"}, + errorExpected: false, + }, { name: "Incorrect formatted dependency name - error expected", yarnDependencies: map[string]*bibuildutils.YarnDependency{ @@ -89,6 +116,22 @@ func TestParseYarnDependenciesMap(t *testing.T) { } } +func TestStripWorkspaceUseLocalSuffix(t *testing.T) { + deps := map[string]*bibuildutils.YarnDependency{ + "ui@workspace:packages/ui": {Value: "ui@workspace:packages/ui", Details: bibuildutils.YarnDepDetails{Version: "0.0.0-use.local"}}, + "root@workspace:.": {Value: "root@workspace:.", Details: bibuildutils.YarnDepDetails{Version: "1.0.0"}}, + "axios@npm:1.6.0": {Value: "axios@npm:1.6.0", Details: bibuildutils.YarnDepDetails{Version: "1.6.0"}}, + } + stripWorkspaceUseLocalSuffix(deps) + + // Workspace member: suffix stripped. + assert.Equal(t, "0.0.0", deps["ui@workspace:packages/ui"].Details.Version) + // Workspace root with a real version: unchanged. + assert.Equal(t, "1.0.0", deps["root@workspace:."].Details.Version) + // Registry package: never touched. + assert.Equal(t, "1.6.0", deps["axios@npm:1.6.0"].Details.Version) +} + func TestIsInstallRequired(t *testing.T) { tempDirPath, createTempDirCallback := tests.CreateTempDirWithCallbackAndAssert(t) defer createTempDirCallback() @@ -200,13 +243,11 @@ func TestIsYarnLockStale(t *testing.T) { pkgJsonPath := filepath.Join(tempDirPath, "package.json") lockPath := filepath.Join(tempDirPath, "yarn.lock") - // Neither file present => staleness is undefined; treat as not stale so - // the check never forces an install on its own. + // Neither file present => not stale (caller handles missing lockfile separately). assert.False(t, isYarnLockStale(tempDirPath)) assert.NoError(t, os.WriteFile(pkgJsonPath, []byte(`{"name":"x"}`), 0o644)) - // Only package.json present => same "undefined => not stale" contract - // (the caller handles missing-lockfile via fileutils.IsFileExists). + // Only package.json present => not stale. assert.False(t, isYarnLockStale(tempDirPath)) assert.NoError(t, os.WriteFile(lockPath, []byte(""), 0o644)) @@ -215,10 +256,24 @@ func TestIsYarnLockStale(t *testing.T) { assert.NoError(t, os.Chtimes(pkgJsonPath, older, older)) assert.False(t, isYarnLockStale(tempDirPath)) - // package.json edited after lockfile written => stale. + // package.json newer than lockfile AND lockfile covers all declared deps => fresh. + // Simulates 'yarn install' writing yarn.lock then stamping packageManager in package.json. + lockBerry := `__metadata: + version: 8 + +"lodash@npm:^4.17.21": + version: 4.17.21 +` + assert.NoError(t, os.WriteFile(pkgJsonPath, []byte(`{"dependencies":{"lodash":"^4.17.21"}}`), 0o644)) + assert.NoError(t, os.WriteFile(lockPath, []byte(lockBerry), 0o644)) newer := time.Now().Add(1 * time.Hour) assert.NoError(t, os.Chtimes(pkgJsonPath, newer, newer)) - assert.True(t, isYarnLockStale(tempDirPath)) + assert.False(t, isYarnLockStale(tempDirPath), "lockfile covers all deps — must not be stale even when package.json is newer") + + // package.json newer AND a dep is missing from lockfile => stale. + assert.NoError(t, os.WriteFile(pkgJsonPath, []byte(`{"dependencies":{"lodash":"^4.17.21","express":"^5.0.0"}}`), 0o644)) + assert.NoError(t, os.Chtimes(pkgJsonPath, newer, newer)) + assert.True(t, isYarnLockStale(tempDirPath), "missing dep in lockfile must be stale") } // TestIsInstallRequiredOverwriteYarnLock covers the overwriteYarnLock branch @@ -231,7 +286,10 @@ func TestIsInstallRequiredOverwriteYarnLock(t *testing.T) { pkgJsonPath := filepath.Join(tempDirPath, "package.json") lockPath := filepath.Join(tempDirPath, "yarn.lock") - assert.NoError(t, os.WriteFile(pkgJsonPath, []byte(`{"name":"x"}`), 0o644)) + // A declared dep that the (empty) lockfile does not cover, so the + // specifier-coverage check in isYarnLockStale reports staleness once + // package.json is the newer file. + assert.NoError(t, os.WriteFile(pkgJsonPath, []byte(`{"name":"x","dependencies":{"lodash":"^4.17.21"}}`), 0o644)) assert.NoError(t, os.WriteFile(lockPath, []byte(""), 0o644)) // yarn.lock is newer than package.json => fresh in either overwrite mode. @@ -1008,23 +1066,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") } } @@ -1510,6 +1569,25 @@ func TestYarnCurationRegistry(t *testing.T) { } } +func TestShouldRouteThroughCurationEndpoint(t *testing.T) { + cases := []struct { + name string + yarnVersion string + isCurationCmd bool + want bool + }{ + {"V2 + curation cmd → route through endpoint", "2.5.0", true, true}, + {"V2 + non-curation cmd → skip endpoint", "2.5.0", false, false}, + {"V3 + curation cmd → skip endpoint", "3.0.0", true, false}, + {"V4 + curation cmd → skip endpoint", "4.0.0", true, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, shouldRouteThroughCurationEndpoint(version.NewVersion(tc.yarnVersion), tc.isCurationCmd)) + }) + } +} + // TestBlockedDepJSONRowTagsMatchPackageStatus pins the JSON field contract of // blockedDepJSONRow and blockedDepPolicyJSON against the expected tags of // commands/curation.PackageStatus and commands/curation.Policy. An import cycle @@ -1644,3 +1722,161 @@ func TestProbeBlockedDirectDeps(t *testing.T) { }) } } + +func TestRegisterYarnPluginInYarnrc(t *testing.T) { + 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)) + data, err := os.ReadFile(filepath.Join(curWd, yarnrcName)) + require.NoError(t, err) + assert.Contains(t, string(data), resolveLockfilePluginRelPath) + assert.Contains(t, string(data), spec) + }) + + t.Run("idempotent - no duplicate entry", func(t *testing.T) { + curWd := t.TempDir() + 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)) + }) + + 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)) + 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), resolveLockfilePluginRelPath) + }) + + 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)) + data, err := os.ReadFile(filepath.Join(curWd, yarnrcName)) + require.NoError(t, err) + assert.Contains(t, string(data), resolveLockfilePluginRelPath) + }) +} + +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)) + }) +}