2727from macaron .json_tools import json_extract
2828from macaron .malware_analyzer .datetime_parser import parse_datetime
2929from macaron .slsa_analyzer .package_registry .package_registry import PackageRegistry
30- from macaron .util import send_get_http_raw
30+ from macaron .util import send_get_http_raw , send_head_http_raw
3131
3232if TYPE_CHECKING :
3333 from macaron .slsa_analyzer .specs .package_registry_spec import PackageRegistryInfo
@@ -469,6 +469,33 @@ def extract_attestation(attestation_data: dict) -> dict | None:
469469 return attestations [0 ]
470470
471471
472+ # as per https://github.com/pypi/inspector/blob/main/inspector/main.py line 125
473+ INSPECTOR_TEMPLATE = (
474+ "{inspector_url_scheme}://{inspector_url_netloc}/project/"
475+ "{name}/{version}/packages/{first}/{second}/{rest}/{filename}"
476+ )
477+
478+
479+ @dataclass
480+ class PyPIInspectorAsset :
481+ """The package PyPI inspector information."""
482+
483+ #: the pypi inspector link to the tarball
484+ package_sdist_link : str
485+
486+ #: the pypi inspector link(s) to the wheel(s)
487+ package_whl_links : list [str ]
488+
489+ #: a mapping of inspector links to whether they are reachable
490+ package_link_reachability : dict [str , bool ]
491+
492+ def __bool__ (self ) -> bool :
493+ """Determine if this inspector object is empty."""
494+ if (self .package_sdist_link or self .package_whl_links ) and self .package_link_reachability :
495+ return True
496+ return False
497+
498+
472499@dataclass
473500class PyPIPackageJsonAsset :
474501 """The package JSON hosted on the PyPI registry."""
@@ -491,6 +518,9 @@ class PyPIPackageJsonAsset:
491518 #: the source code temporary location name
492519 package_sourcecode_path : str
493520
521+ #: the pypi inspector information about this package
522+ inspector_asset : PyPIInspectorAsset
523+
494524 #: The size of the asset (in bytes). This attribute is added to match the AssetLocator
495525 #: protocol and is not used because pypi API registry does not provide it.
496526 @property
@@ -762,6 +792,91 @@ def get_sha256(self) -> str | None:
762792 logger .debug ("Found sha256 hash: %s" , artifact_hash )
763793 return artifact_hash
764794
795+ def get_inspector_links (self ) -> bool :
796+ """Generate PyPI inspector links for this package version's distributions and fill in the inspector asset.
797+
798+ Returns
799+ -------
800+ bool
801+ True if the link generation was successful, False otherwise.
802+ """
803+ if self .inspector_asset :
804+ return True
805+
806+ if not self .package_json and not self .download ("" ):
807+ logger .warning ("No package metadata available, cannot get links" )
808+ return False
809+
810+ releases = self .get_releases ()
811+ if releases is None :
812+ logger .warning ("Package has no releases, cannot create inspector links." )
813+ return False
814+
815+ version = self .component_version
816+ if self .component_version is None :
817+ version = self .get_latest_version ()
818+
819+ if version is None :
820+ logger .warning ("No version set, and no latest version exists. cannot create inspector links." )
821+ return False
822+
823+ distributions = json_extract (releases , [version ], list )
824+
825+ if not distributions :
826+ logger .warning (
827+ "Package has no distributions for release version %s. Cannot create inspector links." , version
828+ )
829+ return False
830+
831+ for distribution in distributions :
832+ package_type = json_extract (distribution , ["packagetype" ], str )
833+ if package_type is None :
834+ logger .warning ("The version %s has no 'package type' field in a distribution" , version )
835+ continue
836+
837+ name = json_extract (self .package_json , ["info" , "name" ], str )
838+ if name is None :
839+ logger .warning ("The version %s has no 'name' field in a distribution" , version )
840+ continue
841+
842+ blake2b_256 = json_extract (distribution , ["digests" , "blake2b_256" ], str )
843+ if blake2b_256 is None :
844+ logger .warning ("The version %s has no 'blake2b_256' field in a distribution" , version )
845+ continue
846+
847+ filename = json_extract (distribution , ["filename" ], str )
848+ if filename is None :
849+ logger .warning ("The version %s has no 'filename' field in a distribution" , version )
850+ continue
851+
852+ link = INSPECTOR_TEMPLATE .format (
853+ inspector_url_scheme = self .pypi_registry .inspector_url_scheme ,
854+ inspector_url_netloc = self .pypi_registry .inspector_url_netloc ,
855+ name = name ,
856+ version = version ,
857+ first = blake2b_256 [0 :2 ],
858+ second = blake2b_256 [2 :4 ],
859+ rest = blake2b_256 [4 :],
860+ filename = filename ,
861+ )
862+
863+ # use a head request because we don't care about the response contents
864+ reachable = False
865+ if send_head_http_raw (link ):
866+ reachable = True # link was reachable
867+
868+ if package_type == "sdist" :
869+ self .inspector_asset .package_sdist_link = link
870+ self .inspector_asset .package_link_reachability [link ] = reachable
871+ elif package_type == "bdist_wheel" :
872+ self .inspector_asset .package_whl_links .append (link )
873+ self .inspector_asset .package_link_reachability [link ] = reachable
874+ else : # no other package types exist, so else statement should never occur
875+ logger .debug ("Unknown package distribution type: %s" , package_type )
876+
877+ # if all distributions were invalid and went along a 'continue' path
878+ return bool (self .inspector_asset )
879+
765880
766881def find_or_create_pypi_asset (
767882 asset_name : str , asset_version : str | None , pypi_registry_info : PackageRegistryInfo
@@ -799,6 +914,8 @@ def find_or_create_pypi_asset(
799914 logger .debug ("Failed to create PyPIPackageJson asset." )
800915 return None
801916
802- asset = PyPIPackageJsonAsset (asset_name , asset_version , False , package_registry , {}, "" )
917+ asset = PyPIPackageJsonAsset (
918+ asset_name , asset_version , False , package_registry , {}, "" , PyPIInspectorAsset ("" , [], {})
919+ )
803920 pypi_registry_info .metadata .append (asset )
804921 return asset
0 commit comments