diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index 0053baa59..3357d4771 100644 --- a/commands/curation/curationaudit.go +++ b/commands/curation/curationaudit.go @@ -58,7 +58,6 @@ import ( "github.com/jfrog/build-info-go/build/utils/dotnet/dependencies" bibuildutils "github.com/jfrog/build-info-go/build/utils" - "github.com/jfrog/gofrog/version" uvtech "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/uv" yarntech "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/yarn" ) @@ -937,7 +936,7 @@ func validateCurationAuditFlags(ca *CurationAuditCommand) error { // 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. +// without a yarn.yaml — which is the native .yarnrc.yml mode case where no jf yarn-config is needed. func resolveNpmYarnTech(tech string) string { if techutils.Technology(tech) != techutils.Npm { return tech @@ -951,7 +950,7 @@ 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 + // Native .yarnrc.yml 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. @@ -973,7 +972,7 @@ func resolveNpmYarnTech(tech string) string { if projectPinsYarnPackageManager(workingDir) { 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 package.json pins yarn and global ~/.yarnrc.yml detected — treating project as yarn (V4 native mode).") + log.Info("No npm.yaml or yarn.yaml found but package.json pins yarn and global ~/.yarnrc.yml detected — treating project as yarn (native .yarnrc.yml mode).") return techutils.Yarn.String() } } @@ -998,23 +997,6 @@ func projectPinsYarnPackageManager(workingDir string) bool { return strings.HasPrefix(strings.TrimSpace(pkg.PackageManager), "yarn@") } -// resolveResolverTechForCuration returns the tech whose *.yaml config drives -// SetResolutionRepoInParamsIfExists. For yarn with no yarn.yaml, falls back to -// npm.yaml — npm and yarn share the same Artifactory npm API. -func resolveResolverTechForCuration(tech techutils.Technology) techutils.Technology { - if tech != techutils.Yarn { - return tech - } - if _, yarnConfigExists, _ := project.GetProjectConfFilePath(techutils.Yarn.GetProjectType()); yarnConfigExists { - return tech - } - if _, npmConfigExists, _ := project.GetProjectConfFilePath(techutils.Npm.GetProjectType()); !npmConfigExists { - return tech - } - log.Info("No yarn.yaml found; using npm.yaml for resolver configuration (npm and yarn share the same Artifactory npm API).") - return techutils.Npm -} - func (ca *CurationAuditCommand) getRtManagerAndAuth(tech techutils.Technology) (rtManager artifactory.ArtifactoryServicesManager, serverDetails *config.ServerDetails, err error) { serverDetails, err = ca.GetAuth(tech) if err != nil { @@ -1046,14 +1028,14 @@ func (ca *CurationAuditCommand) GetAuth(tech techutils.Technology) (serverDetail return } -// getBuildInfoParamsByTech resolves install-time server details. For Pipenv/Pip/Poetry, prefers an +// getBuildInfoParamsByTech resolves install-time server details. For Pipenv/Pip/Poetry/Yarn, prefers an // already-set ca.PackageManagerConfig (native detection) over the generic server so install // and the later GetAuth-based probes hit the same endpoint. Other techs keep using the // generic server, matching their pre-existing behavior. func (ca *CurationAuditCommand) getBuildInfoParamsByTech(tech techutils.Technology) (technologies.BuildInfoBomGeneratorParams, error) { var serverDetails *config.ServerDetails var err error - if (tech == techutils.Pipenv || tech == techutils.Pip || tech == techutils.Poetry) && ca.PackageManagerConfig != nil { + if (tech == techutils.Pipenv || tech == techutils.Pip || tech == techutils.Poetry || tech == techutils.Yarn) && ca.PackageManagerConfig != nil { serverDetails, err = ca.PackageManagerConfig.ServerDetails() } else { serverDetails, err = ca.ServerDetails() @@ -1134,10 +1116,12 @@ func (ca *CurationAuditCommand) auditTree(tech techutils.Technology, results map return err } } - // Resolve Pipenv/Pip/Poetry's native repo/server before getBuildInfoParamsByTech so install and the - // later probes share an endpoint. Other techs resolve later via SetResolutionRepoInParamsIfExists - // and must not be forced through SetRepo this early (they tolerate having no config file yet). - if (tech == techutils.Pipenv || tech == techutils.Pip || tech == techutils.Poetry) && ca.PackageManagerConfig == nil { + // Resolve Pipenv/Pip/Poetry/Yarn's native repo/server early, before getBuildInfoParamsByTech, + // so install and the later probes share an endpoint. Other techs resolve later via + // SetResolutionRepoInParamsIfExists. A failure here is fatal for all four — none has a further + // fallback: pip already checked pip.yaml internally, pipenv/poetry never had one, + // and yarn deliberately skips yarn.yaml too. + if (tech == techutils.Pipenv || tech == techutils.Pip || tech == techutils.Poetry || tech == techutils.Yarn) && ca.PackageManagerConfig == nil { if err := ca.SetRepo(tech); err != nil { return err } @@ -1181,8 +1165,7 @@ func (ca *CurationAuditCommand) auditTree(tech techutils.Technology, results map if ca.RunNative() && tech == techutils.Pnpm { 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). + // --run-native has no effect for yarn; the registry is always read natively from .yarnrc.yml. // 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") @@ -1212,9 +1195,9 @@ func (ca *CurationAuditCommand) auditTree(tech techutils.Technology, results map if ca.RunNative() && tech == techutils.Pipenv { ca.pendingWarnings = append(ca.pendingWarnings, "--run-native has no effect for pipenv; the repository is resolved automatically from ~/.pip/pip.conf, or the Artifactory [[source]] entry in your Pipfile") } - // For yarn with no yarn.yaml, fall back to npm.yaml — npm and yarn share the same Artifactory npm API. - resolverTech := resolveResolverTechForCuration(tech) - serverDetails, err := buildinfo.SetResolutionRepoInParamsIfExists(¶ms, resolverTech) + // Pipenv/Pip/Poetry/Yarn already resolved above — a no-op for them here. + // Still applies to every other tech that resolves via a *.yaml config file (jf -config). + serverDetails, err := buildinfo.SetResolutionRepoInParamsIfExists(¶ms, tech) if err != nil { return err } @@ -1662,10 +1645,8 @@ func (ca *CurationAuditCommand) SetRepo(tech techutils.Technology) error { return ca.setRepoFromPyproject() } - // 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. + // Yarn V2, V3, and V4 all store registry config in the same .yarnrc.yml + // (Berry) format, so curation-audit resolves it natively. if tech == techutils.Yarn { yarnExecPath, yarnExecErr := bibuildutils.GetYarnExecutable() if yarnExecErr != nil { @@ -1673,39 +1654,26 @@ func (ca *CurationAuditCommand) SetRepo(tech techutils.Technology) error { } 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) + return fmt.Errorf("could not determine working directory for yarn native config resolution: %w", wdErr) } - yarnVersion := version.NewVersion(versionStr) - if yarnVersion.Compare(yarntech.YarnV4Version) <= 0 { - return ca.setRepoFromYarnrcForYarnV4(yarnExecPath, workingDir) + // Reject V1 first, or it would hit a confusing .yarnrc.yml error instead of + // the clear "Yarn V1 is not supported" message. + if err := yarntech.VerifyYarnVersionSupportedForCuration(yarnExecPath, workingDir); err != nil { + return err } - // V2/V3: fall through to getRepoParams (yarn.yaml / npm.yaml). + return ca.setRepoFromYarnrc(yarnExecPath, workingDir) } resolverParams, err := ca.getRepoParams(tech.GetProjectType()) if err != nil { - // npm and yarn share the same Artifactory npm API for curation, so their - // repository configs are interchangeable. Fall back to the sibling tech's - // config when the primary one is missing (e.g. the project was configured - // with 'jf yarn-config' but is detected as npm because yarn.lock is absent). - primaryErr := err - switch tech { - case techutils.Npm: - resolverParams, err = ca.getRepoParams(techutils.Yarn.GetProjectType()) - case techutils.Yarn: - resolverParams, err = ca.getRepoParams(techutils.Npm.GetProjectType()) - } - if err != nil { - // Return the primary tech's error so the user sees the correct command. - // Yarn's CLI config command is 'jf yarn-config', not 'jf yarn c'. - if tech == techutils.Yarn { - return errorutils.CheckErrorf("no config file was found! Before running jf ca on a yarn project for the first time, the project should be configured using the 'jf yarn-config' command") + // npm and yarn share the same Artifactory npm API for curation. + if tech == techutils.Npm { + primaryErr := err + if resolverParams, err = ca.getRepoParams(techutils.Yarn.GetProjectType()); err != nil { + return primaryErr } - return primaryErr + } else { + return err } } ca.setPackageManagerConfig(resolverParams) @@ -1955,39 +1923,39 @@ 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 +// setRepoFromYarnrc reads Artifactory connection details from the project's +// .yarnrc.yml via the Yarn CLI. Yarn V2, V3, and V4 all use 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. +// .yarnrc.yml already. This is always called for Yarn 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) +func (ca *CurationAuditCommand) setRepoFromYarnrc(yarnExecPath, workingDir string) error { + registryConfig, err := yarntech.GetNativeYarnRegistryConfig(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) + return fmt.Errorf("yarn: 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") + log.Debug("yarn: 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) + log.Debug("yarn: no token in .yarnrc.yml — using 'jf c' server credentials") + // .yarnrc.yml carries no token, so we're about to attach the configured 'jf c' + // server's real credentials to whatever host it declares. credentialFallbackServerDetails + // refuses that unless the declared host matches the configured server, same as + // pip/poetry/uv, so a project-controlled .yarnrc.yml can't redirect our credentials + // to an unrelated/attacker host. + serverDetails, err = ca.credentialFallbackServerDetails("yarn", ".yarnrc.yml", registryConfig.ArtifactoryUrl) + if err != nil { + return err } - // 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{}). @@ -1995,12 +1963,11 @@ func (ca *CurationAuditCommand) setRepoFromYarnrcForYarnV4(yarnExecPath, working 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 + // returns the correct repository name. In 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). + // downstream by the curation error messages and probeBlockedDirectDeps HEAD checks. ca.SetDepsRepo(registryConfig.RepoName) - log.Info(fmt.Sprintf("yarn V4: using Artifactory URL %q and repository %q from .yarnrc.yml", registryConfig.ArtifactoryUrl, registryConfig.RepoName)) + log.Info(fmt.Sprintf("yarn: 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 776ed9b3a..35d590a9b 100644 --- a/commands/curation/curationaudit_test.go +++ b/commands/curation/curationaudit_test.go @@ -11,6 +11,7 @@ import ( "os/exec" "path/filepath" "regexp" + "runtime" "sort" "strconv" "strings" @@ -4078,6 +4079,169 @@ url = "http://configured-server.example.com/artifactory/api/pypi/uv-test-repo/si assert.Nil(t, ca.PackageManagerConfig, "credentials must not be downgraded to a cleartext http URL on the same host") } +// yarnBerryExecutableForTest returns a "yarn" wrapper that resolves via Corepack to the +// Berry version pinned in the target project's package.json. This avoids relying on +// whatever "yarn" happens to be on PATH (which may be Classic/V1). Skips if corepack is +// missing. +func yarnBerryExecutableForTest(t *testing.T) string { + corepackPath, err := exec.LookPath("corepack") + if err != nil { + t.Skip("corepack not found on PATH; skipping test that requires Yarn Berry (.yarnrc.yml) support") + } + return writeYarnWrapperScript(t, t.TempDir(), + fmt.Sprintf("exec %q yarn \"$@\"\n", corepackPath), + fmt.Sprintf("\"%s\" yarn %%*\n", corepackPath)) +} + +// writeYarnWrapperScript writes a "yarn" wrapper executable in dir that runs unixBody +// on Unix (via a #!/bin/sh script) or windowsBody on Windows (via a yarn.cmd batch +// file, since exec.Command needs a recognized extension to run a file directly on +// Windows — a plain extensionless file is neither found by LookPath nor executable +// by CreateProcess). Returns the wrapper's path. +func writeYarnWrapperScript(t *testing.T, dir, unixBody, windowsBody string) string { + if runtime.GOOS == "windows" { + wrapperPath := filepath.Join(dir, "yarn.cmd") + script := "@echo off\r\n" + windowsBody + require.NoError(t, os.WriteFile(wrapperPath, []byte(script), 0o755)) + return wrapperPath + } + wrapperPath := filepath.Join(dir, "yarn") + script := "#!/bin/sh\n" + unixBody + require.NoError(t, os.WriteFile(wrapperPath, []byte(script), 0o755)) + return wrapperPath +} + +// TestSetRepoFromYarnrcRejectsHostMismatch verifies that a .yarnrc.yml pointing at a +// different host than the configured 'jf c' server never gets that server's credentials. +func TestSetRepoFromYarnrcRejectsHostMismatch(t *testing.T) { + yarnExecPath := yarnBerryExecutableForTest(t) + t.Setenv("HOME", t.TempDir()) + projectDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "package.json"), + []byte(`{"name":"yarn-hostcheck-test","version":"1.0.0","packageManager":"yarn@3.6.4"}`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, ".yarnrc.yml"), + []byte("npmRegistryServer: \"https://attacker.example.com/artifactory/api/npm/yarn-test-repo/\"\n"), 0644)) + + ca := NewCurationAuditCommand() + ca.SetServerDetails(&config.ServerDetails{ + Url: "https://configured-server.example.com/", + ArtifactoryUrl: "https://configured-server.example.com/artifactory/", + AccessToken: "super-secret-token", + }) + + setErr := ca.setRepoFromYarnrc(yarnExecPath, projectDir) + + require.Error(t, setErr) + assert.Contains(t, setErr.Error(), "does not match") + assert.Nil(t, ca.PackageManagerConfig, "credentials must not be attached to the mismatched host") +} + +// TestSetRepoFromYarnrcAcceptsMatchingHost: the host check must not block the legitimate +// same-host case, where .yarnrc.yml has no token of its own and falls back to the configured +// 'jf c' server's credentials for the same Artifactory host. +func TestSetRepoFromYarnrcAcceptsMatchingHost(t *testing.T) { + yarnExecPath := yarnBerryExecutableForTest(t) + t.Setenv("HOME", t.TempDir()) + projectDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "package.json"), + []byte(`{"name":"yarn-hostcheck-test","version":"1.0.0","packageManager":"yarn@3.6.4"}`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, ".yarnrc.yml"), + []byte("npmRegistryServer: \"https://configured-server.example.com/artifactory/api/npm/yarn-test-repo/\"\n"), 0644)) + + ca := NewCurationAuditCommand() + ca.SetServerDetails(&config.ServerDetails{ + Url: "https://configured-server.example.com/", + ArtifactoryUrl: "https://configured-server.example.com/artifactory/", + AccessToken: "super-secret-token", + }) + + require.NoError(t, ca.setRepoFromYarnrc(yarnExecPath, projectDir)) + require.NotNil(t, ca.PackageManagerConfig) +} + +// fakeYarnV1ExecutableForTest returns a "yarn" script that always prints a V1 version, +// so tests don't need a real Yarn V1 install. +func fakeYarnV1ExecutableForTest(t *testing.T) string { + return writeYarnWrapperScript(t, t.TempDir(), "echo 1.22.19\n", "echo 1.22.19\n") +} + +// TestSetRepoRejectsYarnV1BeforeBerryResolution verifies a Yarn V1 project gets the clear +// "Yarn V1 is not supported" error, not a confusing .yarnrc.yml error. +func TestSetRepoRejectsYarnV1BeforeBerryResolution(t *testing.T) { + yarnExecPath := fakeYarnV1ExecutableForTest(t) + yarnDir := filepath.Dir(yarnExecPath) + t.Setenv("PATH", yarnDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + projectDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "package.json"), []byte(`{"name":"root"}`), 0644)) + + origWd, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(projectDir)) + defer func() { require.NoError(t, os.Chdir(origWd)) }() + + ca := NewCurationAuditCommand() + ca.SetServerDetails(&config.ServerDetails{ + Url: "https://configured-server.example.com/", + ArtifactoryUrl: "https://configured-server.example.com/artifactory/", + AccessToken: "super-secret-token", + }) + + setErr := ca.SetRepo(techutils.Yarn) + + require.Error(t, setErr) + assert.Contains(t, setErr.Error(), "not supported for Yarn V1", + "must surface the actionable V1 message instead of a Berry-config error") + assert.NotContains(t, setErr.Error(), ".yarnrc.yml", + "must not fall through to the confusing Berry-config error for a V1 project") + assert.Nil(t, ca.PackageManagerConfig) +} + +// TestSetRepoIgnoresYarnYamlWhenYarnrcPresent verifies that a stale yarn.yaml is ignored +// once a real .yarnrc.yml exists — SetRepo must resolve strictly from .yarnrc.yml. +func TestSetRepoIgnoresYarnYamlWhenYarnrcPresent(t *testing.T) { + corepackPath, err := exec.LookPath("corepack") + if err != nil { + t.Skip("corepack not found on PATH; skipping test that requires Yarn Berry (.yarnrc.yml) support") + } + yarnDir := t.TempDir() + writeYarnWrapperScript(t, yarnDir, + fmt.Sprintf("exec %q yarn \"$@\"\n", corepackPath), + fmt.Sprintf("\"%s\" yarn %%*\n", corepackPath)) + t.Setenv("PATH", yarnDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("HOME", t.TempDir()) + + projectDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "package.json"), + []byte(`{"name":"yarn-yaml-precedence-test","version":"1.0.0","packageManager":"yarn@3.6.4"}`), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, ".yarnrc.yml"), + []byte("npmRegistryServer: \"https://configured-server.example.com/artifactory/api/npm/yarnrc-repo/\"\n"), 0644)) + + // Legacy 'jf yarn-config' output, still on disk. If SetRepo ever read this instead of (or + // in addition to) .yarnrc.yml, the resolved repo would be "stale-yaml-repo", not "yarnrc-repo". + projectsDir := filepath.Join(projectDir, ".jfrog", "projects") + require.NoError(t, os.MkdirAll(projectsDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(projectsDir, "yarn.yaml"), + []byte("resolver:\n serverId: some-other-server\n repo: stale-yaml-repo\n"), 0o644)) + + ca := NewCurationAuditCommand() + ca.SetServerDetails(&config.ServerDetails{ + Url: "https://configured-server.example.com/", + ArtifactoryUrl: "https://configured-server.example.com/artifactory/", + AccessToken: "super-secret-token", + }) + + restoreCwd := changeDirForTest(t, projectDir) + defer restoreCwd() + + require.NoError(t, ca.SetRepo(techutils.Yarn)) + require.NotNil(t, ca.PackageManagerConfig) + assert.Equal(t, "yarnrc-repo", ca.PackageManagerConfig.TargetRepo(), + "repo must come from .yarnrc.yml, not the stale yarn.yaml") + assert.Equal(t, "yarnrc-repo", ca.DepsRepo(), + "SetDepsRepo (consumed by curation probes) must also reflect .yarnrc.yml, not yarn.yaml") +} + // TestPipWinsOverStrayUvLock verifies promotePipToUv's "pip-exclusive files win over // uv.lock" rule: requirements.txt plus a stray leftover uv.lock must still audit as pip. func TestPipWinsOverStrayUvLock(t *testing.T) { @@ -4697,112 +4861,6 @@ func TestValidateRunNativeForTech(t *testing.T) { } -// TestResolveResolverTechForCuration locks in the npm.yaml ↔ yarn.yaml -// fallback for the resolver-config lookup in auditTree. The exact -// reason this fallback has to live here, separate from the existing -// SetRepo fallback, is that auditTree calls -// SetResolutionRepoInParamsIfExists *before* it reaches SetRepo — and -// that earlier call is what populates params.DependenciesRepository, -// which in turn decides whether configureYarnResolutionServerAndRunInstall -// performs the .yarnrc.yml backup/replace/restore round-trip. Without -// the round-trip, a 'yarn install' against curation that hits a 403 -// can leave the workspace install state inconsistent and the -// downstream 'yarn info' enumeration fails with a workspace-assertion -// error. So the contract under test is twofold: -// -// 1. For tech=Yarn with only npm.yaml present, return Npm so the -// resolver lookup reads npm.yaml (npm and yarn share the same -// Artifactory npm API, so the same repo serves both ecosystems). -// 2. For any other input (yarn.yaml present, both present, neither -// present, or tech≠Yarn) return the input tech unchanged. -// -// The Npm-detected case is intentionally not exercised here because -// resolveNpmYarnTech already upgrades that case to Yarn at the -// detection layer (see TestResolveNpmYarnTech-style coverage in -// resolveNpmYarnTech consumers); by the time auditTree sees tech=Npm -// a matching npm.yaml is guaranteed to exist. -// -// Each subtest builds a hermetic .jfrog/projects/ directory, chdirs -// into it, and isolates JFROG_CLI_HOME_DIR so a real config on the -// developer's machine can't leak in. -func TestResolveResolverTechForCuration(t *testing.T) { - type setup struct { - writeYarnYaml bool - writeNpmYaml bool - } - testCases := []struct { - name string - tech techutils.Technology - setup - want techutils.Technology - }{ - { - name: "yarn with yarn.yaml present — no fallback, lookup must use yarn.yaml directly", - tech: techutils.Yarn, - setup: setup{writeYarnYaml: true}, - want: techutils.Yarn, - }, - { - name: "yarn with only npm.yaml — falls back to npm so the resolver lookup reads npm.yaml", - tech: techutils.Yarn, - setup: setup{writeNpmYaml: true}, - want: techutils.Npm, - }, - { - name: "yarn with both configs — yarn.yaml wins; fallback only triggers when primary is missing", - tech: techutils.Yarn, - setup: setup{writeYarnYaml: true, writeNpmYaml: true}, - want: techutils.Yarn, - }, - { - name: "yarn with neither config — no fallback target; return Yarn so the downstream lookup no-ops cleanly", - tech: techutils.Yarn, - want: techutils.Yarn, - }, - { - name: "npm input — never rewritten by this helper (resolveNpmYarnTech owns the inverse direction at the detection layer)", - tech: techutils.Npm, - setup: setup{writeYarnYaml: true}, - want: techutils.Npm, - }, - { - name: "non-npm/yarn tech is passed through untouched even when npm.yaml exists", - tech: techutils.Maven, - setup: setup{writeNpmYaml: true}, - want: techutils.Maven, - }, - } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - tempProjectDir := t.TempDir() - projectsDir := filepath.Join(tempProjectDir, ".jfrog", "projects") - require.NoError(t, os.MkdirAll(projectsDir, 0o755)) - if tc.writeYarnYaml { - require.NoError(t, os.WriteFile(filepath.Join(projectsDir, "yarn.yaml"), []byte("resolver:\n serverId: test\n repo: irrelevant-yarn-repo\n"), 0o644)) - } - if tc.writeNpmYaml { - require.NoError(t, os.WriteFile(filepath.Join(projectsDir, "npm.yaml"), []byte("resolver:\n serverId: test\n repo: irrelevant-npm-repo\n"), 0o644)) - } - // Isolate JFROG_CLI_HOME_DIR so a real ~/.jfrog/projects/*.yaml - // on the developer's machine can't leak into the fallback - // (GetProjectConfFilePath falls back to JFROG_CLI_HOME_DIR - // 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() - - got := resolveResolverTechForCuration(tc.tech) - assert.Equal(t, tc.want, got) - }) - } -} - func TestResolveNpmYarnTech(t *testing.T) { type setup struct { writeYarnYaml bool diff --git a/curation_test.go b/curation_test.go index 6454a82f1..9c31825e9 100644 --- a/curation_test.go +++ b/curation_test.go @@ -88,10 +88,8 @@ func TestCurationAudit(t *testing.T) { // HEAD-walker probes the same /api/npm///-/-.tgz URLs as npm and // reports the blocked package with PkgType "yarn" (curation rejects Yarn V1). // -// V3 and V4 differ ONLY in how the resolution registry is read; everything else (the -// resolve-only plugin and the HEAD-walker) is identical: -// - V3: from yarn.yaml written by the build config ('jf yarn-config' style). -// - V4: natively from .yarnrc.yml (npmRegistryServer), with no 'jf yarn-config'. +// V3 and V4 resolve the registry identically: natively from .yarnrc.yml +// (npmRegistryServer) — no 'jf yarn-config' required for either version. func TestYarnCurationAudit(t *testing.T) { integration.InitCurationTest(t) testCases := []struct { @@ -101,26 +99,22 @@ func TestYarnCurationAudit(t *testing.T) { configureRegistry func(t *testing.T, tempDirPath string, config *config.ServerDetails) }{ { - name: "Yarn V3 (registry from yarn.yaml)", + name: "Yarn V3 (registry from .yarnrc.yml)", project: "yarn-v3", configureRegistry: func(t *testing.T, tempDirPath string, config *config.ServerDetails) { - // npm and yarn share the Artifactory npm API; resolve via the build config. - assert.NoError(t, commonCommands.CreateBuildConfigWithOptions(false, project.Yarn, - commonCommands.WithResolverServerId(config.ServerId), - commonCommands.WithResolverRepo("npms"), - commonCommands.WithDeployerServerId(config.ServerId), - commonCommands.WithDeployerRepo("npm-local"), - )) - // jf ca injects this http mock registry into the temp .yarnrc.yml; Yarn Berry - // only accepts a plain-http registry when its host is whitelisted. - appendToFile(t, filepath.Join(tempDirPath, ".yarnrc.yml"), "\nunsafeHttpWhitelist:\n - \"127.0.0.1\"\n - \"localhost\"\n") + // Native mode: the registry lives in .yarnrc.yml. jf ca injects this + // http mock registry directly; Yarn Berry only accepts a plain-http + // registry when its host is whitelisted. + appendToFile(t, filepath.Join(tempDirPath, ".yarnrc.yml"), fmt.Sprintf( + "\nnpmRegistryServer: \"%sapi/npm/npms/\"\nunsafeHttpWhitelist:\n - \"127.0.0.1\"\n - \"localhost\"\n", + config.ArtifactoryUrl)) }, }, { name: "Yarn V4 (registry from .yarnrc.yml)", project: "yarn-v4", configureRegistry: func(t *testing.T, tempDirPath string, config *config.ServerDetails) { - // V4 native mode: the registry lives in .yarnrc.yml (the http whitelist is + // Native mode: the registry lives in .yarnrc.yml (the http whitelist is // already committed in the yarn-v4 fixture). appendToFile(t, filepath.Join(tempDirPath, ".yarnrc.yml"), fmt.Sprintf("\nnpmRegistryServer: \"%sapi/npm/npms/\"\n", config.ArtifactoryUrl)) }, @@ -197,15 +191,11 @@ func TestYarnV2CurationAudit(t *testing.T) { configCmd := commonCommands.NewConfigCommand(commonCommands.AddOrEdit, config.ServerId).SetDetails(config).SetUseBasicAuthOnly(true).SetInteractive(false) assert.NoError(t, configCmd.Run()) - // V2 resolves the registry via the build config, like V3 (only V4 reads .yarnrc.yml natively). - assert.NoError(t, commonCommands.CreateBuildConfigWithOptions(false, project.Yarn, - commonCommands.WithResolverServerId(config.ServerId), - commonCommands.WithResolverRepo("npms"), - commonCommands.WithDeployerServerId(config.ServerId), - commonCommands.WithDeployerRepo("npm-local"), - )) + // V2 resolves the registry natively from .yarnrc.yml, like V3/V4 — no 'jf yarn-config' required. // Yarn Berry only accepts a plain-http registry when its host is whitelisted. - appendToFile(t, filepath.Join(tempDirPath, ".yarnrc.yml"), "\nunsafeHttpWhitelist:\n - \"127.0.0.1\"\n - \"localhost\"\n") + appendToFile(t, filepath.Join(tempDirPath, ".yarnrc.yml"), fmt.Sprintf( + "\nnpmRegistryServer: \"%sapi/npm/npms/\"\nunsafeHttpWhitelist:\n - \"127.0.0.1\"\n - \"localhost\"\n", + config.ArtifactoryUrl)) localXrayCli := securityTests.PlatformCli.WithoutCredentials() workingDirsFlag := fmt.Sprintf("--working-dirs=%s", tempDirPath) @@ -374,14 +364,14 @@ func yarnCurationServer(t *testing.T, expectedRequest, requestToFail map[string] _, err := w.Write([]byte(`{"xray_version": "3.92.0"}`)) require.NoError(t, err) return - // Yarn V2/V3 resolve the registry via GetYarnAuthDetails, which queries - // these two Artifactory endpoints before the resolve-only plugin runs. - // (Yarn V4 reads the registry natively from .yarnrc.yml and skips them.) case "/api/npm/auth": + // Hit by GetYarnAuthDetails when curation falls back to 'jf c' server + // credentials (no token in .yarnrc.yml) to inject auth into the yarn subprocess. _, err := w.Write([]byte("_auth = YWRtaW46cGFzc3dvcmQ=\nalways-auth = true\n")) require.NoError(t, err) return case "/api/repositories/npms": + // Hit by GetYarnAuthDetails's repo-exists check, same fallback-auth path as above. _, err := w.Write([]byte(`{"key":"npms","rclass":"remote","packageType":"npm"}`)) require.NoError(t, err) return diff --git a/sca/bom/buildinfo/buildinfobom.go b/sca/bom/buildinfo/buildinfobom.go index 9fe852b57..dd701d230 100644 --- a/sca/bom/buildinfo/buildinfobom.go +++ b/sca/bom/buildinfo/buildinfobom.go @@ -396,6 +396,7 @@ func SetResolutionRepoInParamsIfExists(params *technologies.BuildInfoBomGenerato params.DependenciesRepository = artifactoryDetails.TargetRepository params.ServerDetails = artifactoryDetails.ServerDetails serverDetails = artifactoryDetails.ServerDetails + log.Info(fmt.Sprintf("%s: using Artifactory repository %q from %s.yaml config file", tech.String(), artifactoryDetails.TargetRepository, tech.String())) return } diff --git a/sca/bom/buildinfo/technologies/pnpm/pnpm_test.go b/sca/bom/buildinfo/technologies/pnpm/pnpm_test.go index 6a9eec42b..8feab5115 100644 --- a/sca/bom/buildinfo/technologies/pnpm/pnpm_test.go +++ b/sca/bom/buildinfo/technologies/pnpm/pnpm_test.go @@ -46,7 +46,7 @@ func TestBuildDependencyTreeLimitedDepth(t *testing.T) { name: "With transitive dependencies", treeDepth: "1", expectedUniqueDeps: []string{ - "npm://axios:1.19.0", + "npm://axios:1.20.0", "npm://balaganjs:1.0.0", "npm://yargs:13.3.0", "npm://zen-website:1.0.0", @@ -56,7 +56,7 @@ func TestBuildDependencyTreeLimitedDepth(t *testing.T) { Nodes: []*xrayUtils.GraphNode{ { Id: "npm://balaganjs:1.0.0", - Nodes: []*xrayUtils.GraphNode{{Id: "npm://axios:1.19.0"}, {Id: "npm://yargs:13.3.0"}}, + Nodes: []*xrayUtils.GraphNode{{Id: "npm://axios:1.20.0"}, {Id: "npm://yargs:13.3.0"}}, }, }, }, diff --git a/sca/bom/buildinfo/technologies/yarn/yarn.go b/sca/bom/buildinfo/technologies/yarn/yarn.go index 92d66eda1..330634343 100644 --- a/sca/bom/buildinfo/technologies/yarn/yarn.go +++ b/sca/bom/buildinfo/technologies/yarn/yarn.go @@ -22,6 +22,7 @@ import ( "github.com/jfrog/gofrog/version" "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/yarn" outFormat "github.com/jfrog/jfrog-cli-core/v2/common/format" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" "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" @@ -47,7 +48,14 @@ const ( v3UpdateLockfileFlag = "--mode=update-lockfile" // Ignores any build scripts v3SkipBuildFlag = "--mode=skip-build" - yarnV2Version = "2.0.0" + // Env vars yarn reads for npm auth, used to inject curation's fallback credential + // (see injectCurationFallbackAuthEnv) without touching YARN_NPM_REGISTRY_SERVER. + //#nosec G101 + yarnNpmAuthIdentEnv = "YARN_NPM_AUTH_IDENT" + //#nosec G101 + yarnNpmAuthTokenEnv = "YARN_NPM_AUTH_TOKEN" + yarnNpmAlwaysAuthEnv = "YARN_NPM_ALWAYS_AUTH" + 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" @@ -105,7 +113,7 @@ func BuildDependencyTree(params technologies.BuildInfoBomGeneratorParams) (depen // 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 { + if err = VerifyYarnVersionSupportedForCuration(executablePath, currentDir); err != nil { return } } @@ -238,10 +246,9 @@ 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, -// 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 { +// VerifyYarnVersionSupportedForCuration rejects Yarn V1 — curation only supports +// V2/V3/V4, which resolve the registry from .yarnrc.yml. +func VerifyYarnVersionSupportedForCuration(yarnExecPath, curWd string) error { versionStr, err := bibuildutils.GetVersion(yarnExecPath, curWd) if err != nil { return err @@ -296,6 +303,17 @@ func lockfileMtime(yarnLockPath string) time.Time { return info.ModTime() } +// installErrCarriesCurationBlockSignal reports whether installErr looks like a curation +// block (HTTP 403), as opposed to an unrelated failure (e.g. an auth error). Yarn echoes +// curation's HTTP response verbatim, e.g. "YN0035: ... Response Code: 403 (Forbidden)". +func installErrCarriesCurationBlockSignal(installErr error) bool { + if installErr == nil { + return false + } + errText := strings.ToLower(installErr.Error()) + return strings.Contains(errText, "403") || strings.Contains(errText, "forbidden") +} + // curationNoLockfileError builds an actionable error for when 'yarn install' // did not produce yarn.lock. Probes declared direct deps against the curation // repo and renders blocked ones in a table. Error text is version-specific: @@ -303,6 +321,13 @@ func lockfileMtime(yarnLockPath string) time.Time { // blocking manifests (not just tarballs). func curationNoLockfileError(params technologies.BuildInfoBomGeneratorParams, curWd, yarnExecPath, workspaceMemberRel string, installErr error) error { probed, totalProbed := probeBlockedDirectDeps(params, curWd, workspaceMemberRel) + // Only blame curation when there's actual evidence of a block: a rejected direct dep + // from the probe, or a curation-block signal in installErr. Otherwise installErr is + // unrelated, and blaming curation would misdirect engineers into removing packages + // curation never evaluated. + if len(probed) == 0 && !installErrCarriesCurationBlockSignal(installErr) { + return errorutils.CheckErrorf("'jf curation-audit' against curation repo '%s' could not produce '%s' — 'yarn install' failed for a reason unrelated to a curation block (no HTTP 403/rejected-package evidence found). Check the debug log for the underlying 'yarn install' output. Underlying yarn error: %s", params.DependenciesRepository, yarn.YarnLockFileName, installErr.Error()) + } outputRef := string(outFormat.Table) if params.OutputFormat == outFormat.Json { outputRef = "JSON output" @@ -546,25 +571,38 @@ func resolveCurationLockfileDir( // 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) { depsRepo := params.DependenciesRepository - if depsRepo == "" { - // Run install without configuring an Artifactory server - return runYarnInstallAccordingToVersion(curWd, yarnExecPath, params.InstallCommandArgs, params.IsCurationCmd) - } - - executableYarnVersion, err := bibuildutils.GetVersion(yarnExecPath, curWd) - if err != nil { - return err - } - yarnVersion := version.NewVersion(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) + // Skip credential injection when no repo was resolved, or for curation (native + // .yarnrc.yml resolution already has it, for V2/V3/V4 alike). Only non-curation + // V2/V3 with a repo from --deps-repo or 'jf yarn-config' still needs it below. + useNativeInstall := depsRepo == "" || params.IsCurationCmd + if !useNativeInstall { + executableYarnVersion, versionErr := bibuildutils.GetVersion(yarnExecPath, curWd) + if versionErr != nil { + return versionErr + } + useNativeInstall = version.NewVersion(executableYarnVersion).Compare(YarnV4Version) <= 0 + } + if useNativeInstall { + if params.IsCurationCmd && depsRepo != "" { + // If .yarnrc.yml has no token, curation may have resolved a fallback credential + // into params.ServerDetails. Inject it into the subprocess env, since the native + // install path above skips the GetYarnAuthDetails+ModifyYarnConfigurations + // injection below (which also sets YARN_NPM_REGISTRY_SERVER, unwanted here). + restoreAuthEnv, authErr := injectCurationFallbackAuthEnv(params.ServerDetails, depsRepo) + if authErr != nil { + return authErr + } + defer func() { + err = errors.Join(err, restoreAuthEnv()) + }() + } + err = runYarnInstallAccordingToVersion(curWd, yarnExecPath, params.InstallCommandArgs, params.IsCurationCmd) + return } - // V2/V3: inject Artifactory credentials via GetYarnAuthDetails + ModifyYarnConfigurations. - // V1 is rejected earlier by verifyYarnVersionSupportedForCuration (curation) or is unsupported + // V2/V3 (non-curation): 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 { @@ -596,6 +634,52 @@ func configureYarnResolutionServerAndRunInstall(params technologies.BuildInfoBom return err } +// injectCurationFallbackAuthEnv sets YARN_NPM_AUTH_IDENT/YARN_NPM_AUTH_TOKEN/YARN_NPM_ALWAYS_AUTH +// from serverDetails for the yarn subprocess, without setting YARN_NPM_REGISTRY_SERVER — the +// registry must keep coming from .yarnrc.yml. No-op (returns a no-op restore) when serverDetails +// has no usable credentials, so the anonymous case is unchanged. +func injectCurationFallbackAuthEnv(serverDetails *config.ServerDetails, depsRepo string) (restore func() error, err error) { + noOpRestore := func() error { return nil } + if serverDetails == nil || (serverDetails.AccessToken == "" && serverDetails.User == "") { + return noOpRestore, nil + } + _, npmAuthIdent, npmAuthToken, err := yarn.GetYarnAuthDetails(serverDetails, depsRepo) + if err != nil { + return noOpRestore, err + } + if npmAuthIdent == "" && npmAuthToken == "" { + return noOpRestore, nil + } + + envUpdates := map[string]string{ + yarnNpmAuthIdentEnv: npmAuthIdent, + yarnNpmAuthTokenEnv: npmAuthToken, + yarnNpmAlwaysAuthEnv: "true", + } + backup := make(map[string]*string, len(envUpdates)) + for key, value := range envUpdates { + if oldVal, existed := os.LookupEnv(key); existed { + backup[key] = &oldVal + } else { + backup[key] = nil + } + if setErr := os.Setenv(key, value); setErr != nil { + return noOpRestore, setErr + } + } + return func() error { + var restoreErrs []error + for key, oldVal := range backup { + if oldVal == nil { + restoreErrs = append(restoreErrs, os.Unsetenv(key)) + continue + } + restoreErrs = append(restoreErrs, os.Setenv(key, *oldVal)) + } + return errors.Join(restoreErrs...) + }, nil +} + // isInstallRequired reports whether 'yarn install' must run before enumerating // the dependency tree. Install is needed when the user supplied an explicit // install command, yarn.lock is missing, or overwriteYarnLock is set and the @@ -1045,12 +1129,13 @@ func filterYarnDepMapToWorkspaceMember( return filtered, memberRoot, nil } -// 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 +// GetNativeYarnRegistryConfig reads the Artifactory registry URL and auth +// token from the project's .yarnrc.yml via the Yarn CLI. Yarn V2, V3, and V4 +// all use the same Berry .yarnrc.yml format, so curation-audit resolves the +// registry natively for every version — 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) { +func GetNativeYarnRegistryConfig(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) @@ -1116,17 +1201,17 @@ func readNpmAuthTokenFromYarnrcFiles(registryURL, workingDir string) string { } var rc yarnrcFile if err := yaml.Unmarshal(data, &rc); err != nil { - log.Debug(fmt.Sprintf("yarn V4: could not parse %s: %s", path, err)) + log.Debug(fmt.Sprintf("yarn: could not parse %s: %s", path, err)) continue } // Scoped registry entry takes priority (trailing-slash tolerant). if entry, ok := lookupNpmRegistryEntry(rc.NpmRegistries, registryURL); ok && entry.NpmAuthToken != "" { - log.Debug(fmt.Sprintf("yarn V4: using auth token from scoped npmRegistries entry in %s", path)) + log.Debug(fmt.Sprintf("yarn: 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)) + log.Debug(fmt.Sprintf("yarn: using top-level npmAuthToken from %s", path)) return rc.NpmAuthToken } } diff --git a/sca/bom/buildinfo/technologies/yarn/yarn_test.go b/sca/bom/buildinfo/technologies/yarn/yarn_test.go index cf8d92fc0..061aff837 100644 --- a/sca/bom/buildinfo/technologies/yarn/yarn_test.go +++ b/sca/bom/buildinfo/technologies/yarn/yarn_test.go @@ -2,8 +2,10 @@ package yarn import ( "net/http" + "net/http/httptest" "os" "path/filepath" + "runtime" "strings" "testing" "time" @@ -14,6 +16,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/jfrog-cli-core/v2/utils/config" "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" @@ -23,6 +26,79 @@ import ( "github.com/stretchr/testify/require" ) +// TestConfigureYarnResolutionServerAndRunInstallInjectsFallbackAuthIntoSubprocess runs +// configureYarnResolutionServerAndRunInstall end-to-end against a fake yarn executable, using +// a curation fallback credential (no token in .yarnrc.yml, only params.ServerDetails). It +// asserts the subprocess actually receives YARN_NPM_AUTH_TOKEN/YARN_NPM_ALWAYS_AUTH, and that +// YARN_NPM_REGISTRY_SERVER is never set (registry must keep coming from .yarnrc.yml). +func TestConfigureYarnResolutionServerAndRunInstallInjectsFallbackAuthIntoSubprocess(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake yarn executable is a POSIX shell script") + } + mockArtifactory := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer mockArtifactory.Close() + + curWd := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(curWd, "package.json"), []byte(`{"name":"root"}`), 0644)) + + envCaptureFile := filepath.Join(t.TempDir(), "captured-env.txt") + fakeYarnPath := filepath.Join(t.TempDir(), "yarn") + fakeYarnScript := "#!/bin/sh\n" + + "if [ \"$1\" = \"--version\" ]; then echo 4.5.0; exit 0; fi\n" + + "env > " + envCaptureFile + "\n" + + "exit 0\n" + require.NoError(t, os.WriteFile(fakeYarnPath, []byte(fakeYarnScript), 0o755)) + + //#nosec G101 -- test fixture, not a real secret + const fallbackToken = "fallback-jf-c-token-xyz" + params := technologies.BuildInfoBomGeneratorParams{ + IsCurationCmd: true, + DependenciesRepository: "tst-yarn-repo", + ServerDetails: &config.ServerDetails{ + ArtifactoryUrl: mockArtifactory.URL + "/artifactory/", + AccessToken: fallbackToken, + }, + } + + require.NoError(t, configureYarnResolutionServerAndRunInstall(params, curWd, fakeYarnPath)) + + capturedEnvBytes, readErr := os.ReadFile(envCaptureFile) + require.NoError(t, readErr, "the install subprocess (not just the --version probe) must have run") + capturedEnv := string(capturedEnvBytes) + + assert.Contains(t, capturedEnv, "YARN_NPM_AUTH_TOKEN="+fallbackToken, + "the fallback credential resolved from params.ServerDetails must reach the yarn subprocess's env") + assert.Contains(t, capturedEnv, "YARN_NPM_ALWAYS_AUTH=true") + assert.NotContains(t, capturedEnv, "YARN_NPM_REGISTRY_SERVER=", + "registry must keep coming from .yarnrc.yml — this injection must never set it") + + for _, key := range []string{yarnNpmAuthIdentEnv, yarnNpmAuthTokenEnv, yarnNpmAlwaysAuthEnv} { + _, exists := os.LookupEnv(key) + assert.False(t, exists, "%s must be restored (unset) after the subprocess exits", key) + } +} + +// TestInjectCurationFallbackAuthEnvNoOpWithoutCredentials verifies that with no usable +// credentials, injectCurationFallbackAuthEnv sets no env vars and its restore is a no-op — +// the anonymous-resolution case is unchanged. +func TestInjectCurationFallbackAuthEnvNoOpWithoutCredentials(t *testing.T) { + for _, key := range []string{yarnNpmAuthIdentEnv, yarnNpmAuthTokenEnv, yarnNpmAlwaysAuthEnv} { + require.NoError(t, os.Unsetenv(key)) + } + + restore, err := injectCurationFallbackAuthEnv(nil, "some-repo") + require.NoError(t, err) + require.NotNil(t, restore) + require.NoError(t, restore()) + + for _, key := range []string{yarnNpmAuthIdentEnv, yarnNpmAuthTokenEnv, yarnNpmAlwaysAuthEnv} { + _, exists := os.LookupEnv(key) + assert.False(t, exists, "%s must not be set when serverDetails has no credentials", key) + } +} + func TestParseYarnDependenciesMap(t *testing.T) { npmId := techutils.Npm.GetXrayPackageTypeId() @@ -718,6 +794,46 @@ func TestCollectDeclaredDirectDepsAcrossWorkspaces(t *testing.T) { // probe table here — that runs as a side effect (printed to stdout) and // is covered by the probe-collection tests above; this test focuses on // the error string the user sees AFTER the table. +// TestCurationNoLockfileErrorNeutralWhenUnrelatedToCuration verifies that when the probe finds +// no blocked deps (no declared deps here) and installErr's text carries no curation-block +// signal (no "403"/"forbidden"), curationNoLockfileError returns a neutral "unrelated failure" +// message instead of blaming curation. +func TestCurationNoLockfileErrorNeutralWhenUnrelatedToCuration(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"name":"root"}`), 0644)) + + params := technologies.BuildInfoBomGeneratorParams{ + IsCurationCmd: true, + DependenciesRepository: "tst-yarn-repo", + } + installErr := errors.New("EACCES: permission denied, mkdir '/tmp/.yarn/berry/cache'") + + err := curationNoLockfileError(params, root, "", "", installErr) + require.Error(t, err) + msg := err.Error() + assert.Contains(t, msg, "unrelated to a curation block") + assert.NotContains(t, msg, "curation is blocking manifests") + assert.Contains(t, msg, installErr.Error(), "must propagate the underlying error for traceability") +} + +// TestCurationNoLockfileErrorBlamesCurationOnHttp403 verifies that when installErr's text does +// carry a curation-block signal (HTTP 403), curationNoLockfileError keeps the curation-blaming +// wording — this is a regression guard against over-correcting the fix above. +func TestCurationNoLockfileErrorBlamesCurationOnHttp403(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"name":"root"}`), 0644)) + + params := technologies.BuildInfoBomGeneratorParams{ + IsCurationCmd: true, + DependenciesRepository: "tst-yarn-repo", + } + installErr := errors.New("YN0035: Response Code: 403 (Forbidden)") + + err := curationNoLockfileError(params, root, "", "", installErr) + require.Error(t, err) + assert.NotContains(t, err.Error(), "unrelated to a curation block") +} + func TestEnumerateAfterCurationInstallErrorMessage(t *testing.T) { root := t.TempDir() assert.NoError(t, os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"name":"root"}`), 0644))