Skip to content

Commit 3a95ffd

Browse files
authored
XRAY-145623 - pip jf ca failure due to CVS (jfrog#777)
1 parent 4373448 commit 3a95ffd

6 files changed

Lines changed: 1111 additions & 44 deletions

File tree

commands/curation/curationaudit.go

Lines changed: 298 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,18 @@ const (
8585
MinArtiNuGetSupport = "7.93.0"
8686
MinXrayPassThroughSupport = "3.92.0"
8787
MinArtiGradleGemSupport = "7.63.5"
88+
89+
// cvsPartialReportWarning is shown when pip resolution failed because CVS
90+
// stripped a required version from the simple index, but the metadata-API
91+
// fallback succeeded in recovering at least one policy violation.
92+
cvsPartialReportWarning = "The curation audit was unable to fully resolve the dependency tree because one or more pinned package versions " +
93+
"are blocked by the curation policy. Details of the policy violations are shown in the table below.\n" +
94+
"Dependency analysis cannot proceed until these issues are addressed.\n" +
95+
"Once you apply a waiver or switch to an approved version and re-run the audit, additional results will be available."
8896
)
8997

9098
var CurationOutputFormats = []string{string(outFormat.Table), string(outFormat.Json)}
99+
var osGetwd = os.Getwd
91100

92101
var supportedTech = map[techutils.Technology]func(ca *CurationAuditCommand) (bool, error){
93102
techutils.Npm: func(ca *CurationAuditCommand) (bool, error) { return true, nil },
@@ -244,6 +253,11 @@ type CurationAuditCommand struct {
244253
type CurationReport struct {
245254
packagesStatus []*PackageStatus
246255
totalNumberOfPackages int
256+
// isPartial is set when the dependency tree could not be fully resolved
257+
// (e.g. CVS blocked a pip version from the simple index) and the report
258+
// was produced via the metadata-API fallback. The partial-report warning
259+
// is printed after the spinner stops so it is not swallowed by the spinner.
260+
isPartial bool
247261
}
248262

249263
type WaiverResponse struct {
@@ -330,9 +344,16 @@ func (ca *CurationAuditCommand) Run() (err error) {
330344
if ca.Progress() != nil {
331345
err = errors.Join(err, ca.Progress().Quit())
332346
}
333-
// Print after the spinner has stopped so the message appears on the terminal.
347+
// Print after the spinner has stopped so the messages appear on the terminal.
348+
// Don't include scanErr.Error() here — it is in the returned err and the CLI framework
349+
// prints it once; printing it here too would duplicate the full error message.
334350
if scanErr != nil {
335-
log.Error("Curation audit encountered errors while checking some packages; the report below may be incomplete: " + scanErr.Error())
351+
log.Error("Curation audit encountered errors while checking some packages; the report below may be incomplete:")
352+
}
353+
for projectPath, report := range results {
354+
if report.isPartial {
355+
log.Warn(fmt.Sprintf("[%s] %s", projectPath, cvsPartialReportWarning))
356+
}
336357
}
337358

338359
for projectPath, packagesStatus := range results {
@@ -357,6 +378,7 @@ func convertResultsToSummary(results map[string]*CurationReport) formats.Results
357378
CuratedPackages: &formats.CuratedPackages{
358379
PackageCount: packagesStatus.totalNumberOfPackages,
359380
Blocked: getBlocked(packagesStatus.packagesStatus),
381+
IsPartial: packagesStatus.isPartial,
360382
},
361383
})
362384
}
@@ -602,6 +624,14 @@ func (ca *CurationAuditCommand) auditTree(tech techutils.Technology, results map
602624
}
603625
depTreeResult, err := buildinfo.GetTechDependencyTree(params, serverDetails, tech)
604626
if err != nil {
627+
// When CVS strips a pinned version from the simple index, pip can't
628+
// resolve the project and GetTechDependencyTree returns a CvsBlockedError.
629+
// Instead of aborting with no output, run the metadata-API fallback to
630+
// recover the curation policy and render a partial table.
631+
var cvsErr *python.CvsBlockedError
632+
if tech == techutils.Pip && errors.As(err, &cvsErr) {
633+
return ca.runCvsFallback(cvsErr, tech, results)
634+
}
605635
return err
606636
}
607637
// Validate the graph isn't empty.
@@ -1185,6 +1215,272 @@ func (nc *treeAnalyzer) fetchNodeStatus(node xrayUtils.GraphNode, p *sync.Map) e
11851215
return nil
11861216
}
11871217

1218+
// runCvsFallback is called when pip resolution failed because CVS stripped a
1219+
// pinned version from the simple index (CvsBlockedError). It uses the PyPI
1220+
// metadata API to recover each blocker's real download URL, probes the normal
1221+
// (non-audit) download path, and renders the policy in a partial curation table.
1222+
func (ca *CurationAuditCommand) runCvsFallback(cvsErr *python.CvsBlockedError, tech techutils.Technology, results map[string]*CurationReport) error {
1223+
rtManager, serverDetails, err := ca.getRtManagerAndAuth(tech)
1224+
if err != nil {
1225+
return fmt.Errorf("curation-blocked resolution fallback: failed to get Artifactory manager (%w); pip error: %w", err, cvsErr)
1226+
}
1227+
rtAuth, err := serverDetails.CreateArtAuthConfig()
1228+
if err != nil {
1229+
return fmt.Errorf("curation-blocked resolution fallback: failed to create auth config (%w); pip error: %w", err, cvsErr)
1230+
}
1231+
analyzer := treeAnalyzer{
1232+
rtManager: rtManager,
1233+
extractPoliciesRegex: ca.extractPoliciesRegex,
1234+
rtAuth: rtAuth,
1235+
httpClientDetails: rtAuth.CreateHttpClientDetails(),
1236+
url: rtAuth.GetUrl(),
1237+
repo: ca.PackageManagerConfig.TargetRepo(),
1238+
tech: tech,
1239+
}
1240+
packagesStatus := analyzer.fetchCvsBlockedStatus(cvsErr.Packages)
1241+
if len(packagesStatus) == 0 {
1242+
// Fallback produced nothing — surface the original error (current behaviour).
1243+
return cvsErr
1244+
}
1245+
workPath, wdErr := osGetwd()
1246+
if wdErr != nil {
1247+
log.Warn(fmt.Sprintf("curation-blocked resolution fallback: could not determine working directory (%v) — reporting under fallback key", wdErr))
1248+
workPath = "unknown-project"
1249+
}
1250+
results[filepath.Base(workPath)] = &CurationReport{
1251+
packagesStatus: packagesStatus,
1252+
totalNumberOfPackages: len(packagesStatus),
1253+
isPartial: true,
1254+
}
1255+
return nil
1256+
}
1257+
1258+
// lookupPypiAllVersions calls the Artifactory PyPI metadata API for a package
1259+
// name (no version — returns all releases) and returns all available version
1260+
// strings. This endpoint is NOT filtered by CVS, so it includes versions that
1261+
// have been stripped from the simple index.
1262+
func (nc *treeAnalyzer) lookupPypiAllVersions(name string) ([]string, error) {
1263+
metadataURL := fmt.Sprintf("%s/api/pypi/%s/pypi/%s/json",
1264+
strings.TrimSuffix(nc.url, "/"), nc.repo, name)
1265+
1266+
requestDetails := nc.httpClientDetails.Clone()
1267+
resp, body, _, err := nc.rtManager.Client().SendGet(metadataURL, true, requestDetails)
1268+
if err != nil {
1269+
return nil, fmt.Errorf("all-versions metadata API request failed for %s: %w", name, err)
1270+
}
1271+
if resp.StatusCode != http.StatusOK {
1272+
return nil, fmt.Errorf("all-versions metadata API returned HTTP %d for %s", resp.StatusCode, name)
1273+
}
1274+
1275+
var meta struct {
1276+
Releases map[string]json.RawMessage `json:"releases"`
1277+
}
1278+
if err := json.Unmarshal(body, &meta); err != nil {
1279+
return nil, fmt.Errorf("failed to parse all-versions metadata for %s: %w", name, err)
1280+
}
1281+
1282+
versions := make([]string, 0, len(meta.Releases))
1283+
for v := range meta.Releases {
1284+
versions = append(versions, v)
1285+
}
1286+
return versions, nil
1287+
}
1288+
1289+
// lookupPypiNormalDownloadURL calls the Artifactory PyPI metadata API for
1290+
// name@version (which CVS does NOT filter) and returns the first available
1291+
// download URL as a normal Artifactory path (no api/curation/audit/ prefix).
1292+
// The url field in the metadata JSON is a relative path such as
1293+
// "../../packages/packages/<hash>/<file>" — the stable anchor is "packages/"
1294+
// so we slice from there and prepend the Artifactory base + repo.
1295+
func (nc *treeAnalyzer) lookupPypiNormalDownloadURL(name, ver string) (string, error) {
1296+
metadataURL := fmt.Sprintf("%s/api/pypi/%s/pypi/%s/%s/json",
1297+
strings.TrimSuffix(nc.url, "/"), nc.repo, name, ver)
1298+
1299+
requestDetails := nc.httpClientDetails.Clone()
1300+
resp, body, _, err := nc.rtManager.Client().SendGet(metadataURL, true, requestDetails)
1301+
if err != nil {
1302+
return "", fmt.Errorf("metadata API request failed for %s==%s: %w", name, ver, err)
1303+
}
1304+
if resp.StatusCode != http.StatusOK {
1305+
return "", fmt.Errorf("metadata API returned HTTP %d for %s==%s", resp.StatusCode, name, ver)
1306+
}
1307+
1308+
var meta struct {
1309+
Urls []struct {
1310+
PackageType string `json:"packagetype"`
1311+
URL string `json:"url"`
1312+
} `json:"urls"`
1313+
}
1314+
if err := json.Unmarshal(body, &meta); err != nil {
1315+
return "", fmt.Errorf("failed to parse metadata for %s==%s: %w", name, ver, err)
1316+
}
1317+
1318+
// Prefer wheel over source dist — probe whichever we find first.
1319+
for _, preferred := range []string{"bdist_wheel", "sdist"} {
1320+
for _, u := range meta.Urls {
1321+
if u.PackageType != preferred {
1322+
continue
1323+
}
1324+
// Strip the leading relative components; the stable part is "packages/...".
1325+
idx := strings.Index(u.URL, "packages/")
1326+
if idx < 0 {
1327+
continue
1328+
}
1329+
return fmt.Sprintf("%s/api/pypi/%s/%s",
1330+
strings.TrimSuffix(nc.url, "/"), nc.repo, u.URL[idx:]), nil
1331+
}
1332+
}
1333+
return "", fmt.Errorf("no download URL found in metadata for %s==%s", name, ver)
1334+
}
1335+
1336+
// fetchCvsBlockedStatus recovers the curation policy for each CVS-blocked package:
1337+
//
1338+
// 1. For range-based blockers (PinnedRequirement.VersionRange set): resolve the
1339+
// newest version satisfying the range via the unfiltered all-versions metadata API.
1340+
// 2. Call the version-specific metadata API to get the normal download URL.
1341+
// 3. Probe the normal (non-audit) download URL via getBlockedPackageDetails.
1342+
//
1343+
// A blocker that cannot be confirmed as curation-blocked (its version is absent
1344+
// from the metadata API — e.g. removed from the index, or a typo/nonexistent
1345+
// version) is NOT rendered as a row; with no recoverable blockers the command
1346+
// falls back to the graceful PR #761 message listing affected package(s), so a
1347+
// version that is not in the metadata API is never shown as a fake "blocked" row.
1348+
//
1349+
// HTTP calls are sequential per pin. This path is an error-recovery path that
1350+
// typically processes 1-3 packages, so parallelism is not worth the complexity.
1351+
func (nc *treeAnalyzer) fetchCvsBlockedStatus(pins []python.PinnedRequirement) []*PackageStatus {
1352+
var statuses []*PackageStatus
1353+
for _, pin := range pins {
1354+
// ── Step 1: resolve range / no-version → exact version ───────────────
1355+
resolvedVersion := pin.Version
1356+
if pin.VersionRange != "" || resolvedVersion == "" {
1357+
// Either a range spec or a ResolutionImpossible entry with no version.
1358+
// Use the unfiltered all-versions metadata API to find the newest match.
1359+
allVersions, err := nc.lookupPypiAllVersions(pin.Name)
1360+
if err != nil {
1361+
log.Debug(fmt.Sprintf("curation-blocked resolution fallback: all-versions lookup failed for %s%s: %v",
1362+
pin.Name, pin.VersionRange, err))
1363+
continue
1364+
}
1365+
if pin.VersionRange != "" {
1366+
resolvedVersion = python.ResolveVersionRange(pin.VersionRange, allVersions)
1367+
} else {
1368+
// No range — pick the newest available version.
1369+
resolvedVersion = python.ResolveVersionRange(">=0", allVersions)
1370+
}
1371+
if resolvedVersion == "" {
1372+
log.Debug(fmt.Sprintf("curation-blocked resolution fallback: no version found for %s%s",
1373+
pin.Name, pin.VersionRange))
1374+
continue
1375+
}
1376+
log.Debug(fmt.Sprintf("curation-blocked resolution fallback: resolved %s%s → %s",
1377+
pin.Name, pin.VersionRange, resolvedVersion))
1378+
}
1379+
1380+
// ── Step 2: metadata API → normal download URL ────────────────────────
1381+
dlURL, err := nc.lookupPypiNormalDownloadURL(pin.Name, resolvedVersion)
1382+
if err != nil {
1383+
// Version is absent from the metadata API (removed from the index, or
1384+
// a typo/nonexistent version). There is no recoverable policy to show,
1385+
// so skip it: with no recoverable blockers the command falls back to
1386+
// the graceful "Affected package(s)" message (PR #761 behaviour),
1387+
// rather than rendering a misleading empty table row.
1388+
log.Debug(fmt.Sprintf("curation-blocked resolution fallback: metadata lookup failed for %s==%s: %v — treating as unresolved",
1389+
pin.Name, resolvedVersion, err))
1390+
continue
1391+
}
1392+
1393+
// ── Step 3a: HEAD probe — detect whether the version is download-blocked
1394+
headDetails := nc.httpClientDetails.Clone()
1395+
headResp, _, headErr := nc.rtManager.Client().SendHead(dlURL, headDetails)
1396+
if headErr != nil && (headResp == nil || headResp.StatusCode != http.StatusForbidden) {
1397+
log.Debug(fmt.Sprintf("curation-blocked resolution fallback: HEAD probe failed for %s==%s: %v",
1398+
pin.Name, resolvedVersion, headErr))
1399+
continue
1400+
}
1401+
// Package is accessible — CVS cache may be stale; not currently blocked.
1402+
if headErr == nil && headResp != nil && headResp.StatusCode != http.StatusForbidden {
1403+
log.Debug(fmt.Sprintf("curation-blocked resolution fallback: HEAD probe returned %d for %s==%s — not CVS-blocked, skipping",
1404+
headResp.StatusCode, pin.Name, resolvedVersion))
1405+
continue
1406+
}
1407+
1408+
// ── Step 3b: 403 from HEAD → recover policy details via GET with waiver
1409+
var pkStatus *PackageStatus
1410+
if headResp != nil && headResp.StatusCode == http.StatusForbidden {
1411+
var getErr error
1412+
pkStatus, getErr = nc.getBlockedPackageDetails(dlURL, pin.Name, resolvedVersion)
1413+
if getErr != nil {
1414+
log.Debug(fmt.Sprintf("curation-blocked resolution fallback: GET probe failed for %s==%s: %v",
1415+
pin.Name, resolvedVersion, getErr))
1416+
}
1417+
}
1418+
depRelation := directRelation
1419+
if effectiveParent(pin) != pin.Name {
1420+
depRelation = indirectRelation
1421+
} else if pin.Version == "" && pin.VersionRange == "" {
1422+
// Name-only entry from ResolutionImpossible — parent attribution is
1423+
// unknown but these are always transitive deps by definition.
1424+
depRelation = indirectRelation
1425+
}
1426+
if pkStatus == nil {
1427+
// HEAD returned 403 but GET probe errored — CVS stripped the version
1428+
// from the index but policy details aren't available via this path;
1429+
// record with unknown reason so the package is never silently dropped.
1430+
statuses = append(statuses, &PackageStatus{
1431+
PackageName: pin.Name,
1432+
PackageVersion: resolvedVersion,
1433+
ParentName: effectiveParent(pin),
1434+
ParentVersion: effectiveParentVersion(pin),
1435+
DepRelation: depRelation,
1436+
BlockedPackageUrl: dlURL,
1437+
Action: blocked,
1438+
BlockingReason: BlockingReasonUnknown,
1439+
PkgType: string(nc.tech),
1440+
})
1441+
continue
1442+
}
1443+
1444+
// Policy recovered — set parent attribution from the parsed blocker.
1445+
pkStatus.PackageName = pin.Name
1446+
pkStatus.PackageVersion = resolvedVersion
1447+
pkStatus.ParentName = effectiveParent(pin)
1448+
pkStatus.ParentVersion = effectiveParentVersion(pin)
1449+
pkStatus.DepRelation = depRelation
1450+
statuses = append(statuses, pkStatus)
1451+
}
1452+
return statuses
1453+
}
1454+
1455+
// effectiveParent returns the parent name to populate in the curation table row.
1456+
// For direct exact pins, ParentName equals Name (set by parseCvsFailedPackages);
1457+
// for transitive range blockers it is the requiring package. When not yet set,
1458+
// fall back to the package itself.
1459+
func effectiveParent(pin python.PinnedRequirement) string {
1460+
if pin.ParentName != "" && pin.ParentName != pin.Name {
1461+
return pin.ParentName
1462+
}
1463+
return pin.Name
1464+
}
1465+
1466+
// effectiveParentVersion returns the version to show in the "Direct Dependency
1467+
// Version" column. For exact pins it is the pinned version.
1468+
//
1469+
// For a ranged DIRECT dependency (parent == package, e.g. requirements.txt has
1470+
// "langchain-core>=1.4.0") the range spec itself is shown so the column is not
1471+
// blank. For a TRANSITIVE blocker (parent differs from the package) the range
1472+
// describes the blocked package, not the parent — so it must not be shown in
1473+
// the parent column; we leave it blank when the parent version is unknown.
1474+
func effectiveParentVersion(pin python.PinnedRequirement) string {
1475+
if pin.ParentVersion != "" {
1476+
return pin.ParentVersion
1477+
}
1478+
if pin.VersionRange != "" && (pin.ParentName == "" || pin.ParentName == pin.Name) {
1479+
return pin.VersionRange
1480+
}
1481+
return pin.Version
1482+
}
1483+
11881484
// We try to collect curation details from GET response after HEAD request got forbidden status code.
11891485
func (nc *treeAnalyzer) getBlockedPackageDetails(packageUrl string, name string, version string) (*PackageStatus, error) {
11901486
requestDetails := nc.httpClientDetails.Clone()

0 commit comments

Comments
 (0)