Skip to content

Commit d31d30f

Browse files
committed
enh: add package integrity verification
Signed-off-by: habeck <habeck@us.ibm.com>
1 parent 6c94370 commit d31d30f

6 files changed

Lines changed: 1193 additions & 14 deletions

File tree

cpex/tools/catalog.py

Lines changed: 114 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@
3737
PyPiRepo,
3838
)
3939
from cpex.framework.utils import find_package_path
40+
from cpex.tools.integrity import (
41+
IntegrityVerificationError,
42+
fetch_pypi_package_hashes,
43+
find_matching_hash,
44+
verify_package_integrity,
45+
)
4046
from cpex.tools.settings import get_catalog_settings
4147

4248
logger = logging.getLogger(__name__)
@@ -613,14 +619,15 @@ def find(self, plugin_name: str) -> Optional[PluginManifest]:
613619
return manifest
614620
return None
615621

616-
def install_folder_via_pip(self, manifest: PluginManifest) -> Path | None:
622+
def install_folder_via_pip(self, manifest: PluginManifest, verify_integrity: bool = True) -> Path | None:
617623
"""
618624
Runs a pip install using subfolder syntax for monorepo plugins.
619625
For isolated_venv plugins, checks manifest kind BEFORE installing to avoid dependency conflicts.
620626
e.g. "git+https://github.com[extra]&subdirectory=folder_name"
621627
622628
Args:
623629
manifest: The PluginManifest of the plugin to be installed
630+
verify_integrity: Whether to compute and log package hash for verification
624631
625632
Raises:
626633
RuntimeError: If package installation fails.
@@ -635,7 +642,7 @@ def install_folder_via_pip(self, manifest: PluginManifest) -> Path | None:
635642
if manifest.kind == "isolated_venv":
636643
logger.info("Detected isolated_venv plugin from monorepo: %s", manifest.name)
637644
# Install the package to make it available for venv initialization
638-
package_path = self._download_monorepo_folder_to_temp(repo_url, manifest.name)
645+
package_path = self._download_monorepo_folder_to_temp(repo_url, manifest.name, verify_integrity=verify_integrity)
639646
plugin_path = self._initialize_isolated_venv(manifest, package_path)
640647
logger.info("Isolated venv initialized. Plugin will be auto-installed via requirements.txt")
641648
else:
@@ -828,12 +835,13 @@ def _extract_package_archive(self, package_file: Path, extract_dir: Path) -> Non
828835
else:
829836
raise RuntimeError(f"Unsupported package format: {package_file}")
830837

831-
def _download_monorepo_folder_to_temp(self, repo_url: str, package_name: str) -> Path:
838+
def _download_monorepo_folder_to_temp(self, repo_url: str, package_name: str, verify_integrity: bool = True) -> Path:
832839
"""Download monorepo folder to temporary directory.
833840
834841
Args:
835842
repo_url: The URL of the monorepo.
836843
package_name: Name used in error messages.
844+
verify_integrity: Whether to compute and log package hash for verification.
837845
838846
Returns:
839847
Path to the extracted package directory. Caller is responsible for cleanup.
@@ -860,6 +868,24 @@ def _download_monorepo_folder_to_temp(self, repo_url: str, package_name: str) ->
860868
if not downloaded_files:
861869
raise RuntimeError(f"No files downloaded for {package_name}")
862870
package_file = downloaded_files[0]
871+
872+
# Compute and log hash for integrity verification
873+
if verify_integrity:
874+
try:
875+
from cpex.tools.integrity import compute_file_hash
876+
package_hash = compute_file_hash(package_file)
877+
logger.info(
878+
"Package integrity hash for %s (%s): SHA256=%s",
879+
package_name,
880+
package_file.name,
881+
package_hash
882+
)
883+
logger.info(
884+
"Store this hash for future verification or to detect tampering"
885+
)
886+
except Exception as e:
887+
logger.warning("Failed to compute package hash: %s", str(e))
888+
863889
extract_dir = temp_dir / "extracted"
864890
extract_dir.mkdir()
865891

@@ -876,20 +902,22 @@ def _download_monorepo_folder_to_temp(self, repo_url: str, package_name: str) ->
876902
raise RuntimeError(f"Unexpected error downloading {package_name}: {str(e)}") from e
877903

878904
def _download_package_to_temp(
879-
self, package_name: str, version_constraint: str | None, use_test: bool = False
905+
self, package_name: str, version_constraint: str | None, use_test: bool = False, verify_integrity: bool = True
880906
) -> Path:
881907
"""Download package to a temporary directory without installing it.
882908
883909
Args:
884910
package_name: The PyPI package name to download.
885911
version_constraint: Optional version constraint.
886912
use_test: Whether to use test.pypi.org.
913+
verify_integrity: Whether to verify package integrity using SHA256 hashes.
887914
888915
Returns:
889916
Path to the downloaded package directory.
890917
891918
Raises:
892919
RuntimeError: If download fails.
920+
IntegrityVerificationError: If hash verification fails.
893921
"""
894922

