Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 99 additions & 7 deletions commands/curation/curationaudit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
194 changes: 194 additions & 0 deletions commands/curation/curationaudit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading