Skip to content

Commit 564a607

Browse files
authored
XRAY-138689 - Add Poetry support for jf ca (jfrog#768)
1 parent a29e479 commit 564a607

16 files changed

Lines changed: 1564 additions & 28 deletions

File tree

artifactory_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,13 @@ func clearOrRedirectLocalCacheIfNeeded(t *testing.T, projectType project.Project
225225
envVarCallbackFunc()
226226
createTempDirCallback()
227227
}
228+
case project.Poetry:
229+
poetryTempCachePath, createTempDirCallback := coreTests.CreateTempDirWithCallbackAndAssert(t)
230+
envVarCallbackFunc := clientTests.SetEnvWithCallbackAndAssert(t, "POETRY_CACHE_DIR", poetryTempCachePath)
231+
callbackFunc = func() {
232+
envVarCallbackFunc()
233+
createTempDirCallback()
234+
}
228235
}
229236
return
230237
}

cli/docs/flags.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ var flagsMap = map[string]components.Flag{
317317
WorkingDirs: components.NewStringFlag(WorkingDirs, "A comma-separated(,) list of relative working directories, to determine the audit targets locations. If flag isn't provided, a recursive scan is triggered from the root directory of the project."),
318318
OutputDir: components.NewStringFlag(OutputDir, "Target directory to save partial results to.", components.SetHiddenStrFlag()),
319319
UploadRepoPath: components.NewStringFlag(UploadRepoPath, "Artifactory repository name or path to upload the cyclonedx file to. If no name or path are provided, a local generic repository will be created which will automatically be indexed by Xray.", components.WithStrDefaultValue("import-cdx-scan-results")),
320-
SkipAutoInstall: components.NewBoolFlag(SkipAutoInstall, "Set to true to skip auto-install of dependencies in un-built modules. Currently supported only for some package managers.", components.SetHiddenBoolFlag()),
320+
SkipAutoInstall: components.NewBoolFlag(SkipAutoInstall, "Set to true to skip auto-install of dependencies in un-built modules. Currently supported for Yarn, NPM, Pip, and Poetry.", components.SetHiddenBoolFlag()),
321321
AllowPartialResults: components.NewBoolFlag(AllowPartialResults, "Set to true to allow partial results and continuance of the scan in case of certain errors.", components.SetHiddenBoolFlag()),
322322
ExclusionsAudit: components.NewStringFlag(
323323
Exclusions,

commands/curation/curationaudit.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,9 @@ var supportedTech = map[techutils.Technology]func(ca *CurationAuditCommand) (boo
121121
return ca.checkSupportByVersionOrEnv(techutils.Gem, MinArtiGradleGemSupport)
122122
},
123123
techutils.Docker: func(ca *CurationAuditCommand) (bool, error) { return true, nil },
124+
techutils.Poetry: func(ca *CurationAuditCommand) (bool, error) {
125+
return ca.checkSupportByVersionOrEnv(techutils.Poetry, MinArtiPassThroughSupport)
126+
},
124127
}
125128

126129
func (ca *CurationAuditCommand) checkSupportByVersionOrEnv(tech techutils.Technology, minArtiVersion string) (bool, error) {
@@ -1603,7 +1606,7 @@ func getUrlNameAndVersionByTech(tech techutils.Technology, node *xrayUtils.Graph
16031606
return getGradleNameScopeAndVersion(node.Id, artiUrl, repo, node)
16041607
case techutils.Gem:
16051608
return getGemNameScopeAndVersion(node.Id, artiUrl, repo)
1606-
case techutils.Pip:
1609+
case techutils.Pip, techutils.Poetry:
16071610
downloadUrls, name, version = getPythonNameVersion(node.Id, downloadUrlsMap)
16081611
return
16091612
case techutils.Go:
@@ -1643,7 +1646,7 @@ func getPythonNameVersion(id string, downloadUrlsMap map[string]string) (downloa
16431646
if dl, ok := downloadUrlsMap[normalizedId]; ok {
16441647
downloadUrls = []string{dl}
16451648
} else {
1646-
log.Warn(fmt.Sprintf("couldn't find download url for node id %s in report.json", id))
1649+
log.Warn(fmt.Sprintf("Couldn't find download URL for node ID %s", id))
16471650
}
16481651
return
16491652
}

commands/curation/curationaudit_test.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1838,6 +1838,141 @@ func TestFetchNodesStatusConcurrentMapWrite(t *testing.T) {
18381838
})
18391839
assert.Equal(t, numNodes, count, "expected all %d packages to be recorded as blocked", numNodes)
18401840
}
1841+
// =============================================================================
1842+
// Tests for Poetry support added to curationaudit.go.
1843+
// Covers the new dispatcher case (Pip, Poetry -> getPythonNameVersion) and the
1844+
// supportedTech registration.
1845+
// =============================================================================
1846+
1847+
func Test_getPythonNameVersion(t *testing.T) {
1848+
const exampleUrl = "https://test.jfrog.io/artifactory/api/pypi/pypi-remote/packages/aa/bb/flask-2.0.0-py3-none-any.whl"
1849+
1850+
tests := []struct {
1851+
name string
1852+
id string
1853+
downloadUrlsMap map[string]string
1854+
wantDownloadUrls []string
1855+
wantName string
1856+
wantVersion string
1857+
}{
1858+
{
1859+
name: "pip id with matching download url",
1860+
id: "pypi://flask:2.0.0",
1861+
downloadUrlsMap: map[string]string{"pypi://flask:2.0.0": exampleUrl},
1862+
wantDownloadUrls: []string{exampleUrl},
1863+
wantName: "flask",
1864+
wantVersion: "2.0.0",
1865+
},
1866+
{
1867+
name: "poetry id with matching download url (same pypi:// prefix)",
1868+
id: "pypi://click:8.0.1",
1869+
downloadUrlsMap: map[string]string{"pypi://click:8.0.1": exampleUrl},
1870+
wantDownloadUrls: []string{exampleUrl},
1871+
wantName: "click",
1872+
wantVersion: "8.0.1",
1873+
},
1874+
{
1875+
name: "id present in map but no entry returns name+version only",
1876+
id: "pypi://requests:2.31.0",
1877+
downloadUrlsMap: map[string]string{"pypi://other:1.0.0": exampleUrl},
1878+
wantDownloadUrls: nil,
1879+
wantName: "requests",
1880+
wantVersion: "2.31.0",
1881+
},
1882+
{
1883+
name: "nil downloadUrlsMap returns name+version only",
1884+
id: "pypi://requests:2.31.0",
1885+
downloadUrlsMap: nil,
1886+
wantDownloadUrls: nil,
1887+
wantName: "requests",
1888+
wantVersion: "2.31.0",
1889+
},
1890+
{
1891+
name: "malformed id (no version separator) returns empty",
1892+
id: "pypi://malformed",
1893+
downloadUrlsMap: nil,
1894+
wantDownloadUrls: nil,
1895+
wantName: "",
1896+
wantVersion: "",
1897+
},
1898+
{
1899+
name: "hyphenated name resolved via normalization fallback",
1900+
id: "pypi://Flask-Babel:1.0",
1901+
downloadUrlsMap: map[string]string{"pypi://flask_babel:1.0": exampleUrl},
1902+
wantDownloadUrls: []string{exampleUrl},
1903+
wantName: "Flask-Babel",
1904+
wantVersion: "1.0",
1905+
},
1906+
}
1907+
for _, tt := range tests {
1908+
t.Run(tt.name, func(t *testing.T) {
1909+
gotDownloadUrls, gotName, gotVersion := getPythonNameVersion(tt.id, tt.downloadUrlsMap)
1910+
assert.Equal(t, tt.wantDownloadUrls, gotDownloadUrls, "downloadUrls mismatch")
1911+
assert.Equal(t, tt.wantName, gotName, "name mismatch")
1912+
assert.Equal(t, tt.wantVersion, gotVersion, "version mismatch")
1913+
})
1914+
}
1915+
}
1916+
1917+
// TestGetBlockedPackageDetails_403UnparsableBodyReturnsBlocked verifies that
1918+
// getBlockedPackageDetails returns a blocked PackageStatus (no error) when a 403
1919+
// response body cannot be resolved to a known curation block reason:
1920+
// (1) the body is not valid JSON (e.g. an HTML error page), or
1921+
// (2) the body is valid JSON but the Errors array is empty.
1922+
// In both cases the 403 itself is treated as authoritative — the package is
1923+
// recorded as blocked with an unknown policy rather than being dropped silently.
1924+
func TestGetBlockedPackageDetails_403UnparsableBodyReturnsBlocked(t *testing.T) {
1925+
tests := []struct {
1926+
name string
1927+
respBody string
1928+
}{
1929+
{
1930+
name: "non-JSON body (HTML error page)",
1931+
respBody: "<html><body><h1>403 Forbidden</h1></body></html>",
1932+
},
1933+
{
1934+
name: "JSON body with empty errors list",
1935+
respBody: `{"errors":[]}`,
1936+
},
1937+
}
1938+
1939+
const (
1940+
pkgName = "telnyx"
1941+
pkgVersion = "4.87.1"
1942+
)
1943+
1944+
for _, tt := range tests {
1945+
t.Run(tt.name, func(t *testing.T) {
1946+
serverMock, _, rtManager := coreCommonTests.CreateRtRestsMockServer(t, func(w http.ResponseWriter, r *http.Request) {
1947+
w.WriteHeader(http.StatusForbidden)
1948+
_, _ = w.Write([]byte(tt.respBody))
1949+
})
1950+
defer serverMock.Close()
1951+
1952+
rtAuth := rtManager.GetConfig().GetServiceDetails()
1953+
httpClientDetails := rtAuth.CreateHttpClientDetails()
1954+
analyzer := treeAnalyzer{
1955+
rtManager: rtManager,
1956+
rtAuth: rtAuth,
1957+
httpClientDetails: httpClientDetails,
1958+
extractPoliciesRegex: regexp.MustCompile(extractPoliciesRegexTemplate),
1959+
url: rtAuth.GetUrl(),
1960+
repo: "pypi-remote",
1961+
tech: techutils.Poetry,
1962+
}
1963+
packageUrl := fmt.Sprintf("%sapi/pypi/pypi-remote/packages/%s-%s.tar.gz", rtAuth.GetUrl(), pkgName, pkgVersion)
1964+
1965+
got, err := analyzer.getBlockedPackageDetails(packageUrl, pkgName, pkgVersion)
1966+
1967+
require.NoError(t, err, "unparsable 403 body should not surface as an error")
1968+
require.NotNil(t, got, "a blocked PackageStatus must be returned when the 403 block reason is unknown")
1969+
assert.Equal(t, blocked, got.Action)
1970+
assert.Equal(t, BlockingReasonUnknown, got.BlockingReason)
1971+
assert.Equal(t, pkgName, got.PackageName)
1972+
assert.Equal(t, pkgVersion, got.PackageVersion)
1973+
})
1974+
}
1975+
}
18411976

18421977
// TestFetchCvsBlockedStatusTransitive verifies the CVS fallback for a transitive range blocker:
18431978
// the range is resolved to the newest satisfying version and the policy is recovered from the 403 probe.

curation_test.go

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,71 @@ func TestDockerCurationAudit(t *testing.T) {
134134
assert.Equal(t, "Image is not Docker Hub official", results[0].Policy[0].Condition)
135135
}
136136

137-
func curationServer(t *testing.T, expectedRequest map[string]bool, requestToFail map[string]bool) (*httptest.Server, *config.ServerDetails) {
137+
func TestPoetryCurationAudit(t *testing.T) {
138+
integration.InitCurationTest(t)
139+
const repo = "pypi-curation"
140+
tempDirPath, cleanUp := securityTestUtils.CreateTestProjectEnvAndChdir(t, filepath.Join(filepath.FromSlash(securityTests.GetTestResourcesPath()), "projects", "package-managers", "python", "poetry", "poetry-curation-project"))
141+
defer cleanUp()
142+
143+
blockedURL := "/api/pypi/" + repo + "/packages/aa/urllib3-1.26.20-py2.py3-none-any.whl"
144+
expectedRequest := map[string]bool{blockedURL: false}
145+
requestToFail := map[string]bool{blockedURL: false}
146+
serverMock, config := curationServer(t, expectedRequest, requestToFail, map[string]string{
147+
"urllib3": `<a href="../../packages/aa/urllib3-1.26.20-py2.py3-none-any.whl">urllib3-1.26.20-py2.py3-none-any.whl</a>`,
148+
})
149+
defer serverMock.Close()
150+
151+
cleanUpHome := integration.UseTestHomeWithDefaultXrayConfig(t)
152+
defer cleanUpHome()
153+
154+
config.User = "admin"
155+
config.Password = "password"
156+
config.ServerId = "test"
157+
configCmd := commonCommands.NewConfigCommand(commonCommands.AddOrEdit, config.ServerId).SetDetails(config).SetUseBasicAuthOnly(true).SetInteractive(false)
158+
assert.NoError(t, configCmd.Run())
159+
160+
localXrayCli := securityTests.PlatformCli.WithoutCredentials()
161+
workingDirsFlag := fmt.Sprintf("--working-dirs=%s", tempDirPath)
162+
output := localXrayCli.RunCliCmdWithOutput(t, "curation-audit", "--format="+string(format.Json), workingDirsFlag)
163+
164+
expectedResp := getPoetryCurationExpectedResponse(config, repo)
165+
var got []curation.PackageStatus
166+
bracketIndex := strings.Index(output, "[")
167+
require.Less(t, 0, bracketIndex, "Unexpected Curation output with missing '['")
168+
err := json.Unmarshal([]byte(output[bracketIndex:]), &got)
169+
assert.NoError(t, err)
170+
assert.Equal(t, expectedResp, got)
171+
for k, v := range expectedRequest {
172+
assert.Truef(t, v, "didn't receive expected HEAD request for package url %s", k)
173+
}
174+
}
175+
176+
func getPoetryCurationExpectedResponse(config *config.ServerDetails, repo string) []curation.PackageStatus {
177+
return []curation.PackageStatus{
178+
{
179+
Action: "blocked",
180+
PackageName: "urllib3",
181+
PackageVersion: "1.26.20",
182+
BlockedPackageUrl: config.ArtifactoryUrl + "api/pypi/" + repo + "/packages/aa/urllib3-1.26.20-py2.py3-none-any.whl",
183+
BlockingReason: curation.BlockingReasonPolicy,
184+
ParentName: "urllib3",
185+
ParentVersion: "1.26.20",
186+
DepRelation: "direct",
187+
PkgType: "poetry",
188+
Policy: []curation.Policy{
189+
{Policy: "pol1", Condition: "cond1", Explanation: "explanation", Recommendation: "recommendation"},
190+
{Policy: "pol2", Condition: "cond2", Explanation: "explanation2", Recommendation: "recommendation2"},
191+
},
192+
},
193+
}
194+
}
195+
196+
func curationServer(t *testing.T, expectedRequest map[string]bool, requestToFail map[string]bool, simpleIndex ...map[string]string) (*httptest.Server, *config.ServerDetails) {
138197
mapLockReadWrite := sync.Mutex{}
198+
var index map[string]string
199+
if len(simpleIndex) > 0 {
200+
index = simpleIndex[0]
201+
}
139202
serverMock, config, _ := commonTests.CreateRtRestsMockServer(t, func(w http.ResponseWriter, r *http.Request) {
140203
if r.Method == http.MethodHead {
141204
mapLockReadWrite.Lock()
@@ -146,15 +209,26 @@ func curationServer(t *testing.T, expectedRequest map[string]bool, requestToFail
146209
if _, exist := requestToFail[r.RequestURI]; exist {
147210
w.WriteHeader(http.StatusForbidden)
148211
}
212+
return
149213
}
150214
if r.Method == http.MethodGet {
151215
if r.RequestURI == "/api/system/version" {
152-
_, err := w.Write([]byte(`{"version": "7.0.0"}`))
216+
_, err := w.Write([]byte(`{"version": "7.82.0"}`))
153217
require.NoError(t, err)
154-
w.WriteHeader(http.StatusOK)
155218
return
156219
}
157-
220+
if r.RequestURI == "/api/v1/system/version" {
221+
_, err := w.Write([]byte(`{"xray_version": "3.92.0"}`))
222+
require.NoError(t, err)
223+
return
224+
}
225+
for name, href := range index {
226+
if strings.HasSuffix(r.URL.Path, "/simple/"+name+"/") {
227+
_, err := w.Write([]byte("<html><body>" + href + "</body></html>"))
228+
require.NoError(t, err)
229+
return
230+
}
231+
}
158232
if _, exist := requestToFail[r.RequestURI]; exist {
159233
w.WriteHeader(http.StatusForbidden)
160234
_, err := w.Write([]byte("{\n \"errors\": [\n {\n \"status\": 403,\n " +

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ require (
2323
github.com/magiconair/properties v1.8.10
2424
github.com/owenrumney/go-sarif/v3 v3.2.3
2525
github.com/package-url/packageurl-go v0.1.3
26+
github.com/spf13/viper v1.21.0
2627
github.com/stretchr/testify v1.11.1
2728
github.com/urfave/cli v1.22.17
2829
github.com/virtuald/go-ordered-json v0.0.0-20170621173500-b18e6e673d74
@@ -122,7 +123,6 @@ require (
122123
github.com/spf13/afero v1.15.0 // indirect
123124
github.com/spf13/cast v1.10.0 // indirect
124125
github.com/spf13/pflag v1.0.10 // indirect
125-
github.com/spf13/viper v1.21.0 // indirect
126126
github.com/subosito/gotenv v1.6.0 // indirect
127127
github.com/ulikunitz/xz v0.5.15 // indirect
128128
github.com/vbatts/tar-split v0.12.2 // indirect

sca/bom/buildinfo/technologies/common_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ func TestSuspectCurationBlockedError(t *testing.T) {
142142
mvnOutput2 := "status code: 500, reason phrase: Server Error (500)"
143143
pipOutput := "because of HTTP error 403 Client Error: Forbidden for url"
144144
goOutput := "Failed running Go command: 403 Forbidden"
145+
poetryOutput := "because of HTTP error 403 Client Error: Forbidden for url"
145146

146147
tests := []struct {
147148
name string
@@ -190,6 +191,19 @@ func TestSuspectCurationBlockedError(t *testing.T) {
190191
output: goOutput,
191192
expect: fmt.Sprintf(CurationErrorMsgToUserTemplate, techutils.Go),
192193
},
194+
{
195+
name: "poetry 403 error (pass-through disabled)",
196+
isCurationCmd: true,
197+
tech: techutils.Poetry,
198+
output: poetryOutput,
199+
expect: fmt.Sprintf(CurationErrorMsgToUserTemplate, techutils.Poetry),
200+
},
201+
{
202+
name: "poetry not pass through error",
203+
isCurationCmd: true,
204+
tech: techutils.Poetry,
205+
output: "http error 401",
206+
},
193207
{
194208
name: "not a supported tech",
195209
isCurationCmd: true,

0 commit comments

Comments
 (0)