diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index 0d9bb4101..9cde64a5f 100644 --- a/commands/curation/curationaudit.go +++ b/commands/curation/curationaudit.go @@ -52,6 +52,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 +129,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) { @@ -535,10 +539,10 @@ func (ca *CurationAuditCommand) doCurateAudit(results map[string]*CurationReport log.Debug(fmt.Sprintf("Docker image name '%s' was provided, running Docker curation audit.", ca.DockerImageName())) techs = []string{techutils.Docker.String()} } - // 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. + // Resolve npm→yarn and pip→uv when the file-based detector missed the real tech. for i, tech := range techs { techs[i] = resolveNpmYarnTech(tech) + techs[i] = resolveUvTech(techs[i]) } for _, tech := range techs { supportedFunc, ok := supportedTech[techutils.Technology(tech)] @@ -614,6 +618,46 @@ func resolveNpmYarnTech(tech string) string { return tech } +// resolveUvTech upgrades pip→uv when no pip-exclusive files are present and any of: +// 1. uv.lock exists in the working directory (definitive uv signal), or +// 2. pyproject.toml contains [[tool.uv.index]] or [tool.uv], or +// 3. ~/.config/uv/uv.toml exists (user-level, written by Artifactory "Set Me Up"). +func resolveUvTech(tech string) string { + if techutils.Technology(tech) != techutils.Pip { + return tech + } + workingDir, wdErr := coreutils.GetWorkingDirectory() + if wdErr != nil { + return tech + } + for _, pipOnlyFile := range []string{"requirements.txt", "setup.py", "setup.cfg", "Pipfile", "poetry.lock"} { + if _, err := os.Stat(filepath.Join(workingDir, pipOnlyFile)); err == nil { + return tech + } + } + // uv.lock is only created by uv — its presence is a definitive signal. + if _, err := os.Stat(filepath.Join(workingDir, "uv.lock")); err == nil { + log.Info("uv.lock detected — treating project as uv.") + return techutils.Uv.String() + } + // Check pyproject.toml for uv-specific sections. + if data, err := os.ReadFile(filepath.Join(workingDir, "pyproject.toml")); err == nil { + content := string(data) + if strings.Contains(content, "[[tool.uv.index]]") || strings.Contains(content, "[tool.uv]") { + log.Info("pyproject.toml has uv configuration ([tool.uv] or [[tool.uv.index]]) — treating project as uv.") + return techutils.Uv.String() + } + } + // Check user-level ~/.config/uv/uv.toml — written by Artifactory "Set Me Up". + if homeDir, err := os.UserHomeDir(); err == nil { + if _, err := os.Stat(filepath.Join(homeDir, ".config", "uv", "uv.toml")); err == nil { + log.Info("~/.config/uv/uv.toml detected — treating project as uv.") + return techutils.Uv.String() + } + } + return tech +} + // projectPinsYarnPackageManager reports whether package.json pins yarn via the // Corepack "packageManager" field (e.g. "yarn@4.1.0"). func projectPinsYarnPackageManager(workingDir string) bool { @@ -718,6 +762,13 @@ func (ca *CurationAuditCommand) auditTree(tech techutils.Technology, results map if err := validateRunNativeForTech(tech, ca.RunNative()); err != nil { return err } + // Must run before getBuildInfoParamsByTech so params carry the correct repo and server details. + // SetRepo also routes Uv here, but by then GetAuth has already skipped it. + if tech == techutils.Uv { + 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 +778,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 { @@ -746,12 +807,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 @@ -1056,6 +1117,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 @@ -1258,6 +1324,32 @@ 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 || base == nil { + return fmt.Errorf("uv: no 'jf c' server configured: %w", sdErr) + } + 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 +1504,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 @@ -1801,7 +1893,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..6a81b6368 100644 --- a/commands/curation/curationaudit_test.go +++ b/commands/curation/curationaudit_test.go @@ -2778,6 +2778,200 @@ 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) +} + +// TestResolveUvTech verifies that pip is promoted to uv when the right config signals are present. +func TestResolveUvTech(t *testing.T) { + pip := techutils.Pip.String() + uv := techutils.Uv.String() + maven := "maven" + + tests := []struct { + name string + tech 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 + want string + }{ + { + name: "non-pip tech is untouched", + tech: maven, + want: maven, + }, + { + name: "pip with requirements.txt → stays pip", + tech: pip, + hasPipFile: "requirements.txt", + want: pip, + }, + { + name: "pip with setup.py → stays pip", + tech: pip, + hasPipFile: "setup.py", + want: pip, + }, + { + name: "pip + uv.lock → uv", + tech: pip, + hasUvLock: true, + want: uv, + }, + { + name: "pip-exclusive file takes priority over uv.lock → stays pip", + tech: pip, + hasPipFile: "requirements.txt", + hasUvLock: true, + want: pip, + }, + { + name: "pip + pyproject.toml with [tool.uv] → uv", + tech: pip, + pyprojectTOML: "[tool.uv]\npython = \"3.12\"\n", + want: uv, + }, + { + name: "pip + pyproject.toml with [[tool.uv.index]] → uv", + tech: pip, + pyprojectTOML: "[[tool.uv.index]]\nurl = \"https://example.jfrog.io/api/pypi/pypi-virtual/simple\"\n", + want: uv, + }, + { + name: "pip-exclusive file takes priority over [tool.uv] in pyproject.toml → stays pip", + tech: pip, + hasPipFile: "requirements.txt", + pyprojectTOML: "[tool.uv]\npython = \"3.12\"\n", + want: pip, + }, + { + name: "pip + ~/.config/uv/uv.toml → uv", + tech: pip, + hasUvToml: true, + want: uv, + }, + { + name: "pip-exclusive file takes priority over ~/.config/uv/uv.toml → stays pip", + tech: pip, + hasPipFile: "Pipfile", + hasUvToml: true, + want: pip, + }, + { + name: "plain pip project with bare pyproject.toml → stays pip", + tech: pip, + pyprojectTOML: "[build-system]\nrequires = [\"setuptools\"]\n", + want: pip, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + projectDir := t.TempDir() + fakeHome := t.TempDir() + + // Isolate $HOME so ~/.config/uv/uv.toml can be fully controlled. + t.Setenv("HOME", fakeHome) + t.Setenv("USERPROFILE", fakeHome) + + // Create pip-exclusive file if needed. + if tc.hasPipFile != "" { + require.NoError(t, os.WriteFile(filepath.Join(projectDir, tc.hasPipFile), []byte{}, 0o644)) + } + + // Create uv.lock if needed. + if tc.hasUvLock { + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "uv.lock"), []byte{}, 0o644)) + } + + // Create pyproject.toml if needed. + if tc.pyprojectTOML != "" { + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "pyproject.toml"), []byte(tc.pyprojectTOML), 0o644)) + } + + // Create ~/.config/uv/uv.toml if needed. + 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)) + } + + restoreCwd := changeDirForTest(t, projectDir) + defer restoreCwd() + + got := resolveUvTech(tc.tech) + assert.Equal(t, tc.want, got) + }) + } +} + func TestPromoteYarnWorkspaceMember(t *testing.T) { npm := techutils.Npm.String() yarn := techutils.Yarn.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/python/python_cvs_fallback.go b/sca/bom/buildinfo/technologies/python/python_cvs_fallback.go index a7a04abfc..a970f46a1 100644 --- a/sca/bom/buildinfo/technologies/python/python_cvs_fallback.go +++ b/sca/bom/buildinfo/technologies/python/python_cvs_fallback.go @@ -69,7 +69,13 @@ var pipDirectFromRequirementsRegex = regexp.MustCompile( 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. +// 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._+\-]*)`) + +// 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{} @@ -165,6 +171,22 @@ func parseCvsFailedPackages(pipOutput string) []PinnedRequirement { } } + // 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 := 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, + }) + } + } + return failed } @@ -199,7 +221,21 @@ 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") +} + +// WrapUvCurationErr examines the combined output from a failed `uv lock` run +// (against Artifactory) for CVS-stripped versions and wraps it as a +// *CvsBlockedError when found. Returns cause unchanged for any other failure. +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..9e230a432 100644 --- a/sca/bom/buildinfo/technologies/python/python_cvs_fallback_test.go +++ b/sca/bom/buildinfo/technologies/python/python_cvs_fallback_test.go @@ -105,6 +105,20 @@ func TestParseCvsFailedPackages(t *testing.T) { output: "Because sample-poetry-project depends on bar (>=1.0,<2.0) which doesn't match any versions, version solving failed.", want: nil, }, + { + 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"}}, + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -120,7 +134,8 @@ func TestIsCvsVersionFilteredOutput(t *testing.T) { cases := map[string]bool{ "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, + "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..06dc7bb0e --- /dev/null +++ b/sca/bom/buildinfo/technologies/uv/uv.go @@ -0,0 +1,592 @@ +package uv + +import ( + "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-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 ( + depNameRegex = regexp.MustCompile(`\bname\s*=\s*"([^"]+)"`) + urlInlineRegex = regexp.MustCompile(`\burl\s*=\s*"([^"]+)"`) +) + +// 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 + Dependencies []string + 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 { + if indexUrl := parsePyprojectUvIndexUrl(string(data)); indexUrl != "" { + artiUrl, repoName, err := parseArtifactoryPypiUrl(indexUrl) + if err == nil { + log.Info(fmt.Sprintf("uv: using Artifactory URL %q and repository %q from pyproject.toml", artiUrl, repoName)) + return &UvRegistryConfig{ArtifactoryUrl: artiUrl, RepoName: repoName}, nil + } + } + } + } + + // 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) + } + indexUrl := parseUvTomlIndexUrl(string(content)) + if indexUrl == "" { + return nil, errorutils.CheckErrorf( + "uv: no 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, + ) + } + artiUrl, repoName, err := parseArtifactoryPypiUrl(indexUrl) + if err != nil { + return nil, fmt.Errorf("uv: failed to parse Artifactory URL from %q: %w", indexUrl, err) + } + log.Info(fmt.Sprintf("uv: using Artifactory URL %q and repository %q from uv.toml", artiUrl, repoName)) + return &UvRegistryConfig{ArtifactoryUrl: artiUrl, RepoName: repoName}, nil +} + +// parsePyprojectUvIndexUrl returns the url from the first [[tool.uv.index]] section in pyproject.toml. +func parsePyprojectUvIndexUrl(content string) string { + return parseIndexSectionUrl(content, "[[tool.uv.index]]") +} + +// parseUvTomlIndexUrl returns the url value from the first [[index]] section in uv.toml. +func parseUvTomlIndexUrl(content string) string { + return parseIndexSectionUrl(content, "[[index]]") +} + +// parseIndexSectionUrl extracts the url value from the first occurrence of sectionHeader in content. +func parseIndexSectionUrl(content, sectionHeader string) string { + inSection := false + for _, raw := range strings.Split(content, "\n") { + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if line == sectionHeader { + inSection = true + continue + } + if inSection && strings.HasPrefix(line, "[") { + inSection = false + } + if inSection && strings.HasPrefix(line, "url") { + if _, val, ok := strings.Cut(line, "="); ok { + val = strings.TrimSpace(val) + val = strings.Trim(val, `"'`) + if val != "" { + return val + } + } + } + } + return "" +} + +// 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, always generates a fresh uv.lock against Artifactory, +// parses it, and returns the dependency tree and download URL map. +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 + } + + artiIndexUrl := "" + if params.ServerDetails != nil && 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(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 always runs `uv lock` in a temp dir against Artifactory +// to guarantee all URLs in the generated lock point to Artifactory. +func generateUvLockForCuration(artiIndexUrl string) (string, error) { + wd, err := os.Getwd() + if err != nil { + return "", errorutils.CheckError(err) + } + log.Info("uv: running 'uv lock' in a temporary directory to generate a fresh lock against Artifactory") + lockContent, genErr := generateUvLockInTempDir(wd, artiIndexUrl) + if genErr != nil { + return "", fmt.Errorf("uv: lock generation failed: %w", genErr) + } + return lockContent, nil +} + +// generateUvLockInTempDir runs `uv lock` in a temp copy of the project and returns the lock content. +func generateUvLockInTempDir(projectDir, 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) + } + + if err = generateUvLock(tempDir, artiIndexUrl); err != nil { + return "", err + } + + content, err := os.ReadFile(filepath.Join(tempDir, uvLockFile)) + if err != nil { + return "", errorutils.CheckErrorf("uv: could not read generated lock file: %s", err) + } + return string(content), nil +} + +// generateUvLock runs `uv lock` in workDir for curation-audit. +// 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 string) error { + technologies.LogExecutableVersion("uv") + + if artiIndexUrl != "" { + cmd := exec.Command("uv", "lock") + cmd.Dir = workDir + env := envWithoutKey(os.Environ(), "UV_DEFAULT_INDEX") + env = append(env, "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) + if wrapped := python.WrapUvCurationErr(outStr, cause); wrapped != cause { + return wrapped + } + return cause + } + + cmd := exec.Command("uv", "lock") + cmd.Dir = workDir + 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 +} + +// 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 +} + +// verifyUvVersionSupportedForCuration returns an error if uv is below CurationUvMinimumVersion. +func verifyUvVersionSupportedForCuration() error { + out, err := exec.Command("uv", "--version").CombinedOutput() + if err != nil { + return fmt.Errorf("uv: could not determine uv version: %w", err) + } + 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 + } + uvVer := version.NewVersion(versionStr) + if uvVer.Compare(CurationUvMinimumVersion) > 0 { + return fmt.Errorf("'jf curation-audit' requires uv >= %s (detected: %s). Upgrade uv and re-run 'jf ca'.", 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 username != "" && 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 bool + + flush := func() { + if current != nil { + packages = append(packages, *current) + current = nil + } + inDepsArray = false + inWheelsArray = 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 + } + if strings.HasPrefix(line, "[") { + flush() + continue + } + + if current == nil { + continue + } + + if inDepsArray { + if line == "]" { + inDepsArray = false + continue + } + for _, m := range depNameRegex.FindAllStringSubmatch(line, -1) { + current.Dependencies = append(current.Dependencies, m[1]) + } + 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 + } + if strings.HasPrefix(line, "source") && strings.Contains(line, "editable") { + 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, "]") { + for _, m := range depNameRegex.FindAllStringSubmatch(line, -1) { + current.Dependencies = append(current.Dependencies, m[1]) + } + } else { + inDepsArray = true + } + continue + } + } + flush() + return packages +} + +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 +} + +// 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] = &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, 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 _, depName := range rootPkg.Dependencies { + dep, ok := byName[python.NormalizePypiName(depName)] + if !ok { + continue + } + 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, byName, uniqueDeps) + } + + return []*clientutils.GraphNode{rootNode}, uniqueDeps.ToSlice() +} + +func appendUvChildren(node *clientutils.GraphNode, byName map[string]*uvPackage, uniqueDeps *datastructures.Set[string]) { + if node.NodeHasLoop() { + return + } + idNoPrefix := strings.TrimPrefix(node.Id, python.PythonPackageTypeIdentifier) + rawName, _, _ := strings.Cut(idNoPrefix, ":") + pkg, ok := byName[rawName] // rawName is already normalised + if !ok { + return + } + for _, depName := range pkg.Dependencies { + dep, ok := byName[python.NormalizePypiName(depName)] + if !ok { + continue + } + 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, byName, uniqueDeps) + } +} + +// buildUvDownloadUrlsMap returns a package-id → download URL map from uv.lock. +// Strips the curation pass-through prefix 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..ebdeacac3 --- /dev/null +++ b/sca/bom/buildinfo/technologies/uv/uv_test.go @@ -0,0 +1,526 @@ +package uv + +import ( + "testing" + + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies" + "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 TestParseUvTomlIndexUrl(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: "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: "https://host/artifactory/api/pypi/my-repo/simple", + }, + { + name: "picks first [[index]] only", + content: `[[index]] +url = "https://host/artifactory/api/pypi/first-repo/simple" + +[[index]] +url = "https://host/artifactory/api/pypi/second-repo/simple"`, + want: "https://host/artifactory/api/pypi/first-repo/simple", + }, + { + name: "no [[index]] section", + content: `[tool]\nsome = "value"`, + want: "", + }, + { + name: "empty content", + content: "", + want: "", + }, + { + name: "comments are ignored", + content: `# this is a comment +[[index]] +# another comment +url = "https://host/artifactory/api/pypi/my-repo/simple"`, + want: "https://host/artifactory/api/pypi/my-repo/simple", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, parseUvTomlIndexUrl(tc.content)) + }) + } +} + +func TestParsePyprojectUvIndexUrl(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: "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: "", + }, + { + name: "empty content", + content: "", + want: "", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, parsePyprojectUvIndexUrl(tc.content)) + }) + } +} + +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, root.Dependencies, "requests") + assert.Contains(t, 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, pkg.Dependencies, "urllib3") + assert.Contains(t, 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) { + // When no editable root exists, all packages go under a synthetic root. + lockContent := `version = 1 + +[[package]] +name = "requests" +version = "2.28.0" +source = { registry = "https://pypi.org/simple" } +wheels = [{ url = "https://host/packages/requests-2.28.0-py3-none-any.whl", hash = "sha256:x" }] +` + packages := parseUvLock(lockContent) + depTree, uniqueDeps := buildUvDepTree(packages) + + require.Len(t, depTree, 1) + assert.Equal(t, "root", depTree[0].Id) + assert.Contains(t, uniqueDeps, "pypi://requests:2.28.0") +} + +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 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") +} diff --git a/utils/techutils/techutils.go b/utils/techutils/techutils.go index 66a553557..1798f813d 100644 --- a/utils/techutils/techutils.go +++ b/utils/techutils/techutils.go @@ -52,6 +52,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 +84,7 @@ var AllTechnologiesStrings = []string{ Pip.String(), Pipenv.String(), Poetry.String(), + Uv.String(), Nuget.String(), Dotnet.String(), Docker.String(), @@ -217,7 +219,7 @@ var technologiesData = map[Technology]TechData{ indicators: []string{"pyproject.toml", "setup.py", "requirements.txt"}, validators: map[string]ContentValidator{"pyproject.toml": pyProjectTomlIndicatorContent(Pip)}, packageDescriptors: []string{"setup.py", "requirements.txt", "pyproject.toml"}, - exclude: []string{"Pipfile", "Pipfile.lock", "poetry.lock"}, + exclude: []string{"Pipfile", "Pipfile.lock", "poetry.lock", "uv.lock"}, projectType: project.Pip, language: Python, }, @@ -244,6 +246,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"}, @@ -455,11 +467,50 @@ func DetectedTechnologiesListForCurationAudit() (technologies []string) { return } promoteYarnWorkspaceMembers(detected) + promotePipToUv(detected, wd) techStringsList := DetectedTechnologiesToSlice(detected) log.Info(fmt.Sprintf("Detected: %s.", strings.Join(techStringsList, ", "))) return techStringsList } +// promotePipToUv promotes pip→uv when the project has uv signals +// (pyproject.toml with [tool.uv] or ~/.config/uv/uv.toml) and no pip-exclusive files. +func promotePipToUv(detected map[Technology]map[string][]string, wd string) { + pipDirs, hasPip := detected[Pip] + if !hasPip { + return + } + for _, pipOnlyFile := range []string{"requirements.txt", "setup.py", "setup.cfg", "Pipfile", "poetry.lock"} { + if _, err := os.Stat(filepath.Join(wd, pipOnlyFile)); err == nil { + return + } + } + // pyproject.toml with uv-specific sections. + if data, err := os.ReadFile(filepath.Join(wd, "pyproject.toml")); err == nil { + content := string(data) + if strings.Contains(content, "[[tool.uv.index]]") || strings.Contains(content, "[tool.uv]") { + delete(detected, Pip) + if _, exists := detected[Uv]; !exists { + detected[Uv] = map[string][]string{} + } + detected[Uv][wd] = pipDirs[wd] + return + } + } + // ~/.config/uv/uv.toml — written by Artifactory "Set Me Up". + home, err := os.UserHomeDir() + if err != nil { + return + } + if _, err := os.Stat(filepath.Join(home, ".config", "uv", "uv.toml")); err == nil { + delete(detected, Pip) + if _, exists := detected[Uv]; !exists { + detected[Uv] = map[string][]string{} + } + detected[Uv][wd] = pipDirs[wd] + } +} + func detectedTechnologiesListInPath(path string, recursive bool) (technologies []string) { detectedTechnologies, err := DetectTechnologiesDescriptors(path, recursive, []string{}, map[Technology][]string{}, "") if err != nil { @@ -1079,7 +1130,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..1e92e6a3a 100644 --- a/utils/techutils/techutils_test.go +++ b/utils/techutils/techutils_test.go @@ -1165,3 +1165,8 @@ 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()) +}