From 808dbb5b499eabb74fc26bfa86deb6bcee5c7d2d Mon Sep 17 00:00:00 2001 From: CruxExperts Date: Tue, 4 Aug 2026 15:20:50 -0500 Subject: [PATCH] fix: enforce cryptography floor in health checks --- bbackup/management/dependencies.py | 67 ++++++++++++++++++++---------- bbackup/management/health.py | 25 ++--------- bbackup/management/setup_wizard.py | 23 ++-------- docs/architecture.md | 2 +- tests/test_management.py | 20 +++++++++ 5 files changed, 73 insertions(+), 64 deletions(-) diff --git a/bbackup/management/dependencies.py b/bbackup/management/dependencies.py index 93e3d9a..880280c 100644 --- a/bbackup/management/dependencies.py +++ b/bbackup/management/dependencies.py @@ -2,12 +2,13 @@ Dependency checking and installation. """ +import re import subprocess import sys -import re import tomllib +from importlib.metadata import PackageNotFoundError, version as package_version from pathlib import Path -from typing import Dict, List, Tuple +from typing import Dict, List, Optional, Tuple from rich.console import Console from rich.table import Table @@ -15,6 +16,30 @@ console = Console() +REQUIRED_PYTHON_PACKAGES = { + "rich": "rich", + "pyyaml": "yaml", + "docker": "docker", + "click": "click", + "paramiko": "paramiko", + "cryptography": "cryptography", + "requests": "requests", +} +MINIMUM_PYTHON_PACKAGE_VERSIONS = { + "cryptography": (50, 0, 0), +} + + +def _release_tuple(raw_version: str) -> Optional[Tuple[int, int, int]]: + """Return a stable three-part release tuple, rejecting prereleases.""" + match = re.fullmatch( + r"(\d+)\.(\d+)\.(\d+)(?:\.post\d+)?(?:\+[A-Za-z0-9.-]+)?", + raw_version, + ) + if match is None: + return None + return tuple(int(part) for part in match.groups()) + def check_system_dependencies() -> Dict[str, Tuple[bool, str]]: """ @@ -63,32 +88,30 @@ def check_system_dependencies() -> Dict[str, Tuple[bool, str]]: def check_python_dependencies() -> Tuple[bool, List[str], List[str]]: - """ - Check Python package dependencies. - - Returns: - Tuple of (all_installed, installed_packages, missing_packages) - """ - required = { - "rich": "rich", - "pyyaml": "yaml", - "docker": "docker", - "click": "click", - "paramiko": "paramiko", - "cryptography": "cryptography", - "requests": "requests", - } - + """Check required Python package imports and security version floors.""" installed = [] missing = [] - - for package_name, import_name in required.items(): + + for package_name, import_name in REQUIRED_PYTHON_PACKAGES.items(): try: __import__(import_name) - installed.append(package_name) except ImportError: missing.append(package_name) - + continue + + minimum = MINIMUM_PYTHON_PACKAGE_VERSIONS.get(package_name) + if minimum is not None: + try: + current = _release_tuple(package_version(package_name)) + except PackageNotFoundError: + current = None + if current is None or current < minimum: + floor = ".".join(str(part) for part in minimum) + missing.append(f"{package_name}>={floor}") + continue + + installed.append(package_name) + return len(missing) == 0, installed, missing diff --git a/bbackup/management/health.py b/bbackup/management/health.py index 07e63ea..7488b61 100644 --- a/bbackup/management/health.py +++ b/bbackup/management/health.py @@ -13,6 +13,7 @@ from ..config import Config from ..snapshot import check_all_snapshot_profiles +from .dependencies import check_python_dependencies as _check_python_dependencies console = Console() @@ -71,28 +72,8 @@ def check_system_tool(tool: str) -> Tuple[bool, str]: def check_python_packages() -> Tuple[bool, List[str], List[str]]: - """Check if required Python packages are installed.""" - required = { - "rich": "rich", - "pyyaml": "yaml", - "docker": "docker", - "click": "click", - "paramiko": "paramiko", - "cryptography": "cryptography", - "requests": "requests", - } - - installed = [] - missing = [] - - for package_name, import_name in required.items(): - try: - __import__(import_name) - installed.append(package_name) - except ImportError: - missing.append(package_name) - - return len(missing) == 0, installed, missing + """Check required Python package imports and security version floors.""" + return _check_python_dependencies() def check_config_file() -> Tuple[bool, str]: diff --git a/bbackup/management/setup_wizard.py b/bbackup/management/setup_wizard.py index 96adcaf..6bb87cd 100644 --- a/bbackup/management/setup_wizard.py +++ b/bbackup/management/setup_wizard.py @@ -12,6 +12,7 @@ from rich.table import Table from .first_run import mark_first_run_complete, get_config_file +from .dependencies import check_python_dependencies as _check_python_dependencies console = Console() @@ -54,25 +55,9 @@ def check_system_tool(tool: str) -> Tuple[bool, str]: def check_python_packages() -> Tuple[bool, List[str]]: - """Check if required Python packages are installed.""" - required = { - "rich": "rich", - "pyyaml": "yaml", - "docker": "docker", - "click": "click", - "paramiko": "paramiko", - "cryptography": "cryptography", - "requests": "requests", - } - missing = [] - - for package, import_name in required.items(): - try: - __import__(import_name) - except ImportError: - missing.append(package) - - return len(missing) == 0, missing + """Check required Python package imports and security version floors.""" + all_ok, _, missing = _check_python_dependencies() + return all_ok, missing def run_setup_wizard() -> bool: diff --git a/docs/architecture.md b/docs/architecture.md index bad8903..29b4cda 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,7 +14,7 @@ | Docker integration | docker-py SDK | 7.0.0+ | | Config format | PyYAML | 6.0.1+ | | SFTP | paramiko | 3.4.0+ | -| Encryption | cryptography | 48.0.1+ | +| Encryption | cryptography | 50.0.0+ | | HTTP (key fetching) | requests | 2.31.0+ | | Volume backup | rsync | system | | Cloud storage | rclone | optional | diff --git a/tests/test_management.py b/tests/test_management.py index 182175b..8aa3cb4 100644 --- a/tests/test_management.py +++ b/tests/test_management.py @@ -412,6 +412,26 @@ def test_check_python_dependencies_all_present(self): assert isinstance(installed, list) assert isinstance(missing, list) + def test_check_python_dependencies_rejects_old_cryptography(self): + from bbackup.management.dependencies import check_python_dependencies + + with patch( + "bbackup.management.dependencies.package_version", + return_value="49.0.0", + ): + ok, installed, missing = check_python_dependencies() + + assert ok is False + assert "cryptography>=50.0.0" in missing + assert "cryptography" not in installed + + def test_release_tuple_accepts_stable_post_and_local_versions(self): + from bbackup.management.dependencies import _release_tuple + + assert _release_tuple("50.0.0.post1+vendor") == (50, 0, 0) + assert _release_tuple("50.0.0+vendor") == (50, 0, 0) + assert _release_tuple("50.0.0rc1") is None + def test_check_project_dependencies_reads_packages(self): from bbackup.management.dependencies import check_project_dependencies result = check_project_dependencies()