@@ -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
9098var CurationOutputFormats = []string {string (outFormat .Table ), string (outFormat .Json )}
99+ var osGetwd = os .Getwd
91100
92101var 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 {
244253type 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
249263type 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.
11891485func (nc * treeAnalyzer ) getBlockedPackageDetails (packageUrl string , name string , version string ) (* PackageStatus , error ) {
11901486 requestDetails := nc .httpClientDetails .Clone ()
0 commit comments