895923
try:
@@ -902,6 +930,33 @@ def _download_package_to_temp(
902930
if ppi.version_constraint is not None:
903931
tgt = f"{tgt}{ppi.version_constraint}"
904932

933+
# Fetch expected hashes from PyPI before downloading (if verification enabled)
934+
expected_hashes = {}
935+
if verify_integrity:
936+
try:
937+
logger.info("Fetching package hashes from PyPI for %s", package_name)
938+
# Extract version from constraint if available, otherwise fetch latest
939+
version_to_fetch = None
940+
if version_constraint:
941+
# Try to extract exact version from constraint (e.g., "==1.0.0" -> "1.0.0")
942+
import re
943+
version_match = re.search(r'==\s*([0-9.]+)', version_constraint)
944+
if version_match:
945+
version_to_fetch = version_match.group(1)
946+
947+
expected_hashes = fetch_pypi_package_hashes(
948+
package_name=package_name,
949+
version=version_to_fetch,
950+
use_test=use_test
951+
)
952+
if expected_hashes:
953+
logger.info("Retrieved hashes for %d distribution files", len(expected_hashes))
954+
else:
955+
logger.warning("No hashes available from PyPI for %s", package_name)
956+
except Exception as e:
957+
logger.warning("Failed to fetch hashes from PyPI: %s. Proceeding without verification.", str(e))
958+
expected_hashes = {}
959+
905960
# Download package without installing
906961
download_args = [
907962
self.python_executable,
@@ -926,6 +981,24 @@ def _download_package_to_temp(
926981
raise RuntimeError(f"No files downloaded for {package_name}")
927982

928983
package_file = downloaded_files[0]
984+
985+
# Verify package integrity if hashes are available
986+
if verify_integrity and expected_hashes:
987+
expected_hash = find_matching_hash(package_file, expected_hashes, package_name)
988+
if expected_hash:
989+
logger.info("Verifying integrity of %s", package_file.name)
990+
verify_package_integrity(
991+
file_path=package_file,
992+
expected_hash=expected_hash,
993+
package_name=package_name,
994+
strict=True
995+
)
996+
else:
997+
logger.warning(
998+
"No matching hash found for %s. Proceeding without verification.",
999+
package_file.name
1000+
)
1001+
9291002
extract_dir = temp_dir / "extracted"
9301003
extract_dir.mkdir()
9311004

@@ -935,9 +1008,15 @@ def _download_package_to_temp(
9351008
logger.info("Downloaded and extracted %s to %s", package_name, extract_dir)
9361009
return extract_dir
9371010

1011+
except IntegrityVerificationError:
1012+
# Re-raise integrity errors without wrapping
1013+
shutil.rmtree(temp_dir, ignore_errors=True)
1014+
raise
9381015
except subprocess.CalledProcessError as e:
1016+
shutil.rmtree(temp_dir, ignore_errors=True)
9391017
raise RuntimeError(f"Failed to download {package_name}: {e.stderr}") from e
9401018
except Exception as e:
1019+
shutil.rmtree(temp_dir, ignore_errors=True)
9411020
raise RuntimeError(f"Unexpected error downloading {package_name}: {str(e)}") from e
9421021

9431022
def _find_manifest_in_extracted_package(self, extract_dir: Path, package_name: str) -> Path:
@@ -1251,7 +1330,11 @@ def _finalize_plugin_installation(
12511330
return actual_plugin_path if actual_plugin_path is not None else plugin_path
12521331

12531332
def install_from_pypi(
1254-
self, plugin_package_name: str, version_constraint: str | None = None, use_pytest: bool = False
1333+
self,
1334+
plugin_package_name: str,
1335+
version_constraint: str | None = None,
1336+
use_pytest: bool = False,
1337+
verify_integrity: bool = True,
12551338
) -> tuple[PluginManifest, Path | None]:
12561339
"""Install Python package from PyPI and load its plugin-manifest.yaml.
12571340
@@ -1268,17 +1351,22 @@ def install_from_pypi(
12681351
Args:
12691352
plugin_package_name: The name of the package hosted on PyPI.
12701353
version_constraint: Optional version constraint (e.g., ">=1.0.0,<2.0.0").
1354+
use_pytest: Whether to use test.pypi.org instead of pypi.org.
1355+
verify_integrity: Whether to verify package integrity using SHA256 hashes from PyPI.
12711356
12721357
Returns:
12731358
The loaded and validated plugin manifest.
12741359
12751360
Raises:
12761361
RuntimeError: If any step of the installation process fails.
12771362
FileNotFoundError: If plugin-manifest.yaml is not found in the package.
1363+
IntegrityVerificationError: If package hash verification fails.
12781364
"""
12791365

1280-
# Step 1: Download package to temporary location to read manifest
1281-
temp_extract_dir = self._download_package_to_temp(plugin_package_name, version_constraint, use_pytest)
1366+
# Step 1: Download package to temporary location to read manifest (with integrity verification)
1367+
temp_extract_dir = self._download_package_to_temp(
1368+
plugin_package_name, version_constraint, use_pytest, verify_integrity=verify_integrity
1369+
)
12821370

12831371
try:
12841372
# Step 2: Find and load the manifest file
@@ -1312,7 +1400,7 @@ def install_from_pypi(
13121400
if temp_extract_dir.exists():
13131401
shutil.rmtree(temp_extract_dir.parent)
13141402

1315-
def install_from_git(self, url: str) -> tuple[PluginManifest, Path | None]:
1403+
def install_from_git(self, url: str, verify_integrity: bool = True) -> tuple[PluginManifest, Path | None]:
13161404
"""Install Python package from Git repository and load its plugin-manifest.yaml.
13171405
13181406
This method performs the following steps:
@@ -1331,6 +1419,7 @@ def install_from_git(self, url: str) -> tuple[PluginManifest, Path | None]:
13311419
- MyProject @ git+ssh://git@git.example.com/MyProject
13321420
- MyProject @ git+https://git.example.com/MyProject
13331421
- MyProject @ git+https://git.example.com/MyProject@master
1422+
verify_integrity: Whether to compute and log package hash for verification
13341423
13351424
Returns:
13361425
Tuple of (PluginManifest, Path to plugin or None)
@@ -1416,6 +1505,23 @@ def install_from_git(self, url: str) -> tuple[PluginManifest, Path | None]:
14161505
archive_path = archives[0]
14171506
logger.info("Downloaded archive: %s", archive_path.name)
14181507

1508+
# Compute and log hash for integrity verification
1509+
if verify_integrity:
1510+
try:
1511+
from cpex.tools.integrity import compute_file_hash
1512+
package_hash = compute_file_hash(archive_path)
1513+
logger.info(
1514+
"Package integrity hash for %s (%s): SHA256=%s",
1515+
package_name,
1516+
archive_path.name,
1517+
package_hash
1518+
)
1519+
logger.info(
1520+
"Store this hash for future verification or to detect tampering"
1521+
)
1522+
except Exception as e:
1523+
logger.warning("Failed to compute package hash: %s", str(e))
1524+
14191525
# Extract the archive using common helper
14201526
self._extract_package_archive(archive_path, temp_extract_dir)
14211527

cpex/tools/cli.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
)
4848
from cpex.framework.settings import settings
4949
from cpex.tools.catalog import PluginCatalog
50+
from cpex.tools.settings import get_catalog_settings
5051

5152
# Third-Party
5253
from cpex.tools.plugin_registry import PluginRegistry
@@ -518,17 +519,24 @@ def _install_from_local(source: str, catalog: PluginCatalog, use_test: bool = Fa
518519

519520

520521
def _install_from_git(source: str, catalog: PluginCatalog, use_test: bool = False):
521-
"""Handle git-based installation (not yet implemented).
522+
"""Handle git-based installation.
522523
523524
Args:
524525
source: Git repository URL or path.
525526
catalog: The plugin catalog.
526-
527-
Raises:
528-
NotImplementedError: Git installation is not yet supported.
527+
use_test: Unused for git installations (kept for consistency).
529528
"""
529+
# Get integrity verification setting from catalog settings
530+
catalog_settings = get_catalog_settings()
531+
verify_integrity = catalog_settings.VERIFY_PACKAGE_INTEGRITY
532+
533+
if verify_integrity:
534+
console.log("Package integrity verification: enabled (hash will be computed and logged)")
535+
else:
536+
console.log("Package integrity verification: disabled")
537+
530538
with console.status(f"Installing plugin from source {source}...", spinner="dots"):
531-
manifest, installation_path = catalog.install_from_git(source)
539+
manifest, installation_path = catalog.install_from_git(source, verify_integrity=verify_integrity)
532540
_finalize_installation(manifest, "git", catalog, installation_path)
533541
console.print(f":white_heavy_check_mark: {manifest.name} installation complete.")
534542

@@ -564,15 +572,28 @@ def _install_from_pypi(source: str, catalog: PluginCatalog, use_test: bool = Fal
564572
Args:
565573
source: PyPI package name, optionally with version constraint (e.g., "package@>=1.0.0").
566574
catalog: The plugin catalog.
575+
use_test: Whether to use test.pypi.org instead of pypi.org.
567576
"""
568577
logger.info("Trying to install from pypi package %s", source)
569578

570579
# Parse version constraint
571580
package_name, version_constraint = _parse_pypi_source(source)
572581

582+
# Get integrity verification setting from catalog settings
583+
catalog_settings = get_catalog_settings()
584+
verify_integrity = catalog_settings.VERIFY_PACKAGE_INTEGRITY
585+
586+
if verify_integrity:
587+
console.log(f"Package integrity verification: enabled")
588+
else:
589+
console.log(f"Package integrity verification: disabled")
590+
573591
with console.status(f"Installing plugin {package_name} via pypi", spinner="dots"):
574592
manifest, plugin_path = catalog.install_from_pypi(
575-
plugin_package_name=package_name, version_constraint=version_constraint, use_pytest=use_test
593+
plugin_package_name=package_name,
594+
version_constraint=version_constraint,
595+
use_pytest=use_test,
596+
verify_integrity=verify_integrity,
576597
)
577598

578599
if manifest is None:

0 commit comments

Comments
 (0)