From 6b791fb8e07dcdd7da037acca12f57cefacf254b Mon Sep 17 00:00:00 2001 From: Phavya Jayakumar Date: Tue, 7 Jul 2026 14:28:41 +0530 Subject: [PATCH 1/4] XRAY-140550 - Onboarding uv for jf curation-audit --- cli/docs/flags.go | 4 +- cli/docs/scan/curation/help.go | 2 + cli/scancommands.go | 9 + cli/scancommands_test.go | 20 + commands/curation/curationaudit.go | 211 ++- commands/curation/curationaudit_test.go | 562 +++++- curation_test.go | 147 ++ sca/bom/buildinfo/buildinfobom.go | 3 + sca/bom/buildinfo/technologies/common.go | 2 + sca/bom/buildinfo/technologies/common_test.go | 14 + .../python/python_cvs_fallback.go | 159 +- .../python/python_cvs_fallback_test.go | 57 +- sca/bom/buildinfo/technologies/uv/uv.go | 1063 +++++++++++ sca/bom/buildinfo/technologies/uv/uv_test.go | 1560 +++++++++++++++++ .../uv/uv-curation-project/pyproject.toml | 9 + utils/techutils/techutils.go | 91 +- utils/techutils/techutils_test.go | 158 ++ 17 files changed, 4027 insertions(+), 44 deletions(-) create mode 100644 sca/bom/buildinfo/technologies/uv/uv.go create mode 100644 sca/bom/buildinfo/technologies/uv/uv_test.go create mode 100644 tests/testdata/projects/package-managers/python/uv/uv-curation-project/pyproject.toml diff --git a/cli/docs/flags.go b/cli/docs/flags.go index d90e2387c..4f1ad7aaa 100644 --- a/cli/docs/flags.go +++ b/cli/docs/flags.go @@ -165,6 +165,7 @@ const ( LegacyPeerDeps = "legacy-peer-deps" RunNative = "run-native" MvnIncludePluginDeps = "mvn-include-plugin-deps" + Script = "script" // Unique git flags gitPrefix = "git-" @@ -229,7 +230,7 @@ var commandFlags = map[string][]string{ StaticSca, XrayLibPluginBinaryCustomPath, AnalyzerManagerCustomPath, AddSastRules, }, CurationAudit: { - CurationOutput, WorkingDirs, Threads, RequirementsFile, InsecureTls, useWrapperAudit, UseIncludedBuilds, SolutionPath, DockerImageName, IncludeCachedPackages, MvnIncludePluginDeps, LegacyPeerDeps, RunNative, + CurationOutput, WorkingDirs, Threads, RequirementsFile, InsecureTls, useWrapperAudit, UseIncludedBuilds, SolutionPath, DockerImageName, IncludeCachedPackages, MvnIncludePluginDeps, LegacyPeerDeps, RunNative, Script, }, GitCountContributors: { InputFile, ScmType, ScmApiUrl, Token, Owner, RepoName, Months, DetailedSummary, InsecureTls, GitThreads, CacheValidity, @@ -355,6 +356,7 @@ var flagsMap = map[string]components.Flag{ MvnIncludePluginDeps: components.NewBoolFlag(MvnIncludePluginDeps, "[Maven] When set to true, Maven build-plugin transitive dependencies are resolved and included in the curation evaluation. By default only project dependencies are scanned."), LegacyPeerDeps: components.NewBoolFlag(LegacyPeerDeps, "[npm] Pass --legacy-peer-deps to npm install to bypass peer-dependency version conflicts."), RunNative: components.NewBoolFlag(RunNative, "[npm] Use the native npm client for dependency resolution. Reads Artifactory URL and repository from the project's .npmrc registry — no 'jf npm-config' required. Respects .npmrc and Volta configuration."), + Script: components.NewStringFlag(Script, "[uv] Path to a PEP 723 inline-script .py file to audit standalone, instead of scanning the working directory for a pyproject.toml/uv.lock project."), binarySca: components.NewBoolFlag(Sca, fmt.Sprintf("Selective scanners mode: Execute SCA (Software Composition Analysis) sub-scan. Use --%s to run both SCA and Contextual Analysis. Use --%s --%s to to run SCA. Can be combined with --%s.", Sca, Sca, WithoutCA, Secrets)), binarySecrets: components.NewBoolFlag(Secrets, fmt.Sprintf("Selective scanners mode: Execute Secrets sub-scan. Can be combined with --%s.", Sca)), binaryWithoutCA: components.NewBoolFlag(WithoutCA, fmt.Sprintf("Selective scanners mode: Disable Contextual Analysis scanner after SCA. Relevant only with --%s flag.", Sca)), diff --git a/cli/docs/scan/curation/help.go b/cli/docs/scan/curation/help.go index 182736e7e..56b47c73f 100644 --- a/cli/docs/scan/curation/help.go +++ b/cli/docs/scan/curation/help.go @@ -23,12 +23,14 @@ Common patterns: $ jf curation-audit --format=json --threads=4 $ jf curation-audit --requirements-file=requirements-dev.txt $ jf curation-audit --docker-image=my-image:tag + $ jf ca --script=script.py Gotchas: - The user/token must be entitled for Curation; otherwise the command exits with an entitlement notice. - Requires the project's package manager binary on PATH (npm, mvn, etc.). - Run from the project root or pass --working-dirs. - For Maven multi-module: --use-wrapper if mvnw is used. +- --script (uv only) audits one PEP 723 inline-script .py file directly; --script and --working-dirs cannot be used together, run separate commands for each. Related: jf audit, jf rt npm-install, jf rt mvn` } diff --git a/cli/scancommands.go b/cli/scancommands.go index 05ba99a71..2fbd4af85 100644 --- a/cli/scancommands.go +++ b/cli/scancommands.go @@ -34,6 +34,7 @@ import ( uploadCdxDocs "github.com/jfrog/jfrog-cli-security/cli/docs/upload" "github.com/jfrog/jfrog-cli-security/utils" + "github.com/jfrog/jfrog-client-go/utils/errorutils" "github.com/jfrog/jfrog-client-go/utils/io/fileutils" "github.com/jfrog/jfrog-client-go/utils/log" "github.com/urfave/cli" @@ -727,6 +728,13 @@ func ShouldRunCurationAfterFailure(c *components.Context, tech techutils.Technol } func getCurationCommand(c *components.Context) (*curation.CurationAuditCommand, error) { + scriptPath := c.GetStringFlagValue(flags.Script) + if scriptPath != "" && c.GetStringFlagValue(flags.WorkingDirs) != "" { + return nil, errorutils.CheckErrorf("--script and --working-dirs cannot be used together; run separate curation-audit commands for each") + } + if scriptPath != "" && c.GetStringFlagValue(flags.DockerImageName) != "" { + return nil, errorutils.CheckErrorf("--script and --docker-image cannot be used together; run separate curation-audit commands for each") + } threads, err := pluginsCommon.GetThreadsCount(c) if err != nil { return nil, err @@ -758,6 +766,7 @@ func getCurationCommand(c *components.Context) (*curation.CurationAuditCommand, curationAuditCommand.SetMvnIncludePluginDeps(c.GetBoolFlagValue(flags.MvnIncludePluginDeps)) curationAuditCommand.SetLegacyPeerDeps(c.GetBoolFlagValue(flags.LegacyPeerDeps)) curationAuditCommand.SetRunNative(c.GetBoolFlagValue(flags.RunNative)) + curationAuditCommand.SetScriptPath(scriptPath) return curationAuditCommand, nil } diff --git a/cli/scancommands_test.go b/cli/scancommands_test.go index 0fbbf4e86..cd8bc3788 100644 --- a/cli/scancommands_test.go +++ b/cli/scancommands_test.go @@ -167,6 +167,26 @@ func TestCurationAuditCommandFlags_UseWrapperAuditFlag(t *testing.T) { assert.True(t, found, "useWrapperAudit flag should be present in CurationAudit command flags. If this test fails, it means the flag was removed from cli/docs/flags.go") } +func TestGetCurationCommandRejectsScriptWithWorkingDirs(t *testing.T) { + ctx := &components.Context{} + ctx.AddStringFlag(flags.Script, "script.py") + ctx.AddStringFlag(flags.WorkingDirs, "dir1,dir2") + + _, err := getCurationCommand(ctx) + assert.ErrorContains(t, err, "--script") + assert.ErrorContains(t, err, "--working-dirs") +} + +func TestGetCurationCommandRejectsScriptWithDockerImage(t *testing.T) { + ctx := &components.Context{} + ctx.AddStringFlag(flags.Script, "script.py") + ctx.AddStringFlag(flags.DockerImageName, "myimage:latest") + + _, err := getCurationCommand(ctx) + assert.ErrorContains(t, err, "--script") + assert.ErrorContains(t, err, "--docker-image") +} + func TestEffectiveIncludeViolations(t *testing.T) { tests := []struct { name string diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index 0d9bb4101..6078cb310 100644 --- a/commands/curation/curationaudit.go +++ b/commands/curation/curationaudit.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "regexp" + "slices" "sort" "strconv" "strings" @@ -52,6 +53,7 @@ import ( 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" ) @@ -128,6 +130,9 @@ var supportedTech = map[techutils.Technology]func(ca *CurationAuditCommand) (boo techutils.Poetry: func(ca *CurationAuditCommand) (bool, error) { return ca.checkSupportByVersionOrEnv(techutils.Poetry, MinArtiPassThroughSupport) }, + techutils.Uv: func(ca *CurationAuditCommand) (bool, error) { + return ca.checkSupportByVersionOrEnv(techutils.Uv, MinArtiPassThroughSupport) + }, } func (ca *CurationAuditCommand) checkSupportByVersionOrEnv(tech techutils.Technology, minArtiVersion string) (bool, error) { @@ -254,6 +259,7 @@ type CurationAuditCommand struct { dockerImageName string includeCachedPackages bool mvnIncludePluginDeps bool + scriptPath string // 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 @@ -317,6 +323,11 @@ func (ca *CurationAuditCommand) SetMvnIncludePluginDeps(mvnIncludePluginDeps boo return ca } +func (ca *CurationAuditCommand) SetScriptPath(scriptPath string) *CurationAuditCommand { + ca.scriptPath = scriptPath + return ca +} + func (ca *CurationAuditCommand) Run() (err error) { rootDir, err := os.Getwd() if err != nil { @@ -528,19 +539,101 @@ func promoteYarnWorkspaceMember(techs []string) []string { } } -func (ca *CurationAuditCommand) doCurateAudit(results map[string]*CurationReport) error { - techs := promotePnpmWorkspaceMember(techutils.DetectedTechnologiesListForCurationAudit()) - techs = promoteYarnWorkspaceMember(techs) - if ca.DockerImageName() != "" { +// Rule, in order: +// 1. Pip-exclusive file present (requirements.txt, setup.py, setup.cfg, Pipfile, +// poetry.lock) → Pip wins, Uv is dropped. +// 2. Otherwise, any uv signal (uv.lock, pyproject.toml [tool.uv]/[[tool.uv.index]], or +// ~/.config/uv/uv.toml) → Uv wins, Pip is dropped. +func promotePipToUv(techs []string) []string { + if !slices.Contains(techs, techutils.Pip.String()) { + return techs + } + dir, err := os.Getwd() + if err != nil { + return techs + } + for _, pipOnlyFile := range []string{"requirements.txt", "setup.py", "setup.cfg", "Pipfile", "poetry.lock"} { + if _, statErr := os.Stat(filepath.Join(dir, pipOnlyFile)); statErr == nil { + return removeTech(techs, techutils.Uv.String()) + } + } + + uvSignal := "" + if _, statErr := os.Stat(filepath.Join(dir, "uv.lock")); statErr == nil { + uvSignal = "uv.lock detected" + } else if data, readErr := os.ReadFile(filepath.Join(dir, "pyproject.toml")); readErr == nil && + (strings.Contains(string(data), "[tool.uv]") || strings.Contains(string(data), "[[tool.uv.index]]")) { + uvSignal = "pyproject.toml has uv configuration ([tool.uv] or [[tool.uv.index]])" + } else if home, homeErr := os.UserHomeDir(); homeErr == nil { + if _, statErr := os.Stat(filepath.Join(home, ".config", "uv", "uv.toml")); statErr == nil { + uvSignal = "~/.config/uv/uv.toml detected" + } + } + if uvSignal == "" { + return techs + } + log.Info(uvSignal + " — treating project as uv.") + techs = removeTech(techs, techutils.Pip.String()) + if !slices.Contains(techs, techutils.Uv.String()) { + techs = append(techs, techutils.Uv.String()) + } + return techs +} + +// removeTech returns techs without any entry equal to tech. +func removeTech(techs []string, tech string) []string { + filtered := make([]string, 0, len(techs)) + for _, t := range techs { + if t != tech { + filtered = append(filtered, t) + } + } + return filtered +} + +// techsToAudit picks doCurateAudit's technologies, in priority order: --script, then +// --docker-image, then file-based detection (with npm→yarn and pip→uv promotion). +func (ca *CurationAuditCommand) techsToAudit() []string { + switch { + case ca.scriptPath != "": + log.Debug(fmt.Sprintf("--script %q was provided, auditing it directly as a uv PEP 723 script — skipping project detection.", ca.scriptPath)) + return []string{techutils.Uv.String()} + case ca.DockerImageName() != "": log.Debug(fmt.Sprintf("Docker image name '%s' was provided, running Docker curation audit.", ca.DockerImageName())) - techs = []string{techutils.Docker.String()} + return []string{techutils.Docker.String()} + default: + techs := promotePnpmWorkspaceMember(techutils.DetectedTechnologiesListForCurationAudit()) + techs = promoteYarnWorkspaceMember(techs) + techs = promotePipToUv(techs) + for i, tech := range techs { + techs[i] = resolveNpmYarnTech(tech) + } + // Logged here, after all promotions, so it reflects what's actually audited — + // logging right after file-based detection could show a technology (e.g. "pip") + // that a promotion below immediately supersedes (e.g. "...treating project as uv."). + if len(techs) > 0 { + log.Info(fmt.Sprintf("Detected: %s.", strings.Join(techs, ", "))) + } + ca.queuePep723HintIfUv(techs) + return techs + } +} + +func (ca *CurationAuditCommand) queuePep723HintIfUv(techs []string) { + if !slices.Contains(techs, techutils.Uv.String()) { + return } - // Resolve npm→yarn when the project was configured with 'jf yarn-config' (yarn.yaml exists) - // but has no yarn.lock/.yarnrc.yml so the file-based detector picked npm instead. - for i, tech := range techs { - techs[i] = resolveNpmYarnTech(tech) + wd, err := os.Getwd() + if err != nil { + return } - for _, tech := range techs { + if hint := techutils.Pep723ScriptUnauditedHint(wd); hint != "" { + ca.pendingWarnings = append(ca.pendingWarnings, hint) + } +} + +func (ca *CurationAuditCommand) doCurateAudit(results map[string]*CurationReport) error { + for _, tech := range ca.techsToAudit() { supportedFunc, ok := supportedTech[techutils.Technology(tech)] if !ok { log.Info(fmt.Sprintf(errorTemplateUnsupportedTech, tech)) @@ -710,14 +803,25 @@ func (ca *CurationAuditCommand) getBuildInfoParamsByTech() (technologies.BuildIn DockerImageName: ca.DockerImageName(), // NuGet params SolutionFilePath: ca.SolutionFilePath(), + // Uv params + ScriptPath: ca.scriptPath, }, err } func (ca *CurationAuditCommand) auditTree(tech techutils.Technology, results map[string]*CurationReport) error { - // --run-native is only meaningful for npm/pnpm (.npmrc-based); reject it early for other techs. + // --run-native is meaningful for npm; pnpm/yarn/uv accept it as a no-op; reject it + // early for every other tech. if err := validateRunNativeForTech(tech, ca.RunNative()); err != nil { return err } + // Must run before getBuildInfoParamsByTech so params carry the correct repo and server details. + // Usually already set by checkSupportByVersionOrEnv's GetAuth(Uv) call before auditTree; + // only re-read here if that didn't happen (e.g. CurationSupportFlag skips GetAuth). + if tech == techutils.Uv && ca.PackageManagerConfig == nil { + if err := ca.setRepoFromUvToml(); err != nil { + return err + } + } params, err := ca.getBuildInfoParamsByTech() if err != nil { return errorutils.CheckErrorf("failed to get build info params for %s: %v", tech.String(), err) @@ -727,6 +831,16 @@ func (ca *CurationAuditCommand) auditTree(tech techutils.Technology, results map if (ca.RunNative() && tech == techutils.Npm) || tech == techutils.Pnpm { params.IgnoreConfigFile = true } + // uv has no jf uv-config yaml; skip config file lookup and use server details + // from uv.toml so BuildDependencyTree builds correct Artifactory download URLs. + if tech == techutils.Uv { + params.IgnoreConfigFile = true + if ca.PackageManagerConfig != nil { + if uvSD, sdErr := ca.PackageManagerConfig.ServerDetails(); sdErr == nil && uvSD != nil { + params.ServerDetails = uvSD + } + } + } // 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 { @@ -738,6 +852,11 @@ func (ca *CurationAuditCommand) auditTree(tech techutils.Technology, results map if ca.RunNative() && tech == techutils.Yarn { ca.pendingWarnings = append(ca.pendingWarnings, "--run-native has no effect for yarn") } + // --run-native has no effect for uv; it always resolves natively (there is no + // 'jf uv-config' to opt out of). + if ca.RunNative() && tech == techutils.Uv { + ca.pendingWarnings = append(ca.pendingWarnings, "--run-native has no effect for uv; uv always resolves natively from uv.toml/pyproject.toml") + } // 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) @@ -746,12 +865,12 @@ func (ca *CurationAuditCommand) auditTree(tech techutils.Technology, results map } depTreeResult, err := buildinfo.GetTechDependencyTree(params, serverDetails, tech) if err != nil { - // When CVS strips a pinned version from the simple index, pip can't - // resolve the project and GetTechDependencyTree returns a CvsBlockedError. + // When CVS strips a pinned version from the simple index, pip/poetry/uv + // can't resolve the project and GetTechDependencyTree returns a CvsBlockedError. // Instead of aborting with no output, run the metadata-API fallback to // recover the curation policy and render a partial table. var cvsErr *python.CvsBlockedError - if (tech == techutils.Pip || tech == techutils.Poetry) && errors.As(err, &cvsErr) { + if (tech == techutils.Pip || tech == techutils.Poetry || tech == techutils.Uv) && errors.As(err, &cvsErr) { return ca.runCvsFallback(cvsErr, tech, results) } return err @@ -771,13 +890,18 @@ func (ca *CurationAuditCommand) auditTree(tech techutils.Technology, results map rootNode := depTreeResult.FullDepTrees[0] // Extract project name from the dependency tree _, projectName, projectScope, projectVersion := getUrlNameAndVersionByTech(tech, rootNode, nil, "", "") - // If the project name is not set, we use the current working directory name + // If the project name is not set, fall back to the script name (--script) or the + // current working directory name. if projectName == "" { - workPath, err := os.Getwd() - if err != nil { - return err + if params.ScriptPath != "" { + projectName = filepath.Base(params.ScriptPath) + } else { + workPath, err := os.Getwd() + if err != nil { + return err + } + projectName = filepath.Base(workPath) } - projectName = filepath.Base(workPath) } fullProjectName := projectName if projectVersion != "" { @@ -1056,6 +1180,11 @@ func (ca *CurationAuditCommand) SetRepo(tech techutils.Technology) error { return ca.setRepoFromNpmrcForPnpm() } + // uv reads Artifactory repo details from ~/.config/uv/uv.toml — no 'jf uv-config' required. + if tech == techutils.Uv { + return ca.setRepoFromUvToml() + } + // 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 @@ -1108,7 +1237,7 @@ func (ca *CurationAuditCommand) SetRepo(tech techutils.Technology) error { // validateRunNativeForTech rejects --run-native for techs that don't implement // native-config semantics. npm uses it to read Artifactory details from .npmrc; -// pnpm accepts it as a no-op (it always resolves from .npmrc). Extend the +// pnpm/yarn/uv accept it as a no-op (they always resolve natively). Extend the // allow-list below when a new tech adds the matching native-config flow. func validateRunNativeForTech(tech techutils.Technology, runNative bool) error { if !runNative { @@ -1123,6 +1252,9 @@ func validateRunNativeForTech(tech techutils.Technology, runNative bool) error { techutils.Pnpm: {}, // --run-native has no effect for yarn regardless of version; a warning is emitted in auditTree. techutils.Yarn: {}, + // uv always resolves natively; no 'jf uv-config' exists, + // so --run-native is a redundant no-op. + techutils.Uv: {}, } if _, ok := supported[tech]; ok { return nil @@ -1258,6 +1390,35 @@ func (ca *CurationAuditCommand) setRepoFromYarnrcForYarnV4(yarnExecPath, working return nil } +// setRepoFromUvToml resolves the Artifactory URL and repo name via GetNativeUvRegistryConfig +// (pyproject.toml [[tool.uv.index]] first, then ~/.config/uv/uv.toml) and configures the command. +// Auth comes from the jf c server config. +func (ca *CurationAuditCommand) setRepoFromUvToml() error { + registryConfig, err := uvtech.GetNativeUvRegistryConfig() + if err != nil { + log.Warn("Ensure an [[index]] entry with an Artifactory PyPI URL is set in ~/.config/uv/uv.toml") + return fmt.Errorf("uv: failed to read Artifactory details from uv.toml: %w", err) + } + + base, sdErr := ca.ServerDetails() + if sdErr != nil { + return fmt.Errorf("uv: failed to read 'jf c' server configuration: %w", sdErr) + } + if base == nil { + return errorutils.CheckErrorf("uv: no 'jf c' server configured") + } + copied := *base + copied.ArtifactoryUrl = registryConfig.ArtifactoryUrl + + repoConfig := (&project.RepositoryConfig{}). + SetTargetRepo(registryConfig.RepoName). + SetServerDetails(&copied) + ca.setPackageManagerConfig(repoConfig) + ca.SetDepsRepo(registryConfig.RepoName) + log.Info(fmt.Sprintf("uv: using Artifactory URL %q and repository %q from uv.toml", 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 { @@ -1412,7 +1573,7 @@ func (nc *treeAnalyzer) fetchNodeStatus(node xrayUtils.GraphNode, p *sync.Map) e return nil } -// runCvsFallback is called when pip or poetry resolution failed because CVS +// runCvsFallback is called when pip, poetry, or uv resolution failed because CVS // stripped a pinned version from the simple index (CvsBlockedError). It uses // the PyPI metadata API to recover each blocker's real download URL, probes // the normal (non-audit) download path, and renders the policy in a partial @@ -1437,7 +1598,11 @@ func (ca *CurationAuditCommand) runCvsFallback(cvsErr *python.CvsBlockedError, t } packagesStatus := analyzer.fetchCvsBlockedStatus(cvsErr.Packages) if len(packagesStatus) == 0 { - // Fallback produced nothing — surface the original error (current behaviour). + // No policy match found for the stripped version(s) — surface the + // generic curation-block guidance instead of the bare error. + if msgToUser := technologies.GetMsgToUserForCurationBlock(true, tech, cvsErr.Cause.Error()); msgToUser != "" { + return errors.Join(cvsErr.Cause, errors.New(msgToUser)) + } return cvsErr } workPath, wdErr := osGetwd() @@ -1801,7 +1966,7 @@ func getUrlNameAndVersionByTech(tech techutils.Technology, node *xrayUtils.Graph return getGradleNameScopeAndVersion(node.Id, artiUrl, repo, node) case techutils.Gem: return getGemNameScopeAndVersion(node.Id, artiUrl, repo) - case techutils.Pip, techutils.Poetry: + case techutils.Pip, techutils.Poetry, techutils.Uv: downloadUrls, name, version = getPythonNameVersion(node.Id, downloadUrlsMap) return case techutils.Go: diff --git a/commands/curation/curationaudit_test.go b/commands/curation/curationaudit_test.go index 96297df2c..ed78912de 100644 --- a/commands/curation/curationaudit_test.go +++ b/commands/curation/curationaudit_test.go @@ -17,6 +17,7 @@ import ( "sync" "testing" + "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies" "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/java" "github.com/jfrog/jfrog-cli-security/utils/formats" @@ -27,6 +28,7 @@ import ( "github.com/jfrog/jfrog-cli-core/v2/utils/config" "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" "github.com/jfrog/jfrog-client-go/utils/io/fileutils" + "github.com/jfrog/jfrog-client-go/utils/log" clienttestutils "github.com/jfrog/jfrog-client-go/utils/tests" xrayUtils "github.com/jfrog/jfrog-client-go/xray/services/utils" "github.com/stretchr/testify/assert" @@ -2150,6 +2152,96 @@ func TestFetchCvsBlockedStatusPoetry(t *testing.T) { assert.Equal(t, blocked, s.Action) } +// TestFetchCvsBlockedStatusPoetryTransitive verifies the CVS fallback for a transitive +// blocker under poetry. +func TestFetchCvsBlockedStatusPoetryTransitive(t *testing.T) { + const ( + repo = "test-poetry-repo" + blockedPkg = "langchain-core" + blockedVer = "1.4.7" + parentPkg = "deepagents" + parentVer = "0.6.12" + rangeSpec = ">=1.4.0" + expectedPolicy = "immature-strict" + expectedCond = "Package version is immature (strict)" + expectedExpl = "Package version is 3 days old" + expectedRec = "Use an older version or wait until this version is no longer immature" + whlRelativePath = "packages/ab/cd/langchain_core-1.4.7-py3-none-any.whl" + ) + + blockMsg := fmt.Sprintf( + "Package %s:%s download was blocked by JFrog Packages Curation service due to the following policies violated {%s, %s, %s, %s}.", + blockedPkg, blockedVer, expectedPolicy, expectedCond, expectedExpl, expectedRec, + ) + blockResponse := fmt.Sprintf(`{"errors":[{"status":403,"message":%q}]}`, blockMsg) + allVersionsJSON := `{"releases":{"1.4.0":[],"1.4.1":[],"1.4.5":[],"1.4.7":[]}}` + versionMetaJSON := fmt.Sprintf(`{"urls":[{"packagetype":"bdist_wheel","url":"../../%s"}]}`, whlRelativePath) + + serverMock, _, rtManager := coreCommonTests.CreateRtRestsMockServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/pypi/"+blockedPkg+"/json"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(allVersionsJSON)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/pypi/"+blockedPkg+"/"+blockedVer+"/json"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(versionMetaJSON)) + case r.Method == http.MethodHead && strings.Contains(r.URL.Path, whlRelativePath): + w.WriteHeader(http.StatusForbidden) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, whlRelativePath): + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(blockResponse)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + defer serverMock.Close() + + rtAuth := rtManager.GetConfig().GetServiceDetails() + httpClientDetails := rtAuth.CreateHttpClientDetails() + + analyzer := treeAnalyzer{ + rtManager: rtManager, + extractPoliciesRegex: regexp.MustCompile(extractPoliciesRegexTemplate), + rtAuth: rtAuth, + httpClientDetails: httpClientDetails, + url: rtAuth.GetUrl(), + repo: repo, + tech: techutils.Poetry, + parallelRequests: 1, + } + + pins := []python.PinnedRequirement{ + { + Name: blockedPkg, + VersionRange: rangeSpec, + ParentName: parentPkg, + ParentVersion: parentVer, + }, + } + + statuses := analyzer.fetchCvsBlockedStatus(pins) + require.Len(t, statuses, 1) + + s := statuses[0] + + // Blocked package attribution + assert.Equal(t, blockedPkg, s.PackageName, "blocked package name") + assert.Equal(t, blockedVer, s.PackageVersion, "blocked package version — newest satisfying range") + + // Parent (direct dep) attribution — must differ from the blocked package. + assert.Equal(t, parentPkg, s.ParentName, "direct dependency name") + assert.Equal(t, parentVer, s.ParentVersion, "direct dependency version") + assert.NotEqual(t, s.PackageName, s.ParentName, "transitive blocker must show a different direct-dependency name") + + assert.Equal(t, string(techutils.Poetry), s.PkgType, "package type must be poetry") + require.Len(t, s.Policy, 1) + assert.Equal(t, expectedPolicy, s.Policy[0].Policy) + assert.Equal(t, expectedCond, s.Policy[0].Condition) + assert.Equal(t, expectedExpl, s.Policy[0].Explanation) + assert.Equal(t, expectedRec, s.Policy[0].Recommendation) + assert.Equal(t, blocked, s.Action) +} + // TestFetchCvsBlockedStatusNotInMetadataNotRendered verifies that a version absent from the metadata API is not rendered as a blocked row. func TestFetchCvsBlockedStatusNotInMetadataNotRendered(t *testing.T) { const ( @@ -2385,6 +2477,168 @@ func TestRunCvsFallbackGetWdFailurePreservesResults(t *testing.T) { assert.Contains(t, results, "unknown-project", "results key must be the fallback key when Getwd fails") } +// TestRunCvsFallbackNoMatchesFound covers runCvsFallback's empty-recovery branch: when +// fetchCvsBlockedStatus can't recover a policy for any of cvsErr's packages (e.g. the +// metadata API has no record of the stripped version), no partial table should be +// rendered. Instead: a generic curation-block message is joined to the original cause when +// the tech's forbidden-output pattern matches it, or the bare cvsErr is returned unchanged +// otherwise. Either way, results must stay untouched — no misleading empty/partial entry. +func TestRunCvsFallbackNoMatchesFound(t *testing.T) { + const repo = "test-pip-repo" + // Metadata API 404s for every package/version — fetchCvsBlockedStatus recovers nothing. + serverMock, serverDetails, _ := coreCommonTests.CreateRtRestsMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + defer serverMock.Close() + + repoConfig := (&project.RepositoryConfig{}). + SetTargetRepo(repo). + SetServerDetails(serverDetails) + ca := &CurationAuditCommand{ + PackageManagerConfig: repoConfig, + extractPoliciesRegex: regexp.MustCompile(extractPoliciesRegexTemplate), + } + unresolvedPkg := []python.PinnedRequirement{{Name: "unresolvable-pkg", Version: "9.9.9", ParentName: "unresolvable-pkg", ParentVersion: "9.9.9"}} + + t.Run("forbidden-output cause — wraps cause with generic curation-block message", func(t *testing.T) { + cvsErr := &python.CvsBlockedError{ + Packages: unresolvedPkg, + Cause: errors.New("ERROR: HTTP error 403 while getting https://example.com/simple/unresolvable-pkg/"), + } + results := map[string]*CurationReport{} + err := ca.runCvsFallback(cvsErr, techutils.Pip, results) + + require.Error(t, err) + assert.ErrorIs(t, err, cvsErr.Cause, "original cause must still be reachable via errors.Is") + assert.Contains(t, err.Error(), fmt.Sprintf(technologies.CurationErrorMsgToUserTemplate, techutils.Pip), + "generic curation-block guidance must be joined in when the tech's forbidden-output pattern matches") + assert.Empty(t, results, "no partial/empty table entry must be recorded") + }) + + t.Run("non-forbidden cause — returns cvsErr unchanged", func(t *testing.T) { + cvsErr := &python.CvsBlockedError{ + Packages: unresolvedPkg, + Cause: errors.New("some unrelated resolution failure"), + } + results := map[string]*CurationReport{} + err := ca.runCvsFallback(cvsErr, techutils.Pip, results) + + assert.Same(t, cvsErr, err, "with no recognizable forbidden pattern, the bare cvsErr must be returned as-is") + assert.Empty(t, results, "no partial/empty table entry must be recorded") + }) +} + +// TestSetRepoFromUvTomlNoServerConfigured is a regression test for a garbled error +// message: ca.ServerDetails() (AuditBasicParams.ServerDetails) always returns a nil +// error, so wrapping it with %w produced "...: %!w()" — a raw Go fmt-verb artifact +// leaking to the user instead of a clean "no server configured" message. +func TestSetRepoFromUvTomlNoServerConfigured(t *testing.T) { + projectDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "pyproject.toml"), []byte(`[[tool.uv.index]] +name = "artifactory-repo" +url = "https://host/artifactory/api/pypi/uv-test-repo/simple" +`), 0644)) + prevWd, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(projectDir)) + defer clienttestutils.ChangeDirAndAssert(t, prevWd) + + ca := NewCurationAuditCommand() + + setErr := ca.setRepoFromUvToml() + + require.Error(t, setErr) + assert.NotContains(t, setErr.Error(), "%!w", "error must not leak a raw Go fmt-verb artifact to the user") + assert.Contains(t, setErr.Error(), "no 'jf c' server configured") +} + +// TestAuditTreeSkipsRedundantSetRepoFromUvTomlWhenAlreadySet is a regression test: +// setRepoFromUvToml() used to run twice per uv run — once via GetAuth(Uv), again +// unconditionally in auditTree. auditTree now skips it once PackageManagerConfig is set. +func TestAuditTreeSkipsRedundantSetRepoFromUvTomlWhenAlreadySet(t *testing.T) { + projectDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "pyproject.toml"), []byte(`[[tool.uv.index]] +name = "artifactory-repo" +url = "https://host/artifactory/api/pypi/uv-test-repo/simple" +`), 0644)) + prevWd, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(projectDir)) + defer clienttestutils.ChangeDirAndAssert(t, prevWd) + + ca := NewCurationAuditCommand() + ca.SetServerDetails(&config.ServerDetails{Url: "https://host/"}) + + var buf bytes.Buffer + origLogger := log.Logger + log.SetLogger(log.NewLogger(log.INFO, &buf)) + defer log.SetLogger(origLogger) + + firstServerDetails, err := ca.GetAuth(techutils.Uv) + require.NoError(t, err) + require.NotNil(t, firstServerDetails) + require.NotNil(t, ca.PackageManagerConfig, "GetAuth must populate PackageManagerConfig via setRepoFromUvToml") + logCountAfterGetAuth := strings.Count(buf.String(), "using Artifactory URL") + assert.Equal(t, 2, logCountAfterGetAuth, "one setRepoFromUvToml() call must log exactly twice: "+ + "once from GetNativeUvRegistryConfig, once from setRepoFromUvToml itself") + + _ = ca.auditTree(techutils.Uv, map[string]*CurationReport{}) + + logCountAfterAuditTree := strings.Count(buf.String(), "using Artifactory URL") + assert.Equal(t, logCountAfterGetAuth, logCountAfterAuditTree, + "auditTree must not re-read uv.toml when PackageManagerConfig is already set") +} + +// 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) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "requirements.txt"), []byte("requests==2.31.0\n"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "uv.lock"), []byte("# stray uv.lock\n"), 0644)) + t.Chdir(root) + + techs := promotePipToUv(techutils.DetectedTechnologiesListForCurationAudit()) + + assert.Contains(t, techs, techutils.Pip.String(), "a pip-exclusive file (requirements.txt) must win over a stray uv.lock") + assert.NotContains(t, techs, techutils.Uv.String(), "must not report uv when a pip-exclusive file is present") +} + +// TestPureUvProjectNotReportedAsPip guards the flip side: since Pip's indicators no +// longer exclude uv.lock, a plain uv project fires both pip (pyproject.toml) and uv +// (uv.lock) — promotePipToUv must still collapse that to uv alone. +func TestPureUvProjectNotReportedAsPip(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "pyproject.toml"), []byte("[project]\nname = \"demo\"\n"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "uv.lock"), []byte("version = 1\n"), 0644)) + t.Chdir(root) + + techs := promotePipToUv(techutils.DetectedTechnologiesListForCurationAudit()) + + assert.Contains(t, techs, techutils.Uv.String(), "uv.lock present, no pip-exclusive files — must report uv") + assert.NotContains(t, techs, techutils.Pip.String(), "must not also report pip for a plain uv-only project") +} + +// TestTechsToAuditQueuesPep723HintForDeferredLogging: the PEP 723 hint must be queued in pendingWarnings. +func TestTechsToAuditQueuesPep723HintForDeferredLogging(t *testing.T) { + projectDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "pyproject.toml"), []byte("[project]\nname = \"demo\"\n"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "uv.lock"), []byte("version = 1\n"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "script.py"), + []byte("# /// script\n# dependencies = [\"six\"]\n# ///\n\nimport six\n"), 0644)) + prevWd, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(projectDir)) + defer clienttestutils.ChangeDirAndAssert(t, prevWd) + + ca := NewCurationAuditCommand() + + techs := ca.techsToAudit() + + assert.Equal(t, []string{techutils.Uv.String()}, techs) + require.Len(t, ca.pendingWarnings, 1, "the hint must be queued, not logged immediately") + assert.Contains(t, ca.pendingWarnings[0], "--script") +} + // TestEffectiveParentVersion covers all branches of the effectiveParentVersion helper. func TestEffectiveParentVersion(t *testing.T) { cases := []struct { @@ -2406,7 +2660,7 @@ 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 +// allow-listed native-config techs (npm, pnpm, yarn, uv) and rejected for all other // techs with an error that names the offending tech. func TestValidateRunNativeForTech(t *testing.T) { // Sanity: npm and pnpm are allow-listed techs. Both flag states pass. @@ -2421,6 +2675,12 @@ func TestValidateRunNativeForTech(t *testing.T) { assert.NoError(t, validateRunNativeForTech(techutils.Yarn, false)) }) + // uv has no 'jf uv-config', so --run-native must be a no-op like pnpm/yarn + t.Run("uv accepts --run-native as a redundant no-op", func(t *testing.T) { + assert.NoError(t, validateRunNativeForTech(techutils.Uv, true)) + assert.NoError(t, validateRunNativeForTech(techutils.Uv, false)) + }) + // Every other supported tech follows the same contract. Catch silent // acceptance for any tech that's in the doc-table-of-supported but // hasn't implemented a native flow — same UX as yarn. @@ -2778,6 +3038,166 @@ func TestPromotePnpmWorkspaceMember(t *testing.T) { } } +// TestFetchCvsBlockedStatusUv verifies the CVS fallback for uv: metadata fetch → HEAD probe → policy parse. +func TestFetchCvsBlockedStatusUv(t *testing.T) { + const ( + repo = "test-uv-pypi-repo" + blockedPkg = "requests" + blockedVer = "2.19.1" + expectedPolicy = "immature-30" + expectedCond = "Package version is immature (strict)" + expectedExpl = "Package version is 3 days old" + expectedRec = "Use an older version or wait until this version is no longer immature" + whlRelativePath = "packages/re/qu/requests-2.19.1-py2.py3-none-any.whl" + ) + + blockMsg := fmt.Sprintf( + "Package %s:%s download was blocked by JFrog Packages Curation service due to the following policies violated {%s, %s, %s, %s}.", + blockedPkg, blockedVer, expectedPolicy, expectedCond, expectedExpl, expectedRec, + ) + blockResponse := fmt.Sprintf(`{"errors":[{"status":403,"message":%q}]}`, blockMsg) + versionMetaJSON := fmt.Sprintf(`{"urls":[{"packagetype":"bdist_wheel","url":"../../%s"}]}`, whlRelativePath) + + serverMock, _, rtManager := coreCommonTests.CreateRtRestsMockServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/pypi/"+blockedPkg+"/"+blockedVer+"/json"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(versionMetaJSON)) + case r.Method == http.MethodHead && strings.Contains(r.URL.Path, whlRelativePath): + w.WriteHeader(http.StatusForbidden) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, whlRelativePath): + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(blockResponse)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + defer serverMock.Close() + + rtAuth := rtManager.GetConfig().GetServiceDetails() + httpClientDetails := rtAuth.CreateHttpClientDetails() + + analyzer := treeAnalyzer{ + rtManager: rtManager, + extractPoliciesRegex: regexp.MustCompile(extractPoliciesRegexTemplate), + rtAuth: rtAuth, + httpClientDetails: httpClientDetails, + url: rtAuth.GetUrl(), + repo: repo, + tech: techutils.Uv, + parallelRequests: 1, + } + + pins := []python.PinnedRequirement{ + {Name: blockedPkg, Version: blockedVer, ParentName: blockedPkg, ParentVersion: blockedVer}, + } + + statuses := analyzer.fetchCvsBlockedStatus(pins) + require.Len(t, statuses, 1) + + s := statuses[0] + assert.Equal(t, blockedPkg, s.PackageName) + assert.Equal(t, blockedVer, s.PackageVersion) + assert.Equal(t, string(techutils.Uv), s.PkgType, "package type must be uv") + require.Len(t, s.Policy, 1) + assert.Equal(t, expectedPolicy, s.Policy[0].Policy) + assert.Equal(t, expectedCond, s.Policy[0].Condition) + assert.Equal(t, expectedExpl, s.Policy[0].Explanation) + assert.Equal(t, expectedRec, s.Policy[0].Recommendation) + assert.Equal(t, blocked, s.Action) +} + +// TestFetchCvsBlockedStatusUvTransitive verifies the CVS fallback for a transitive +// blocker under uv +func TestFetchCvsBlockedStatusUvTransitive(t *testing.T) { + const ( + repo = "test-uv-pypi-repo" + blockedPkg = "langchain-core" + blockedVer = "1.4.7" + parentPkg = "deepagents" + parentVer = "0.6.12" + rangeSpec = ">=1.4.0" + expectedPolicy = "immature-strict" + expectedCond = "Package version is immature (strict)" + expectedExpl = "Package version is 3 days old" + expectedRec = "Use an older version or wait until this version is no longer immature" + whlRelativePath = "packages/ab/cd/langchain_core-1.4.7-py3-none-any.whl" + ) + + blockMsg := fmt.Sprintf( + "Package %s:%s download was blocked by JFrog Packages Curation service due to the following policies violated {%s, %s, %s, %s}.", + blockedPkg, blockedVer, expectedPolicy, expectedCond, expectedExpl, expectedRec, + ) + blockResponse := fmt.Sprintf(`{"errors":[{"status":403,"message":%q}]}`, blockMsg) + allVersionsJSON := `{"releases":{"1.4.0":[],"1.4.1":[],"1.4.5":[],"1.4.7":[]}}` + versionMetaJSON := fmt.Sprintf(`{"urls":[{"packagetype":"bdist_wheel","url":"../../%s"}]}`, whlRelativePath) + + serverMock, _, rtManager := coreCommonTests.CreateRtRestsMockServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/pypi/"+blockedPkg+"/json"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(allVersionsJSON)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/pypi/"+blockedPkg+"/"+blockedVer+"/json"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(versionMetaJSON)) + case r.Method == http.MethodHead && strings.Contains(r.URL.Path, whlRelativePath): + w.WriteHeader(http.StatusForbidden) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, whlRelativePath): + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(blockResponse)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + defer serverMock.Close() + + rtAuth := rtManager.GetConfig().GetServiceDetails() + httpClientDetails := rtAuth.CreateHttpClientDetails() + + analyzer := treeAnalyzer{ + rtManager: rtManager, + extractPoliciesRegex: regexp.MustCompile(extractPoliciesRegexTemplate), + rtAuth: rtAuth, + httpClientDetails: httpClientDetails, + url: rtAuth.GetUrl(), + repo: repo, + tech: techutils.Uv, + parallelRequests: 1, + } + + pins := []python.PinnedRequirement{ + { + Name: blockedPkg, + VersionRange: rangeSpec, + ParentName: parentPkg, + ParentVersion: parentVer, + }, + } + + statuses := analyzer.fetchCvsBlockedStatus(pins) + require.Len(t, statuses, 1) + + s := statuses[0] + + // Blocked package attribution + assert.Equal(t, blockedPkg, s.PackageName, "blocked package name") + assert.Equal(t, blockedVer, s.PackageVersion, "blocked package version — newest satisfying range") + + // Parent (direct dep) attribution — must differ from the blocked package. + assert.Equal(t, parentPkg, s.ParentName, "direct dependency name") + assert.Equal(t, parentVer, s.ParentVersion, "direct dependency version") + assert.NotEqual(t, s.PackageName, s.ParentName, "transitive blocker must show a different direct-dependency name") + + assert.Equal(t, string(techutils.Uv), s.PkgType, "package type must be uv") + require.Len(t, s.Policy, 1) + assert.Equal(t, expectedPolicy, s.Policy[0].Policy) + assert.Equal(t, expectedCond, s.Policy[0].Condition) + assert.Equal(t, expectedExpl, s.Policy[0].Explanation) + assert.Equal(t, expectedRec, s.Policy[0].Recommendation) + assert.Equal(t, blocked, s.Action) +} + +// TestResolveUvTech verifies that pip is promoted to uv when the right config signals are present. func TestPromoteYarnWorkspaceMember(t *testing.T) { npm := techutils.Npm.String() yarn := techutils.Yarn.String() @@ -2875,3 +3295,143 @@ func TestPromoteYarnWorkspaceMember(t *testing.T) { assert.NotContains(t, result, yarn, "npm must not be promoted to yarn from a $HOME-level indicator") }) } + +// TestPromotePipToUv covers every uv signal promotePipToUv checks (uv.lock, pyproject.toml +// [tool.uv]/[[tool.uv.index]], ~/.config/uv/uv.toml), confirms pip-exclusive files always +// win, and confirms it collapses a tech list already containing both pip and uv into one. +func TestPromotePipToUv(t *testing.T) { + pip := techutils.Pip.String() + uv := techutils.Uv.String() + other := "maven" + + tests := []struct { + name string + techs []string + pyprojectTOML string // content written to pyproject.toml; empty = don't create + hasPipFile string // name of a pip-exclusive file to create (e.g. "requirements.txt") + hasUvLock bool // create uv.lock in the project dir + hasUvToml bool // create ~/.config/uv/uv.toml + expectedHasPip bool + expectedHasUv bool + }{ + { + name: "no pip in techs — no change", + techs: []string{other}, + }, + { + name: "pip with requirements.txt — stays pip", + techs: []string{pip}, + hasPipFile: "requirements.txt", + expectedHasPip: true, + }, + { + name: "pip with setup.py — stays pip", + techs: []string{pip}, + hasPipFile: "setup.py", + expectedHasPip: true, + }, + { + name: "pip + uv.lock — promoted to uv", + techs: []string{pip}, + hasUvLock: true, + expectedHasUv: true, + }, + { + name: "pip-exclusive file takes priority over uv.lock — stays pip", + techs: []string{pip}, + hasPipFile: "requirements.txt", + hasUvLock: true, + expectedHasPip: true, + }, + { + name: "pip + pyproject.toml with [tool.uv] — promoted to uv", + techs: []string{pip}, + pyprojectTOML: "[tool.uv]\npython = \"3.12\"\n", + expectedHasUv: true, + }, + { + name: "pip + pyproject.toml with [[tool.uv.index]] — promoted to uv", + techs: []string{pip}, + pyprojectTOML: "[[tool.uv.index]]\nurl = \"https://example.jfrog.io/api/pypi/pypi-virtual/simple\"\n", + expectedHasUv: true, + }, + { + name: "pip-exclusive file takes priority over [tool.uv] in pyproject.toml — stays pip", + techs: []string{pip}, + hasPipFile: "requirements.txt", + pyprojectTOML: "[tool.uv]\npython = \"3.12\"\n", + expectedHasPip: true, + }, + { + name: "pip + ~/.config/uv/uv.toml — promoted to uv", + techs: []string{pip}, + hasUvToml: true, + expectedHasUv: true, + }, + { + name: "pip-exclusive file takes priority over ~/.config/uv/uv.toml — stays pip", + techs: []string{pip}, + hasPipFile: "Pipfile", + hasUvToml: true, + expectedHasPip: true, + }, + { + name: "plain pip project with bare pyproject.toml — stays pip", + techs: []string{pip}, + pyprojectTOML: "[build-system]\nrequires = [\"setuptools\"]\n", + expectedHasPip: true, + }, + { + name: "already detected as both pip and uv, uv.lock present — collapses to uv alone", + techs: []string{pip, uv}, + hasUvLock: true, + expectedHasUv: true, + }, + { + name: "already detected as both pip and uv, pip-exclusive file present — collapses to pip alone", + techs: []string{pip, uv}, + hasPipFile: "requirements.txt", + expectedHasPip: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + projectDir := t.TempDir() + fakeHome := t.TempDir() + + t.Setenv("HOME", fakeHome) + t.Setenv("USERPROFILE", fakeHome) + t.Chdir(projectDir) + + if tc.hasPipFile != "" { + require.NoError(t, os.WriteFile(filepath.Join(projectDir, tc.hasPipFile), []byte{}, 0o644)) + } + if tc.hasUvLock { + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "uv.lock"), []byte{}, 0o644)) + } + if tc.pyprojectTOML != "" { + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "pyproject.toml"), []byte(tc.pyprojectTOML), 0o644)) + } + if tc.hasUvToml { + uvCfgDir := filepath.Join(fakeHome, ".config", "uv") + require.NoError(t, os.MkdirAll(uvCfgDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(uvCfgDir, "uv.toml"), []byte("[[index]]\nurl = \"https://example.jfrog.io/api/pypi/pypi-virtual/simple\"\n"), 0o644)) + } + + result := promotePipToUv(tc.techs) + + hasPip, hasUv := false, false + for _, tech := range result { + switch tech { + case pip: + hasPip = true + case uv: + hasUv = true + } + } + assert.Equal(t, tc.expectedHasPip, hasPip, "pip presence") + assert.Equal(t, tc.expectedHasUv, hasUv, "uv presence") + }) + } +} diff --git a/curation_test.go b/curation_test.go index da796d5e2..c16983ff6 100644 --- a/curation_test.go +++ b/curation_test.go @@ -404,6 +404,153 @@ func getPoetryCurationExpectedResponse(config *config.ServerDetails, repo string } } +// TestUvCurationAudit exercises 'jf curation-audit' end-to-end for uv. With no uv.lock +// present, 'jf ca' runs 'uv lock' against the mock's curation pass-through endpoint, +// resolving pexpect + ptyprocess from synthetic PEP 503/658 responses (see +// uvCurationServer). The curation HEAD-walker then probes the plain download URL +// recorded in the generated uv.lock and reports the blocked package as PkgType "uv". +func TestUvCurationAudit(t *testing.T) { + integration.InitCurationTest(t) + const repo = "pypi-curation" + tempDirPath, cleanUp := securityTestUtils.CreateTestProjectEnvAndChdir(t, filepath.Join(filepath.FromSlash(securityTests.GetTestResourcesPath()), "projects", "package-managers", "python", "uv", "uv-curation-project")) + defer cleanUp() + + blockedURL := "/api/pypi/" + repo + "/packages/pexpect-4.8.0-py2.py3-none-any.whl" + expectedRequest := map[string]bool{blockedURL: false} + requestToFail := map[string]bool{blockedURL: false} + serverMock, config := uvCurationServer(t, expectedRequest, requestToFail) + defer serverMock.Close() + + cleanUpHome := integration.UseTestHomeWithDefaultXrayConfig(t) + defer cleanUpHome() + + config.User = "admin" + config.Password = "password" + config.ServerId = "test" + configCmd := commonCommands.NewConfigCommand(commonCommands.AddOrEdit, config.ServerId).SetDetails(config).SetUseBasicAuthOnly(true).SetInteractive(false) + assert.NoError(t, configCmd.Run()) + + appendToFile(t, filepath.Join(tempDirPath, "pyproject.toml"), + fmt.Sprintf("\n[[tool.uv.index]]\nurl = \"%sapi/pypi/%s/simple\"\n", config.ArtifactoryUrl, repo)) + + localXrayCli := securityTests.PlatformCli.WithoutCredentials() + workingDirsFlag := fmt.Sprintf("--working-dirs=%s", tempDirPath) + output := localXrayCli.RunCliCmdWithOutput(t, "curation-audit", "--format="+string(format.Json), workingDirsFlag) + + expectedResp := getUvCurationExpectedResponse(config, repo) + var got []curation.PackageStatus + bracketIndex := strings.Index(output, "[") + require.Less(t, 0, bracketIndex, "Unexpected Curation output with missing '['") + err := json.Unmarshal([]byte(output[bracketIndex:]), &got) + assert.NoError(t, err) + assert.Equal(t, expectedResp, got) + for k, v := range expectedRequest { + assert.Truef(t, v, "didn't receive expected HEAD request for package url %s", k) + } +} + +func getUvCurationExpectedResponse(config *config.ServerDetails, repo string) []curation.PackageStatus { + return []curation.PackageStatus{ + { + Action: "blocked", + PackageName: "pexpect", + PackageVersion: "4.8.0", + BlockedPackageUrl: config.ArtifactoryUrl + "api/pypi/" + repo + "/packages/pexpect-4.8.0-py2.py3-none-any.whl", + BlockingReason: curation.BlockingReasonPolicy, + ParentName: "pexpect", + ParentVersion: "4.8.0", + DepRelation: "direct", + PkgType: "uv", + Policy: []curation.Policy{ + {Policy: "pol1", Condition: "cond1", Explanation: "explanation", Recommendation: "recommendation"}, + {Policy: "pol2", Condition: "cond2", Explanation: "explanation2", Recommendation: "recommendation2"}, + }, + }, + } +} + +// uvSimplePackage holds the fixed pexpect/ptyprocess synthetic PyPI package data that +// the uv curation test resolves against. +type uvSimplePackage struct { + name, version, sha256, requiresDist string +} + +var uvSimplePackages = map[string]uvSimplePackage{ + "pexpect": {name: "pexpect", version: "4.8.0", sha256: "0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937", requiresDist: "Requires-Dist: ptyprocess (>=0.5)\n"}, + "ptyprocess": {name: "ptyprocess", version: "0.7.0", sha256: "4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, +} + +func (p uvSimplePackage) wheelName() string { + return fmt.Sprintf("%s-%s-py2.py3-none-any.whl", p.name, p.version) +} + +// simpleIndexHtml is a minimal PEP 503 simple-index page for p, advertising PEP 658 +// so 'uv lock' fetches the metadata sidecar below instead of the wheel itself. +func (p uvSimplePackage) simpleIndexHtml() string { + return fmt.Sprintf(` +Simple index +%s`, + p.wheelName(), p.sha256, p.wheelName()) +} + +// coreMetadata is p's PEP 658 sidecar: just enough METADATA (Name/Version/Requires-Dist) +// for uv to resolve the dependency graph. +func (p uvSimplePackage) coreMetadata() string { + return fmt.Sprintf("Metadata-Version: 2.1\nName: %s\nVersion: %s\n%s", p.name, p.version, p.requiresDist) +} + +// uvCurationServer mocks Artifactory's PyPI curation pass-through for a real 'uv lock' +// subprocess to resolve against, serving synthetic PEP 503/658 responses. +func uvCurationServer(t *testing.T, expectedRequest, requestToFail map[string]bool) (*httptest.Server, *config.ServerDetails) { + mapLock := sync.Mutex{} + serverMock, serverConfig, _ := commonTests.CreateRtRestsMockServer(t, func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodHead: + mapLock.Lock() + if _, exist := expectedRequest[r.RequestURI]; exist { + expectedRequest[r.RequestURI] = true + } + mapLock.Unlock() + if _, exist := requestToFail[r.RequestURI]; exist { + w.WriteHeader(http.StatusForbidden) + } + case http.MethodGet: + switch r.RequestURI { + case "/api/system/version": + _, err := w.Write([]byte(`{"version": "7.82.0"}`)) + require.NoError(t, err) + return + case "/api/v1/system/version": + _, err := w.Write([]byte(`{"xray_version": "3.92.0"}`)) + require.NoError(t, err) + return + } + if strings.Contains(r.RequestURI, "api/curation/audit") { + for _, pkg := range uvSimplePackages { + if strings.HasSuffix(r.RequestURI, "/simple/"+pkg.name+"/") { + _, err := w.Write([]byte(pkg.simpleIndexHtml())) + require.NoError(t, err) + return + } + if strings.HasSuffix(r.RequestURI, "/"+pkg.wheelName()+".metadata") { + _, err := w.Write([]byte(pkg.coreMetadata())) + require.NoError(t, err) + return + } + } + } + if _, exist := requestToFail[r.RequestURI]; exist { + w.WriteHeader(http.StatusForbidden) + _, err := w.Write([]byte(curationBlockedTarballResponse)) + require.NoError(t, err) + return + } + w.WriteHeader(http.StatusNotFound) + } + }) + return serverMock, serverConfig +} + func curationServer(t *testing.T, expectedRequest map[string]bool, requestToFail map[string]bool, simpleIndex ...map[string]string) (*httptest.Server, *config.ServerDetails) { mapLockReadWrite := sync.Mutex{} var index map[string]string diff --git a/sca/bom/buildinfo/buildinfobom.go b/sca/bom/buildinfo/buildinfobom.go index 66fed8596..ca831dc7b 100644 --- a/sca/bom/buildinfo/buildinfobom.go +++ b/sca/bom/buildinfo/buildinfobom.go @@ -38,6 +38,7 @@ import ( "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/pnpm" "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/python" "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/swift" + uvtech "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/uv" "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/yarn" ) @@ -279,6 +280,8 @@ func GetTechDependencyTree(params technologies.BuildInfoBomGeneratorParams, arti depTreeResult.FullDepTrees, uniqueDepsIds, err = yarn.BuildDependencyTree(params) case techutils.Go: depTreeResult.FullDepTrees, uniqueDepsIds, err = _go.BuildDependencyTree(params) + case techutils.Uv: + depTreeResult.FullDepTrees, uniqueDepsIds, depTreeResult.DownloadUrls, err = uvtech.BuildDependencyTree(params) case techutils.Pipenv, techutils.Pip, techutils.Poetry: depTreeResult.FullDepTrees, uniqueDepsIds, depTreeResult.DownloadUrls, err = python.BuildDependencyTree(params, tech) diff --git a/sca/bom/buildinfo/technologies/common.go b/sca/bom/buildinfo/technologies/common.go index cdf6a0a4f..fdd9feb9d 100644 --- a/sca/bom/buildinfo/technologies/common.go +++ b/sca/bom/buildinfo/technologies/common.go @@ -77,6 +77,8 @@ type BuildInfoBomGeneratorParams struct { DockerImageName string // NuGet params SolutionFilePath string + // Uv params + ScriptPath string } func (bbp *BuildInfoBomGeneratorParams) SetNpmScope(depType string) *BuildInfoBomGeneratorParams { diff --git a/sca/bom/buildinfo/technologies/common_test.go b/sca/bom/buildinfo/technologies/common_test.go index c69f8021d..5f7153175 100644 --- a/sca/bom/buildinfo/technologies/common_test.go +++ b/sca/bom/buildinfo/technologies/common_test.go @@ -143,6 +143,7 @@ func TestSuspectCurationBlockedError(t *testing.T) { pipOutput := "because of HTTP error 403 Client Error: Forbidden for url" goOutput := "Failed running Go command: 403 Forbidden" poetryOutput := "because of HTTP error 403 Client Error: Forbidden for url" + uvOutput := "error: Failed to fetch: `https://example.jfrog.io/api/pypi/repo/packages/urllib3-1.23.whl`\n Caused by: HTTP status client error (403 Forbidden) for url (...)" tests := []struct { name string @@ -204,6 +205,19 @@ func TestSuspectCurationBlockedError(t *testing.T) { tech: techutils.Poetry, output: "http error 401", }, + { + name: "uv 403 error (pass-through blocked package)", + isCurationCmd: true, + tech: techutils.Uv, + output: uvOutput, + expect: fmt.Sprintf(CurationErrorMsgToUserTemplate, techutils.Uv), + }, + { + name: "uv not pass through error", + isCurationCmd: true, + tech: techutils.Uv, + output: "error: network timeout", + }, { name: "not a supported tech", isCurationCmd: true, diff --git a/sca/bom/buildinfo/technologies/python/python_cvs_fallback.go b/sca/bom/buildinfo/technologies/python/python_cvs_fallback.go index a7a04abfc..0308c844e 100644 --- a/sca/bom/buildinfo/technologies/python/python_cvs_fallback.go +++ b/sca/bom/buildinfo/technologies/python/python_cvs_fallback.go @@ -11,7 +11,7 @@ import ( "github.com/jfrog/gofrog/version" ) -// PinnedRequirement is a blocker extracted from pip's failure output. +// PinnedRequirement is a blocker extracted from pip, poetry, or uv failure output. type PinnedRequirement struct { Name string Version string @@ -63,13 +63,51 @@ var pipDirectFromRequirementsRegex = regexp.MustCompile( // poetryCvsBlockedReqRegex extracts a pinned `name (version)` from poetry's // "X (Y) which doesn't match any versions" error lines. Both `name (X.Y.Z)` -// and `name (==X.Y.Z)` notations are accepted; range specifiers -// (e.g. `name (>=1.0,<2.0)`) are skipped because they represent transitive -// constraints, not the user's direct pin. +// and `name (==X.Y.Z)` notations are accepted. var poetryCvsBlockedReqRegex = regexp.MustCompile( `([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?\s+\((?:==)?\s*([0-9][0-9A-Za-z._+\-]*)\)\s+which doesn't match any versions`) -// parseCvsFailedPackages extracts blockers from pip's and poetry's failure output. +// poetryCvsBlockedRangeRegex extracts a range-constrained blocker (e.g. +// "langchain-core (>=1.4.0,<2.0.0) which doesn't match any versions"), the form +// poetry uses for a transitive dependency's constraint rather than an exact pin. +var poetryCvsBlockedRangeRegex = regexp.MustCompile( + `([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?\s+\(([~^!]?[<>=][^)]+)\)\s+which doesn't match any versions`) + +// poetryDependsOnParentRegex extracts "parent (parentVersion) depends on +// name (...)" clauses from poetry's derivation chain, to recover the real +// parent of a transitively CVS-stripped package. Keyed by child name only, +// since poetry may restate the constraint slightly differently between clauses. +var poetryDependsOnParentRegex = regexp.MustCompile( + `([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?\s+\(([0-9][0-9A-Za-z._+\-]*)\)\s+depends on\s+` + + `([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?\s+\([^)]+\)`) + +// uvCvsBlockedReqRegex extracts a pinned name==version from uv's +// "there is no version of name==version" error line, emitted when CVS has +// stripped the release from Artifactory's simple index. +var uvCvsBlockedReqRegex = regexp.MustCompile( + `there is no version of ([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?==([0-9][0-9A-Za-z._+\-]*)`) + +// uvNotFoundInRegistryRegex extracts the package name from uv's +// " was not found in the package registry" error line, emitted when CVS +// has stripped all versions of a package so that the package entry itself is absent. +var uvNotFoundInRegistryRegex = regexp.MustCompile( + `([A-Za-z0-9][A-Za-z0-9._-]*) was not found in the package registry`) + +// uvDependsOnPinnedRegex extracts name==version from uv's "depends on name==version" clause, +// used to recover the pinned version when paired with uvNotFoundInRegistryRegex. +var uvDependsOnPinnedRegex = regexp.MustCompile( + `depends on ([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?==([0-9][0-9A-Za-z._+\-]*)`) + +// uvDependsOnParentRegex extracts "parent==parentVersion depends on name==version" +// clauses from uv's derivation chain, to recover the real parent of a transitively +// CVS-stripped package (e.g. "deepagents==0.6.12 depends on langchain-core==1.4.7"). +// Doesn't match "your project depends on ..." (no version), leaving direct-dependency +// attribution to the caller. +var uvDependsOnParentRegex = regexp.MustCompile( + `([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?==([0-9][0-9A-Za-z._+\-]*)\s+depends on\s+` + + `([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?==([0-9][0-9A-Za-z._+\-]*)`) + +// parseCvsFailedPackages extracts blockers from pip, poetry, and uv failure output. // Only packages that caused the failure are returned, not every requirements entry. func parseCvsFailedPackages(pipOutput string) []PinnedRequirement { seen := map[string]bool{} @@ -149,19 +187,95 @@ func parseCvsFailedPackages(pipOutput string) []PinnedRequirement { } } - // Poetry: "name (version) which doesn't match any versions" + // Poetry: recover the real parent from "parent (v) depends on name (...)" clauses. + // Falls back to self-attribution when none found (a direct dependency). + poetryParentByChild := map[string]PinnedRequirement{} + for _, m := range poetryDependsOnParentRegex.FindAllStringSubmatch(pipOutput, -1) { + n := normalizePyPIName(m[3]) + if _, already := poetryParentByChild[n]; !already { + poetryParentByChild[n] = PinnedRequirement{Name: normalizePyPIName(m[1]), Version: m[2]} + } + } + + // Poetry: "name (version) which doesn't match any versions" — exact pin. for _, m := range poetryCvsBlockedReqRegex.FindAllStringSubmatch(pipOutput, -1) { name := normalizePyPIName(m[1]) ver := strings.TrimRight(m[2], ")") key := name + "==" + ver if !seen[key] { seen[key] = true - failed = append(failed, PinnedRequirement{ - Name: name, - Version: ver, - ParentName: name, - ParentVersion: ver, - }) + pr := PinnedRequirement{Name: name, Version: ver, ParentName: name, ParentVersion: ver} + if parent, ok := poetryParentByChild[name]; ok { + pr.ParentName, pr.ParentVersion = parent.Name, parent.Version + } + failed = append(failed, pr) + } + } + + // Poetry: "name (range) which doesn't match any versions" — a transitive + // dependency's constraint, attributed to its real parent when recoverable. + for _, m := range poetryCvsBlockedRangeRegex.FindAllStringSubmatch(pipOutput, -1) { + name := normalizePyPIName(m[1]) + rangeSpec := strings.TrimSpace(m[2]) + key := name + rangeSpec + if seen[key] { + continue + } + seen[key] = true + pr := PinnedRequirement{Name: name, VersionRange: rangeSpec, ParentName: name} + if parent, ok := poetryParentByChild[name]; ok { + pr.ParentName, pr.ParentVersion = parent.Name, parent.Version + } + failed = append(failed, pr) + } + + // uv: recover the real parent from "parent==v depends on name==v" clauses. + // Order-independent — uv's derivation chain doesn't guarantee this appears + // before or after the "no version of" / "was not found" clause it pairs with. + uvParentByChild := map[string]PinnedRequirement{} + for _, m := range uvDependsOnParentRegex.FindAllStringSubmatch(pipOutput, -1) { + childKey := normalizePyPIName(m[3]) + "==" + m[4] + if _, already := uvParentByChild[childKey]; !already { + uvParentByChild[childKey] = PinnedRequirement{Name: normalizePyPIName(m[1]), Version: m[2]} + } + } + + // uv: "there is no version of name==version" (CVS stripped the release from the simple index) + for _, m := range uvCvsBlockedReqRegex.FindAllStringSubmatch(pipOutput, -1) { + name := normalizePyPIName(m[1]) + ver := m[2] + key := name + "==" + ver + if !seen[key] { + seen[key] = true + pr := PinnedRequirement{Name: name, Version: ver, ParentName: name, ParentVersion: ver} + if parent, ok := uvParentByChild[key]; ok { + pr.ParentName, pr.ParentVersion = parent.Name, parent.Version + } + failed = append(failed, pr) + } + } + + // uv: " was not found in the package registry" — CVS stripped all versions so the + // package entry itself is absent. Recover the pinned version from the adjacent + // "depends on ==" clause in the same error message. + dependsOnByName := map[string]string{} + for _, m := range uvDependsOnPinnedRegex.FindAllStringSubmatch(pipOutput, -1) { + n := normalizePyPIName(m[1]) + if _, already := dependsOnByName[n]; !already { + dependsOnByName[n] = m[2] + } + } + for _, m := range uvNotFoundInRegistryRegex.FindAllStringSubmatch(pipOutput, -1) { + name := normalizePyPIName(m[1]) + ver := dependsOnByName[name] + key := name + "==" + ver + if !seen[key] { + seen[key] = true + pr := PinnedRequirement{Name: name, Version: ver, ParentName: name, ParentVersion: ver} + if parent, ok := uvParentByChild[key]; ok { + pr.ParentName, pr.ParentVersion = parent.Name, parent.Version + } + failed = append(failed, pr) } } @@ -199,7 +313,26 @@ func isCvsVersionFilteredOutput(output string) bool { strings.Contains(output, "doesn't match any versions") || // ResolutionImpossible: direct dep resolved but a transitive dep was stripped by CVS. (strings.Contains(output, "ResolutionImpossible") && - strings.Contains(output, "no matching distributions available for your environment")) + strings.Contains(output, "no matching distributions available for your environment")) || + strings.Contains(output, "there is no version of") || + strings.Contains(output, "was not found in the package registry") +} + +// WrapUvCurationErr checks a failed `uv lock` run's output for a CVS-stripped +// version, wrapping it as a *CvsBlockedError so the caller can recover policy +// details via the metadata-API fallback. A generic download-blocked 403 (e.g. uv +// fetching a wheel for metadata) is NOT treated as CVS-blocked, matching +// pip/poetry — cause is returned unchanged, leaving the caller to decide how to +// present it (see classifyUvCurationLockError, which falls through to a plain +// "contact admin" message for that case). +func WrapUvCurationErr(combinedOutput string, cause error) error { + if cause == nil { + return nil + } + if isCvsVersionFilteredOutput(combinedOutput) { + return &CvsBlockedError{Packages: parseCvsFailedPackages(combinedOutput), Cause: cause} + } + return cause } // ResolveVersionRange returns the newest version from candidates satisfying rangeSpec. diff --git a/sca/bom/buildinfo/technologies/python/python_cvs_fallback_test.go b/sca/bom/buildinfo/technologies/python/python_cvs_fallback_test.go index 25e13f37e..249fec7ca 100644 --- a/sca/bom/buildinfo/technologies/python/python_cvs_fallback_test.go +++ b/sca/bom/buildinfo/technologies/python/python_cvs_fallback_test.go @@ -101,9 +101,61 @@ func TestParseCvsFailedPackages(t *testing.T) { want: []PinnedRequirement{{Name: "telnyx", Version: "4.87.1", ParentName: "telnyx", ParentVersion: "4.87.1"}}, }, { - name: "poetry: range specifier is not captured", + name: "poetry: range specifier without a real parent is self-attributed", output: "Because sample-poetry-project depends on bar (>=1.0,<2.0) which doesn't match any versions, version solving failed.", - want: nil, + want: []PinnedRequirement{{Name: "bar", VersionRange: ">=1.0,<2.0", ParentName: "bar"}}, + }, + { + name: "poetry: transitive CVS-stripped range attributed to its real parent", + output: "Because deepagents (0.6.12) depends on langchain-core (>=1.4.0) which doesn't match any versions,\n" + + "deepagents (0.6.12) requires langchain-core (>=1.4.0).\n" + + "And because sample-poetry-project depends on deepagents (0.6.12), version solving failed.", + want: []PinnedRequirement{{Name: "langchain-core", VersionRange: ">=1.4.0", ParentName: "deepagents", ParentVersion: "0.6.12"}}, + }, + { + name: "poetry: transitive CVS-stripped exact pin attributed to its real parent", + output: "Because deepagents (0.6.12) depends on langchain-core (1.4.7) which doesn't match any versions,\n" + + "deepagents (0.6.12) requires langchain-core (1.4.7).\n" + + "And because sample-poetry-project depends on deepagents (0.6.12), version solving failed.", + want: []PinnedRequirement{{Name: "langchain-core", Version: "1.4.7", ParentName: "deepagents", ParentVersion: "0.6.12"}}, + }, + { + name: "uv: there is no version of (CVS stripped from Artifactory index)", + output: "× No solution found when resolving dependencies:\n" + + "╰─▶ Because there is no version of telnyx==4.87.1 and your project depends\n" + + " on telnyx==4.87.1, we can conclude that your project's requirements\n" + + " are unsatisfiable.", + want: []PinnedRequirement{{Name: "telnyx", Version: "4.87.1", ParentName: "telnyx", ParentVersion: "4.87.1"}}, + }, + { + name: "uv: deduplicates repeated name==version in output", + output: "Because there is no version of requests==2.28.0 and your project depends\n" + + "on requests==2.28.0, we can conclude...", + want: []PinnedRequirement{{Name: "requests", Version: "2.28.0", ParentName: "requests", ParentVersion: "2.28.0"}}, + }, + { + name: "uv: transitive CVS-stripped dependency attributed to its real parent", + output: "× No solution found when resolving dependencies:\n" + + "╰─▶ Because there is no version of langchain-core==1.4.7 and deepagents==0.6.12\n" + + " depends on langchain-core==1.4.7, we can conclude that deepagents==0.6.12\n" + + " cannot be used.\n" + + " And because your project depends on deepagents==0.6.12, we can conclude\n" + + " that your project's requirements are unsatisfiable.", + want: []PinnedRequirement{{Name: "langchain-core", Version: "1.4.7", ParentName: "deepagents", ParentVersion: "0.6.12"}}, + }, + { + name: "uv: transitive attribution is independent of clause order", + output: "Because deepagents==0.6.12 depends on langchain-core==1.4.7 and there is no version of langchain-core==1.4.7,\n" + + "we can conclude that deepagents==0.6.12\n" + + "cannot be used.", + want: []PinnedRequirement{{Name: "langchain-core", Version: "1.4.7", ParentName: "deepagents", ParentVersion: "0.6.12"}}, + }, + { + name: "uv: transitive package-not-found attributed to its real parent", + output: "Because deepagents==0.6.12 depends on langsmith==0.10.0 and langsmith was not found in the package registry,\n" + + "we can conclude that deepagents==0.6.12 cannot\n" + + "be used.", + want: []PinnedRequirement{{Name: "langsmith", Version: "0.10.0", ParentName: "deepagents", ParentVersion: "0.6.12"}}, }, } for _, tc := range cases { @@ -121,6 +173,7 @@ func TestIsCvsVersionFilteredOutput(t *testing.T) { "ERROR: No matching distribution found for deepagents==0.5.5": true, "ERROR: Could not find a version that satisfies the requirement langchain-core<2.0.0,>=1.3.2": true, "Because sample-poetry-project depends on telnyx (4.87.1) which doesn't match any versions, version solving failed.": true, + "× No solution found when resolving dependencies:\n╰─▶ Because there is no version of telnyx==4.87.1 and your project depends on telnyx==4.87.1, we can conclude that your project's requirements are unsatisfiable.": true, resolutionImpossible: true, "ERROR: 403 Forbidden": false, "ERROR: ResolutionImpossible: some other conflict": false, // no "no matching distributions" line diff --git a/sca/bom/buildinfo/technologies/uv/uv.go b/sca/bom/buildinfo/technologies/uv/uv.go new file mode 100644 index 000000000..783b6f063 --- /dev/null +++ b/sca/bom/buildinfo/technologies/uv/uv.go @@ -0,0 +1,1063 @@ +package uv + +import ( + "errors" + "fmt" + "net/url" + "os" + "os/exec" + "path" + "path/filepath" + "regexp" + "strings" + + biutils "github.com/jfrog/build-info-go/utils" + "github.com/jfrog/gofrog/datastructures" + "github.com/jfrog/gofrog/version" + artifactoryutils "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/python" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" + "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies" + "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/python" + "github.com/jfrog/jfrog-cli-security/utils" + "github.com/jfrog/jfrog-cli-security/utils/techutils" + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/jfrog/jfrog-client-go/utils/io/fileutils" + "github.com/jfrog/jfrog-client-go/utils/log" + clientutils "github.com/jfrog/jfrog-client-go/xray/services/utils" +) + +const ( + uvLockFile = "uv.lock" + uvTomlConfigRelPath = ".config/uv/uv.toml" + CurationUvMinimumVersion = "0.6.17" +) + +var ( + depEntryRegex = regexp.MustCompile(`name\s*=\s*"([^"]+)"(?:[^{}]*?\bversion\s*=\s*"([^"]+)")?`) + urlInlineRegex = regexp.MustCompile(`\burl\s*=\s*"([^"]+)"`) +) + +// uvDependency is one dependency edge from a package's "dependencies" array. +type uvDependency struct { + Name string + Version string +} + +// UvRegistryConfig holds the Artifactory URL and repo name resolved for the current uv project. +type UvRegistryConfig struct { + ArtifactoryUrl string + RepoName string +} + +type uvPackage struct { + Name string + Version string + IsRoot bool + IsWorkspace bool + Dependencies []uvDependency + DownloadURLs []string +} + +// GetNativeUvRegistryConfig returns the Artifactory URL and repo name for the current uv project. +func GetNativeUvRegistryConfig() (*UvRegistryConfig, error) { + // 1. Try pyproject.toml [[tool.uv.index]] first. + if wd, err := os.Getwd(); err == nil { + if data, err := os.ReadFile(filepath.Join(wd, "pyproject.toml")); err == nil { + candidates := parsePyprojectUvIndexUrls(string(data)) + switch len(candidates) { + case 0: + // Nothing declared here — fall through to uv.toml. + case 1: + if cfg, ok := firstArtifactoryPypiConfig(candidates); ok { + log.Info(fmt.Sprintf("uv: using Artifactory URL %q and repository %q from pyproject.toml", cfg.ArtifactoryUrl, cfg.RepoName)) + return cfg, nil + } + default: + log.Warn(fmt.Sprintf( + "uv: pyproject.toml declares %d [[tool.uv.index]] entries — ignoring them and using ~/.config/uv/uv.toml instead, "+ + "since picking one from a project-declared list can't be done safely", len(candidates))) + } + } + } + + // 2. Fall back to ~/.config/uv/uv.toml. + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("uv: could not determine home directory: %w", err) + } + configPath := filepath.Join(home, uvTomlConfigRelPath) + content, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("uv: no Artifactory index found in pyproject.toml and could not read %s: %w", configPath, err) + } + cfg, ok := firstArtifactoryPypiConfig(parseUvTomlIndexUrls(string(content))) + if !ok { + return nil, errorutils.CheckErrorf( + "uv: no Artifactory index url found in pyproject.toml or %s — "+ + "add [[tool.uv.index]] to pyproject.toml or configure via the Artifactory 'Set Me Up' page for uv", + configPath, + ) + } + log.Info(fmt.Sprintf("uv: using Artifactory URL %q and repository %q from uv.toml", cfg.ArtifactoryUrl, cfg.RepoName)) + return cfg, nil +} + +// firstArtifactoryPypiConfig returns the config for the first Artifactory-shaped +// URL among candidates (in order), or ok=false if none match. +func firstArtifactoryPypiConfig(candidates []string) (cfg *UvRegistryConfig, ok bool) { + for _, indexUrl := range candidates { + if artiUrl, repoName, err := parseArtifactoryPypiUrl(indexUrl); err == nil { + return &UvRegistryConfig{ArtifactoryUrl: artiUrl, RepoName: repoName}, true + } + } + return nil, false +} + +// parsePyprojectUvIndexUrls returns the urls of all non-explicit [[tool.uv.index]] +// sections in pyproject.toml, in file order. +func parsePyprojectUvIndexUrls(content string) []string { + return parseIndexSectionUrls(content, "[[tool.uv.index]]") +} + +// parseUvTomlIndexUrls returns the url values of all non-explicit [[index]] +// sections in uv.toml, in file order. +func parseUvTomlIndexUrls(content string) []string { + return parseIndexSectionUrls(content, "[[index]]") +} + +// parseIndexSectionUrls extracts the urls of every non-explicit sectionHeader +// block in content, in file order. An index marked `explicit = true` is uv's +// documented way to pin one package to it via [tool.uv.sources] — it's never +// used for general resolution, so it's excluded here. +func parseIndexSectionUrls(content, sectionHeader string) []string { + var urls []string + var currentUrl string + var currentExplicit bool + inSection := false + + flush := func() { + if inSection && !currentExplicit && currentUrl != "" { + urls = append(urls, currentUrl) + } + } + + for _, raw := range strings.Split(content, "\n") { + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if line == sectionHeader { + flush() + inSection = true + currentUrl = "" + currentExplicit = false + continue + } + if inSection && strings.HasPrefix(line, "[") { + flush() + inSection = false + continue + } + if !inSection { + continue + } + if strings.HasPrefix(line, "url") { + if _, val, ok := strings.Cut(line, "="); ok { + val = strings.TrimSpace(val) + val = strings.Trim(val, `"'`) + if val != "" { + currentUrl = val + } + } + continue + } + if strings.HasPrefix(line, "explicit") { + if _, val, ok := strings.Cut(line, "="); ok && strings.TrimSpace(val) == "true" { + currentExplicit = true + } + continue + } + } + flush() + return urls +} + +// parseArtifactoryPypiUrl splits an Artifactory PyPI URL into its base URL and repo name. +func parseArtifactoryPypiUrl(rawUrl string) (artiUrl, repoName string, err error) { + const marker = "/api/pypi/" + idx := strings.Index(rawUrl, marker) + if idx < 0 { + err = fmt.Errorf("URL %q does not match Artifactory PyPI format (.../api/pypi//...)", rawUrl) + return + } + artiUrl = rawUrl[:idx] + rest := rawUrl[idx+len(marker):] + repoName = strings.SplitN(rest, "/", 2)[0] + if repoName == "" { + err = fmt.Errorf("could not extract repo name from URL %q", rawUrl) + } + return +} + +// BuildDependencyTree is supported only for jf curation-audit. It verifies the uv +// version, ensures a temp copy of the project has an up-to-date uv.lock, +// parses it, and returns the dependency tree and download URLs. +// When params.ScriptPath is set, it audits that single PEP 723 inline script instead +func BuildDependencyTree(params technologies.BuildInfoBomGeneratorParams) ( + depTree []*clientutils.GraphNode, + uniqueDeps []string, + downloadUrls map[string]string, + err error, +) { + if !params.IsCurationCmd { + err = errorutils.CheckErrorf("uv is supported only for 'jf curation-audit', not 'jf audit'") + return + } + + if err = verifyUvVersionSupportedForCuration(); err != nil { + return + } + + if params.ScriptPath != "" { + return buildDependencyTreeForScript(params) + } + + artiIndexUrl, artifactoryUrl, repoName := "", "", "" + if params.ServerDetails != nil && params.DependenciesRepository != "" { + artifactoryUrl = params.ServerDetails.GetArtifactoryUrl() + repoName = params.DependenciesRepository + var buildErr error + artiIndexUrl, buildErr = buildUvCurationIndexUrl(params.ServerDetails, params.DependenciesRepository) + if buildErr != nil { + log.Warn(fmt.Sprintf("uv: failed to build curation index URL: %v — will fall back to public PyPI", buildErr)) + } + } + + lockContent, err := generateUvLockForCuration(artifactoryUrl, repoName, artiIndexUrl) + if err != nil { + return + } + + packages := parseUvLock(lockContent) + if len(packages) == 0 { + err = errorutils.CheckErrorf("uv.lock is empty or could not be parsed") + return + } + + depTree, uniqueDeps = buildUvDepTree(packages) + + if params.ServerDetails == nil || params.ServerDetails.GetArtifactoryUrl() == "" || params.DependenciesRepository == "" { + log.Warn("uv: skipping download URL resolution — Artifactory server details or repository not configured") + return + } + downloadUrls = buildUvDownloadUrlsMap(params, packages) + return +} + +// generateUvLockForCuration ensures a temp copy of the project has a uv.lock that is +// trustworthy for curation — regenerating it against Artifactory unless it's already +// verified as resolved from this exact repo — see generateUvLockInTempDir. +func generateUvLockForCuration(artifactoryUrl, repoName, artiIndexUrl string) (string, error) { + wd, err := os.Getwd() + if err != nil { + return "", errorutils.CheckError(err) + } + log.Info("uv: copying project to a temporary directory and validating uv.lock for curation") + lockContent, genErr := generateUvLockInTempDir(wd, artifactoryUrl, repoName, artiIndexUrl) + if genErr != nil { + return "", fmt.Errorf("uv: lock generation failed: %w", genErr) + } + return lockContent, nil +} + +// buildDependencyTreeForScript audits a single PEP 723 script (jf ca --script). It +// resolves the script in isolation from any surrounding project, always fresh through +// the curation gateway (there's no existing lock to reuse). +func buildDependencyTreeForScript(params technologies.BuildInfoBomGeneratorParams) ( + depTree []*clientutils.GraphNode, + uniqueDeps []string, + downloadUrls map[string]string, + err error, +) { + absScriptPath, pathErr := filepath.Abs(params.ScriptPath) + if pathErr != nil { + err = errorutils.CheckError(pathErr) + return + } + data, readErr := os.ReadFile(absScriptPath) + if readErr != nil { + err = errorutils.CheckErrorf("uv: could not read script %q: %s", params.ScriptPath, readErr) + return + } + if !strings.HasSuffix(absScriptPath, ".py") || !techutils.HasPep723ScriptMetadata(string(data)) { + err = errorutils.CheckErrorf( + "uv: %q is not a PEP 723 inline script — it must be a .py file with a "+ + "'# /// script' ... '# ///' metadata block", params.ScriptPath) + return + } + + artiIndexUrl, artifactoryUrl, repoName := "", "", "" + if params.ServerDetails != nil && params.DependenciesRepository != "" { + artifactoryUrl = params.ServerDetails.GetArtifactoryUrl() + repoName = params.DependenciesRepository + var buildErr error + artiIndexUrl, buildErr = buildUvCurationIndexUrl(params.ServerDetails, params.DependenciesRepository) + if buildErr != nil { + log.Warn(fmt.Sprintf("uv: failed to build curation index URL: %v — will fall back to public PyPI", buildErr)) + } + } + + lockContent, genErr := generateUvScriptLockForCuration(absScriptPath, artiIndexUrl) + if genErr != nil { + err = genErr + return + } + + packages := parseUvLock(lockContent) + if len(packages) == 0 { + err = errorutils.CheckErrorf("script %q has no dependencies to audit", params.ScriptPath) + return + } + + depTree, uniqueDeps = buildUvDepTree(packages) + + if artifactoryUrl == "" || repoName == "" { + log.Warn("uv: skipping download URL resolution — Artifactory server details or repository not configured") + return + } + downloadUrls = buildUvDownloadUrlsMap(params, packages) + return +} + +// generateUvScriptLockForCuration copies scriptPath into an isolated temp dir and runs +// 'uv lock --script' there through the curation gateway, returning the lock content. +func generateUvScriptLockForCuration(scriptPath, artiIndexUrl string) (string, error) { + tempDir, err := fileutils.CreateTempDir() + if err != nil { + return "", err + } + defer func() { + if rmErr := fileutils.RemoveTempDir(tempDir); rmErr != nil { + log.Warn(fmt.Sprintf("uv: could not remove temp dir %s: %v", tempDir, rmErr)) + } + }() + + scriptName := filepath.Base(scriptPath) + data, readErr := os.ReadFile(scriptPath) + if readErr != nil { + return "", errorutils.CheckErrorf("uv: could not read script %q: %s", scriptPath, readErr) + } + tempScriptPath := filepath.Join(tempDir, scriptName) + if writeErr := os.WriteFile(tempScriptPath, data, 0644); writeErr != nil { + return "", errorutils.CheckErrorf("uv: could not copy script to temp dir: %s", writeErr) + } + + log.Info(fmt.Sprintf("uv: generating a lock for script %q through the curation gateway", scriptName)) + if err = generateUvLock(tempDir, artiIndexUrl, scriptName); err != nil { + return "", err + } + + content, readErr := os.ReadFile(tempScriptPath + ".lock") + if readErr != nil { + return "", errorutils.CheckErrorf("uv: could not read script lock file: %s", readErr) + } + return string(content), nil +} + +// generateUvLockInTempDir copies the project into a temp dir and makes sure uv.lock there +// is trustworthy for curation, then returns its content: +// - Missing, or stale per `uv lock --check` -> always re-resolve through the curation +// gateway. +// - In sync, but not verified as already resolved from this exact Artifactory repo -> +// also re-resolve. "In sync" only means pyproject.toml didn't change — it says nothing +// about which index produced the lock, so an unverified lock could have been resolved +// against public PyPI, skipping curation for every package it pins. +// - In sync AND every package's recorded source is already this Artifactory repo (plain +// or curation pass-through URL) -> reuse as-is. Nothing is lost by skipping `uv lock` +// here: the later HEAD-probe step re-checks each package's current policy status +// straight from these URLs regardless of whether the lock was just regenerated. +func generateUvLockInTempDir(projectDir, artifactoryUrl, repoName, artiIndexUrl string) (string, error) { + tempDir, err := fileutils.CreateTempDir() + if err != nil { + return "", err + } + defer func() { + if rmErr := fileutils.RemoveTempDir(tempDir); rmErr != nil { + log.Warn(fmt.Sprintf("uv: could not remove temp dir %s: %v", tempDir, rmErr)) + } + }() + + if err = biutils.CopyDir(projectDir, tempDir, true, []string{technologies.DotVsRepoSuffix}); err != nil { + return "", fmt.Errorf("uv: could not copy project to temp dir: %w", err) + } + // Neutralize the project's own indexes before checking staleness, not just before + // generating: otherwise "uv lock --check" compares against the project's ambient + // index (e.g. its own uv.toml), not the curation gateway, and a lock that's genuinely + // in sync with Artifactory gets misreported as stale. + if err = neutralizeTempPyprojectIndexes(tempDir); err != nil { + return "", err + } + + lockNeedsGenerate, lockIsStale, err := checkUvLockState(tempDir, artiIndexUrl) + if err != nil { + return "", err + } + lockPath := filepath.Join(tempDir, uvLockFile) + + switch { + case lockNeedsGenerate: + log.Debug("uv: no uv.lock found — generating through the curation gateway") + case lockIsStale: + log.Debug("uv: uv.lock is stale — updating it through the curation gateway") + default: + verified, verifyErr := lockAlreadyResolvedFromArtifactory(lockPath, artifactoryUrl, repoName) + if verifyErr != nil { + return "", verifyErr + } + if verified { + log.Debug("uv: uv.lock is up to date and every package is already sourced from this Artifactory repo — reusing as-is") + content, readErr := os.ReadFile(lockPath) + if readErr != nil { + return "", errorutils.CheckErrorf("uv: could not read lock file: %s", readErr) + } + return string(content), nil + } + log.Debug("uv: uv.lock is up to date but not verified as resolved from this Artifactory repo — re-resolving through the curation gateway") + } + if err = generateUvLock(tempDir, artiIndexUrl, ""); err != nil { + return "", err + } + + content, err := os.ReadFile(lockPath) + if err != nil { + return "", errorutils.CheckErrorf("uv: could not read lock file: %s", err) + } + return string(content), nil +} + +// lockAlreadyResolvedFromArtifactory reports whether every registry-backed package in the +// uv.lock at lockPath is already sourced from the given Artifactory PyPI repo — either its +// plain URL or its curation pass-through variant (see uvArtifactoryRegistryBases). Returns +// ok=false, nil when artifactoryUrl or repoName is empty, since there's nothing to verify +// against in that case (curation index config could not be resolved). +func lockAlreadyResolvedFromArtifactory(lockPath, artifactoryUrl, repoName string) (ok bool, err error) { + if artifactoryUrl == "" || repoName == "" { + return false, nil + } + content, readErr := os.ReadFile(lockPath) + if readErr != nil { + return false, readErr + } + plainBase, passThroughBase := uvArtifactoryRegistryBases(artifactoryUrl, repoName) + return allPackagesUseRegistry(string(content), plainBase, passThroughBase), nil +} + +// uvArtifactoryRegistryBases returns the two URL forms a package's uv.lock +// "source = { registry = ... }" can legitimately have when resolved through this +// Artifactory PyPI repo: the plain repo URL, and its curation pass-through variant +// (the one generateUvLock's UV_DEFAULT_INDEX override actually resolves against). +func uvArtifactoryRegistryBases(artifactoryUrl, repoName string) (plain, passThrough string) { + base := strings.TrimRight(artifactoryUrl, "/") + plain = base + "/api/pypi/" + repoName + "/simple" + passThrough = base + "/" + strings.Trim(coreutils.CurationPassThroughApi, "/") + "/api/pypi/" + repoName + "/simple" + return +} + +// allPackagesUseRegistry reports whether every "source = { ... }" line in uv.lock content +// is either a registry matching one of allowedRegistries, or a source curation doesn't +// apply to (the project root or a workspace member, or a local path/git dependency — none +// of these are resolved from a package index). A registry not in allowedRegistries, or any +// "source" shape not recognized here (e.g. a direct url = "..." dependency), makes the +// whole lock unverified — the caller falls back to a full re-resolution in that case. +func allPackagesUseRegistry(content string, allowedRegistries ...string) bool { + allowed := make(map[string]bool, len(allowedRegistries)) + for _, r := range allowedRegistries { + allowed[r] = true + } + for _, raw := range strings.Split(content, "\n") { + line := strings.TrimSpace(raw) + if !strings.HasPrefix(line, "source") { + continue + } + switch { + case strings.Contains(line, "virtual"), strings.Contains(line, "editable"), + strings.Contains(line, "path ="), strings.Contains(line, "git ="), strings.Contains(line, "directory ="): + continue // not resolved from a package index — curation doesn't apply + case strings.Contains(line, "registry"): + registry, found := extractQuotedField(line, "registry") + if !found || !allowed[registry] { + return false + } + default: + return false + } + } + return true +} + +// extractQuotedField returns the quoted value of `key = "..."` within line, e.g. +// extractQuotedField(`source = { registry = "https://x" }`, "registry") -> "https://x", true. +func extractQuotedField(line, key string) (string, bool) { + marker := key + ` = "` + idx := strings.Index(line, marker) + if idx < 0 { + return "", false + } + rest := line[idx+len(marker):] + end := strings.Index(rest, `"`) + if end < 0 { + return "", false + } + return rest[:end], true +} + +// checkUvLockState reports whether uv.lock is missing or stale relative to pyproject.toml, +// via `uv lock --check`. artiIndexUrl is set as UV_DEFAULT_INDEX for the check too, matching +// generateUvLock — otherwise the check would compare against the project's ambient index +// instead of the curation gateway, and a lock genuinely in sync with Artifactory would be +// misreported as stale. +func checkUvLockState(tempDir, artiIndexUrl string) (lockNeedsGenerate, lockIsStale bool, err error) { + lockExists, existErr := fileutils.IsFileExists(filepath.Join(tempDir, uvLockFile), false) + if existErr != nil { + return false, false, existErr + } + log.Debug(fmt.Sprintf("uv: uv.lock exists in temp dir: %v", lockExists)) + if !lockExists { + log.Debug("uv: no uv.lock found — will generate a fresh lock") + return true, false, nil + } + cmd := exec.Command("uv", "lock", "--check") + cmd.Dir = tempDir + env, envErr := curationCacheEnv() + if envErr != nil { + return false, false, envErr + } + if artiIndexUrl != "" { + env = append(envWithoutKey(env, "UV_DEFAULT_INDEX"), "UV_DEFAULT_INDEX="+artiIndexUrl) + } + cmd.Env = env + out, checkErr := cmd.CombinedOutput() + lockIsStale = checkErr != nil + if lockIsStale { + outStr := maskPassword(string(out), artiIndexUrl) + log.Debug(fmt.Sprintf("uv: 'uv lock --check' failed, treating uv.lock as stale: %v — %s", checkErr, outStr)) + } + log.Debug(fmt.Sprintf("uv: stale check result: stale=%v", lockIsStale)) + return false, lockIsStale, nil +} + +// neutralizeTempPyprojectIndexes strips the temp copy's own non-explicit +// [[tool.uv.index]] entries so `uv lock` can only resolve through the +// UV_DEFAULT_INDEX we inject. Without this, a project's own index (or a +// mirrored public one) could bypass the curation gateway entirely — either +// a raw 403 or silently resolving packages Artifactory never checked +// (XRAY-146949). Explicit entries are kept — they're only used for +// per-package pinning via [tool.uv.sources]. +func neutralizeTempPyprojectIndexes(tempDir string) error { + pyprojectPath := filepath.Join(tempDir, "pyproject.toml") + data, err := os.ReadFile(pyprojectPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return errorutils.CheckErrorf("uv: could not read %s: %s", pyprojectPath, err) + } + stripped := stripNonExplicitPyprojectIndexes(string(data)) + if stripped == string(data) { + return nil + } + if err = os.WriteFile(pyprojectPath, []byte(stripped), 0644); err != nil { + return errorutils.CheckErrorf("uv: could not rewrite %s: %s", pyprojectPath, err) + } + log.Debug("uv: removed non-explicit [[tool.uv.index]] entries from the temp pyproject.toml copy to force curation-gateway resolution") + return nil +} + +// stripNonExplicitPyprojectIndexes removes every non-explicit [[tool.uv.index]] +// block from content, leaving explicit ([tool.uv.sources]-pinned) blocks untouched. +func stripNonExplicitPyprojectIndexes(content string) string { + lines := strings.Split(content, "\n") + var out []string + i := 0 + for i < len(lines) { + if strings.TrimSpace(lines[i]) != "[[tool.uv.index]]" { + out = append(out, lines[i]) + i++ + continue + } + blockEnd := i + 1 + explicit := false + for blockEnd < len(lines) { + line := strings.TrimSpace(lines[blockEnd]) + if strings.HasPrefix(line, "[") { + break + } + if strings.HasPrefix(line, "explicit") { + if _, val, ok := strings.Cut(line, "="); ok && strings.TrimSpace(val) == "true" { + explicit = true + } + } + blockEnd++ + } + if explicit { + out = append(out, lines[i:blockEnd]...) + } + i = blockEnd + } + return strings.Join(out, "\n") +} + +// generateUvLock runs `uv lock` in workDir for curation-audit. When scriptName is set, +// it runs `uv lock --script ` instead, locking that single PEP 723 inline +// script rather than the project in workDir. +// When artiIndexUrl is set, UV_DEFAULT_INDEX is overridden so uv resolves through Artifactory; +// CVS failures (version stripped from the simple index) become CvsBlockedError. +// When artiIndexUrl is empty (index URL could not be built), uv lock runs with its own config. +func generateUvLock(workDir, artiIndexUrl, scriptName string) error { + technologies.LogExecutableVersion("uv") + args := []string{"lock"} + if scriptName != "" { + args = append(args, "--script", scriptName) + } + + if artiIndexUrl != "" { + cmd := exec.Command("uv", args...) + cmd.Dir = workDir + env, envErr := curationCacheEnv() + if envErr != nil { + return envErr + } + env = append(envWithoutKey(env, "UV_DEFAULT_INDEX"), "UV_DEFAULT_INDEX="+artiIndexUrl) + cmd.Env = env + log.Debug("Running uv lock (against Artifactory curation pass-through endpoint)") + out, err := cmd.CombinedOutput() + if err == nil { + return nil + } + outStr := maskPassword(string(out), artiIndexUrl) + log.Debug(fmt.Sprintf("uv: curation lock output (exit %v):\n%s", err, outStr)) + cause := fmt.Errorf("'uv lock' against Artifactory failed: %s — %s", err, outStr) + return classifyUvCurationLockError(outStr, cause) + } + + cmd := exec.Command("uv", args...) + cmd.Dir = workDir + env, envErr := curationCacheEnv() + if envErr != nil { + return envErr + } + cmd.Env = env + log.Debug("Running", coreutils.GetMaskedCommandString(cmd)) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("'uv lock' failed: %s — %s", err, out) + } + return nil +} + +// classifyUvCurationLockError decides how to surface a failed `uv lock` run. +// A CVS-stripped version (removed from the simple index) becomes a +// *CvsBlockedError, routed to the metadata-API fallback for a policy table. +func classifyUvCurationLockError(outStr string, cause error) error { + wrapped := python.WrapUvCurationErr(outStr, cause) + if wrapped != cause { + return wrapped + } + if msgToUser := technologies.GetMsgToUserForCurationBlock(true, techutils.Uv, outStr); msgToUser != "" { + return errors.Join(cause, errors.New(msgToUser)) + } + return cause +} + +// maskPassword replaces the password from rawIndexUrl with "***" in s. +// Prevents credentials from leaking into logs or error output. +func maskPassword(s, rawIndexUrl string) string { + u, err := url.Parse(rawIndexUrl) + if err != nil { + return s + } + pw, hasPw := u.User.Password() + if !hasPw || pw == "" { + return s + } + return strings.ReplaceAll(s, pw, "***") +} + +// envWithoutKey returns env with all "KEY=value" entries for the given key removed. +func envWithoutKey(env []string, key string) []string { + prefix := key + "=" + filtered := make([]string, 0, len(env)) + for _, e := range env { + if !strings.HasPrefix(e, prefix) { + filtered = append(filtered, e) + } + } + return filtered +} + +// curationCacheEnv returns os.Environ() with UV_CACHE_DIR pointed at a dedicated +// curation cache folder, so `uv lock` never reads from or writes to the developer's +// real, shared uv cache (~/.cache/uv by default). Same cache-isolation pattern used by +// maven and go curation via utils.GetCurationCacheFolderByTech (pip and nuget use their +// own dedicated cache-folder helpers, but isolate for the same reason). +func curationCacheEnv() ([]string, error) { + cacheDir, err := utils.GetCurationCacheFolderByTech(techutils.Uv.String()) + if err != nil { + return nil, errorutils.CheckErrorf("uv: could not resolve isolated curation cache folder: %s", err) + } + return append(envWithoutKey(os.Environ(), "UV_CACHE_DIR"), "UV_CACHE_DIR="+cacheDir), nil +} + +// verifyUvVersionSupportedForCuration returns an error if uv isn't installed or is +// below CurationUvMinimumVersion. +func verifyUvVersionSupportedForCuration() error { + out, err := exec.Command("uv", "--version").CombinedOutput() + if err != nil { + return errorutils.CheckErrorf("JFrog CLI uv curation requires uv %s or higher to be installed.", CurationUvMinimumVersion) + } + raw := strings.TrimSpace(string(out)) + versionStr := parseUvVersionFromOutput(raw) + if versionStr == "" { + log.Debug(fmt.Sprintf("uv: could not parse version from %q — skipping minimum version check", raw)) + return nil + } + if !version.NewVersion(versionStr).AtLeast(CurationUvMinimumVersion) { + return errorutils.CheckErrorf("JFrog CLI uv curation requires uv %s or higher. The current version is: %s", CurationUvMinimumVersion, versionStr) + } + return nil +} + +// parseUvVersionFromOutput extracts the semver from `uv --version` output ("uv 0.11.21 (...)"). +func parseUvVersionFromOutput(raw string) string { + parts := strings.Fields(raw) + if len(parts) >= 2 { + return parts[1] + } + return "" +} + +// buildUvCurationIndexUrl returns the Artifactory curation pass-through simple-index URL +// with embedded credentials, ready to be set as UV_DEFAULT_INDEX. +func buildUvCurationIndexUrl(serverDetails *config.ServerDetails, repo string) (string, error) { + rtUrl, username, password, err := artifactoryutils.GetPypiRepoUrlWithCredentials(serverDetails, repo, true) + if err != nil { + return "", err + } + if password != "" { + rtUrl.User = url.UserPassword(username, password) + } + return rtUrl.String(), nil +} + +// parseUvLock parses uv.lock content into a list of uvPackage structs. +func parseUvLock(content string) []uvPackage { + var packages []uvPackage + var current *uvPackage + var inDepsArray, inWheelsArray, inGroupDeps bool + + flush := func() { + if current != nil { + packages = append(packages, *current) + current = nil + } + inDepsArray = false + inWheelsArray = false + inGroupDeps = false + } + + for _, raw := range strings.Split(content, "\n") { + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + if line == "[[package]]" { + flush() + current = &uvPackage{} + continue + } + // Extras and PEP 735 dependency-groups resolve into these sub-tables + // instead of the plain "dependencies" array — audit them the same way. + if line == "[package.optional-dependencies]" || line == "[package.dev-dependencies]" { + inGroupDeps = true + continue + } + if strings.HasPrefix(line, "[") { + flush() + continue + } + + if current == nil { + continue + } + + if inGroupDeps && !inDepsArray { + if strings.Contains(line, "[") { + current.Dependencies = append(current.Dependencies, parseDepEntries(line)...) + if !strings.Contains(line, "]") { + inDepsArray = true + } + continue + } + } + + if inDepsArray { + if line == "]" { + inDepsArray = false + continue + } + current.Dependencies = append(current.Dependencies, parseDepEntries(line)...) + continue + } + if inWheelsArray { + if line == "]" { + inWheelsArray = false + continue + } + for _, m := range urlInlineRegex.FindAllStringSubmatch(line, -1) { + current.DownloadURLs = append(current.DownloadURLs, m[1]) + } + continue + } + + if v, ok := parseTomlScalar(line, "name"); ok && current.Name == "" { + current.Name = v + continue + } + if v, ok := parseTomlScalar(line, "version"); ok && current.Version == "" { + current.Version = v + continue + } + // The audited project's own package is always at path "." (`editable = "."` + // or `virtual = "."`). Other workspace members live at their own paths and + // must not be mistaken for the root, but still need auditing (buildUvDepTree). + if strings.HasPrefix(line, "source") && (strings.Contains(line, "editable = ") || strings.Contains(line, "virtual = ")) { + current.IsWorkspace = true + if strings.Contains(line, `editable = "."`) || strings.Contains(line, `virtual = "."`) { + current.IsRoot = true + } + continue + } + if strings.HasPrefix(line, "sdist") { + for _, m := range urlInlineRegex.FindAllStringSubmatch(line, -1) { + current.DownloadURLs = append(current.DownloadURLs, m[1]) + } + continue + } + if strings.HasPrefix(line, "wheels") { + if strings.Contains(line, "]") { + for _, m := range urlInlineRegex.FindAllStringSubmatch(line, -1) { + current.DownloadURLs = append(current.DownloadURLs, m[1]) + } + } else { + inWheelsArray = true + } + continue + } + if strings.HasPrefix(line, "dependencies") { + if strings.Contains(line, "]") { + current.Dependencies = append(current.Dependencies, parseDepEntries(line)...) + } else { + inDepsArray = true + } + continue + } + } + flush() + return packages +} + +// parseDepEntries extracts every dependency entry (name and, when present, +// its disambiguating version) found in line. +func parseDepEntries(line string) []uvDependency { + var deps []uvDependency + for _, m := range depEntryRegex.FindAllStringSubmatch(line, -1) { + deps = append(deps, uvDependency{Name: m[1], Version: m[2]}) + } + return deps +} + +func parseTomlScalar(line, key string) (string, bool) { + rest := strings.TrimSpace(strings.TrimPrefix(line, key)) + if !strings.HasPrefix(rest, "=") { + return "", false + } + rest = strings.TrimSpace(rest[1:]) + if !strings.HasPrefix(rest, `"`) { + return "", false + } + rest = rest[1:] + end := strings.IndexByte(rest, '"') + if end < 0 { + return "", false + } + return rest[:end], true +} + +// resolveUvDependency returns the package(s) a dependency edge refers to. uv's +// universal resolver can fork a package into multiple versions (one [[package]] +// block each); when the edge names an exact version (see depEntryRegex), match +// it precisely. Otherwise fall back to every version under that name, so a +// blocked fork is never silently dropped (XRAY-146962). +func resolveUvDependency(byName map[string][]*uvPackage, dep uvDependency) []*uvPackage { + candidates := byName[python.NormalizePypiName(dep.Name)] + if dep.Version == "" || len(candidates) <= 1 { + return candidates + } + for _, c := range candidates { + if c.Version == dep.Version { + return []*uvPackage{c} + } + } + // Disambiguating version didn't match any known package — stay safe and check every fork. + return candidates +} + +// buildUvDepTree builds an Xray GraphNode dependency tree from the parsed package list. +func buildUvDepTree(packages []uvPackage) ([]*clientutils.GraphNode, []string) { + byName := make(map[string][]*uvPackage, len(packages)) + for i := range packages { + key := python.NormalizePypiName(packages[i].Name) + byName[key] = append(byName[key], &packages[i]) + } + + var rootPkg *uvPackage + for i := range packages { + if packages[i].IsRoot { + rootPkg = &packages[i] + break + } + } + + uniqueDeps := datastructures.MakeSet[string]() + + if rootPkg == nil { + // No editable root found — wrap all packages under a synthetic root. + syntheticRoot := &clientutils.GraphNode{Id: "root"} + for i := range packages { + if packages[i].Version == "" { + continue + } + id := python.PythonPackageTypeIdentifier + python.NormalizePypiName(packages[i].Name) + ":" + packages[i].Version + uniqueDeps.Add(id) + child := &clientutils.GraphNode{Id: id, Parent: syntheticRoot} + appendUvChildren(child, &packages[i], byName, uniqueDeps) + syntheticRoot.Nodes = append(syntheticRoot.Nodes, child) + } + return []*clientutils.GraphNode{syntheticRoot}, uniqueDeps.ToSlice() + } + + rootId := python.PythonPackageTypeIdentifier + python.NormalizePypiName(rootPkg.Name) + ":" + rootPkg.Version + rootNode := &clientutils.GraphNode{Id: rootId} + + for _, depEdge := range rootPkg.Dependencies { + for _, dep := range resolveUvDependency(byName, depEdge) { + id := python.PythonPackageTypeIdentifier + python.NormalizePypiName(dep.Name) + ":" + dep.Version + uniqueDeps.Add(id) + child := &clientutils.GraphNode{Id: id, Parent: rootNode} + rootNode.Nodes = append(rootNode.Nodes, child) + appendUvChildren(child, dep, byName, uniqueDeps) + } + } + + depTree := []*clientutils.GraphNode{rootNode} + + // Other workspace members may not be reachable from the root's dependencies + // (e.g. a shared tool the root app doesn't itself depend on). Audit them too, + // as sibling roots, instead of silently skipping their dependencies. + for i := range packages { + member := &packages[i] + if !member.IsWorkspace || member.IsRoot || member.Version == "" { + continue + } + id := python.PythonPackageTypeIdentifier + python.NormalizePypiName(member.Name) + ":" + member.Version + if uniqueDeps.Exists(id) { + continue + } + uniqueDeps.Add(id) + memberNode := &clientutils.GraphNode{Id: id} + appendUvChildren(memberNode, member, byName, uniqueDeps) + depTree = append(depTree, memberNode) + } + + return depTree, uniqueDeps.ToSlice() +} + +// appendUvChildren adds pkg's dependencies as children of node. pkg is the exact +// resolved package node represents; byName resolves each dependency edge via +// resolveUvDependency (see buildUvDepTree). +func appendUvChildren(node *clientutils.GraphNode, pkg *uvPackage, byName map[string][]*uvPackage, uniqueDeps *datastructures.Set[string]) { + if node.NodeHasLoop() { + return + } + for _, depEdge := range pkg.Dependencies { + for _, dep := range resolveUvDependency(byName, depEdge) { + id := python.PythonPackageTypeIdentifier + python.NormalizePypiName(dep.Name) + ":" + dep.Version + uniqueDeps.Add(id) + child := &clientutils.GraphNode{Id: id, Parent: node} + node.Nodes = append(node.Nodes, child) + appendUvChildren(child, dep, byName, uniqueDeps) + } + } +} + +// buildUvDownloadUrlsMap returns a package-id → download URL map from uv.lock. +// Strips the curation pass-through prefix and any URL hash fragment when present. +func buildUvDownloadUrlsMap(params technologies.BuildInfoBomGeneratorParams, packages []uvPackage) map[string]string { + artiBase := strings.TrimSuffix(params.ServerDetails.GetArtifactoryUrl(), "/") + urls := map[string]string{} + skipped := 0 + + for _, pkg := range packages { + if pkg.IsRoot || pkg.Name == "" || pkg.Version == "" || len(pkg.DownloadURLs) == 0 { + skipped++ + continue + } + rawURL := pickWheelURL(pkg.DownloadURLs) + if rawURL == "" { + log.Debug(fmt.Sprintf("uv: no download URL for %s:%s — skipping HEAD check", pkg.Name, pkg.Version)) + skipped++ + continue + } + direct := strings.Replace(rawURL, "api/curation/audit/", "", 1) + if idx := strings.Index(direct, "#"); idx >= 0 { + direct = direct[:idx] + } + if !strings.HasPrefix(direct, artiBase) { + log.Debug(fmt.Sprintf("uv: %s:%s URL %q is not an Artifactory URL — skipping HEAD check", pkg.Name, pkg.Version, direct)) + skipped++ + continue + } + compId := python.PythonPackageTypeIdentifier + python.NormalizePypiName(pkg.Name) + ":" + pkg.Version + urls[compId] = direct + log.Debug(fmt.Sprintf("uv: %s:%s -> %s", pkg.Name, pkg.Version, direct)) + } + + expected := len(packages) - skipped + resolved := len(urls) + if resolved < expected { + log.Warn(fmt.Sprintf( + "uv: resolved download URLs for %d/%d packages — %d package(s) will not be HEAD-checked. "+ + "Re-run with JFROG_CLI_LOG_LEVEL=DEBUG to see per-package details.", + resolved, expected, expected-resolved, + )) + } + log.Debug(fmt.Sprintf("uv: resolved %d download URLs (skipped %d entries)", resolved, skipped)) + return urls +} + +// pickWheelURL returns the first .whl URL, or the first sdist URL if no wheel is present. +func pickWheelURL(downloadURLs []string) string { + sdist := "" + for _, u := range downloadURLs { + base := path.Base(strings.SplitN(u, "#", 2)[0]) + if strings.HasSuffix(base, ".whl") { + return u + } + if sdist == "" { + sdist = u + } + } + return sdist +} diff --git a/sca/bom/buildinfo/technologies/uv/uv_test.go b/sca/bom/buildinfo/technologies/uv/uv_test.go new file mode 100644 index 000000000..a6c399e53 --- /dev/null +++ b/sca/bom/buildinfo/technologies/uv/uv_test.go @@ -0,0 +1,1560 @@ +package uv + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies" + "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/python" + clientutils "github.com/jfrog/jfrog-client-go/xray/services/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEnvWithoutKey(t *testing.T) { + cases := []struct { + name string + env []string + key string + want []string + }{ + { + name: "removes matching entry", + env: []string{"FOO=1", "UV_DEFAULT_INDEX=https://example.com", "BAR=2"}, + key: "UV_DEFAULT_INDEX", + want: []string{"FOO=1", "BAR=2"}, + }, + { + name: "key not present — returns unchanged", + env: []string{"FOO=1", "BAR=2"}, + key: "UV_DEFAULT_INDEX", + want: []string{"FOO=1", "BAR=2"}, + }, + { + name: "removes all occurrences", + env: []string{"UV_DEFAULT_INDEX=a", "FOO=1", "UV_DEFAULT_INDEX=b"}, + key: "UV_DEFAULT_INDEX", + want: []string{"FOO=1"}, + }, + { + name: "does not remove partial prefix match", + env: []string{"UV_DEFAULT_INDEX_EXTRA=x", "FOO=1"}, + key: "UV_DEFAULT_INDEX", + want: []string{"UV_DEFAULT_INDEX_EXTRA=x", "FOO=1"}, + }, + { + name: "empty env", + env: []string{}, + key: "UV_DEFAULT_INDEX", + want: []string{}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, envWithoutKey(tc.env, tc.key)) + }) + } +} + +func TestParseTomlScalar(t *testing.T) { + cases := []struct { + line string + key string + wantVal string + wantOk bool + }{ + {`name = "requests"`, "name", "requests", true}, + {`version = "1.2.3"`, "version", "1.2.3", true}, + {`name = "my-package"`, "version", "", false}, // wrong key + {`name = 42`, "name", "", false}, // not a string + {`name = `, "name", "", false}, // no value + {`name`, "name", "", false}, // no = sign + } + for _, tc := range cases { + t.Run(tc.line, func(t *testing.T) { + val, ok := parseTomlScalar(tc.line, tc.key) + assert.Equal(t, tc.wantOk, ok) + assert.Equal(t, tc.wantVal, val) + }) + } +} + +func TestParseUvTomlIndexUrls(t *testing.T) { + cases := []struct { + name string + content string + want []string + }{ + { + name: "standard uv.toml with [[index]]", + content: `[[index]] +name = "uv-test-repo" +url = "https://host/artifactory/api/pypi/uv-test-repo/simple" +default = true`, + want: []string{"https://host/artifactory/api/pypi/uv-test-repo/simple"}, + }, + { + name: "url with single quotes", + content: `[[index]] +url = 'https://host/artifactory/api/pypi/my-repo/simple'`, + want: []string{"https://host/artifactory/api/pypi/my-repo/simple"}, + }, + { + name: "returns every [[index]] in file order", + content: `[[index]] +url = "https://host/artifactory/api/pypi/first-repo/simple" + +[[index]] +url = "https://host/artifactory/api/pypi/second-repo/simple"`, + want: []string{ + "https://host/artifactory/api/pypi/first-repo/simple", + "https://host/artifactory/api/pypi/second-repo/simple", + }, + }, + { + name: "no [[index]] section", + content: `[tool]\nsome = "value"`, + want: nil, + }, + { + name: "empty content", + content: "", + want: nil, + }, + { + name: "comments are ignored", + content: `# this is a comment +[[index]] +# another comment +url = "https://host/artifactory/api/pypi/my-repo/simple"`, + want: []string{"https://host/artifactory/api/pypi/my-repo/simple"}, + }, + { + name: "explicit index is skipped even when defined first", + content: `[[index]] +name = "pinned-only" +url = "https://host/artifactory/api/pypi/decoy-repo/simple" +explicit = true + +[[index]] +name = "main" +url = "https://host/artifactory/api/pypi/real-repo/simple"`, + want: []string{"https://host/artifactory/api/pypi/real-repo/simple"}, + }, + { + name: "explicit index defined after the real one is still skipped", + content: `[[index]] +name = "main" +url = "https://host/artifactory/api/pypi/real-repo/simple" + +[[index]] +name = "pinned-only" +url = "https://host/artifactory/api/pypi/decoy-repo/simple" +explicit = true`, + want: []string{"https://host/artifactory/api/pypi/real-repo/simple"}, + }, + { + name: "only an explicit index exists — no general repo found", + content: `[[index]] +name = "pinned-only" +url = "https://host/artifactory/api/pypi/decoy-repo/simple" +explicit = true`, + want: nil, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, parseUvTomlIndexUrls(tc.content)) + }) + } +} + +func TestParsePyprojectUvIndexUrls(t *testing.T) { + cases := []struct { + name string + content string + want []string + }{ + { + name: "standard pyproject.toml with [[tool.uv.index]]", + content: `[project] +name = "my-app" + +[[tool.uv.index]] +name = "uv-test-repo" +url = "https://host/artifactory/api/pypi/uv-test-repo/simple" +default = true`, + want: []string{"https://host/artifactory/api/pypi/uv-test-repo/simple"}, + }, + { + name: "no [[tool.uv.index]] section", + content: `[project] +name = "my-app" + +[tool.uv] +publish-url = "https://host/artifactory/api/pypi/uv-test-repo"`, + want: nil, + }, + { + name: "empty content", + content: "", + want: nil, + }, + { + name: "index pinning via [tool.uv.sources] — explicit index skipped", + content: `[project] +name = "my-app" +dependencies = [ + "requests==2.19.1", + "idna==2.7", +] + +[tool.uv.sources] +idna = { index = "decoy-index" } + +[[tool.uv.index]] +name = "decoy-index" +url = "https://host/artifactory/api/pypi/decoy-repo/simple" +explicit = true + +[[tool.uv.index]] +name = "uv-test-repo" +url = "https://host/artifactory/api/pypi/uv-test-repo/simple"`, + want: []string{"https://host/artifactory/api/pypi/uv-test-repo/simple"}, + }, + { + name: "multiple non-explicit indexes — all returned in order", + content: `[project] +name = "my-app" + +[[tool.uv.index]] +name = "public-pypi" +url = "https://pypi.org/simple" + +[[tool.uv.index]] +name = "artifactory-repo" +url = "https://host/artifactory/api/pypi/uv-test-repo/simple"`, + want: []string{ + "https://pypi.org/simple", + "https://host/artifactory/api/pypi/uv-test-repo/simple", + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, parsePyprojectUvIndexUrls(tc.content)) + }) + } +} + +func TestFirstArtifactoryPypiConfig(t *testing.T) { + t.Run("skips non-Artifactory candidates and picks the first Artifactory-shaped one", func(t *testing.T) { + cfg, ok := firstArtifactoryPypiConfig([]string{ + "https://pypi.org/simple", + "https://host/artifactory/api/pypi/uv-test-repo/simple", + }) + require.True(t, ok) + assert.Equal(t, "https://host/artifactory", cfg.ArtifactoryUrl) + assert.Equal(t, "uv-test-repo", cfg.RepoName) + }) + + t.Run("Artifactory-shaped entry listed first is used directly", func(t *testing.T) { + cfg, ok := firstArtifactoryPypiConfig([]string{ + "https://host/artifactory/api/pypi/uv-test-repo/simple", + "https://pypi.org/simple", + }) + require.True(t, ok) + assert.Equal(t, "uv-test-repo", cfg.RepoName) + }) + + t.Run("no Artifactory-shaped candidate — not found", func(t *testing.T) { + _, ok := firstArtifactoryPypiConfig([]string{"https://pypi.org/simple"}) + assert.False(t, ok) + }) + + t.Run("empty candidates — not found", func(t *testing.T) { + _, ok := firstArtifactoryPypiConfig(nil) + assert.False(t, ok) + }) +} + +func setupUvRegistryFixture(t *testing.T) (projectDir, uvTomlPath string) { + t.Helper() + projectDir = t.TempDir() + homeDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(homeDir, filepath.Dir(uvTomlConfigRelPath)), 0755)) + uvTomlPath = filepath.Join(homeDir, uvTomlConfigRelPath) + t.Setenv("HOME", homeDir) + t.Chdir(projectDir) + return projectDir, uvTomlPath +} + +func TestGetNativeUvRegistryConfig(t *testing.T) { + t.Run("single unambiguous pyproject.toml entry is used directly", func(t *testing.T) { + projectDir, _ := setupUvRegistryFixture(t) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "pyproject.toml"), []byte(`[[tool.uv.index]] +name = "artifactory-repo" +url = "https://host/artifactory/api/pypi/uv-test-repo/simple" +`), 0644)) + + cfg, err := GetNativeUvRegistryConfig() + require.NoError(t, err) + assert.Equal(t, "uv-test-repo", cfg.RepoName) + }) + + t.Run( + "XRAY-146949: ambiguous pyproject.toml (multiple entries) is ignored — falls back to uv.toml, "+ + "even when one of the project's own entries would otherwise be picked", + func(t *testing.T) { + projectDir, uvTomlPath := setupUvRegistryFixture(t) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "pyproject.toml"), []byte(`[[tool.uv.index]] +name = "public-pypi" +url = "https://pypi.org/simple" + +[[tool.uv.index]] +name = "maybe-wrong-host" +url = "https://untrusted-host/artifactory/api/pypi/uv-test-repo/simple" +`), 0644)) + require.NoError(t, os.WriteFile(uvTomlPath, []byte(`[[index]] +name = "uv-test-repo" +url = "https://host/artifactory/api/pypi/uv-test-repo/simple" +default = true +`), 0644)) + + cfg, err := GetNativeUvRegistryConfig() + require.NoError(t, err) + assert.Equal(t, "https://host/artifactory", cfg.ArtifactoryUrl) + }, + ) + + t.Run("no pyproject.toml entries falls back to uv.toml", func(t *testing.T) { + projectDir, uvTomlPath := setupUvRegistryFixture(t) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "pyproject.toml"), []byte(`[project] +name = "my-app" +`), 0644)) + require.NoError(t, os.WriteFile(uvTomlPath, []byte(`[[index]] +name = "uv-test-repo" +url = "https://host/artifactory/api/pypi/uv-test-repo/simple" +default = true +`), 0644)) + + cfg, err := GetNativeUvRegistryConfig() + require.NoError(t, err) + assert.Equal(t, "uv-test-repo", cfg.RepoName) + }) + + t.Run("ambiguous pyproject.toml and no usable uv.toml — error, not a guess", func(t *testing.T) { + projectDir, _ := setupUvRegistryFixture(t) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "pyproject.toml"), []byte(`[[tool.uv.index]] +name = "public-pypi" +url = "https://pypi.org/simple" + +[[tool.uv.index]] +name = "untrusted" +url = "https://untrusted-host/artifactory/api/pypi/uv-test-repo/simple" +`), 0644)) + // No uv.toml written at all. + + _, err := GetNativeUvRegistryConfig() + require.Error(t, err) + }) +} + +func TestStripNonExplicitPyprojectIndexes(t *testing.T) { + t.Run("removes non-explicit index, keeps explicit index and everything else", func(t *testing.T) { + content := `[project] +name = "my-app" +dependencies = [ + "requests==2.19.1", +] + +[[tool.uv.index]] +name = "public-pypi" +url = "https://pypi.org/simple" + +[[tool.uv.index]] +name = "pinned-only" +url = "https://host/artifactory/api/pypi/decoy-repo/simple" +explicit = true +` + got := stripNonExplicitPyprojectIndexes(content) + assert.NotContains(t, got, "public-pypi") + assert.NotContains(t, got, "pypi.org") + assert.Contains(t, got, "pinned-only") + assert.Contains(t, got, "explicit = true") + assert.Contains(t, got, `name = "my-app"`) + }) + + t.Run("no [[tool.uv.index]] section — content unchanged", func(t *testing.T) { + content := "[project]\nname = \"my-app\"\n" + assert.Equal(t, content, stripNonExplicitPyprojectIndexes(content)) + }) + + t.Run("multiple non-explicit indexes all removed", func(t *testing.T) { + content := `[[tool.uv.index]] +name = "one" +url = "https://pypi.org/simple" + +[[tool.uv.index]] +name = "two" +url = "https://host/artifactory/api/pypi/uv-test-repo/simple" +` + got := stripNonExplicitPyprojectIndexes(content) + assert.NotContains(t, got, "tool.uv.index") + }) +} + +func TestParseArtifactoryPypiUrl(t *testing.T) { + cases := []struct { + name string + rawUrl string + wantArti string + wantRepo string + wantErrSubs string + }{ + { + name: "standard Artifactory PyPI URL", + rawUrl: "https://host/artifactory/api/pypi/my-repo/simple", + wantArti: "https://host/artifactory", + wantRepo: "my-repo", + }, + { + name: "URL without trailing path", + rawUrl: "https://host/artifactory/api/pypi/my-repo", + wantArti: "https://host/artifactory", + wantRepo: "my-repo", + }, + { + name: "no /api/pypi/ marker", + rawUrl: "https://pypi.org/simple", + wantErrSubs: "does not match Artifactory PyPI format", + }, + { + name: "missing repo name", + rawUrl: "https://host/artifactory/api/pypi/", + wantErrSubs: "could not extract repo name", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + artiUrl, repoName, err := parseArtifactoryPypiUrl(tc.rawUrl) + if tc.wantErrSubs != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErrSubs) + return + } + require.NoError(t, err) + assert.Equal(t, tc.wantArti, artiUrl) + assert.Equal(t, tc.wantRepo, repoName) + }) + } +} + +func TestParseUvVersionFromOutput(t *testing.T) { + cases := []struct { + name string + raw string + want string + }{ + { + name: "standard uv --version output", + raw: "uv 0.11.21 (5aa65dd7a 2026-06-11 aarch64-apple-darwin)", + want: "0.11.21", + }, + { + name: "minimum supported version", + raw: "uv 0.6.17 (abc123 2025-01-01 x86_64-linux)", + want: "0.6.17", + }, + { + name: "only binary name and version", + raw: "uv 1.0.0", + want: "1.0.0", + }, + { + name: "empty string", + raw: "", + want: "", + }, + { + name: "unexpected single token", + raw: "uv", + want: "", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, parseUvVersionFromOutput(tc.raw)) + }) + } +} + +func TestPickWheelURL(t *testing.T) { + cases := []struct { + name string + urls []string + want string + }{ + { + name: "prefers first wheel over sdist", + urls: []string{ + "https://host/packages/pkg-1.0.tar.gz", + "https://host/packages/pkg-1.0-py3-none-any.whl", + }, + want: "https://host/packages/pkg-1.0-py3-none-any.whl", + }, + { + name: "returns sdist when no wheel", + urls: []string{"https://host/packages/pkg-1.0.tar.gz"}, + want: "https://host/packages/pkg-1.0.tar.gz", + }, + { + name: "strips hash fragment before extension check", + urls: []string{"https://host/packages/pkg-1.0-py3-none-any.whl#sha256=abc"}, + want: "https://host/packages/pkg-1.0-py3-none-any.whl#sha256=abc", + }, + { + name: "multiple wheels — returns first", + urls: []string{ + "https://host/packages/pkg-1.0-cp310-cp310-linux_x86_64.whl", + "https://host/packages/pkg-1.0-py3-none-any.whl", + }, + want: "https://host/packages/pkg-1.0-cp310-cp310-linux_x86_64.whl", + }, + { + name: "empty list", + urls: []string{}, + want: "", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, pickWheelURL(tc.urls)) + }) + } +} + +const sampleUvLock = `version = 1 +requires-python = ">=3.11" + +[[package]] +name = "requests" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, + { name = "certifi" }, +] +sdist = { url = "https://host/artifactory/api/pypi/repo/packages/requests-2.19.1.tar.gz", hash = "sha256:abc", size = 131068 } +wheels = [ + { url = "https://host/artifactory/api/pypi/repo/packages/requests-2.19.1-py2.py3-none-any.whl", hash = "sha256:def", size = 91979 }, +] + +[[package]] +name = "urllib3" +version = "1.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://host/artifactory/api/pypi/repo/packages/urllib3-1.23.tar.gz", hash = "sha256:ghi", size = 228314 } +wheels = [ + { url = "https://host/artifactory/api/pypi/repo/packages/urllib3-1.23-py2.py3-none-any.whl", hash = "sha256:jkl", size = 133303 }, +] + +[[package]] +name = "certifi" +version = "2024.1.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://host/artifactory/api/pypi/repo/packages/certifi-2024.1.1-py3-none-any.whl", hash = "sha256:mno", size = 100 }, +] + +[[package]] +name = "my-app" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "requests" }, + { name = "urllib3" }, +] + +[package.metadata] +requires-dist = [ + { name = "requests", specifier = "==2.19.1" }, +] +` + +func TestParseUvLock(t *testing.T) { + packages := parseUvLock(sampleUvLock) + require.Len(t, packages, 4) + + byName := make(map[string]uvPackage) + for _, p := range packages { + byName[p.Name] = p + } + + t.Run("root package detected", func(t *testing.T) { + root, ok := byName["my-app"] + require.True(t, ok) + assert.True(t, root.IsRoot) + assert.Equal(t, "0.1.0", root.Version) + assert.Contains(t, depNames(root.Dependencies), "requests") + assert.Contains(t, depNames(root.Dependencies), "urllib3") + }) + + t.Run("wheel URL stored", func(t *testing.T) { + pkg := byName["requests"] + assert.False(t, pkg.IsRoot) + assert.Equal(t, "2.19.1", pkg.Version) + assert.Contains(t, pkg.DownloadURLs, "https://host/artifactory/api/pypi/repo/packages/requests-2.19.1-py2.py3-none-any.whl") + }) + + t.Run("sdist URL stored when present", func(t *testing.T) { + pkg := byName["requests"] + assert.Contains(t, pkg.DownloadURLs, "https://host/artifactory/api/pypi/repo/packages/requests-2.19.1.tar.gz") + }) + + t.Run("dependencies parsed", func(t *testing.T) { + pkg := byName["requests"] + assert.Contains(t, depNames(pkg.Dependencies), "urllib3") + assert.Contains(t, depNames(pkg.Dependencies), "certifi") + }) + + t.Run("empty lock returns empty slice", func(t *testing.T) { + assert.Empty(t, parseUvLock("")) + }) +} + +func TestBuildUvDepTree(t *testing.T) { + packages := parseUvLock(sampleUvLock) + depTree, uniqueDeps := buildUvDepTree(packages) + + require.Len(t, depTree, 1) + root := depTree[0] + assert.Equal(t, "pypi://my-app:0.1.0", root.Id) + + t.Run("direct deps under root", func(t *testing.T) { + ids := make([]string, 0, len(root.Nodes)) + for _, n := range root.Nodes { + ids = append(ids, n.Id) + } + assert.Contains(t, ids, "pypi://requests:2.19.1") + assert.Contains(t, ids, "pypi://urllib3:1.23") + }) + + t.Run("uniqueDeps contains all transitive packages", func(t *testing.T) { + assert.Contains(t, uniqueDeps, "pypi://requests:2.19.1") + assert.Contains(t, uniqueDeps, "pypi://urllib3:1.23") + assert.Contains(t, uniqueDeps, "pypi://certifi:2024.1.1") + }) +} + +func TestBuildUvDepTreeNoRoot(t *testing.T) { + lockContent := `version = 1 + +[[package]] +name = "requests" +version = "2.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, + { name = "certifi" }, +] +wheels = [{ url = "https://host/packages/requests-2.28.0-py3-none-any.whl", hash = "sha256:x" }] + +[[package]] +name = "urllib3" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +wheels = [{ url = "https://host/packages/urllib3-2.0.0-py3-none-any.whl", hash = "sha256:y" }] + +[[package]] +name = "certifi" +version = "2024.1.1" +source = { registry = "https://pypi.org/simple" } +wheels = [{ url = "https://host/packages/certifi-2024.1.1-py3-none-any.whl", hash = "sha256:z" }] + +[[package]] +name = "standalone-tool" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +wheels = [{ url = "https://host/packages/standalone-tool-1.0.0-py3-none-any.whl", hash = "sha256:w" }] + +[[package]] +name = "no-version-pkg" +source = { registry = "https://pypi.org/simple" } +` + packages := parseUvLock(lockContent) + + t.Run("no package is marked as root", func(t *testing.T) { + for _, p := range packages { + assert.False(t, p.IsRoot, "package %s must not be mistaken for the root", p.Name) + } + }) + + depTree, uniqueDeps := buildUvDepTree(packages) + + t.Run("wraps everything under a single synthetic root", func(t *testing.T) { + require.Len(t, depTree, 1) + assert.Equal(t, "root", depTree[0].Id) + }) + + t.Run("every versioned package is included, none dropped", func(t *testing.T) { + assert.Contains(t, uniqueDeps, "pypi://requests:2.28.0") + assert.Contains(t, uniqueDeps, "pypi://urllib3:2.0.0") + assert.Contains(t, uniqueDeps, "pypi://certifi:2024.1.1") + assert.Contains(t, uniqueDeps, "pypi://standalone-tool:1.0.0") + assert.Len(t, uniqueDeps, 4, "the version-less package must be skipped, not counted") + }) + + t.Run("transitive dependencies are preserved under the synthetic root", func(t *testing.T) { + root := depTree[0] + requestsNode := findChildById(root, "pypi://requests:2.28.0") + require.NotNil(t, requestsNode, "requests must be a direct child of the synthetic root") + childIds := make([]string, 0, len(requestsNode.Nodes)) + for _, n := range requestsNode.Nodes { + childIds = append(childIds, n.Id) + } + assert.Contains(t, childIds, "pypi://urllib3:2.0.0") + assert.Contains(t, childIds, "pypi://certifi:2024.1.1") + }) + + t.Run("unrelated standalone package is still a direct child", func(t *testing.T) { + root := depTree[0] + assert.NotNil(t, findChildById(root, "pypi://standalone-tool:1.0.0")) + }) +} + +func depNames(deps []uvDependency) []string { + names := make([]string, len(deps)) + for i, d := range deps { + names[i] = d.Name + } + return names +} + +func findChildById(node *clientutils.GraphNode, id string) *clientutils.GraphNode { + for _, n := range node.Nodes { + if n.Id == id { + return n + } + } + return nil +} + +func TestBuildUvDepTreeMultiVersionForkBothVersionsAudited(t *testing.T) { + lockContent := `version = 1 +requires-python = ">=3.9" + +[[package]] +name = "requests" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +wheels = [{ url = "https://host/artifactory/api/pypi/repo/packages/requests-2.19.1-py2.py3-none-any.whl", hash = "sha256:aaa" }] + +[[package]] +name = "requests" +version = "2.31.0" +source = { registry = "https://pypi.org/simple" } +wheels = [{ url = "https://host/artifactory/api/pypi/repo/packages/requests-2.31.0-py3-none-any.whl", hash = "sha256:bbb" }] + +[[package]] +name = "uv-fork-test" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "requests" }, +] +` + packages := parseUvLock(lockContent) + depTree, uniqueDeps := buildUvDepTree(packages) + + require.Len(t, depTree, 1) + assert.Contains(t, uniqueDeps, "pypi://requests:2.19.1", "the older, potentially-blocked fork must not be dropped") + assert.Contains(t, uniqueDeps, "pypi://requests:2.31.0", "the newer fork must also be present") + assert.NotNil(t, findChildById(depTree[0], "pypi://requests:2.19.1")) + assert.NotNil(t, findChildById(depTree[0], "pypi://requests:2.31.0")) +} + +func TestBuildUvDepTreeMultiVersionForkPrecisePairing(t *testing.T) { + lockContent := `version = 1 +requires-python = ">=3.9" + +[[package]] +name = "idna" +version = "2.7" +source = { registry = "https://pypi.org/simple" } +wheels = [{ url = "https://host/artifactory/api/pypi/repo/packages/idna-2.7-py2.py3-none-any.whl", hash = "sha256:aaa" }] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +wheels = [{ url = "https://host/artifactory/api/pypi/repo/packages/idna-3.18-py3-none-any.whl", hash = "sha256:bbb" }] + +[[package]] +name = "requests" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +wheels = [{ url = "https://host/artifactory/api/pypi/repo/packages/requests-2.19.1-py2.py3-none-any.whl", hash = "sha256:ccc" }] +dependencies = [ + { name = "idna", version = "2.7", source = { registry = "https://pypi.org/simple" } }, +] + +[[package]] +name = "requests" +version = "2.31.0" +source = { registry = "https://pypi.org/simple" } +wheels = [{ url = "https://host/artifactory/api/pypi/repo/packages/requests-2.31.0-py3-none-any.whl", hash = "sha256:ddd" }] +dependencies = [ + { name = "idna", version = "3.18", source = { registry = "https://pypi.org/simple" } }, +] + +[[package]] +name = "uv-fork-precision-test" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "requests", version = "2.19.1", source = { registry = "https://pypi.org/simple" } }, + { name = "requests", version = "2.31.0", source = { registry = "https://pypi.org/simple" } }, +] +` + packages := parseUvLock(lockContent) + depTree, uniqueDeps := buildUvDepTree(packages) + + require.Len(t, depTree, 1) + assert.Contains(t, uniqueDeps, "pypi://requests:2.19.1") + assert.Contains(t, uniqueDeps, "pypi://requests:2.31.0") + + requests1 := findChildById(depTree[0], "pypi://requests:2.19.1") + require.NotNil(t, requests1) + require.Len(t, requests1.Nodes, 1, "requests==2.19.1 must link to exactly one idna fork, not both") + assert.Equal(t, "pypi://idna:2.7", requests1.Nodes[0].Id) + + requests2 := findChildById(depTree[0], "pypi://requests:2.31.0") + require.NotNil(t, requests2) + require.Len(t, requests2.Nodes, 1, "requests==2.31.0 must link to exactly one idna fork, not both") + assert.Equal(t, "pypi://idna:3.18", requests2.Nodes[0].Id) +} + +func TestParseUvLockWorkspaceRoot(t *testing.T) { + lockContent := `version = 1 +requires-python = ">=3.11" + +[[package]] +name = "localpkg" +version = "0.1.0" +source = { editable = "local_pkg" } + +[[package]] +name = "requests" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://host/artifactory/api/pypi/repo/packages/requests-2.19.1-py2.py3-none-any.whl", hash = "sha256:def" }, +] + +[[package]] +name = "uv-ca-test" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "localpkg" }, + { name = "requests" }, +] +` + packages := parseUvLock(lockContent) + byName := make(map[string]uvPackage) + for _, p := range packages { + byName[p.Name] = p + } + + t.Run("virtual root at path . is the root", func(t *testing.T) { + root, ok := byName["uv-ca-test"] + require.True(t, ok) + assert.True(t, root.IsRoot) + }) + + t.Run("editable workspace member is not the root", func(t *testing.T) { + member, ok := byName["localpkg"] + require.True(t, ok) + assert.False(t, member.IsRoot) + }) + + t.Run("dep tree still contains real dependencies", func(t *testing.T) { + depTree, uniqueDeps := buildUvDepTree(packages) + require.Len(t, depTree, 1) + assert.Equal(t, "pypi://uv-ca-test:0.1.0", depTree[0].Id) + assert.NotEmpty(t, depTree[0].Nodes, "root's dependencies must not be dropped") + assert.Contains(t, uniqueDeps, "pypi://requests:2.19.1") + }) +} + +func TestBuildUvDepTreeWorkspaceMemberNotReachableFromRoot(t *testing.T) { + lockContent := `version = 1 +requires-python = ">=3.11" + +[[package]] +name = "root-app" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "requests" }, +] + +[[package]] +name = "requests" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +wheels = [{ url = "https://host/artifactory/api/pypi/repo/packages/requests-2.19.1-py2.py3-none-any.whl", hash = "sha256:aaa" }] + +[[package]] +name = "internal-tool" +version = "0.2.0" +source = { editable = "tools/internal-tool" } +dependencies = [ + { name = "pyyaml" }, +] + +[[package]] +name = "pyyaml" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://host/artifactory/api/pypi/repo/packages/PyYAML-5.3.1.tar.gz", hash = "sha256:bbb" } +` + packages := parseUvLock(lockContent) + depTree, uniqueDeps := buildUvDepTree(packages) + + t.Run("root subtree is still built normally", func(t *testing.T) { + root := depTree[0] + assert.Equal(t, "pypi://root-app:0.1.0", root.Id) + assert.NotNil(t, findChildById(root, "pypi://requests:2.19.1")) + }) + + t.Run("unreachable workspace member becomes its own tree entry", func(t *testing.T) { + var memberNode *clientutils.GraphNode + for _, n := range depTree { + if n.Id == "pypi://internal-tool:0.2.0" { + memberNode = n + } + } + require.NotNil(t, memberNode, "internal-tool must be audited even though root doesn't depend on it") + assert.NotNil(t, findChildById(memberNode, "pypi://pyyaml:5.3.1"), "the member's own dependencies must be traversed too") + }) + + t.Run("member's dependencies are counted as unique deps", func(t *testing.T) { + assert.Contains(t, uniqueDeps, "pypi://internal-tool:0.2.0") + assert.Contains(t, uniqueDeps, "pypi://pyyaml:5.3.1") + }) +} + +func TestBuildUvDepTreeWorkspaceMemberReachableFromRootNotDuplicated(t *testing.T) { + lockContent := `version = 1 +requires-python = ">=3.11" + +[[package]] +name = "root-app" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "localpkg" }, +] + +[[package]] +name = "localpkg" +version = "0.1.0" +source = { editable = "local_pkg" } +` + packages := parseUvLock(lockContent) + depTree, _ := buildUvDepTree(packages) + + require.Len(t, depTree, 1, "localpkg is already reachable from root — no duplicate sibling should be added") + assert.NotNil(t, findChildById(depTree[0], "pypi://localpkg:0.1.0")) +} + +func TestParseUvLockExtrasAndDependencyGroups(t *testing.T) { + lockContent := `version = 1 +requires-python = ">=3.11" + +[[package]] +name = "idna" +version = "2.7" +source = { registry = "https://pypi.org/simple" } +wheels = [{ url = "https://host/artifactory/api/pypi/repo/packages/idna-2.7-py2.py3-none-any.whl", hash = "sha256:aaa" }] + +[[package]] +name = "pyyaml" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://host/artifactory/api/pypi/repo/packages/PyYAML-5.3.1.tar.gz", hash = "sha256:bbb" } + +[[package]] +name = "urllib3" +version = "1.23" +source = { registry = "https://pypi.org/simple" } +wheels = [{ url = "https://host/artifactory/api/pypi/repo/packages/urllib3-1.23-py2.py3-none-any.whl", hash = "sha256:ccc" }] + +[[package]] +name = "uv-ca-groups-test" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "pyyaml" }, +] + +[package.optional-dependencies] +extra1 = [ + { name = "idna" }, +] + +[package.dev-dependencies] +dev = [ + { name = "urllib3" }, +] + +[package.metadata] +requires-dist = [ + { name = "idna", marker = "extra == 'extra1'", specifier = "==2.7" }, + { name = "pyyaml", specifier = "==5.3.1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "urllib3", specifier = "==1.23" }, +] +` + packages := parseUvLock(lockContent) + byName := make(map[string]uvPackage) + for _, p := range packages { + byName[p.Name] = p + } + + t.Run("root package's dependency list includes the base, extra, and dev-group members", func(t *testing.T) { + root, ok := byName["uv-ca-groups-test"] + require.True(t, ok) + assert.ElementsMatch(t, []string{"pyyaml", "idna", "urllib3"}, depNames(root.Dependencies)) + }) + + t.Run("metadata sub-table after the group tables is not mistaken for package data", func(t *testing.T) { + // [package.metadata] must not add spurious entries beyond the 3 real ones. + root := byName["uv-ca-groups-test"] + assert.Len(t, root.Dependencies, 3) + }) + + depTree, uniqueDeps := buildUvDepTree(packages) + + t.Run("extra and dev-group members appear in the dependency tree", func(t *testing.T) { + require.Len(t, depTree, 1) + root := depTree[0] + assert.NotNil(t, findChildById(root, "pypi://idna:2.7"), "extra member idna must be a child of the root") + assert.NotNil(t, findChildById(root, "pypi://urllib3:1.23"), "dev-group member urllib3 must be a child of the root") + assert.NotNil(t, findChildById(root, "pypi://pyyaml:5.3.1")) + }) + + t.Run("extra and dev-group members are counted as unique deps", func(t *testing.T) { + assert.Contains(t, uniqueDeps, "pypi://idna:2.7") + assert.Contains(t, uniqueDeps, "pypi://urllib3:1.23") + }) +} + +func TestBuildUvDownloadUrlsMap(t *testing.T) { + artiBase := "https://host/artifactory" + packages := parseUvLock(sampleUvLock) + + params := technologies.BuildInfoBomGeneratorParams{ + ServerDetails: &config.ServerDetails{ArtifactoryUrl: artiBase + "/"}, + DependenciesRepository: "repo", + } + + urls := buildUvDownloadUrlsMap(params, packages) + + t.Run("wheel URL preferred over sdist", func(t *testing.T) { + assert.Equal(t, + "https://host/artifactory/api/pypi/repo/packages/requests-2.19.1-py2.py3-none-any.whl", + urls["pypi://requests:2.19.1"], + ) + }) + + t.Run("root package skipped", func(t *testing.T) { + _, ok := urls["pypi://my-app:0.1.0"] + assert.False(t, ok) + }) + + t.Run("all non-root packages resolved", func(t *testing.T) { + assert.Contains(t, urls, "pypi://requests:2.19.1") + assert.Contains(t, urls, "pypi://urllib3:1.23") + assert.Contains(t, urls, "pypi://certifi:2024.1.1") + }) +} + +func TestBuildUvDownloadUrlsMapStripsCurationPrefix(t *testing.T) { + lockContent := `version = 1 + +[[package]] +name = "requests" +version = "2.19.1" +source = { registry = "https://host/artifactory/api/curation/audit/api/pypi/repo/simple" } +wheels = [{ url = "https://host/artifactory/api/curation/audit/api/pypi/repo/packages/requests-2.19.1-py3-none-any.whl", hash = "sha256:x" }] + +[[package]] +name = "my-app" +version = "0.1.0" +source = { editable = "." } +` + packages := parseUvLock(lockContent) + params := technologies.BuildInfoBomGeneratorParams{ + ServerDetails: &config.ServerDetails{ArtifactoryUrl: "https://host/artifactory/"}, + DependenciesRepository: "repo", + } + + urls := buildUvDownloadUrlsMap(params, packages) + + assert.Equal(t, + "https://host/artifactory/api/pypi/repo/packages/requests-2.19.1-py3-none-any.whl", + urls["pypi://requests:2.19.1"], + ) +} + +func TestBuildUvCurationIndexUrlReferenceTokenNoUsername(t *testing.T) { + serverDetails := &config.ServerDetails{ + ArtifactoryUrl: "https://host/artifactory/", + AccessToken: "reference-token-without-jwt-claims", + } + + indexUrl, err := buildUvCurationIndexUrl(serverDetails, "repo") + require.NoError(t, err) + + assert.Contains(t, indexUrl, "reference-token-without-jwt-claims@", + "the access token must be embedded as credentials even without an extractable username") +} + +// TestMaskPassword covers the helper both generateUvLock and checkUvLockState rely on to +// keep the credentials embedded in UV_DEFAULT_INDEX out of debug logs and error messages. +func TestMaskPassword(t *testing.T) { + t.Run("password present — masked wherever it appears in s", func(t *testing.T) { + rawIndexUrl := "https://user:s3cr3t-token@host/artifactory/api/curation/audit/api/pypi/repo/simple" + out := "fetching https://user:s3cr3t-token@host/artifactory/... : 403 Forbidden (token=s3cr3t-token)" + + masked := maskPassword(out, rawIndexUrl) + + assert.NotContains(t, masked, "s3cr3t-token") + assert.Contains(t, masked, "https://user:***@host/artifactory/") + assert.Contains(t, masked, "token=***") + }) + + t.Run("no password in rawIndexUrl — s returned unchanged", func(t *testing.T) { + out := "some uv output with no secrets" + assert.Equal(t, out, maskPassword(out, "https://host/artifactory/api/pypi/repo/simple")) + }) + + t.Run("empty rawIndexUrl — s returned unchanged", func(t *testing.T) { + out := "some uv output" + assert.Equal(t, out, maskPassword(out, "")) + }) + + t.Run("unparseable rawIndexUrl — s returned unchanged", func(t *testing.T) { + out := "some uv output" + assert.Equal(t, out, maskPassword(out, "://not a url")) + }) +} + +func TestBuildUvDownloadUrlsMapSkipsNonArtifactoryUrls(t *testing.T) { + lockContent := `version = 1 + +[[package]] +name = "requests" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +wheels = [{ url = "https://files.pythonhosted.org/packages/requests-2.19.1-py3-none-any.whl", hash = "sha256:x" }] + +[[package]] +name = "my-app" +version = "0.1.0" +source = { editable = "." } +` + packages := parseUvLock(lockContent) + params := technologies.BuildInfoBomGeneratorParams{ + ServerDetails: &config.ServerDetails{ArtifactoryUrl: "https://host/artifactory/"}, + DependenciesRepository: "repo", + } + + urls := buildUvDownloadUrlsMap(params, packages) + + _, ok := urls["pypi://requests:2.19.1"] + assert.False(t, ok, "public PyPI URL must not be included in the curation HEAD-probe map") +} + +func TestBuildDependencyTreeRejectsAuditMode(t *testing.T) { + params := technologies.BuildInfoBomGeneratorParams{ + IsCurationCmd: false, + } + _, _, _, err := BuildDependencyTree(params) + require.Error(t, err) + assert.Contains(t, err.Error(), "jf curation-audit") +} + +func TestClassifyUvCurationLockError_WheelFetchBlocked(t *testing.T) { + outStr := "error: Failed to fetch: `https://host/artifactory/api/curation/audit/api/pypi/repo/packages/langsmith-0.10.0-py3-none-any.whl`" + + "\n Caused by: HTTP status client error (403 Forbidden) for url (https://host/artifactory/api/curation/audit/api/pypi/repo/packages/langsmith-0.10.0-py3-none-any.whl)" + cause := errors.New("'uv lock' against Artifactory failed: exit status 1 — " + outStr) + + err := classifyUvCurationLockError(outStr, cause) + + var cvsErr *python.CvsBlockedError + assert.False(t, errors.As(err, &cvsErr), "generic wheel-403 must NOT be treated as a partial-table CvsBlockedError, got: %v", err) + assert.ErrorIs(t, err, cause) +} + +func TestClassifyUvCurationLockError_CvsStrippedVersion(t *testing.T) { + outStr := "error: Because there is no version of telnyx==4.87.1 and your project depends on telnyx==4.87.1, we can conclude that your project's requirements are unsatisfiable." + cause := errors.New("'uv lock' against Artifactory failed: exit status 1 — " + outStr) + + err := classifyUvCurationLockError(outStr, cause) + + var cvsErr *python.CvsBlockedError + require.True(t, errors.As(err, &cvsErr)) + require.Len(t, cvsErr.Packages, 1) + assert.Equal(t, "telnyx", cvsErr.Packages[0].Name) + assert.Equal(t, "4.87.1", cvsErr.Packages[0].Version) +} + +func TestClassifyUvCurationLockError_UnrecognizedFailure(t *testing.T) { + outStr := "error: No solution found when resolving dependencies: some unrelated conflict" + cause := errors.New("'uv lock' against Artifactory failed: exit status 1 — " + outStr) + + err := classifyUvCurationLockError(outStr, cause) + + assert.Same(t, cause, err) +} + +// writeFakeUvExecutable puts a fake "uv" on PATH that: +// - "uv --version" -> prints a fake version, exit 0. +// - "uv lock --check" -> exit 0 (always reports the lock as already in sync). +// - "uv lock --script " -> records the UV_DEFAULT_INDEX it was invoked with, writes +// marker into ./.lock identifying that index, exit 0. +// - "uv lock" -> same, but writes ./uv.lock instead. +// +// Every invocation is appended to the file at the FAKE_UV_LOG env var so the test can +// assert whether "uv lock" (the real regeneration, not the --check probe) ran. +func writeFakeUvExecutable(t *testing.T, dir string) { + t.Helper() + name := filepath.Join(dir, "uv") + script := `#!/bin/sh +echo "CALL:$*" >> "$FAKE_UV_LOG" +if [ "$1" = "--version" ]; then + echo "uv 0.11.21 (fake)" + exit 0 +fi +if [ "$1" = "lock" ] && [ "$2" = "--check" ]; then + exit 0 +fi +if [ "$1" = "lock" ] && [ "$2" = "--script" ]; then + echo "REGENERATED_WITH_INDEX=$UV_DEFAULT_INDEX" >> "$FAKE_UV_LOG" + echo "regenerated-lock-via-$UV_DEFAULT_INDEX" > "$3.lock" + exit 0 +fi +if [ "$1" = "lock" ]; then + echo "REGENERATED_WITH_INDEX=$UV_DEFAULT_INDEX" >> "$FAKE_UV_LOG" + echo "regenerated-lock-via-$UV_DEFAULT_INDEX" > uv.lock + exit 0 +fi +exit 1 +` + if runtime.GOOS == "windows" { + name += ".bat" + script = `@echo off +echo CALL:%* >> "%FAKE_UV_LOG%" +if "%~1"=="--version" ( + echo uv 0.11.21 (fake) + exit /b 0 +) +if "%~1"=="lock" if "%~2"=="--check" ( + exit /b 0 +) +if "%~1"=="lock" if "%~2"=="--script" ( + echo REGENERATED_WITH_INDEX=%UV_DEFAULT_INDEX% >> "%FAKE_UV_LOG%" + echo regenerated-lock-via-%UV_DEFAULT_INDEX% > "%~3.lock" + exit /b 0 +) +if "%~1"=="lock" ( + echo REGENERATED_WITH_INDEX=%UV_DEFAULT_INDEX% >> "%FAKE_UV_LOG%" + echo regenerated-lock-via-%UV_DEFAULT_INDEX% > uv.lock + exit /b 0 +) +exit /b 1 +` + } + require.NoError(t, os.WriteFile(name, []byte(script), 0755)) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +const ( + testUvArtifactoryUrl = "https://arti.example.com" + testUvRepoName = "pypi-remote" + testUvArtiIndexUrl = "https://arti.example.com/api/curation/audit/api/pypi/pypi-remote/simple" +) + +// TestGenerateUvLockInTempDir_UnverifiedLock_Regenerates is a regression test for a +// critical bypass: a committed uv.lock that's already in sync with pyproject.toml (`uv +// lock --check` passes) but resolved from an unrecognized registry (here, public PyPI) +// must still be re-resolved through the curation gateway — "in sync" alone says nothing +// about which index produced it. Before the fix, this case (the old `default:` branch) +// never called generateUvLock at all, so the returned lock content was just the untouched +// committed file, and no package was ever routed through Artifactory. +func TestGenerateUvLockInTempDir_UnverifiedLock_Regenerates(t *testing.T) { + fakeBinDir := t.TempDir() + writeFakeUvExecutable(t, fakeBinDir) + + logPath := filepath.Join(t.TempDir(), "fake_uv.log") + t.Setenv("FAKE_UV_LOG", logPath) + + projectDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "pyproject.toml"), []byte(`[project] +name = "demo" +version = "0.1.0" +requires-python = ">=3.9" +dependencies = [] +`), 0644)) + committedLock := `version = 1 +requires-python = ">=3.9" + +[[package]] +name = "demo" +version = "0.1.0" +source = { virtual = "." } + +[[package]] +name = "requests" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +` + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "uv.lock"), []byte(committedLock), 0644)) + + content, err := generateUvLockInTempDir(projectDir, testUvArtifactoryUrl, testUvRepoName, testUvArtiIndexUrl) + require.NoError(t, err) + + assert.NotEqual(t, committedLock, content, + "a lock resolved from an unrecognized registry must never be reused as-is — it must be re-resolved through the curation gateway") + assert.Equal(t, "regenerated-lock-via-"+testUvArtiIndexUrl, strings.TrimSpace(content)) + + logBytes, err := os.ReadFile(logPath) + require.NoError(t, err, "expected 'uv lock' to actually run, but no invocation was logged") + log := string(logBytes) + assert.Contains(t, log, "CALL:lock --check", "sanity check: the staleness probe should still run") + assert.Contains(t, log, "REGENERATED_WITH_INDEX="+testUvArtiIndexUrl, + "'uv lock' must run against the curation gateway when the existing lock's registry isn't verified") +} + +// TestGenerateUvLockInTempDir_VerifiedLock_Reused covers the optimization: when the lock +// is in sync AND every package's recorded registry is already this Artifactory repo (plain +// or curation pass-through form), re-resolving would be redundant — the later HEAD-probe +// step re-checks each package's current policy status straight from these URLs regardless. +// 'uv lock' must not run again in this case. +func TestGenerateUvLockInTempDir_VerifiedLock_Reused(t *testing.T) { + fakeBinDir := t.TempDir() + writeFakeUvExecutable(t, fakeBinDir) + + logPath := filepath.Join(t.TempDir(), "fake_uv.log") + t.Setenv("FAKE_UV_LOG", logPath) + + projectDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "pyproject.toml"), []byte(`[project] +name = "demo" +version = "0.1.0" +requires-python = ">=3.9" +dependencies = [] +`), 0644)) + plainBase, passThroughBase := uvArtifactoryRegistryBases(testUvArtifactoryUrl, testUvRepoName) + committedLock := fmt.Sprintf(`version = 1 +requires-python = ">=3.9" + +[[package]] +name = "demo" +version = "0.1.0" +source = { virtual = "." } + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = %q } + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = %q } +`, plainBase, passThroughBase) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "uv.lock"), []byte(committedLock), 0644)) + + content, err := generateUvLockInTempDir(projectDir, testUvArtifactoryUrl, testUvRepoName, testUvArtiIndexUrl) + require.NoError(t, err) + + assert.Equal(t, committedLock, content, + "a lock already verified as resolved from this Artifactory repo must be reused as-is") + + logBytes, err := os.ReadFile(logPath) + require.NoError(t, err, "expected the staleness probe to run, but no invocation was logged") + log := string(logBytes) + assert.Contains(t, log, "CALL:lock --check", "sanity check: the staleness probe should still run") + assert.NotContains(t, log, "REGENERATED_WITH_INDEX=", + "'uv lock' must not run again once the existing lock is verified as already resolved from this repo") +} + +// TestGenerateUvLockInTempDir_NoLock_Generates covers the no-uv.lock-yet case +// (checkUvLockState's lockNeedsGenerate branch), which always calls generateUvLock +// unconditionally — there's nothing to verify when no lock exists yet. +func TestGenerateUvLockInTempDir_NoLock_Generates(t *testing.T) { + fakeBinDir := t.TempDir() + writeFakeUvExecutable(t, fakeBinDir) + + logPath := filepath.Join(t.TempDir(), "fake_uv.log") + t.Setenv("FAKE_UV_LOG", logPath) + + projectDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "pyproject.toml"), []byte(`[project] +name = "demo" +version = "0.1.0" +requires-python = ">=3.9" +dependencies = [] +`), 0644)) + content, err := generateUvLockInTempDir(projectDir, testUvArtifactoryUrl, testUvRepoName, testUvArtiIndexUrl) + require.NoError(t, err) + + assert.Equal(t, "regenerated-lock-via-"+testUvArtiIndexUrl, strings.TrimSpace(content)) + + logBytes, err := os.ReadFile(logPath) + require.NoError(t, err, "expected 'uv lock' to actually run, but no invocation was logged") + log := string(logBytes) + assert.NotContains(t, log, "CALL:lock --check", + "sanity check: checkUvLockState should skip the --check probe when there's no lock file yet") + assert.Contains(t, log, "REGENERATED_WITH_INDEX="+testUvArtiIndexUrl, + "'uv lock' must run against the curation gateway when generating a fresh lock") +} + +const pep723ScriptContent = `# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "six==1.16.0", +# ] +# /// + +import six +` + +func TestBuildDependencyTreeForScript_RejectsNonPyFile(t *testing.T) { + dir := t.TempDir() + scriptPath := filepath.Join(dir, "notascript.txt") + require.NoError(t, os.WriteFile(scriptPath, []byte(pep723ScriptContent), 0644)) + + _, _, _, err := buildDependencyTreeForScript(technologies.BuildInfoBomGeneratorParams{ScriptPath: scriptPath}) + require.Error(t, err) + assert.Contains(t, err.Error(), "PEP 723") +} + +func TestBuildDependencyTreeForScript_RejectsMissingPep723Metadata(t *testing.T) { + dir := t.TempDir() + scriptPath := filepath.Join(dir, "plain.py") + require.NoError(t, os.WriteFile(scriptPath, []byte("import six\n"), 0644)) + + _, _, _, err := buildDependencyTreeForScript(technologies.BuildInfoBomGeneratorParams{ScriptPath: scriptPath}) + require.Error(t, err) + assert.Contains(t, err.Error(), "PEP 723") +} + +// writeFakeUvExecutableForScriptLock fakes "uv lock --script" to write a lock with one +// resolved "six" package, so tests can exercise the full parse -> tree -> URLs pipeline. +func writeFakeUvExecutableForScriptLock(t *testing.T, dir, artifactoryUrl, repoName string) { + t.Helper() + lockContent := fmt.Sprintf(`version = 1 +requires-python = ">=3.11" + +[[package]] +name = "six" +version = "1.16.0" +source = { registry = "%[1]s/api/curation/audit/api/pypi/%[2]s/simple" } +wheels = [ + { url = "%[1]s/api/curation/audit/api/pypi/%[2]s/packages/six-1.16.0-py3-none-any.whl" }, +] +`, strings.TrimSuffix(artifactoryUrl, "/"), repoName) + + name := filepath.Join(dir, "uv") + script := "#!/bin/sh\n" + + `if [ "$1" = "--version" ]; then echo "uv 0.11.21 (fake)"; exit 0; fi` + "\n" + + `if [ "$1" = "lock" ] && [ "$2" = "--script" ]; then cat > "$3.lock" << 'LOCKEOF'` + "\n" + + lockContent + + "LOCKEOF\nexit 0\nfi\nexit 1\n" + if runtime.GOOS == "windows" { + t.Skip("fake uv script uses a POSIX heredoc; skip this test on Windows") + } + require.NoError(t, os.WriteFile(name, []byte(script), 0755)) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +// writeFakeUvExecutableForEmptyScriptLock fakes "uv lock --script" to write an empty lock, +// so the "nothing to audit" guard in buildDependencyTreeForScript can be tested. +func writeFakeUvExecutableForEmptyScriptLock(t *testing.T, dir string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("fake uv script uses a POSIX heredoc; skip this test on Windows") + } + name := filepath.Join(dir, "uv") + script := "#!/bin/sh\n" + + `if [ "$1" = "--version" ]; then echo "uv 0.11.21 (fake)"; exit 0; fi` + "\n" + + `if [ "$1" = "lock" ] && [ "$2" = "--script" ]; then cat > "$3.lock" << 'LOCKEOF'` + "\n" + + "version = 1\nrequires-python = \">=3.11\"\n" + + "LOCKEOF\nexit 0\nfi\nexit 1\n" + require.NoError(t, os.WriteFile(name, []byte(script), 0755)) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +// TestBuildDependencyTreeForScript_NoDependencies: a script with no dependencies resolves +// to an empty lock, so buildDependencyTreeForScript must return a clear error, not an +// empty/misleading result. +func TestBuildDependencyTreeForScript_NoDependencies(t *testing.T) { + fakeBinDir := t.TempDir() + writeFakeUvExecutableForEmptyScriptLock(t, fakeBinDir) + + dir := t.TempDir() + scriptPath := filepath.Join(dir, "no_deps_script.py") + require.NoError(t, os.WriteFile(scriptPath, []byte("# /// script\n# requires-python = \">=3.11\"\n# ///\n\nprint(\"hi\")\n"), 0644)) + + _, _, _, err := buildDependencyTreeForScript(technologies.BuildInfoBomGeneratorParams{ScriptPath: scriptPath}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "no dependencies to audit") +} + +// TestBuildDependencyTreeForScript_ResolvesThroughGateway is the happy-path test: a valid +// PEP 723 script resolves through the curation gateway into a dep tree with a clean +// (pass-through-prefix-stripped) download URL. +func TestBuildDependencyTreeForScript_ResolvesThroughGateway(t *testing.T) { + fakeBinDir := t.TempDir() + const artifactoryUrl = "https://arti.example.com" + const repoName = "pypi-remote" + writeFakeUvExecutableForScriptLock(t, fakeBinDir, artifactoryUrl, repoName) + + dir := t.TempDir() + scriptPath := filepath.Join(dir, "demo_script.py") + require.NoError(t, os.WriteFile(scriptPath, []byte(pep723ScriptContent), 0644)) + + params := technologies.BuildInfoBomGeneratorParams{ + ScriptPath: scriptPath, + ServerDetails: &config.ServerDetails{ArtifactoryUrl: artifactoryUrl + "/"}, + DependenciesRepository: repoName, + } + + depTree, uniqueDeps, downloadUrls, err := buildDependencyTreeForScript(params) + require.NoError(t, err) + + require.Len(t, depTree, 1, "no editable/virtual root in a script lock -> synthetic root") + assert.Equal(t, "root", depTree[0].Id) + require.Len(t, depTree[0].Nodes, 1) + sixId := python.PythonPackageTypeIdentifier + "six:1.16.0" + assert.Equal(t, sixId, depTree[0].Nodes[0].Id) + assert.Contains(t, uniqueDeps, sixId) + assert.Equal(t, artifactoryUrl+"/api/pypi/"+repoName+"/packages/six-1.16.0-py3-none-any.whl", downloadUrls[sixId], + "the curation pass-through prefix must be stripped from the download URL") +} diff --git a/tests/testdata/projects/package-managers/python/uv/uv-curation-project/pyproject.toml b/tests/testdata/projects/package-managers/python/uv/uv-curation-project/pyproject.toml new file mode 100644 index 000000000..a092731a1 --- /dev/null +++ b/tests/testdata/projects/package-managers/python/uv/uv-curation-project/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "uv-curation-project" +version = "0.1.0" +description = "Fixture project for the jf curation-audit (e2e) uv test." +requires-python = ">=3.11" +dependencies = ["pexpect==4.8.0"] + +[tool.uv] +package = false diff --git a/utils/techutils/techutils.go b/utils/techutils/techutils.go index 66a553557..ecc7fdb57 100644 --- a/utils/techutils/techutils.go +++ b/utils/techutils/techutils.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "io/fs" "os" "path/filepath" "regexp" @@ -52,6 +53,7 @@ const ( Pip Technology = "pip" Pipenv Technology = "pipenv" Poetry Technology = "poetry" + Uv Technology = "uv" Nuget Technology = "nuget" Dotnet Technology = "dotnet" Conan Technology = "conan" @@ -83,6 +85,7 @@ var AllTechnologiesStrings = []string{ Pip.String(), Pipenv.String(), Poetry.String(), + Uv.String(), Nuget.String(), Dotnet.String(), Docker.String(), @@ -244,6 +247,16 @@ var technologiesData = map[Technology]TechData{ projectType: project.Poetry, language: Python, }, + Uv: { + formal: "uv", + packageType: Pypi, + xrayPackageType: Pypi, + indicators: []string{"uv.lock"}, + packageDescriptors: []string{"pyproject.toml"}, + execCommand: "uv", + projectType: project.UV, + language: Python, + }, Nuget: { formal: "NuGet", indicators: []string{".sln", ".csproj"}, @@ -438,6 +451,11 @@ func DetectedTechnologiesList() (technologies []string) { // commands ('jf audit', 'jf scan', etc.) keep the legacy npm-fallback // behaviour for yarn workspace members so this change cannot regress them. // +// Unlike DetectedTechnologiesList, this does not log the result: the caller still has +// more promotions to apply (pnpm/yarn, pip→uv) before the list is final, and logging +// early could show a technology immediately superseded (e.g. "Detected: pip." right +// before "...treating as uv."). +// // Used by doCurateAudit so 'jf ca --working-dirs=' // resolves through yarn (matching how the project's lockfile was produced) // instead of npm (which would synthesise a different dependency set and @@ -455,9 +473,74 @@ func DetectedTechnologiesListForCurationAudit() (technologies []string) { return } promoteYarnWorkspaceMembers(detected) - techStringsList := DetectedTechnologiesToSlice(detected) - log.Info(fmt.Sprintf("Detected: %s.", strings.Join(techStringsList, ", "))) - return techStringsList + return DetectedTechnologiesToSlice(detected) +} + +// Pep723ScriptUnauditedHint returns a hint when wd contains a PEP 723 script (or "" if +// none), since jf ca only audits one via --script. +// +// Callers must only invoke this once uv was confirmed detected in wd, and must log the +// result themselves — this function never logs directly, since jf ca's progress spinner +// would swallow a log line emitted while it's active. +func Pep723ScriptUnauditedHint(wd string) string { + found, err := hasUnauditedPep723Script(wd) + if err != nil || !found { + return "" + } + return "Found PEP 723 inline-script(s) that were not audited by this run — use 'jf ca --script ' to audit them." +} + +var ( + pep723OpenMarkerRegex = regexp.MustCompile(`(?m)^#\s*///\s*script\s*$`) + pep723CloseMarkerRegex = regexp.MustCompile(`(?m)^#\s*///\s*$`) +) + +// HasPep723ScriptMetadata reports whether content contains a PEP 723 inline +// script metadata block: a "# /// script" line followed later by a lone +// "# ///" closing line. +func HasPep723ScriptMetadata(content string) bool { + loc := pep723OpenMarkerRegex.FindStringIndex(content) + if loc == nil { + return false + } + return pep723CloseMarkerRegex.MatchString(content[loc[1]:]) +} + +// hasUnauditedPep723Script reports whether dir contains, at any depth, a .py file with +// PEP 723 inline script metadata. It stops at the first match (filepath.SkipAll) since +// only a yes/no signal is needed, and prunes directories matching +// utils.DefaultScaExcludePatterns (.git, node_modules, venv, test, dist, target) instead +// of filtering them out after walking, since walking into a large excluded tree (e.g. a +// committed .venv) is the dominant cost. +func hasUnauditedPep723Script(dir string) (bool, error) { + var found bool + walkErr := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil || path == dir { + return nil + } + if d.IsDir() { + if utils.IsPathExcluded(path, utils.DefaultScaExcludePatterns) { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(d.Name(), ".py") || utils.IsPathExcluded(path, utils.DefaultScaExcludePatterns) { + return nil + } + data, readErr := os.ReadFile(path) + if readErr != nil { + return nil + } + if HasPep723ScriptMetadata(string(data)) { + found = true + return filepath.SkipAll + } + return nil + }) + if walkErr != nil { + return false, walkErr + } + return found, nil } func detectedTechnologiesListInPath(path string, recursive bool) (technologies []string) { @@ -1079,7 +1162,7 @@ func technologySharesXrayEcosystem(tech Technology, xrayType string) bool { } switch xrayType { case Pypi: - return tech == Pip || tech == Pipenv || tech == Poetry + return tech == Pip || tech == Pipenv || tech == Poetry || tech == Uv case Gav: return tech == Maven || tech == Gradle case string(Npm): diff --git a/utils/techutils/techutils_test.go b/utils/techutils/techutils_test.go index 22857bd68..1108e8b0c 100644 --- a/utils/techutils/techutils_test.go +++ b/utils/techutils/techutils_test.go @@ -9,6 +9,7 @@ import ( "github.com/jfrog/jfrog-client-go/utils/io/fileutils" clientTests "github.com/jfrog/jfrog-client-go/utils/tests" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "golang.org/x/exp/maps" ) @@ -1165,3 +1166,160 @@ func TestPromoteYarnWorkspaceMembers(t *testing.T) { assert.NotContains(t, detected, Yarn) }) } + +// TestUvToFormal verifies that uv's formal name matches the tool's own branding (lowercase). +func TestUvToFormal(t *testing.T) { + assert.Equal(t, "uv", Uv.ToFormal()) +} + +func TestPep723ScriptUnauditedHint(t *testing.T) { + // This only returns a hint message; it never alters detection itself. + t.Run("PEP 723 script present returns a hint", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "script.py"), + []byte("# /// script\n# dependencies = [\"requests\"]\n# ///\n\nimport requests\n"), 0644)) + assert.NotEmpty(t, Pep723ScriptUnauditedHint(dir)) + }) + + t.Run("no PEP 723 script present returns no hint", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "plain.py"), []byte("print('hi')\n"), 0644)) + assert.Empty(t, Pep723ScriptUnauditedHint(dir)) + }) +} + +// TestPep723Hint_BareScript_NoHint: a lone .py file with nothing else in the directory +// detects no technology, so the PEP 723 hint must not fire either. +func TestPep723Hint_BareScript_NoHint(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "script.py"), + []byte("# /// script\n# dependencies = [\"six\"]\n# ///\n\nimport six\n"), 0644)) + + prevWd, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(root)) + defer clientTests.ChangeDirAndAssert(t, prevWd) + + techs := DetectedTechnologiesListForCurationAudit() + + assert.Empty(t, techs, "a lone .py file detects no technology at all") +} + +// TestPep723Hint_UvSibling_HintFires: a uv project can have an unrelated PEP 723 script +// alongside it. The project's own audit never resolves that script's deps, so the hint +// must fire even though uv was also detected. +func TestPep723Hint_UvSibling_HintFires(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "pyproject.toml"), []byte("[project]\nname = \"demo\"\n"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "uv.lock"), []byte("version = 1\n"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "verify_isolation.py"), + []byte("# /// script\n# dependencies = [\"six\"]\n# ///\n\nimport six\n"), 0644)) + + prevWd, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(root)) + defer clientTests.ChangeDirAndAssert(t, prevWd) + + techs := DetectedTechnologiesListForCurationAudit() + + assert.Contains(t, techs, Uv.String(), "the project itself must still be detected normally") + found, findErr := hasUnauditedPep723Script(root) + require.NoError(t, findErr) + assert.True(t, found, "the sibling script's inline deps are out of scope for the project's own audit") +} + +func TestHasPep723ScriptMetadata(t *testing.T) { + tests := []struct { + name string + content string + expected bool + }{ + { + name: "valid PEP 723 block", + content: `# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "requests<3", +# ] +# /// + +import requests +`, + expected: true, + }, + { + name: "no metadata block at all", + content: "import requests\nprint('hello')\n", + expected: false, + }, + { + name: "open marker with no closing marker", + content: `# /// script +# dependencies = ["requests"] +import requests +`, + expected: false, + }, + { + name: "closing marker with no opening marker", + content: "# ///\nimport requests\n", + expected: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, HasPep723ScriptMetadata(tt.content)) + }) + } +} + +func TestHasUnauditedPep723Script_FindsNestedScript(t *testing.T) { + dir := t.TempDir() + + pep723Content := "# /// script\n# dependencies = [\"requests\"]\n# ///\n\nimport requests\n" + require.NoError(t, os.WriteFile(filepath.Join(dir, "plain.py"), []byte("print('hi')\n"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("# /// script\n# ///\n"), 0644)) + require.NoError(t, os.Mkdir(filepath.Join(dir, "subdir"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "subdir", "nested.py"), []byte(pep723Content), 0644)) + + found, err := hasUnauditedPep723Script(dir) + require.NoError(t, err) + assert.True(t, found, "a PEP 723 file nested at any depth must be found, even with plain .py and non-.py files present") +} + +// TestHasUnauditedPep723Script_ExcludesDefaultPatterns verifies excluded directories +// (venv, node_modules, .git, etc.) are skipped, while non-excluded ones are still scanned. +func TestHasUnauditedPep723Script_ExcludesDefaultPatterns(t *testing.T) { + pep723Content := "# /// script\n# dependencies = [\"requests\"]\n# ///\n\nimport requests\n" + + t.Run("only excluded dir has a script — not found", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".venv", "lib"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".venv", "lib", "vendored.py"), []byte(pep723Content), 0644)) + + found, err := hasUnauditedPep723Script(dir) + require.NoError(t, err) + assert.False(t, found, "a script only under an excluded directory like .venv must be pruned, not found") + }) + + t.Run("non-excluded sibling also has a script — found", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".venv", "lib"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".venv", "lib", "vendored.py"), []byte(pep723Content), 0644)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "tools"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "tools", "deploy.py"), []byte(pep723Content), 0644)) + + found, err := hasUnauditedPep723Script(dir) + require.NoError(t, err) + assert.True(t, found, "a script under a non-excluded subdirectory must still be found") + }) +} + +func TestHasUnauditedPep723Script_NoScripts(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "plain.py"), []byte("print('hi')\n"), 0644)) + + found, err := hasUnauditedPep723Script(dir) + require.NoError(t, err) + assert.False(t, found) +} From dd454d9e86ffb389ff323895340e34bb777cb3fa Mon Sep 17 00:00:00 2001 From: Phavya Jayakumar Date: Fri, 17 Jul 2026 19:46:18 +0530 Subject: [PATCH 2/4] Review changes --- _typos.toml | 1 + commands/curation/curationaudit.go | 27 ++- commands/curation/curationaudit_test.go | 87 +++++++- sca/bom/buildinfo/technologies/uv/uv.go | 131 +++++++----- sca/bom/buildinfo/technologies/uv/uv_test.go | 200 +++++++++++++++++-- utils/techutils/techutils.go | 6 +- 6 files changed, 371 insertions(+), 81 deletions(-) diff --git a/_typos.toml b/_typos.toml index 1ca105b31..7f485ae9a 100644 --- a/_typos.toml +++ b/_typos.toml @@ -2,6 +2,7 @@ testng = "testng" afe = "afe" Bouned = "Bouned" +certifi = "certifi" [files] extend-exclude = ["go.mod", "go.sum", "tests/testdata"] \ No newline at end of file diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index 6078cb310..e778d7e66 100644 --- a/commands/curation/curationaudit.go +++ b/commands/curation/curationaudit.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "os" "path/filepath" "regexp" @@ -1391,8 +1392,8 @@ func (ca *CurationAuditCommand) setRepoFromYarnrcForYarnV4(yarnExecPath, working } // setRepoFromUvToml resolves the Artifactory URL and repo name via GetNativeUvRegistryConfig -// (pyproject.toml [[tool.uv.index]] first, then ~/.config/uv/uv.toml) and configures the command. -// Auth comes from the jf c server config. +// (~/.config/uv/uv.toml first, then pyproject.toml [[tool.uv.index]] as a fallback) and +// configures the command. func (ca *CurationAuditCommand) setRepoFromUvToml() error { registryConfig, err := uvtech.GetNativeUvRegistryConfig() if err != nil { @@ -1407,6 +1408,13 @@ func (ca *CurationAuditCommand) setRepoFromUvToml() error { if base == nil { return errorutils.CheckErrorf("uv: no 'jf c' server configured") } + if !sameArtifactoryHost(base.ArtifactoryUrl, registryConfig.ArtifactoryUrl) { + return errorutils.CheckErrorf( + "uv: the Artifactory URL declared in pyproject.toml/uv.toml (%s) does not match the "+ + "configured 'jf c' server (%s) — refusing to send its credentials to a different host. "+ + "Align the two, or select the matching server with --server-id.", + registryConfig.ArtifactoryUrl, base.ArtifactoryUrl) + } copied := *base copied.ArtifactoryUrl = registryConfig.ArtifactoryUrl @@ -1419,6 +1427,21 @@ func (ca *CurationAuditCommand) setRepoFromUvToml() error { return nil } +// sameArtifactoryHost reports whether configuredUrl and discoveredUrl share the same +// scheme and host[:port], ignoring path and trailing slashes. +func sameArtifactoryHost(configuredUrl, discoveredUrl string) bool { + configured, err := url.Parse(configuredUrl) + if err != nil || configured.Host == "" { + return false + } + discovered, err := url.Parse(discoveredUrl) + if err != nil || discovered.Host == "" { + return false + } + return strings.EqualFold(configured.Host, discovered.Host) && + strings.EqualFold(configured.Scheme, discovered.Scheme) +} + 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 ed78912de..04b71314f 100644 --- a/commands/curation/curationaudit_test.go +++ b/commands/curation/curationaudit_test.go @@ -2533,6 +2533,7 @@ func TestRunCvsFallbackNoMatchesFound(t *testing.T) { // error, so wrapping it with %w produced "...: %!w()" — a raw Go fmt-verb artifact // leaking to the user instead of a clean "no server configured" message. func TestSetRepoFromUvTomlNoServerConfigured(t *testing.T) { + t.Setenv("HOME", t.TempDir()) projectDir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(projectDir, "pyproject.toml"), []byte(`[[tool.uv.index]] name = "artifactory-repo" @@ -2556,6 +2557,7 @@ url = "https://host/artifactory/api/pypi/uv-test-repo/simple" // setRepoFromUvToml() used to run twice per uv run — once via GetAuth(Uv), again // unconditionally in auditTree. auditTree now skips it once PackageManagerConfig is set. func TestAuditTreeSkipsRedundantSetRepoFromUvTomlWhenAlreadySet(t *testing.T) { + t.Setenv("HOME", t.TempDir()) projectDir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(projectDir, "pyproject.toml"), []byte(`[[tool.uv.index]] name = "artifactory-repo" @@ -2567,7 +2569,7 @@ url = "https://host/artifactory/api/pypi/uv-test-repo/simple" defer clienttestutils.ChangeDirAndAssert(t, prevWd) ca := NewCurationAuditCommand() - ca.SetServerDetails(&config.ServerDetails{Url: "https://host/"}) + ca.SetServerDetails(&config.ServerDetails{Url: "https://host/", ArtifactoryUrl: "https://host/artifactory/"}) var buf bytes.Buffer origLogger := log.Logger @@ -2589,6 +2591,89 @@ url = "https://host/artifactory/api/pypi/uv-test-repo/simple" "auditTree must not re-read uv.toml when PackageManagerConfig is already set") } +// TestSetRepoFromUvTomlRejectsHostMismatch: a pyproject.toml [[tool.uv.index]] entry +// pointing at a different host than the configured 'jf c' server must not receive that +// server's credentials. +func TestSetRepoFromUvTomlRejectsHostMismatch(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + projectDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "pyproject.toml"), []byte(`[[tool.uv.index]] +name = "artifactory-repo" +url = "https://attacker.example.com/artifactory/api/pypi/uv-test-repo/simple" +`), 0644)) + prevWd, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(projectDir)) + defer clienttestutils.ChangeDirAndAssert(t, prevWd) + + 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.setRepoFromUvToml() + + 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") +} + +// TestSetRepoFromUvTomlAcceptsMatchingHost: the host check must not block the legitimate +// same-host case. +func TestSetRepoFromUvTomlAcceptsMatchingHost(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + projectDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "pyproject.toml"), []byte(`[[tool.uv.index]] +name = "artifactory-repo" +url = "https://configured-server.example.com/artifactory/api/pypi/uv-test-repo/simple" +`), 0644)) + prevWd, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(projectDir)) + defer clienttestutils.ChangeDirAndAssert(t, prevWd) + + ca := NewCurationAuditCommand() + ca.SetServerDetails(&config.ServerDetails{ + Url: "https://configured-server.example.com/", + ArtifactoryUrl: "https://configured-server.example.com/artifactory/", + }) + + require.NoError(t, ca.setRepoFromUvToml()) + require.NotNil(t, ca.PackageManagerConfig) +} + +// TestSetRepoFromUvTomlRejectsSchemeDowngrade: a pyproject.toml +// [[tool.uv.index]] entry pointing at the *same host* as the configured 'jf c' server, but +// over http instead of https, must not receive that server's credentials — otherwise the +// host-only check would let a project-local file downgrade them to cleartext. +func TestSetRepoFromUvTomlRejectsSchemeDowngrade(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + projectDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "pyproject.toml"), []byte(`[[tool.uv.index]] +name = "artifactory-repo" +url = "http://configured-server.example.com/artifactory/api/pypi/uv-test-repo/simple" +`), 0644)) + prevWd, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(projectDir)) + defer clienttestutils.ChangeDirAndAssert(t, prevWd) + + 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.setRepoFromUvToml() + + require.Error(t, setErr) + assert.Contains(t, setErr.Error(), "does not match") + assert.Nil(t, ca.PackageManagerConfig, "credentials must not be downgraded to a cleartext http URL on the same host") +} + // 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) { diff --git a/sca/bom/buildinfo/technologies/uv/uv.go b/sca/bom/buildinfo/technologies/uv/uv.go index 783b6f063..96141c482 100644 --- a/sca/bom/buildinfo/technologies/uv/uv.go +++ b/sca/bom/buildinfo/technologies/uv/uv.go @@ -60,47 +60,58 @@ type uvPackage struct { } // GetNativeUvRegistryConfig returns the Artifactory URL and repo name for the current uv project. +// +// ~/.config/uv/uv.toml is a trusted, user-controlled file: whenever it exists, it is used +// exclusively, and pyproject.toml is never even inspected. pyproject.toml ([[tool.uv.index]]) +// is project-local and travels with the source code, so it's only trusted as a fallback +// when no uv.toml is present. func GetNativeUvRegistryConfig() (*UvRegistryConfig, error) { - // 1. Try pyproject.toml [[tool.uv.index]] first. - if wd, err := os.Getwd(); err == nil { - if data, err := os.ReadFile(filepath.Join(wd, "pyproject.toml")); err == nil { - candidates := parsePyprojectUvIndexUrls(string(data)) - switch len(candidates) { - case 0: - // Nothing declared here — fall through to uv.toml. - case 1: - if cfg, ok := firstArtifactoryPypiConfig(candidates); ok { - log.Info(fmt.Sprintf("uv: using Artifactory URL %q and repository %q from pyproject.toml", cfg.ArtifactoryUrl, cfg.RepoName)) - return cfg, nil - } - default: - log.Warn(fmt.Sprintf( - "uv: pyproject.toml declares %d [[tool.uv.index]] entries — ignoring them and using ~/.config/uv/uv.toml instead, "+ - "since picking one from a project-declared list can't be done safely", len(candidates))) + // 1. ~/.config/uv/uv.toml, if present, is the sole source of truth. + home, homeErr := os.UserHomeDir() + if homeErr == nil { + configPath := filepath.Join(home, uvTomlConfigRelPath) + if content, err := os.ReadFile(configPath); err == nil { + cfg, ok := firstArtifactoryPypiConfig(parseUvTomlIndexUrls(string(content))) + if !ok { + return nil, errorutils.CheckErrorf( + "uv: no Artifactory index url found in %s — "+ + "add an [[index]] entry, or configure via the Artifactory 'Set Me Up' page for uv", + configPath, + ) } + log.Info(fmt.Sprintf("uv: using Artifactory URL %q and repository %q from uv.toml", cfg.ArtifactoryUrl, cfg.RepoName)) + return cfg, nil } } - // 2. Fall back to ~/.config/uv/uv.toml. - home, err := os.UserHomeDir() + // 2. No usable ~/.config/uv/uv.toml — fall back to pyproject.toml [[tool.uv.index]]. + wd, err := os.Getwd() if err != nil { - return nil, fmt.Errorf("uv: could not determine home directory: %w", err) + return nil, fmt.Errorf("uv: could not determine working directory: %w", err) } - configPath := filepath.Join(home, uvTomlConfigRelPath) - content, err := os.ReadFile(configPath) + data, err := os.ReadFile(filepath.Join(wd, "pyproject.toml")) if err != nil { - return nil, fmt.Errorf("uv: no Artifactory index found in pyproject.toml and could not read %s: %w", configPath, err) - } - cfg, ok := firstArtifactoryPypiConfig(parseUvTomlIndexUrls(string(content))) - if !ok { return nil, errorutils.CheckErrorf( - "uv: no Artifactory index url found in pyproject.toml or %s — "+ - "add [[tool.uv.index]] to pyproject.toml or configure via the Artifactory 'Set Me Up' page for uv", - configPath, - ) + "uv: no config url found — add [[tool.uv.index]] to pyproject.toml or configure via the Artifactory 'Set Me Up' page for uv") + } + candidates := parsePyprojectUvIndexUrls(string(data)) + switch len(candidates) { + case 1: + if cfg, ok := firstArtifactoryPypiConfig(candidates); ok { + log.Info(fmt.Sprintf("uv: using Artifactory URL %q and repository %q from pyproject.toml", cfg.ArtifactoryUrl, cfg.RepoName)) + return cfg, nil + } + return nil, errorutils.CheckErrorf( + "uv: no config url found — add [[tool.uv.index]] to pyproject.toml or configure via the Artifactory 'Set Me Up' page for uv") + case 0: + return nil, errorutils.CheckErrorf( + "uv: no config url found — add [[tool.uv.index]] to pyproject.toml or configure via the Artifactory 'Set Me Up' page for uv") + default: + return nil, errorutils.CheckErrorf( + "uv: pyproject.toml declares %d [[tool.uv.index]] entries — picking one from a project-declared "+ + "list can't be done safely; add an unambiguous [[index]] to ~/.config/uv/uv.toml instead", + len(candidates)) } - log.Info(fmt.Sprintf("uv: using Artifactory URL %q and repository %q from uv.toml", cfg.ArtifactoryUrl, cfg.RepoName)) - return cfg, nil } // firstArtifactoryPypiConfig returns the config for the first Artifactory-shaped @@ -227,10 +238,10 @@ func BuildDependencyTree(params technologies.BuildInfoBomGeneratorParams) ( if params.ServerDetails != nil && params.DependenciesRepository != "" { artifactoryUrl = params.ServerDetails.GetArtifactoryUrl() repoName = params.DependenciesRepository - var buildErr error - artiIndexUrl, buildErr = buildUvCurationIndexUrl(params.ServerDetails, params.DependenciesRepository) - if buildErr != nil { - log.Warn(fmt.Sprintf("uv: failed to build curation index URL: %v — will fall back to public PyPI", buildErr)) + artiIndexUrl, err = buildUvCurationIndexUrl(params.ServerDetails, params.DependenciesRepository) + if err != nil { + err = fmt.Errorf("uv: failed to build curation index URL: %w", err) + return } } @@ -301,10 +312,10 @@ func buildDependencyTreeForScript(params technologies.BuildInfoBomGeneratorParam if params.ServerDetails != nil && params.DependenciesRepository != "" { artifactoryUrl = params.ServerDetails.GetArtifactoryUrl() repoName = params.DependenciesRepository - var buildErr error - artiIndexUrl, buildErr = buildUvCurationIndexUrl(params.ServerDetails, params.DependenciesRepository) - if buildErr != nil { - log.Warn(fmt.Sprintf("uv: failed to build curation index URL: %v — will fall back to public PyPI", buildErr)) + artiIndexUrl, err = buildUvCurationIndexUrl(params.ServerDetails, params.DependenciesRepository) + if err != nil { + err = fmt.Errorf("uv: failed to build curation index URL: %w", err) + return } } @@ -349,7 +360,7 @@ func generateUvScriptLockForCuration(scriptPath, artiIndexUrl string) (string, e return "", errorutils.CheckErrorf("uv: could not read script %q: %s", scriptPath, readErr) } tempScriptPath := filepath.Join(tempDir, scriptName) - if writeErr := os.WriteFile(tempScriptPath, data, 0644); writeErr != nil { + if writeErr := os.WriteFile(tempScriptPath, data, 0644); writeErr != nil { // #nosec G703 -- tempDir is our own fileutils.CreateTempDir(), and scriptName is sanitized via filepath.Base return "", errorutils.CheckErrorf("uv: could not copy script to temp dir: %s", writeErr) } @@ -481,14 +492,14 @@ func allPackagesUseRegistry(content string, allowedRegistries ...string) bool { continue } switch { - case strings.Contains(line, "virtual"), strings.Contains(line, "editable"), - strings.Contains(line, "path ="), strings.Contains(line, "git ="), strings.Contains(line, "directory ="): - continue // not resolved from a package index — curation doesn't apply - case strings.Contains(line, "registry"): + case strings.Contains(line, "registry ="): registry, found := extractQuotedField(line, "registry") if !found || !allowed[registry] { return false } + case strings.Contains(line, "virtual ="), strings.Contains(line, "editable ="), + strings.Contains(line, "path ="), strings.Contains(line, "git ="), strings.Contains(line, "directory ="): + continue // not resolved from a package index — curation doesn't apply default: return false } @@ -567,7 +578,7 @@ func neutralizeTempPyprojectIndexes(tempDir string) error { if stripped == string(data) { return nil } - if err = os.WriteFile(pyprojectPath, []byte(stripped), 0644); err != nil { + if err = os.WriteFile(pyprojectPath, []byte(stripped), 0644); err != nil { // #nosec G703 -- tempDir is our own fileutils.CreateTempDir(); "pyproject.toml" is a hardcoded literal return errorutils.CheckErrorf("uv: could not rewrite %s: %s", pyprojectPath, err) } log.Debug("uv: removed non-explicit [[tool.uv.index]] entries from the temp pyproject.toml copy to force curation-gateway resolution") @@ -670,8 +681,9 @@ func classifyUvCurationLockError(outStr string, cause error) error { return cause } -// maskPassword replaces the password from rawIndexUrl with "***" in s. -// Prevents credentials from leaking into logs or error output. +// maskPassword replaces the password from rawIndexUrl with "***" in s, in both its decoded +// and percent-encoded forms (uv can echo the URL back either way). Prevents credentials +// from leaking into logs or error output. func maskPassword(s, rawIndexUrl string) string { u, err := url.Parse(rawIndexUrl) if err != nil { @@ -681,7 +693,11 @@ func maskPassword(s, rawIndexUrl string) string { if !hasPw || pw == "" { return s } - return strings.ReplaceAll(s, pw, "***") + s = strings.ReplaceAll(s, pw, "***") + if _, encodedPw, found := strings.Cut(u.User.String(), ":"); found && encodedPw != "" { + s = strings.ReplaceAll(s, encodedPw, "***") + } + return s } // envWithoutKey returns env with all "KEY=value" entries for the given key removed. @@ -1008,6 +1024,13 @@ func buildUvDownloadUrlsMap(params technologies.BuildInfoBomGeneratorParams, pac artiBase := strings.TrimSuffix(params.ServerDetails.GetArtifactoryUrl(), "/") urls := map[string]string{} skipped := 0 + // nonArtifactoryUrl is tracked separately from skipped: it's the only skip reason that + // represents an actual coverage gap (a package resolved to a real URL, but one HEAD-check + // can't use). Root packages and packages with no download URL at all were never candidates + // for a HEAD check to begin with, so lumping them together with skipped would make the + // "N package(s) will not be HEAD-checked" warning fire on every run instead of only when + // there's something real to investigate. + nonArtifactoryUrl := 0 for _, pkg := range packages { if pkg.IsRoot || pkg.Name == "" || pkg.Version == "" || len(pkg.DownloadURLs) == 0 { @@ -1026,7 +1049,7 @@ func buildUvDownloadUrlsMap(params technologies.BuildInfoBomGeneratorParams, pac } if !strings.HasPrefix(direct, artiBase) { log.Debug(fmt.Sprintf("uv: %s:%s URL %q is not an Artifactory URL — skipping HEAD check", pkg.Name, pkg.Version, direct)) - skipped++ + nonArtifactoryUrl++ continue } compId := python.PythonPackageTypeIdentifier + python.NormalizePypiName(pkg.Name) + ":" + pkg.Version @@ -1034,16 +1057,14 @@ func buildUvDownloadUrlsMap(params technologies.BuildInfoBomGeneratorParams, pac log.Debug(fmt.Sprintf("uv: %s:%s -> %s", pkg.Name, pkg.Version, direct)) } - expected := len(packages) - skipped - resolved := len(urls) - if resolved < expected { + if nonArtifactoryUrl > 0 { log.Warn(fmt.Sprintf( - "uv: resolved download URLs for %d/%d packages — %d package(s) will not be HEAD-checked. "+ + "uv: %d package(s) resolved to a non-Artifactory download URL and will not be HEAD-checked. "+ "Re-run with JFROG_CLI_LOG_LEVEL=DEBUG to see per-package details.", - resolved, expected, expected-resolved, + nonArtifactoryUrl, )) } - log.Debug(fmt.Sprintf("uv: resolved %d download URLs (skipped %d entries)", resolved, skipped)) + log.Debug(fmt.Sprintf("uv: resolved %d download URLs (skipped %d entries, %d non-Artifactory)", len(urls), skipped, nonArtifactoryUrl)) return urls } diff --git a/sca/bom/buildinfo/technologies/uv/uv_test.go b/sca/bom/buildinfo/technologies/uv/uv_test.go index a6c399e53..7d1ba7185 100644 --- a/sca/bom/buildinfo/technologies/uv/uv_test.go +++ b/sca/bom/buildinfo/technologies/uv/uv_test.go @@ -1,8 +1,10 @@ package uv import ( + "bytes" "errors" "fmt" + "net/url" "os" "path/filepath" "runtime" @@ -12,6 +14,7 @@ import ( "github.com/jfrog/jfrog-cli-core/v2/utils/config" "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies" "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/python" + "github.com/jfrog/jfrog-client-go/utils/log" clientutils "github.com/jfrog/jfrog-client-go/xray/services/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -296,24 +299,26 @@ func setupUvRegistryFixture(t *testing.T) (projectDir, uvTomlPath string) { } func TestGetNativeUvRegistryConfig(t *testing.T) { - t.Run("single unambiguous pyproject.toml entry is used directly", func(t *testing.T) { - projectDir, _ := setupUvRegistryFixture(t) + t.Run("uv.toml present — wins outright, even over a single unambiguous pyproject.toml entry", func(t *testing.T) { + projectDir, uvTomlPath := setupUvRegistryFixture(t) require.NoError(t, os.WriteFile(filepath.Join(projectDir, "pyproject.toml"), []byte(`[[tool.uv.index]] name = "artifactory-repo" +url = "https://untrusted-host/artifactory/api/pypi/uv-test-repo/simple" +`), 0644)) + require.NoError(t, os.WriteFile(uvTomlPath, []byte(`[[index]] +name = "uv-test-repo" url = "https://host/artifactory/api/pypi/uv-test-repo/simple" +default = true `), 0644)) cfg, err := GetNativeUvRegistryConfig() require.NoError(t, err) - assert.Equal(t, "uv-test-repo", cfg.RepoName) + assert.Equal(t, "https://host/artifactory", cfg.ArtifactoryUrl, "uv.toml must win — pyproject.toml is never consulted when uv.toml exists") }) - t.Run( - "XRAY-146949: ambiguous pyproject.toml (multiple entries) is ignored — falls back to uv.toml, "+ - "even when one of the project's own entries would otherwise be picked", - func(t *testing.T) { - projectDir, uvTomlPath := setupUvRegistryFixture(t) - require.NoError(t, os.WriteFile(filepath.Join(projectDir, "pyproject.toml"), []byte(`[[tool.uv.index]] + t.Run("XRAY-146949: uv.toml present — wins outright over an ambiguous (multi-entry) pyproject.toml too", func(t *testing.T) { + projectDir, uvTomlPath := setupUvRegistryFixture(t) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "pyproject.toml"), []byte(`[[tool.uv.index]] name = "public-pypi" url = "https://pypi.org/simple" @@ -321,19 +326,18 @@ url = "https://pypi.org/simple" name = "maybe-wrong-host" url = "https://untrusted-host/artifactory/api/pypi/uv-test-repo/simple" `), 0644)) - require.NoError(t, os.WriteFile(uvTomlPath, []byte(`[[index]] + require.NoError(t, os.WriteFile(uvTomlPath, []byte(`[[index]] name = "uv-test-repo" url = "https://host/artifactory/api/pypi/uv-test-repo/simple" default = true `), 0644)) - cfg, err := GetNativeUvRegistryConfig() - require.NoError(t, err) - assert.Equal(t, "https://host/artifactory", cfg.ArtifactoryUrl) - }, - ) + cfg, err := GetNativeUvRegistryConfig() + require.NoError(t, err) + assert.Equal(t, "https://host/artifactory", cfg.ArtifactoryUrl) + }) - t.Run("no pyproject.toml entries falls back to uv.toml", func(t *testing.T) { + t.Run("uv.toml present, no pyproject.toml entries — uv.toml still used", func(t *testing.T) { projectDir, uvTomlPath := setupUvRegistryFixture(t) require.NoError(t, os.WriteFile(filepath.Join(projectDir, "pyproject.toml"), []byte(`[project] name = "my-app" @@ -349,7 +353,48 @@ default = true assert.Equal(t, "uv-test-repo", cfg.RepoName) }) - t.Run("ambiguous pyproject.toml and no usable uv.toml — error, not a guess", func(t *testing.T) { + t.Run("uv.toml present but has no usable Artifactory entry — errors, does not fall back to pyproject.toml", func(t *testing.T) { + projectDir, uvTomlPath := setupUvRegistryFixture(t) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "pyproject.toml"), []byte(`[[tool.uv.index]] +name = "artifactory-repo" +url = "https://host/artifactory/api/pypi/uv-test-repo/simple" +`), 0644)) + require.NoError(t, os.WriteFile(uvTomlPath, []byte(`[[index]] +name = "public-pypi" +url = "https://pypi.org/simple" +`), 0644)) + + _, err := GetNativeUvRegistryConfig() + require.Error(t, err, "a present but unusable uv.toml must not silently fall back to pyproject.toml") + }) + + t.Run("no uv.toml, single unambiguous pyproject.toml entry is used", func(t *testing.T) { + projectDir, _ := setupUvRegistryFixture(t) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "pyproject.toml"), []byte(`[[tool.uv.index]] +name = "artifactory-repo" +url = "https://host/artifactory/api/pypi/uv-test-repo/simple" +`), 0644)) + // No uv.toml written at all. + + cfg, err := GetNativeUvRegistryConfig() + require.NoError(t, err) + assert.Equal(t, "uv-test-repo", cfg.RepoName) + }) + + t.Run("no uv.toml, single pyproject.toml entry with a non-Artifactory URL — no config url found", func(t *testing.T) { + projectDir, _ := setupUvRegistryFixture(t) + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "pyproject.toml"), []byte(`[[tool.uv.index]] +name = "public-pypi" +url = "https://pypi.org/simple" +`), 0644)) + // No uv.toml written at all. + + _, err := GetNativeUvRegistryConfig() + require.Error(t, err) + assert.Contains(t, err.Error(), "no config url found") + }) + + t.Run("no uv.toml, ambiguous pyproject.toml — error, not a guess", func(t *testing.T) { projectDir, _ := setupUvRegistryFixture(t) require.NoError(t, os.WriteFile(filepath.Join(projectDir, "pyproject.toml"), []byte(`[[tool.uv.index]] name = "public-pypi" @@ -1136,8 +1181,8 @@ func TestBuildUvCurationIndexUrlReferenceTokenNoUsername(t *testing.T) { // keep the credentials embedded in UV_DEFAULT_INDEX out of debug logs and error messages. func TestMaskPassword(t *testing.T) { t.Run("password present — masked wherever it appears in s", func(t *testing.T) { - rawIndexUrl := "https://user:s3cr3t-token@host/artifactory/api/curation/audit/api/pypi/repo/simple" - out := "fetching https://user:s3cr3t-token@host/artifactory/... : 403 Forbidden (token=s3cr3t-token)" + rawIndexUrl := "https://user:s3cr3t-token@host/artifactory/api/curation/audit/api/pypi/repo/simple" // #nosec G101 -- dummy test credentials only + out := "fetching https://user:s3cr3t-token@host/artifactory/... : 403 Forbidden (token=s3cr3t-token)" // #nosec G101 -- dummy test credentials only masked := maskPassword(out, rawIndexUrl) @@ -1156,12 +1201,29 @@ func TestMaskPassword(t *testing.T) { assert.Equal(t, out, maskPassword(out, "")) }) - t.Run("unparseable rawIndexUrl — s returned unchanged", func(t *testing.T) { + t.Run("unparsable rawIndexUrl — s returned unchanged", func(t *testing.T) { out := "some uv output" assert.Equal(t, out, maskPassword(out, "://not a url")) }) } +// TestMaskPasswordMasksPercentEncodedPassword: maskPassword must also redact the +// percent-encoded form of the password, not just the decoded one. +func TestMaskPasswordMasksPercentEncodedPassword(t *testing.T) { + u := &url.URL{ + Scheme: "https", + Host: "host.example", + Path: "/artifactory/api/pypi/repo/simple", + User: url.UserPassword("user", "p@ss/word"), + } + encodedSecret := "p%40ss%2Fword" // #nosec G101 -- dummy test credentials only + out := "uv failed while fetching " + u.String() + + masked := maskPassword(out, u.String()) + + assert.NotContains(t, masked, encodedSecret) +} + func TestBuildUvDownloadUrlsMapSkipsNonArtifactoryUrls(t *testing.T) { lockContent := `version = 1 @@ -1182,10 +1244,89 @@ source = { editable = "." } DependenciesRepository: "repo", } + var buf bytes.Buffer + origLogger := log.Logger + log.SetLogger(log.NewLogger(log.INFO, &buf)) + defer log.SetLogger(origLogger) + urls := buildUvDownloadUrlsMap(params, packages) _, ok := urls["pypi://requests:2.19.1"] assert.False(t, ok, "public PyPI URL must not be included in the curation HEAD-probe map") + assert.Contains(t, buf.String(), "will not be HEAD-checked", + "a package resolving to a non-Artifactory URL is a real coverage gap and must warn") +} + +// TestBuildUvDownloadUrlsMapNoWarningOnOrdinarySkips is a regression test: root packages +// and packages with no download URL at all were never HEAD-check candidates, so they must +// not trigger the "will not be HEAD-checked" warning — only a package that actually +// resolved to a non-Artifactory URL should. +func TestBuildUvDownloadUrlsMapNoWarningOnOrdinarySkips(t *testing.T) { + packages := parseUvLock(sampleUvLock) + params := technologies.BuildInfoBomGeneratorParams{ + ServerDetails: &config.ServerDetails{ArtifactoryUrl: "https://host/artifactory/"}, + DependenciesRepository: "repo", + } + + var buf bytes.Buffer + origLogger := log.Logger + log.SetLogger(log.NewLogger(log.INFO, &buf)) + defer log.SetLogger(origLogger) + + buildUvDownloadUrlsMap(params, packages) + + assert.NotContains(t, buf.String(), "will not be HEAD-checked", + "the root package being skipped is expected, not a coverage gap") +} + +// TestAllPackagesUseRegistryDetectsDisallowedRegistryContainingVirtualSubstring is a +// regression test: source-kind checks used to match "virtual"/"editable" as bare +// substrings anywhere in the line, so a disallowed registry URL that happens to contain +// one of those words (e.g. a mirror named ".../virtual-pypi/...") was misrouted into the +// "curation doesn't apply" branch and never checked against the allowed registry set. +func TestAllPackagesUseRegistryDetectsDisallowedRegistryContainingVirtualSubstring(t *testing.T) { + content := `[[package]] +name = "requests" +version = "2.19.1" +source = { registry = "https://attacker.example.com/virtual-pypi/simple" } +` + assert.False(t, allPackagesUseRegistry(content, "https://host/artifactory/api/pypi/repo/simple"), + "a disallowed registry URL containing 'virtual' as a substring must still be checked against the allow-set, not skipped") +} + +// TestAllPackagesUseRegistryStillRecognizesLegitimateNonRegistrySources confirms the +// reordering/anchoring fix above didn't break the actual virtual/editable/path/git/directory +// sources it's meant to recognize. +func TestAllPackagesUseRegistryStillRecognizesLegitimateNonRegistrySources(t *testing.T) { + for _, tt := range []struct { + name string + source string + }{ + {"workspace root", `source = { virtual = "." }`}, + {"editable install", `source = { editable = "." }`}, + {"local path dependency", `source = { path = "../local-pkg" }`}, + {"git dependency", `source = { git = "https://github.com/example/repo" }`}, + {"directory dependency", `source = { directory = "./subdir" }`}, + } { + t.Run(tt.name, func(t *testing.T) { + content := "[[package]]\nname = \"pkg\"\nversion = \"0.1.0\"\n" + tt.source + "\n" + assert.True(t, allPackagesUseRegistry(content, "https://host/artifactory/api/pypi/repo/simple"), + "a %s source doesn't go through a package index, so curation shouldn't block it", tt.name) + }) + } +} + +// TestAllPackagesUseRegistryAllowsMatchingRegistry and rejects a non-matching one, covering +// the ordinary registry-comparison path. +func TestAllPackagesUseRegistryAllowsMatchingRegistry(t *testing.T) { + allowedBase := "https://host/artifactory/api/pypi/repo/simple" + content := `[[package]] +name = "requests" +version = "2.19.1" +source = { registry = "` + allowedBase + `" } +` + assert.True(t, allPackagesUseRegistry(content, allowedBase)) + assert.False(t, allPackagesUseRegistry(content, "https://other.example.com/api/pypi/repo/simple")) } func TestBuildDependencyTreeRejectsAuditMode(t *testing.T) { @@ -1558,3 +1699,22 @@ func TestBuildDependencyTreeForScript_ResolvesThroughGateway(t *testing.T) { assert.Equal(t, artifactoryUrl+"/api/pypi/"+repoName+"/packages/six-1.16.0-py3-none-any.whl", downloadUrls[sixId], "the curation pass-through prefix must be stripped from the download URL") } + +// TestScriptAuditFailsClosedWhenCurationIndexCannotBeBuilt: if the curation index URL +// can't be built, the audit must fail instead of falling back to an unprotected index. +func TestScriptAuditFailsClosedWhenCurationIndexCannotBeBuilt(t *testing.T) { + fakeBinDir := t.TempDir() + writeFakeUvExecutableForScriptLock(t, fakeBinDir, "https://ambient.example", "repo") + + scriptPath := filepath.Join(t.TempDir(), "demo.py") + require.NoError(t, os.WriteFile(scriptPath, []byte(pep723ScriptContent), 0o644)) + + params := technologies.BuildInfoBomGeneratorParams{ + ScriptPath: scriptPath, + ServerDetails: &config.ServerDetails{ArtifactoryUrl: ":// malformed"}, + DependenciesRepository: "repo", + } + + _, _, _, err := buildDependencyTreeForScript(params) + require.Error(t, err, "curation audit must not run uv against an ambient index") +} diff --git a/utils/techutils/techutils.go b/utils/techutils/techutils.go index ecc7fdb57..a5445e66d 100644 --- a/utils/techutils/techutils.go +++ b/utils/techutils/techutils.go @@ -516,7 +516,7 @@ func hasUnauditedPep723Script(dir string) (bool, error) { var found bool walkErr := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { if err != nil || path == dir { - return nil + return nil //nolint:nilerr // a single unreadable entry shouldn't abort the whole yes/no scan } if d.IsDir() { if utils.IsPathExcluded(path, utils.DefaultScaExcludePatterns) { @@ -527,9 +527,9 @@ func hasUnauditedPep723Script(dir string) (bool, error) { if !strings.HasSuffix(d.Name(), ".py") || utils.IsPathExcluded(path, utils.DefaultScaExcludePatterns) { return nil } - data, readErr := os.ReadFile(path) + data, readErr := os.ReadFile(path) // #nosec G122 -- path comes from WalkDir over dir, a local project directory the CLI was already pointed at if readErr != nil { - return nil + return nil //nolint:nilerr // a single unreadable file shouldn't abort the whole yes/no scan } if HasPep723ScriptMetadata(string(data)) { found = true From 516ab85d54666eca36723327c37573d5c43b33d7 Mon Sep 17 00:00:00 2001 From: Phavya Jayakumar Date: Tue, 28 Jul 2026 11:15:33 +0530 Subject: [PATCH 3/4] Checks failure fixed --- .github/actions/install-and-setup/action.yml | 4 ++++ curation_test.go | 3 ++- sca/bom/buildinfo/technologies/uv/uv_test.go | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/actions/install-and-setup/action.yml b/.github/actions/install-and-setup/action.yml index ff7677963..baa9b27c1 100644 --- a/.github/actions/install-and-setup/action.yml +++ b/.github/actions/install-and-setup/action.yml @@ -83,6 +83,10 @@ runs: - name: Setup Poetry run: python -m pip install poetry shell: ${{ runner.os == 'Windows' && 'powershell' || 'bash' }} + - name: Setup uv + uses: astral-sh/setup-uv@v9.0.0 + with: + version: "0.11.32" - name: Setup Conan run: | python -m pip install conan diff --git a/curation_test.go b/curation_test.go index c16983ff6..8ed1a2809 100644 --- a/curation_test.go +++ b/curation_test.go @@ -427,7 +427,8 @@ func TestUvCurationAudit(t *testing.T) { config.User = "admin" config.Password = "password" config.ServerId = "test" - configCmd := commonCommands.NewConfigCommand(commonCommands.AddOrEdit, config.ServerId).SetDetails(config).SetUseBasicAuthOnly(true).SetInteractive(false) + config.XrayUrl = config.Url + configCmd := commonCommands.NewConfigCommand(commonCommands.AddOrEdit, config.ServerId).SetDetails(config).SetUseBasicAuthOnly(true).SetInteractive(false).SetMakeDefault(true) assert.NoError(t, configCmd.Run()) appendToFile(t, filepath.Join(tempDirPath, "pyproject.toml"), diff --git a/sca/bom/buildinfo/technologies/uv/uv_test.go b/sca/bom/buildinfo/technologies/uv/uv_test.go index 7d1ba7185..9ba48d750 100644 --- a/sca/bom/buildinfo/technologies/uv/uv_test.go +++ b/sca/bom/buildinfo/technologies/uv/uv_test.go @@ -1410,7 +1410,7 @@ exit 1 script = `@echo off echo CALL:%* >> "%FAKE_UV_LOG%" if "%~1"=="--version" ( - echo uv 0.11.21 (fake) + echo uv 0.11.21 ^(fake^) exit /b 0 ) if "%~1"=="lock" if "%~2"=="--check" ( From 7d03464ec3a9637ba1b8cc5e4e1bf2a4bad892ac Mon Sep 17 00:00:00 2001 From: Phavya Jayakumar Date: Tue, 28 Jul 2026 14:07:31 +0530 Subject: [PATCH 4/4] build-info-go updated --- go.mod | 2 +- go.sum | 4 ++-- sca/bom/buildinfo/technologies/uv/uv_test.go | 3 +++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 9942dac4e..e6212a222 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/gookit/color v1.6.1 github.com/hashicorp/go-hclog v1.6.3 github.com/hashicorp/go-plugin v1.6.3 - github.com/jfrog/build-info-go v1.13.1-0.20260713073853-4f3044bf0940 + github.com/jfrog/build-info-go v1.13.1-0.20260728083052-16a97012811d github.com/jfrog/froggit-go v1.23.1 github.com/jfrog/gofrog v1.7.6 github.com/jfrog/jfrog-apps-config v1.0.1 diff --git a/go.sum b/go.sum index d59ab376e..c7a78be46 100644 --- a/go.sum +++ b/go.sum @@ -161,8 +161,8 @@ github.com/jedib0t/go-pretty/v6 v6.7.10 h1:B/2qW2Bkv2L6n14PP8o1kx75kWzHOQ3YTluWz github.com/jedib0t/go-pretty/v6 v6.7.10/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= github.com/jfrog/archiver/v3 v3.6.3 h1:hkAmPjBw393tPmQ07JknLNWFNZjXdy2xFEnOW9wwOxI= github.com/jfrog/archiver/v3 v3.6.3/go.mod h1:5V9l+Fte30Y4qe9dUOAd3yNTf8lmtVNuhKNrvI8PMhg= -github.com/jfrog/build-info-go v1.13.1-0.20260713073853-4f3044bf0940 h1:LthrPDN8YjDKrDNzOWS/w9NLFyeebdnWkmp8vhma0qo= -github.com/jfrog/build-info-go v1.13.1-0.20260713073853-4f3044bf0940/go.mod h1:CYRUCvLKfyARjoJXLWAxce1qNUxTEtbRKAARkV42vpE= +github.com/jfrog/build-info-go v1.13.1-0.20260728083052-16a97012811d h1:Yqbx+/9cIiZJmpyIkWDyC9Qjh4OaVeGWutADFaUzHr0= +github.com/jfrog/build-info-go v1.13.1-0.20260728083052-16a97012811d/go.mod h1:CYRUCvLKfyARjoJXLWAxce1qNUxTEtbRKAARkV42vpE= github.com/jfrog/froggit-go v1.23.1 h1:4wmaHeuptxVINbovMaeITzVhi3+VQoc/FFIjF4axzu0= github.com/jfrog/froggit-go v1.23.1/go.mod h1:wRDryqyp3oe+eHgME2mpnEQmO8XBECIPagFwj0nHmdI= github.com/jfrog/gofrog v1.7.6 h1:QmfAiRzVyaI7JYGsB7cxfAJePAZTzFz0gRWZSE27c6s= diff --git a/sca/bom/buildinfo/technologies/uv/uv_test.go b/sca/bom/buildinfo/technologies/uv/uv_test.go index 9ba48d750..115a1af97 100644 --- a/sca/bom/buildinfo/technologies/uv/uv_test.go +++ b/sca/bom/buildinfo/technologies/uv/uv_test.go @@ -294,6 +294,9 @@ func setupUvRegistryFixture(t *testing.T) (projectDir, uvTomlPath string) { require.NoError(t, os.MkdirAll(filepath.Join(homeDir, filepath.Dir(uvTomlConfigRelPath)), 0755)) uvTomlPath = filepath.Join(homeDir, uvTomlConfigRelPath) t.Setenv("HOME", homeDir) + if runtime.GOOS == "windows" { + t.Setenv("USERPROFILE", homeDir) + } t.Chdir(projectDir) return projectDir, uvTomlPath }