diff --git a/changelog.d/20260219_155531_clement.tourriere_plugin_signing.md b/changelog.d/20260219_155531_clement.tourriere_plugin_signing.md new file mode 100644 index 0000000000..d95b1388c2 --- /dev/null +++ b/changelog.d/20260219_155531_clement.tourriere_plugin_signing.md @@ -0,0 +1,7 @@ +### Added + +- Add sigstore signature verification for plugin wheels, enforcing identity-based trust via OIDC. Plugins can be verified in STRICT, WARN, or DISABLED mode, configurable through enterprise settings or the `--allow-unsigned` flag. Note: `--allow-unsigned` is intentionally separate from the existing `--insecure` option, as they address different security concerns (content authenticity vs. transport security). + +### Fixed + +- Forward `signature_mode` through GitHub release and GitHub artifact download paths, ensuring signature verification is applied consistently across all install sources. diff --git a/ggshield/__main__.py b/ggshield/__main__.py index 0baf27b776..db8e36201f 100644 --- a/ggshield/__main__.py +++ b/ggshield/__main__.py @@ -274,14 +274,14 @@ def main(args: Optional[List[str]] = None) -> Any: `args` is only used by unit-tests. """ + log_utils.disable_logs() + _register_plugin_commands() # Required by pyinstaller when forking. # See https://pyinstaller.org/en/latest/common-issues-and-pitfalls.html#multi-processing multiprocessing.freeze_support() - log_utils.disable_logs() - if not os.getenv("GG_PLAINTEXT_OUTPUT", False) and sys.stderr.isatty(): ui.set_ui(RichGGShieldUI()) diff --git a/ggshield/cmd/plugin/install.py b/ggshield/cmd/plugin/install.py index 721eadfeff..1ebd3fd150 100644 --- a/ggshield/cmd/plugin/install.py +++ b/ggshield/cmd/plugin/install.py @@ -27,6 +27,10 @@ InsecureSourceError, PluginDownloader, ) +from ggshield.core.plugin.signature import ( + SignatureVerificationError, + SignatureVerificationMode, +) def detect_source_type(plugin_source: str) -> PluginSourceType: @@ -64,12 +68,6 @@ def detect_source_type(plugin_source: str) -> PluginSourceType: return PluginSourceType.GITGUARDIAN_API -def _display_unsigned_warning() -> None: - """Display warning about installing unsigned plugins.""" - ui.display_warning("This plugin is not from GitGuardian and has not been verified.") - ui.display_warning("Only install plugins from sources you trust.") - - @click.command() @click.argument("plugin_source") @click.option( @@ -85,10 +83,10 @@ def _display_unsigned_warning() -> None: help="Expected SHA256 checksum for URL verification", ) @click.option( - "--force", - "force", + "--allow-unsigned", + "allow_unsigned", is_flag=True, - help="Skip security warnings for non-GitGuardian sources", + help="Allow installing plugins without valid signatures (overrides strict mode)", ) @add_common_options() @click.pass_context @@ -97,7 +95,7 @@ def install_cmd( plugin_source: str, version: Optional[str], sha256: Optional[str], - force: bool, + allow_unsigned: bool, **kwargs: Any, ) -> None: """ @@ -127,24 +125,31 @@ def install_cmd( ggshield plugin install https://github.com/owner/repo/actions/runs/123/artifacts/456 """ + # Determine signature verification mode + enterprise_config = EnterpriseConfig.load() + signature_mode = enterprise_config.get_signature_mode() + if allow_unsigned: + signature_mode = SignatureVerificationMode.WARN + source_type = detect_source_type(plugin_source) if source_type == PluginSourceType.GITHUB_ARTIFACT: - _install_from_github_artifact(ctx, plugin_source, force) + _install_from_github_artifact(ctx, plugin_source, signature_mode) elif source_type == PluginSourceType.GITHUB_RELEASE: - _install_from_github_release(ctx, plugin_source, sha256, force) + _install_from_github_release(ctx, plugin_source, sha256, signature_mode) elif source_type == PluginSourceType.URL: - _install_from_url(ctx, plugin_source, sha256, force) + _install_from_url(ctx, plugin_source, sha256, signature_mode) elif source_type == PluginSourceType.LOCAL_FILE: - _install_from_local_wheel(ctx, plugin_source, force) + _install_from_local_wheel(ctx, plugin_source, signature_mode) else: - _install_from_gitguardian(ctx, plugin_source, version) + _install_from_gitguardian(ctx, plugin_source, version, signature_mode) def _install_from_gitguardian( ctx: click.Context, plugin_name: str, version: Optional[str], + signature_mode: SignatureVerificationMode = SignatureVerificationMode.STRICT, ) -> None: """Install a plugin from GitGuardian API.""" ctx_obj = ContextObj.get(ctx) @@ -192,7 +197,9 @@ def _install_from_gitguardian( ) # Download and install - downloader.download_and_install(download_info, plugin_name) + downloader.download_and_install( + download_info, plugin_name, signature_mode=signature_mode + ) # Enable in config enterprise_config.enable_plugin(plugin_name, version=download_info.version) @@ -202,6 +209,12 @@ def _install_from_gitguardian( ui.display_info(f"Installed {plugin_name} v{download_info.version}") + except SignatureVerificationError as e: + ui.display_error(f"Signature verification failed for {plugin_name}: {e}") + ui.display_info( + "Use --allow-unsigned to install without signature verification" + ) + ctx.exit(ExitCode.UNEXPECTED_ERROR) except PluginNotAvailableError as e: ui.display_error(f"Failed to install {plugin_name}: {e}") ctx.exit(ExitCode.UNEXPECTED_ERROR) @@ -216,7 +229,7 @@ def _install_from_gitguardian( def _install_from_local_wheel( ctx: click.Context, wheel_path_str: str, - force: bool, + signature_mode: SignatureVerificationMode = SignatureVerificationMode.STRICT, ) -> None: """Install a plugin from a local wheel file.""" wheel_path = Path(wheel_path_str) @@ -225,16 +238,15 @@ def _install_from_local_wheel( ui.display_error(f"Wheel file not found: {wheel_path}") ctx.exit(ExitCode.USAGE_ERROR) - if not force: - _display_unsigned_warning() - downloader = PluginDownloader() enterprise_config = EnterpriseConfig.load() ui.display_info(f"Installing from {wheel_path.name}...") try: - plugin_name, version, _ = downloader.install_from_wheel(wheel_path, force) + plugin_name, version, _ = downloader.install_from_wheel( + wheel_path, signature_mode=signature_mode + ) # Enable in config enterprise_config.enable_plugin(plugin_name, version=version) @@ -242,6 +254,12 @@ def _install_from_local_wheel( ui.display_info(f"Installed {plugin_name} v{version}") + except SignatureVerificationError as e: + ui.display_error(f"Signature verification failed: {e}") + ui.display_info( + "Use --allow-unsigned to install without signature verification" + ) + ctx.exit(ExitCode.UNEXPECTED_ERROR) except DownloadError as e: ui.display_error(f"Failed to install from wheel: {e}") ctx.exit(ExitCode.UNEXPECTED_ERROR) @@ -254,23 +272,18 @@ def _install_from_url( ctx: click.Context, url: str, sha256: Optional[str], - force: bool, + signature_mode: SignatureVerificationMode = SignatureVerificationMode.STRICT, ) -> None: """Install a plugin from a URL.""" - if not force: - _display_unsigned_warning() - if not sha256: - ui.display_warning( - "No SHA256 checksum provided. Consider using --sha256 for verification." - ) - downloader = PluginDownloader() enterprise_config = EnterpriseConfig.load() ui.display_info("Installing from URL...") try: - plugin_name, version, _ = downloader.download_from_url(url, sha256, force) + plugin_name, version, _ = downloader.download_from_url( + url, sha256, signature_mode=signature_mode + ) # Enable in config enterprise_config.enable_plugin(plugin_name, version=version) @@ -278,6 +291,12 @@ def _install_from_url( ui.display_info(f"Installed {plugin_name} v{version}") + except SignatureVerificationError as e: + ui.display_error(f"Signature verification failed: {e}") + ui.display_info( + "Use --allow-unsigned to install without signature verification" + ) + ctx.exit(ExitCode.UNEXPECTED_ERROR) except InsecureSourceError as e: ui.display_error(str(e)) ctx.exit(ExitCode.USAGE_ERROR) @@ -296,12 +315,9 @@ def _install_from_github_release( ctx: click.Context, url: str, sha256: Optional[str], - force: bool, + signature_mode: SignatureVerificationMode = SignatureVerificationMode.STRICT, ) -> None: """Install a plugin from a GitHub release asset.""" - if not force: - _display_unsigned_warning() - downloader = PluginDownloader() enterprise_config = EnterpriseConfig.load() @@ -309,7 +325,7 @@ def _install_from_github_release( try: plugin_name, version, _ = downloader.download_from_github_release( - url, sha256, force + url, sha256, signature_mode=signature_mode ) # Enable in config @@ -318,6 +334,12 @@ def _install_from_github_release( ui.display_info(f"Installed {plugin_name} v{version}") + except SignatureVerificationError as e: + ui.display_error(f"Signature verification failed: {e}") + ui.display_info( + "Use --allow-unsigned to install without signature verification" + ) + ctx.exit(ExitCode.UNEXPECTED_ERROR) except InsecureSourceError as e: ui.display_error(str(e)) ctx.exit(ExitCode.USAGE_ERROR) @@ -335,12 +357,10 @@ def _install_from_github_release( def _install_from_github_artifact( ctx: click.Context, url: str, - force: bool, + signature_mode: SignatureVerificationMode = SignatureVerificationMode.STRICT, ) -> None: """Install a plugin from a GitHub Actions artifact.""" - if not force: - _display_unsigned_warning() - ui.display_warning("GitHub artifacts are ephemeral and cannot be auto-updated.") + ui.display_warning("GitHub artifacts are ephemeral and cannot be auto-updated.") downloader = PluginDownloader() enterprise_config = EnterpriseConfig.load() @@ -348,7 +368,9 @@ def _install_from_github_artifact( ui.display_info("Installing from GitHub artifact...") try: - plugin_name, version, _ = downloader.download_from_github_artifact(url, force) + plugin_name, version, _ = downloader.download_from_github_artifact( + url, signature_mode=signature_mode + ) # Enable in config enterprise_config.enable_plugin(plugin_name, version=version) @@ -356,6 +378,12 @@ def _install_from_github_artifact( ui.display_info(f"Installed {plugin_name} v{version}") + except SignatureVerificationError as e: + ui.display_error(f"Signature verification failed: {e}") + ui.display_info( + "Use --allow-unsigned to install without signature verification" + ) + ctx.exit(ExitCode.UNEXPECTED_ERROR) except GitHubArtifactError as e: ui.display_error(str(e)) ctx.exit(ExitCode.UNEXPECTED_ERROR) diff --git a/ggshield/cmd/plugin/status.py b/ggshield/cmd/plugin/status.py index 639ef37b56..e4b75af28c 100644 --- a/ggshield/cmd/plugin/status.py +++ b/ggshield/cmd/plugin/status.py @@ -13,7 +13,7 @@ from ggshield.core.config.enterprise_config import EnterpriseConfig from ggshield.core.errors import ExitCode from ggshield.core.plugin.client import PluginAPIClient, PluginAPIError -from ggshield.core.plugin.downloader import PluginDownloader +from ggshield.core.plugin.downloader import PluginDownloader, get_signature_label @click.command() @@ -79,6 +79,12 @@ def status_cmd(ctx: click.Context, **kwargs: Any) -> None: status_str = ", ".join(status_parts) ui.display_info(f" {plugin.display_name} ({plugin.name})") ui.display_info(f" Status: {status_str}") + if installed_version: + manifest = downloader.get_manifest(plugin.name) + if manifest: + sig_label = get_signature_label(manifest) + if sig_label: + ui.display_info(f" Signature: {sig_label}") ui.display_info(f" {plugin.description}") else: ui.display_info(f" {plugin.display_name} ({plugin.name}) - not available") diff --git a/ggshield/core/config/enterprise_config.py b/ggshield/core/config/enterprise_config.py index ea2c562752..d29131f697 100644 --- a/ggshield/core/config/enterprise_config.py +++ b/ggshield/core/config/enterprise_config.py @@ -2,14 +2,24 @@ Enterprise configuration - plugin settings. """ +from __future__ import annotations + from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Dict, Optional +from typing import TYPE_CHECKING, Any, Dict, Optional from ggshield.core.config.utils import load_yaml_dict, save_yaml_dict from ggshield.core.dirs import get_config_dir +if TYPE_CHECKING: + from ggshield.core.plugin.signature import SignatureVerificationMode + + +_PLUGIN_SIGNATURE_MODE_KEY = "plugin_signature_mode" +_DEFAULT_SIGNATURE_MODE = "strict" + + def get_enterprise_config_filepath() -> Path: """Get the path to the enterprise config file.""" return get_config_dir() / "enterprise_config.yaml" @@ -29,9 +39,10 @@ class EnterpriseConfig: """Enterprise configuration stored in ~/.config/ggshield/enterprise_config.yaml""" plugins: Dict[str, PluginConfig] = field(default_factory=dict) + plugin_signature_mode: str = _DEFAULT_SIGNATURE_MODE @classmethod - def load(cls) -> "EnterpriseConfig": + def load(cls) -> EnterpriseConfig: """Load enterprise config from file.""" config_path = get_enterprise_config_filepath() data = load_yaml_dict(config_path) @@ -54,7 +65,11 @@ def load(cls) -> "EnterpriseConfig": else: plugins[name] = PluginConfig(enabled=True) - return cls(plugins=plugins) + plugin_signature_mode = data.get( + _PLUGIN_SIGNATURE_MODE_KEY, _DEFAULT_SIGNATURE_MODE + ) + + return cls(plugins=plugins, plugin_signature_mode=plugin_signature_mode) def save(self) -> None: """Save enterprise config to file.""" @@ -72,6 +87,8 @@ def save(self) -> None: } } + data[_PLUGIN_SIGNATURE_MODE_KEY] = self.plugin_signature_mode + # Remove None values for cleaner YAML for plugin_data in data["plugins"].values(): if plugin_data["version"] is None: @@ -79,6 +96,17 @@ def save(self) -> None: save_yaml_dict(data, config_path) + def get_signature_mode(self) -> SignatureVerificationMode: + """Get the signature verification mode.""" + # Deferred import to avoid circular dependency: + # enterprise_config -> plugin.__init__ -> loader -> enterprise_config + from ggshield.core.plugin.signature import SignatureVerificationMode + + try: + return SignatureVerificationMode(self.plugin_signature_mode) + except ValueError: + return SignatureVerificationMode.STRICT + def enable_plugin(self, plugin_name: str, version: Optional[str] = None) -> None: """Enable a plugin.""" if plugin_name not in self.plugins: @@ -91,15 +119,16 @@ def enable_plugin(self, plugin_name: str, version: Optional[str] = None) -> None def disable_plugin(self, plugin_name: str) -> None: """Disable a plugin.""" if plugin_name not in self.plugins: - raise ValueError(f"Plugin '{plugin_name}' is not configured") + self.plugins[plugin_name] = PluginConfig(enabled=False) + return self.plugins[plugin_name].enabled = False def is_plugin_enabled(self, plugin_name: str) -> bool: """Check if a plugin is enabled.""" plugin_config = self.plugins.get(plugin_name) - # Default: disabled if not explicitly configured - return plugin_config.enabled if plugin_config else False + # Default: enabled if not explicitly configured + return plugin_config.enabled if plugin_config else True def get_plugin_version(self, plugin_name: str) -> Optional[str]: """Get the configured version of a plugin.""" diff --git a/ggshield/core/plugin/client.py b/ggshield/core/plugin/client.py index bfecf503d8..56fec24c2c 100644 --- a/ggshield/core/plugin/client.py +++ b/ggshield/core/plugin/client.py @@ -97,6 +97,7 @@ class PluginDownloadInfo: sha256: str version: str expires_at: str + signature_url: Optional[str] = None class PluginAPIError(Exception): @@ -215,6 +216,7 @@ def get_download_info( sha256=data["sha256"], version=data["version"], expires_at=data["expires_at"], + signature_url=data.get("signature_url"), ) def _is_plugin_available( diff --git a/ggshield/core/plugin/downloader.py b/ggshield/core/plugin/downloader.py index 11612f691a..38afc0d2f8 100644 --- a/ggshield/core/plugin/downloader.py +++ b/ggshield/core/plugin/downloader.py @@ -21,12 +21,35 @@ PluginSource, PluginSourceType, ) +from ggshield.core.plugin.signature import ( + SignatureInfo, + SignatureVerificationError, + SignatureVerificationMode, + verify_wheel_signature, +) from ggshield.core.plugin.wheel_utils import WheelError, extract_wheel_metadata logger = logging.getLogger(__name__) +def get_signature_label(manifest: Dict[str, Any]) -> Optional[str]: + """Get a human-readable signature status label from a manifest. + + Returns a string like "valid (GitGuardian/satori)", "missing", etc. + or None if no signature info is in the manifest. + """ + sig_info = manifest.get("signature") + if not sig_info: + return None + + status = sig_info.get("status", "unknown") + identity = sig_info.get("identity") + if identity: + return f"{status} ({identity})" + return status + + class DownloadError(Exception): """Error downloading or installing a plugin.""" @@ -67,6 +90,7 @@ def download_and_install( download_info: PluginDownloadInfo, plugin_name: str, source: Optional[PluginSource] = None, + signature_mode: SignatureVerificationMode = SignatureVerificationMode.STRICT, ) -> Path: """Download a plugin wheel and install it locally.""" self._validate_plugin_name(plugin_name) @@ -92,6 +116,13 @@ def download_and_install( if computed_hash.lower() != download_info.sha256.lower(): raise ChecksumMismatchError(download_info.sha256, computed_hash) + # Download signature bundle if available + temp_path.rename(wheel_path) + self._download_bundle(download_info, plugin_dir) + + # Verify signature + sig_info = verify_wheel_signature(wheel_path, signature_mode) + # Use GitGuardian API as default source if not provided if source is None: source = PluginSource(type=PluginSourceType.GITGUARDIAN_API) @@ -103,16 +134,19 @@ def download_and_install( wheel_filename=download_info.filename, sha256=download_info.sha256, source=source, + signature_info=sig_info, ) - temp_path.rename(wheel_path) - logger.info("Installed %s v%s", plugin_name, download_info.version) return wheel_path except requests.RequestException as e: + self._cleanup_failed_install(wheel_path) raise DownloadError(f"Failed to download plugin: {e}") from e + except SignatureVerificationError: + self._cleanup_failed_install(wheel_path) + raise finally: if temp_path.exists(): temp_path.unlink() @@ -120,14 +154,14 @@ def download_and_install( def install_from_wheel( self, wheel_path: Path, - force: bool = False, + signature_mode: SignatureVerificationMode = SignatureVerificationMode.STRICT, ) -> Tuple[str, str, Path]: """ Install a plugin from a local wheel file. Args: wheel_path: Path to the wheel file. - force: Skip security warnings if True. + signature_mode: Signature verification mode. Returns: Tuple of (plugin_name, version, installed_wheel_path). @@ -135,6 +169,7 @@ def install_from_wheel( Raises: WheelError: If the wheel file is invalid. DownloadError: If installation fails. + SignatureVerificationError: In STRICT mode when signature is invalid. """ # Extract metadata from wheel try: @@ -154,6 +189,16 @@ def install_from_wheel( dest_wheel_path = plugin_dir / wheel_path.name shutil.copy2(wheel_path, dest_wheel_path) + # Copy bundle if it exists alongside the wheel + from ggshield.core.plugin.signature import get_bundle_path + + bundle_path = get_bundle_path(wheel_path) + if bundle_path is not None: + shutil.copy2(bundle_path, plugin_dir / bundle_path.name) + + # Verify signature + sig_info = verify_wheel_signature(dest_wheel_path, signature_mode) + # Compute SHA256 sha256 = self._compute_sha256(dest_wheel_path) @@ -171,6 +216,7 @@ def install_from_wheel( wheel_filename=wheel_path.name, sha256=sha256, source=source, + signature_info=sig_info, ) logger.info("Installed %s v%s from local wheel", plugin_name, version) @@ -181,7 +227,7 @@ def download_from_url( self, url: str, sha256: Optional[str] = None, - force: bool = False, + signature_mode: SignatureVerificationMode = SignatureVerificationMode.STRICT, ) -> Tuple[str, str, Path]: """ Download and install a plugin from a URL. @@ -189,7 +235,7 @@ def download_from_url( Args: url: URL to download the wheel from. sha256: Expected SHA256 checksum (optional but recommended). - force: Skip security warnings if True. + signature_mode: Signature verification mode. Returns: Tuple of (plugin_name, version, installed_wheel_path). @@ -198,6 +244,7 @@ def download_from_url( InsecureSourceError: If URL uses HTTP instead of HTTPS. ChecksumMismatchError: If checksum doesn't match. DownloadError: If download or installation fails. + SignatureVerificationError: In STRICT mode when signature is invalid. """ # Security check: require HTTPS if url.startswith("http://"): @@ -254,6 +301,9 @@ def download_from_url( dest_wheel_path = plugin_dir / temp_wheel_path.name shutil.copy2(temp_wheel_path, dest_wheel_path) + # Verify signature + sig_info = verify_wheel_signature(dest_wheel_path, signature_mode) + # Create source tracking source = PluginSource( type=PluginSourceType.URL, @@ -268,6 +318,7 @@ def download_from_url( wheel_filename=dest_wheel_path.name, sha256=computed_hash, source=source, + signature_info=sig_info, ) logger.info("Installed %s v%s from URL", plugin_name, version) @@ -278,7 +329,7 @@ def download_from_github_release( self, url: str, sha256: Optional[str] = None, - force: bool = False, + signature_mode: SignatureVerificationMode = SignatureVerificationMode.STRICT, ) -> Tuple[str, str, Path]: """ Download and install a plugin from a GitHub release asset. @@ -286,7 +337,7 @@ def download_from_github_release( Args: url: GitHub release asset URL. sha256: Expected SHA256 checksum (optional). - force: Skip security warnings if True. + signature_mode: Signature verification mode. Returns: Tuple of (plugin_name, version, installed_wheel_path). @@ -295,7 +346,9 @@ def download_from_github_release( github_repo = self._extract_github_repo(url) # Download using standard URL method - plugin_name, version, wheel_path = self.download_from_url(url, sha256, force) + plugin_name, version, wheel_path = self.download_from_url( + url, sha256, signature_mode=signature_mode + ) # Update source to track GitHub release manifest_path = self.plugins_dir / plugin_name / "manifest.json" @@ -315,7 +368,7 @@ def download_from_github_release( def download_from_github_artifact( self, url: str, - force: bool = False, + signature_mode: SignatureVerificationMode = SignatureVerificationMode.STRICT, ) -> Tuple[str, str, Path]: """ Download and install a plugin from a GitHub Actions artifact. @@ -327,7 +380,7 @@ def download_from_github_artifact( Args: url: GitHub artifact URL (browser URL or API URL). - force: Skip security warnings if True. + signature_mode: Signature verification mode. Returns: Tuple of (plugin_name, version, installed_wheel_path). @@ -335,6 +388,7 @@ def download_from_github_artifact( Raises: GitHubArtifactError: If artifact cannot be downloaded or processed. DownloadError: If installation fails. + SignatureVerificationError: In STRICT mode when signature is invalid. """ # Parse artifact URL to get API endpoint artifact_info = self._parse_github_artifact_url(url) @@ -425,6 +479,9 @@ def download_from_github_artifact( # Compute SHA256 sha256 = self._compute_sha256(dest_wheel_path) + # Verify signature + sig_info = verify_wheel_signature(dest_wheel_path, signature_mode) + # Create source tracking source = PluginSource( type=PluginSourceType.GITHUB_ARTIFACT, @@ -440,6 +497,7 @@ def download_from_github_artifact( wheel_filename=dest_wheel_path.name, sha256=sha256, source=source, + signature_info=sig_info, ) logger.info("Installed %s v%s from GitHub artifact", plugin_name, version) @@ -600,9 +658,10 @@ def _write_manifest( wheel_filename: str, sha256: str, source: PluginSource, + signature_info: Optional[SignatureInfo] = None, ) -> None: """Write the plugin manifest file.""" - manifest = { + manifest: Dict[str, Any] = { "plugin_name": plugin_name, "version": version, "wheel_filename": wheel_filename, @@ -610,9 +669,54 @@ def _write_manifest( "source": source.to_dict(), "installed_at": datetime.now(timezone.utc).isoformat(), } + if signature_info is not None: + sig_data: Dict[str, Any] = {"status": signature_info.status.value} + if signature_info.identity: + sig_data["identity"] = signature_info.identity + if signature_info.message: + sig_data["message"] = signature_info.message + manifest["signature"] = sig_data + manifest_path = plugin_dir / "manifest.json" manifest_path.write_text(json.dumps(manifest, indent=2)) + def _download_bundle( + self, + download_info: PluginDownloadInfo, + plugin_dir: Path, + ) -> Optional[Path]: + """Download the sigstore bundle for a wheel if a signature URL is available.""" + if not download_info.signature_url: + return None + + bundle_filename = download_info.filename + ".sigstore" + bundle_path = plugin_dir / bundle_filename + + try: + logger.info("Downloading signature bundle...") + response = requests.get(download_info.signature_url, stream=True) + response.raise_for_status() + + with open(bundle_path, "wb") as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + + return bundle_path + except requests.RequestException as e: + logger.warning("Failed to download signature bundle: %s", e) + return None + + def _cleanup_failed_install(self, wheel_path: Path) -> None: + """Remove wheel and bundle files after a failed install.""" + if wheel_path.exists(): + wheel_path.unlink() + + # Also clean up any bundle files + for ext in (".sigstore", ".sigstore.json"): + bundle = wheel_path.parent / (wheel_path.name + ext) + if bundle.exists(): + bundle.unlink() + def _compute_sha256(self, file_path: Path) -> str: """Compute SHA256 hash of a file.""" sha256_hash = hashlib.sha256() diff --git a/ggshield/core/plugin/loader.py b/ggshield/core/plugin/loader.py index 719720f944..011d39c761 100644 --- a/ggshield/core/plugin/loader.py +++ b/ggshield/core/plugin/loader.py @@ -18,6 +18,12 @@ from ggshield.core.dirs import get_plugins_dir from ggshield.core.plugin.base import GGShieldPlugin, PluginMetadata from ggshield.core.plugin.registry import PluginRegistry +from ggshield.core.plugin.signature import ( + SignatureStatus, + SignatureVerificationError, + SignatureVerificationMode, + verify_wheel_signature, +) class WheelInfo(TypedDict): @@ -81,9 +87,18 @@ class DiscoveredPlugin: class PluginLoader: """Discovers and loads ggshield plugins from entry points and local wheels.""" - def __init__(self, enterprise_config: EnterpriseConfig) -> None: + def __init__( + self, + enterprise_config: EnterpriseConfig, + signature_mode: Optional[SignatureVerificationMode] = None, + ) -> None: self.enterprise_config = enterprise_config self.plugins_dir = get_plugins_dir() + self.signature_mode = ( + signature_mode + if signature_mode is not None + else enterprise_config.get_signature_mode() + ) def discover_plugins(self) -> List[DiscoveredPlugin]: """Discover all available plugins from entry points and local wheels.""" @@ -186,6 +201,29 @@ def _load_from_wheel(self, wheel_path: Path) -> Optional[GGShieldPlugin]: Wheels are extracted to a directory before loading because Python cannot import native extensions (.so/.pyd) directly from zip files. """ + # Verify signature before loading + try: + sig_info = verify_wheel_signature(wheel_path, self.signature_mode) + if sig_info.status == SignatureStatus.VALID: + logger.info( + "Signature valid for %s (identity: %s)", + wheel_path.name, + sig_info.identity, + ) + elif sig_info.status in ( + SignatureStatus.MISSING, + SignatureStatus.INVALID, + ): + logger.warning( + "Signature %s for %s: %s", + sig_info.status.value, + wheel_path.name, + sig_info.message or "", + ) + except SignatureVerificationError as e: + logger.error("Signature verification failed for %s: %s", wheel_path.name, e) + return None + # Extract wheel to a directory alongside the wheel file extract_dir = wheel_path.parent / f".{wheel_path.stem}_extracted" diff --git a/ggshield/core/plugin/signature.py b/ggshield/core/plugin/signature.py new file mode 100644 index 0000000000..a7857096a5 --- /dev/null +++ b/ggshield/core/plugin/signature.py @@ -0,0 +1,174 @@ +""" +Plugin signature verification using sigstore. + +Provides keyless signature verification for plugin wheels using sigstore +bundles. Signatures are identity-based: ggshield trusts a configured set +of GitHub Actions workflow identities (OIDC). + +Verification modes: +- STRICT: block unsigned or invalid plugins +- WARN: log a warning but allow loading +- DISABLED: skip verification entirely +""" + +import enum +import logging +from dataclasses import dataclass +from pathlib import Path +from typing import List, Optional + +from sigstore.models import Bundle +from sigstore.verify import Verifier +from sigstore.verify.policy import AllOf, GitHubWorkflowRepository, OIDCIssuer + + +logger = logging.getLogger(__name__) + + +class SignatureVerificationMode(enum.Enum): + """How strictly to enforce plugin signatures.""" + + STRICT = "strict" + WARN = "warn" + DISABLED = "disabled" + + +class SignatureStatus(enum.Enum): + """Result of signature verification.""" + + VALID = "valid" + MISSING = "missing" + INVALID = "invalid" + SKIPPED = "skipped" + + +@dataclass +class TrustedIdentity: + """An OIDC identity trusted to sign plugins.""" + + repository: str + """GitHub repository (e.g. "GitGuardian/satori").""" + + issuer: str + """OIDC issuer URL.""" + + +@dataclass +class SignatureInfo: + """Result of verifying a wheel's signature.""" + + status: SignatureStatus + identity: Optional[str] = None + message: Optional[str] = None + + +class SignatureVerificationError(Exception): + """Raised when signature verification fails in strict mode.""" + + def __init__(self, status: SignatureStatus, message: str) -> None: + self.status = status + super().__init__(message) + + +# Default trusted identities: GitHub Actions workflows that are authorized +# to sign plugin wheels. +DEFAULT_TRUSTED_IDENTITIES: List[TrustedIdentity] = [ + TrustedIdentity( + repository="GitGuardian/satori", + issuer="https://token.actions.githubusercontent.com", + ), +] + + +def get_bundle_path(wheel_path: Path) -> Optional[Path]: + """Return the sigstore bundle path for a wheel, or None if not found. + + Checks for `.sigstore` first, then `.sigstore.json` (sigstore-python 3.x + default) so that both old and new naming conventions are supported. + """ + for ext in (".sigstore", ".sigstore.json"): + p = wheel_path.parent / (wheel_path.name + ext) + if p.exists(): + return p + return None + + +def verify_wheel_signature( + wheel_path: Path, + mode: SignatureVerificationMode, + trusted_identities: Optional[List[TrustedIdentity]] = None, +) -> SignatureInfo: + """ + Verify the sigstore signature of a plugin wheel. + + Args: + wheel_path: Path to the .whl file. + mode: Verification strictness. + trusted_identities: OIDC identities to trust. + Defaults to DEFAULT_TRUSTED_IDENTITIES. + + Returns: + SignatureInfo with the verification result. + + Raises: + SignatureVerificationError: In STRICT mode when the signature is + missing or invalid. + """ + if mode == SignatureVerificationMode.DISABLED: + return SignatureInfo(status=SignatureStatus.SKIPPED) + + if trusted_identities is None: + trusted_identities = DEFAULT_TRUSTED_IDENTITIES + + bundle_path = get_bundle_path(wheel_path) + + # Missing bundle + if bundle_path is None: + msg = f"No signature bundle found for {wheel_path.name}" + if mode == SignatureVerificationMode.STRICT: + raise SignatureVerificationError(SignatureStatus.MISSING, msg) + logger.warning("%s", msg) + return SignatureInfo(status=SignatureStatus.MISSING, message=msg) + + # Verify bundle + bundle = Bundle.from_json(bundle_path.read_bytes()) + wheel_bytes = wheel_path.read_bytes() + verifier = Verifier.production() + + for trusted in trusted_identities: + policy = AllOf( + [ + OIDCIssuer(trusted.issuer), + GitHubWorkflowRepository(trusted.repository), + ] + ) + try: + verifier.verify_artifact( + input_=wheel_bytes, + bundle=bundle, + policy=policy, + ) + logger.info( + "Signature valid for %s (repository: %s)", + wheel_path.name, + trusted.repository, + ) + return SignatureInfo( + status=SignatureStatus.VALID, + identity=trusted.repository, + ) + except Exception as e: + logger.debug( + "Identity %s did not match for %s: %s", + trusted.repository, + wheel_path.name, + e, + ) + continue + + # No identity matched + msg = f"Signature verification failed for {wheel_path.name}: no trusted identity matched" + if mode == SignatureVerificationMode.STRICT: + raise SignatureVerificationError(SignatureStatus.INVALID, msg) + logger.warning("%s", msg) + return SignatureInfo(status=SignatureStatus.INVALID, message=msg) diff --git a/pdm.lock b/pdm.lock index 7f6c304115..79619bb5bc 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev", "standalone", "tests"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:c6447e433087da150f8d810b7a3497c9813db94a7218ad31c5e675021755d745" +content_hash = "sha256:2cd56ccfccfc3d50c64fe14f3503769a6258d48d314e863a54ada1b157f86a54" [[metadata.targets]] requires_python = ">=3.9" @@ -25,7 +25,7 @@ name = "annotated-types" version = "0.7.0" requires_python = ">=3.8" summary = "Reusable constraint types to use with typing.Annotated" -groups = ["dev"] +groups = ["default", "dev"] dependencies = [ "typing-extensions>=4.0.0; python_version < \"3.9\"", ] @@ -359,19 +359,6 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -[[package]] -name = "commonmark" -version = "0.9.1" -summary = "Python parser for the CommonMark Markdown spec" -groups = ["default"] -dependencies = [ - "future>=0.14.0; python_version < \"3\"", -] -files = [ - {file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"}, - {file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"}, -] - [[package]] name = "coverage" version = "7.10.7" @@ -545,6 +532,32 @@ files = [ {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, ] +[[package]] +name = "dnspython" +version = "2.7.0" +requires_python = ">=3.9" +summary = "DNS toolkit" +groups = ["default"] +files = [ + {file = "dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86"}, + {file = "dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1"}, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +requires_python = ">=3.8" +summary = "A robust email address syntax and deliverability validation library." +groups = ["default"] +dependencies = [ + "dnspython>=2.0.0", + "idna>=2.0.0", +] +files = [ + {file = "email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4"}, + {file = "email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426"}, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -788,6 +801,20 @@ files = [ {file = "grimp-3.13.tar.gz", hash = "sha256:759bf6e05186e6473ee71af4119ec181855b2b324f4fcdd78dee9e5b59d87874"}, ] +[[package]] +name = "id" +version = "1.6.1" +requires_python = ">=3.9" +summary = "A tool for generating OIDC identities" +groups = ["default"] +dependencies = [ + "urllib3<3,>=2", +] +files = [ + {file = "id-1.6.1-py3-none-any.whl", hash = "sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca"}, + {file = "id-1.6.1.tar.gz", hash = "sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069"}, +] + [[package]] name = "identify" version = "2.6.15" @@ -842,6 +869,21 @@ files = [ {file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"}, ] +[[package]] +name = "importlib-resources" +version = "5.13.0" +requires_python = ">=3.8" +summary = "Read resources from Python packages" +groups = ["default"] +marker = "python_version < \"3.11\"" +dependencies = [ + "zipp>=3.1.0; python_version < \"3.10\"", +] +files = [ + {file = "importlib_resources-5.13.0-py3-none-any.whl", hash = "sha256:9f7bd0c97b79972a6cce36a366356d16d5e13b09679c11a58f1014bfdf8e64b2"}, + {file = "importlib_resources-5.13.0.tar.gz", hash = "sha256:82d5c6cca930697dbbd86c93333bb2c2e72861d4789a11c2662b933e5ad2b528"}, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -1000,7 +1042,7 @@ name = "markdown-it-py" version = "3.0.0" requires_python = ">=3.8" summary = "Python port of markdown-it. Markdown parsing, done right!" -groups = ["dev"] +groups = ["default", "dev"] dependencies = [ "mdurl~=0.1", ] @@ -1170,7 +1212,7 @@ name = "mdurl" version = "0.1.2" requires_python = ">=3.7" summary = "Markdown URL utilities" -groups = ["dev"] +groups = ["default", "dev"] files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, @@ -1428,16 +1470,13 @@ files = [ [[package]] name = "platformdirs" -version = "3.0.0" -requires_python = ">=3.7" -summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +version = "4.4.0" +requires_python = ">=3.9" +summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." groups = ["default", "dev"] -dependencies = [ - "typing-extensions>=4.4; python_version < \"3.8\"", -] files = [ - {file = "platformdirs-3.0.0-py3-none-any.whl", hash = "sha256:b1d5eb14f221506f50d6604a561f4c5786d9e80355219694a1b244bcd96f4567"}, - {file = "platformdirs-3.0.0.tar.gz", hash = "sha256:8a1228abb1ef82d788f74139988b137e78692984ec7b08eaa6c65f1723af28f9"}, + {file = "platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85"}, + {file = "platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf"}, ] [[package]] @@ -1635,6 +1674,17 @@ files = [ {file = "pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42"}, ] +[[package]] +name = "pyasn1" +version = "0.6.2" +requires_python = ">=3.8" +summary = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +groups = ["default"] +files = [ + {file = "pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf"}, + {file = "pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b"}, +] + [[package]] name = "pycodestyle" version = "2.14.0" @@ -1660,130 +1710,160 @@ files = [ [[package]] name = "pydantic" -version = "2.11.10" +version = "2.12.5" requires_python = ">=3.9" summary = "Data validation using Python type hints" -groups = ["dev"] +groups = ["default", "dev"] dependencies = [ "annotated-types>=0.6.0", - "pydantic-core==2.33.2", - "typing-extensions>=4.12.2", - "typing-inspection>=0.4.0", + "pydantic-core==2.41.5", + "typing-extensions>=4.14.1", + "typing-inspection>=0.4.2", ] files = [ - {file = "pydantic-2.11.10-py3-none-any.whl", hash = "sha256:802a655709d49bd004c31e865ef37da30b540786a46bfce02333e0e24b5fe29a"}, - {file = "pydantic-2.11.10.tar.gz", hash = "sha256:dc280f0982fbda6c38fada4e476dc0a4f3aeaf9c6ad4c28df68a666ec3c61423"}, + {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, + {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, ] [[package]] name = "pydantic-core" -version = "2.33.2" +version = "2.41.5" requires_python = ">=3.9" summary = "Core functionality for Pydantic validation and serialization" -groups = ["dev"] +groups = ["default", "dev"] dependencies = [ - "typing-extensions!=4.7.0,>=4.6.0", -] -files = [ - {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, - {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"}, - {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"}, - {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"}, - {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"}, - {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"}, - {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, - {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, - {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"}, - {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"}, - {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"}, - {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"}, - {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, + "typing-extensions>=4.14.1", +] +files = [ + {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, + {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"}, + {file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"}, + {file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"}, + {file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"}, + {file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"}, + {file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"}, + {file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"}, + {file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"}, + {file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"}, + {file = "pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"}, + {file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"}, + {file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"}, + {file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"}, + {file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"}, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +extras = ["email"] +requires_python = ">=3.9" +summary = "Data validation using Python type hints" +groups = ["default"] +dependencies = [ + "email-validator>=2.0.0", + "pydantic==2.12.5", +] +files = [ + {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, + {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, ] [[package]] @@ -1895,6 +1975,21 @@ files = [ {file = "PyJWT-2.6.0.tar.gz", hash = "sha256:69285c7e31fc44f68a1feb309e948e0df53259d579295e6cfe2b1792329f05fd"}, ] +[[package]] +name = "pyopenssl" +version = "25.1.0" +requires_python = ">=3.7" +summary = "Python wrapper module around the OpenSSL library" +groups = ["default"] +dependencies = [ + "cryptography<46,>=41.0.5", + "typing-extensions>=4.9; python_version < \"3.13\" and python_version >= \"3.8\"", +] +files = [ + {file = "pyopenssl-25.1.0-py3-none-any.whl", hash = "sha256:2b11f239acc47ac2e5aca04fd7fa829800aeee22a2eb30d744572a157bd8a1ab"}, + {file = "pyopenssl-25.1.0.tar.gz", hash = "sha256:8d031884482e0c67ee92bf9a4d8cceb08d92aba7136432ffb0703c5280fc205b"}, +] + [[package]] name = "pyproject-hooks" version = "1.2.0" @@ -2129,21 +2224,56 @@ files = [ {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, ] +[[package]] +name = "rfc3161-client" +version = "1.0.5" +requires_python = ">=3.9" +summary = "" +groups = ["default"] +dependencies = [ + "cryptography<47,>=43", +] +files = [ + {file = "rfc3161_client-1.0.5-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:8a54fdb2f9e64481272b89137a7b71403cf1d30f5505c2e0c15a47a1cc100264"}, + {file = "rfc3161_client-1.0.5-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:d9ed8e597d0ee7387da1945e1583c4516b26f133770b3956e079606e2d90b69c"}, + {file = "rfc3161_client-1.0.5-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61c04b4953453e5c26a1949c20adac415b65cd062dab0960574d6c36240222d2"}, + {file = "rfc3161_client-1.0.5-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:31b6ee79f15b93d90952efd0395bb3f5ebf07941469c5c6eb32f9b64312cda6e"}, + {file = "rfc3161_client-1.0.5-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9c53a6711bab0c3f77dc9cf1e2fd750da475ff7abbc40ffe0333d8c518a8a9c8"}, + {file = "rfc3161_client-1.0.5-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:09c47582ecea2ca4a3debf8a1eda775cc3d5ae1379da40272cc065d32e639a7a"}, + {file = "rfc3161_client-1.0.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d31d30e354d2349ae8483ce811ef61498a3780daf8622c0b79d8cd44d271b46b"}, + {file = "rfc3161_client-1.0.5-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ae440461a310ae097417afe536d9d22fd71c95fbc9d21db3561b2707bed0aff0"}, + {file = "rfc3161_client-1.0.5-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:8fb34470e867a29cc15dc4987ea14f19d3bd25c863e132b6f75dca583e2cc67e"}, + {file = "rfc3161_client-1.0.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3106f3361a5a36789f43d2700e5678c847a9d3460a23f455f4c20cd39314c557"}, + {file = "rfc3161_client-1.0.5-cp39-abi3-win32.whl", hash = "sha256:078e4bbf0770ddc472e2ca96cf1e23efd0c313e6682b4c2c9765e1fdf09f55a3"}, + {file = "rfc3161_client-1.0.5-cp39-abi3-win_amd64.whl", hash = "sha256:e904430e27e75a5a379fc4aac09bd60ba5f4b48054f0481b2fb417297e404047"}, + {file = "rfc3161_client-1.0.5.tar.gz", hash = "sha256:f1a2e32e2a053455cee1ff9b325b88dbc7c66c8882dde60962add92f572df5c5"}, +] + +[[package]] +name = "rfc8785" +version = "0.1.4" +requires_python = ">=3.8" +summary = "A pure-Python implementation of RFC 8785 (JSON Canonicalization Scheme)" +groups = ["default"] +files = [ + {file = "rfc8785-0.1.4-py3-none-any.whl", hash = "sha256:520d690b448ecf0703691c76e1a34a24ddcd4fc5bc41d589cb7c58ec651bcd48"}, + {file = "rfc8785-0.1.4.tar.gz", hash = "sha256:e545841329fe0eee4f6a3b44e7034343100c12b4ec566dc06ca9735681deb4da"}, +] + [[package]] name = "rich" -version = "12.5.1" -requires_python = ">=3.6.3,<4.0.0" +version = "13.9.4" +requires_python = ">=3.8.0" summary = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" groups = ["default"] dependencies = [ - "commonmark<0.10.0,>=0.9.0", - "dataclasses<0.9,>=0.7; python_version < \"3.7\"", - "pygments<3.0.0,>=2.6.0", - "typing-extensions<5.0,>=4.0.0; python_version < \"3.9\"", + "markdown-it-py>=2.2.0", + "pygments<3.0.0,>=2.13.0", + "typing-extensions<5.0,>=4.0.0; python_version < \"3.11\"", ] files = [ - {file = "rich-12.5.1-py3-none-any.whl", hash = "sha256:2eb4e6894cde1e017976d2975ac210ef515d7548bc595ba20e195fb9628acdeb"}, - {file = "rich-12.5.1.tar.gz", hash = "sha256:63a5c5ce3673d3d5fbbf23cd87e11ab84b6b451436f1b7f19ec54b6bc36ed7ca"}, + {file = "rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90"}, + {file = "rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"}, ] [[package]] @@ -2345,6 +2475,17 @@ files = [ {file = "scriv-1.7.0.tar.gz", hash = "sha256:7c1a8be6351d03692e5e75784deea0d8f11b2c90f91be18b0fb3a375555b525e"}, ] +[[package]] +name = "securesystemslib" +version = "1.3.1" +requires_python = "~=3.8" +summary = "A library that provides cryptographic and general-purpose routines for Secure Systems Lab projects at NYU" +groups = ["default"] +files = [ + {file = "securesystemslib-1.3.1-py3-none-any.whl", hash = "sha256:2e5414bbdde33155a91805b295cbedc4ae3f12b48dccc63e1089093537f43c81"}, + {file = "securesystemslib-1.3.1.tar.gz", hash = "sha256:ca915f4b88209bb5450ac05426b859d74b7cd1421cafcf73b8dd3418a0b17486"}, +] + [[package]] name = "seed-isort-config" version = "2.2.0" @@ -2370,6 +2511,64 @@ files = [ {file = "setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb"}, ] +[[package]] +name = "sigstore" +version = "4.1.0" +requires_python = ">=3.9" +summary = "A tool for signing Python package distributions" +groups = ["default"] +dependencies = [ + "cryptography<47,>=42", + "id>=1.1.0", + "importlib-resources~=5.7; python_version < \"3.11\"", + "platformdirs~=4.2", + "pyOpenSSL>=23.0.0", + "pyasn1~=0.6", + "pydantic<3,>=2", + "pyjwt>=2.1", + "requests", + "rfc3161-client<1.1.0,>=1.0.3", + "rfc8785~=0.1.2", + "rich<15,>=13", + "sigstore-models==0.0.5", + "sigstore-rekor-types==0.0.18", + "tuf~=6.0", +] +files = [ + {file = "sigstore-4.1.0-py3-none-any.whl", hash = "sha256:ec3ed0d92bf53ffb23261245a78d4ad3da5af5cc9af889c86461a7d02407249a"}, + {file = "sigstore-4.1.0.tar.gz", hash = "sha256:312f7f73fe27127784245f523b86b6334978c555fe4ba7831be5602c089807c1"}, +] + +[[package]] +name = "sigstore-models" +version = "0.0.5" +requires_python = ">=3.9" +summary = "Pydantic based models for Sigstore's protobuf specifications" +groups = ["default"] +dependencies = [ + "pydantic>=2.11.7", + "typing-extensions>=4.14.1", +] +files = [ + {file = "sigstore_models-0.0.5-py3-none-any.whl", hash = "sha256:ac3ca1554d5dd509a6710699d83a035a09ba112d1fa180959cbfcdd5d97633b7"}, + {file = "sigstore_models-0.0.5.tar.gz", hash = "sha256:8eda90fe16ef3e4e624edd029f4cbbc9832a192dc5c8f66011d94ec4253f9f3f"}, +] + +[[package]] +name = "sigstore-rekor-types" +version = "0.0.18" +requires_python = ">=3.8" +summary = "Python models for Rekor's API types" +groups = ["default"] +dependencies = [ + "pydantic[email]<3,>=2", + "typing-extensions; python_version < \"3.9\"", +] +files = [ + {file = "sigstore_rekor_types-0.0.18-py3-none-any.whl", hash = "sha256:b62bf38c5b1a62bc0d7fe0ee51a0709e49311d137c7880c329882a8f4b2d1d78"}, + {file = "sigstore_rekor_types-0.0.18.tar.gz", hash = "sha256:19aef25433218ebf9975a1e8b523cc84aaf3cd395ad39a30523b083ea7917ec5"}, +] + [[package]] name = "stack-data" version = "0.6.3" @@ -2480,14 +2679,29 @@ files = [ ] [[package]] -name = "typing-extensions" -version = "4.12.2" +name = "tuf" +version = "6.0.0" requires_python = ">=3.8" -summary = "Backported and Experimental Type Hints for Python 3.8+" +summary = "A secure updater framework for Python" +groups = ["default"] +dependencies = [ + "securesystemslib~=1.0", + "urllib3<3,>=1.21.1", +] +files = [ + {file = "tuf-6.0.0-py3-none-any.whl", hash = "sha256:458f663a233d95cc76dde0e1a3d01796516a05ce2781fefafebe037f7729601a"}, + {file = "tuf-6.0.0.tar.gz", hash = "sha256:9eed0f7888c5fff45dc62164ff243a05d47fb8a3208035eb268974287e0aee8d"}, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +requires_python = ">=3.9" +summary = "Backported and Experimental Type Hints for Python 3.9+" groups = ["default", "dev", "tests"] files = [ - {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, - {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] [[package]] @@ -2510,7 +2724,7 @@ name = "typing-inspection" version = "0.4.2" requires_python = ">=3.9" summary = "Runtime typing introspection tools" -groups = ["dev"] +groups = ["default", "dev"] dependencies = [ "typing-extensions>=4.12.0", ] @@ -2562,19 +2776,20 @@ files = [ [[package]] name = "virtualenv" -version = "20.21.1" -requires_python = ">=3.7" +version = "20.35.4" +requires_python = ">=3.8" summary = "Virtual Python Environment builder" groups = ["dev"] dependencies = [ - "distlib<1,>=0.3.6", - "filelock<4,>=3.4.1", - "importlib-metadata>=4.8.3; python_version < \"3.8\"", - "platformdirs<4,>=2.4", + "distlib<1,>=0.3.7", + "filelock<4,>=3.12.2", + "importlib-metadata>=6.6; python_version < \"3.8\"", + "platformdirs<5,>=3.9.1", + "typing-extensions>=4.13.2; python_version < \"3.11\"", ] files = [ - {file = "virtualenv-20.21.1-py3-none-any.whl", hash = "sha256:09ddbe1af0c8ed2bb4d6ed226b9e6415718ad18aef9fa0ba023d96b7a8356049"}, - {file = "virtualenv-20.21.1.tar.gz", hash = "sha256:4c104ccde994f8b108163cf9ba58f3d11511d9403de87fb9b4f52bf33dbc8668"}, + {file = "virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b"}, + {file = "virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c"}, ] [[package]] @@ -2842,7 +3057,7 @@ name = "zipp" version = "3.23.0" requires_python = ">=3.9" summary = "Backport of pathlib-compatible object wrapper for zip files" -groups = ["dev", "standalone"] +groups = ["default", "dev", "standalone"] marker = "python_full_version < \"3.10.2\"" files = [ {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, diff --git a/pyproject.toml b/pyproject.toml index d87b37f8d2..02cb0f8466 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ classifiers = [ license = { text = "MIT" } requires-python = ">=3.9" dependencies = [ - "platformdirs~=3.0.0", + "platformdirs~=4.2", "charset-normalizer~=3.1.0", "click~=8.1.0", "cryptography~=43.0.1", @@ -44,8 +44,9 @@ dependencies = [ "python-dotenv~=0.21.0", "pyyaml~=6.0.1", "requests~=2.32.0", - "rich~=12.5.1", - "typing-extensions~=4.12.2", + "rich~=13.0", + "sigstore>=3.0.0", + "typing-extensions~=4.14", "urllib3>=2.2.2,<3", "truststore>=0.10.1; python_version >= \"3.10\"", ] diff --git a/tests/unit/cmd/plugin/test_install.py b/tests/unit/cmd/plugin/test_install.py index a9e0c10862..92b6ada11c 100644 --- a/tests/unit/cmd/plugin/test_install.py +++ b/tests/unit/cmd/plugin/test_install.py @@ -5,6 +5,8 @@ from pathlib import Path from unittest import mock +import pytest + from ggshield.__main__ import cli from ggshield.cmd.plugin.install import detect_source_type from ggshield.core.errors import ExitCode @@ -14,6 +16,8 @@ PluginInfo, PluginSourceType, ) +from ggshield.core.plugin.downloader import ChecksumMismatchError, DownloadError +from ggshield.core.plugin.signature import SignatureStatus, SignatureVerificationError class TestPluginInstall: @@ -96,20 +100,22 @@ def test_install_single_plugin(self, cli_fs_runner): assert result.exit_code == ExitCode.SUCCESS assert "Installing tokenscanner" in result.output assert "Installed tokenscanner v1.0.0" in result.output - mock_downloader.download_and_install.assert_called_once_with( - mock_download_info, "tokenscanner" - ) + mock_downloader.download_and_install.assert_called_once() + call_args = mock_downloader.download_and_install.call_args + assert call_args[0] == (mock_download_info, "tokenscanner") mock_config.enable_plugin.assert_called_once_with( "tokenscanner", version="1.0.0" ) mock_config.save.assert_called_once() - def test_install_unavailable_plugin(self, cli_fs_runner): - """ - GIVEN a plugin exists but is not available - WHEN running 'ggshield plugin install ' - THEN it shows an error with reason - """ + @pytest.mark.parametrize( + "reason", + [ + pytest.param("Requires Business plan", id="with_reason"), + pytest.param(None, id="without_reason"), + ], + ) + def test_install_unavailable_plugin(self, cli_fs_runner, reason): mock_catalog = PluginCatalog( plan="Free", plugins=[ @@ -119,7 +125,7 @@ def test_install_unavailable_plugin(self, cli_fs_runner): description="Local secret scanning", available=False, latest_version="1.0.0", - reason="Requires Business plan", + reason=reason, ), ], features={}, @@ -144,7 +150,10 @@ def test_install_unavailable_plugin(self, cli_fs_runner): assert result.exit_code == ExitCode.USAGE_ERROR assert "not available" in result.output - assert "Requires Business plan" in result.output + if reason: + assert reason in result.output + else: + assert "Reason:" not in result.output def test_install_unknown_plugin(self, cli_fs_runner): """ @@ -253,8 +262,6 @@ def test_install_download_error(self, cli_fs_runner): WHEN running 'ggshield plugin install ' THEN it shows an error """ - from ggshield.core.plugin.downloader import DownloadError - mock_catalog = PluginCatalog( plan="Enterprise", plugins=[ @@ -496,49 +503,6 @@ def test_install_generic_error(self, cli_fs_runner): assert result.exit_code == ExitCode.UNEXPECTED_ERROR assert "Failed to install tokenscanner" in result.output - def test_install_unavailable_plugin_without_reason(self, cli_fs_runner): - """ - GIVEN a plugin exists but is not available (without reason) - WHEN running 'ggshield plugin install ' - THEN it shows an error without reason - """ - mock_catalog = PluginCatalog( - plan="Free", - plugins=[ - PluginInfo( - name="tokenscanner", - display_name="Token Scanner", - description="Local secret scanning", - available=False, - latest_version="1.0.0", - reason=None, - ), - ], - features={}, - ) - - with ( - mock.patch( - "ggshield.cmd.plugin.install.create_client_from_config" - ) as mock_create_client, - mock.patch( - "ggshield.cmd.plugin.install.PluginAPIClient" - ) as mock_plugin_api_client_class, - ): - mock_client = mock.MagicMock() - mock_create_client.return_value = mock_client - - mock_plugin_api_client = mock.MagicMock() - mock_plugin_api_client.get_available_plugins.return_value = mock_catalog - mock_plugin_api_client_class.return_value = mock_plugin_api_client - - result = cli_fs_runner.invoke(cli, ["plugin", "install", "tokenscanner"]) - - assert result.exit_code == ExitCode.USAGE_ERROR - assert "not available" in result.output - # Should not show "Reason:" when reason is None - assert "Reason:" not in result.output - class TestDetectSourceType: """Tests for detect_source_type function.""" @@ -622,7 +586,7 @@ def test_install_local_wheel_success(self, cli_fs_runner, tmp_path: Path) -> Non result = cli_fs_runner.invoke( cli, - ["plugin", "install", str(wheel_path), "--force"], + ["plugin", "install", str(wheel_path)], catch_exceptions=False, ) @@ -631,47 +595,6 @@ def test_install_local_wheel_success(self, cli_fs_runner, tmp_path: Path) -> Non mock_downloader.install_from_wheel.assert_called_once() mock_config.enable_plugin.assert_called_once_with("myplugin", version="1.0.0") - def test_install_local_wheel_warning(self, cli_fs_runner, tmp_path: Path) -> None: - """ - GIVEN a valid local wheel file without --force - WHEN running 'ggshield plugin install ' - THEN a warning is displayed - """ - wheel_path = tmp_path / "myplugin-1.0.0.whl" - wheel_path.touch() - - with ( - mock.patch( - "ggshield.cmd.plugin.install.PluginDownloader" - ) as mock_downloader_class, - mock.patch( - "ggshield.cmd.plugin.install.EnterpriseConfig" - ) as mock_config_class, - mock.patch( - "ggshield.cmd.plugin.install.detect_source_type", - return_value=PluginSourceType.LOCAL_FILE, - ), - ): - mock_downloader = mock.MagicMock() - mock_downloader.install_from_wheel.return_value = ( - "myplugin", - "1.0.0", - wheel_path, - ) - mock_downloader_class.return_value = mock_downloader - - mock_config = mock.MagicMock() - mock_config_class.load.return_value = mock_config - - result = cli_fs_runner.invoke( - cli, - ["plugin", "install", str(wheel_path)], - catch_exceptions=False, - ) - - assert result.exit_code == ExitCode.SUCCESS - assert "not from GitGuardian" in result.output - class TestInstallFromUrl: """Tests for installing from URLs.""" @@ -713,54 +636,15 @@ def test_install_url_with_sha256(self, cli_fs_runner) -> None: "https://example.com/plugin.whl", "--sha256", "abc123", - "--force", ], catch_exceptions=False, ) assert result.exit_code == ExitCode.SUCCESS assert "Installed urlplugin v2.0.0" in result.output - mock_downloader.download_from_url.assert_called_once_with( - "https://example.com/plugin.whl", "abc123", True - ) - - def test_install_url_warning_no_sha256(self, cli_fs_runner) -> None: - """ - GIVEN a URL without SHA256 checksum - WHEN running 'ggshield plugin install ' without --force - THEN a warning about missing checksum is displayed - """ - with ( - mock.patch( - "ggshield.cmd.plugin.install.PluginDownloader" - ) as mock_downloader_class, - mock.patch( - "ggshield.cmd.plugin.install.EnterpriseConfig" - ) as mock_config_class, - mock.patch( - "ggshield.cmd.plugin.install.detect_source_type", - return_value=PluginSourceType.URL, - ), - ): - mock_downloader = mock.MagicMock() - mock_downloader.download_from_url.return_value = ( - "urlplugin", - "2.0.0", - Path("/fake/path.whl"), - ) - mock_downloader_class.return_value = mock_downloader - - mock_config = mock.MagicMock() - mock_config_class.load.return_value = mock_config - - result = cli_fs_runner.invoke( - cli, - ["plugin", "install", "https://example.com/plugin.whl"], - catch_exceptions=False, - ) - - assert result.exit_code == ExitCode.SUCCESS - assert "No SHA256 checksum provided" in result.output + mock_downloader.download_from_url.assert_called_once() + call_args = mock_downloader.download_from_url.call_args + assert call_args[0] == ("https://example.com/plugin.whl", "abc123") def test_install_http_url_rejected(self, cli_fs_runner) -> None: """ @@ -793,7 +677,7 @@ def test_install_http_url_rejected(self, cli_fs_runner) -> None: result = cli_fs_runner.invoke( cli, - ["plugin", "install", "http://example.com/plugin.whl", "--force"], + ["plugin", "install", "http://example.com/plugin.whl"], ) assert result.exit_code == ExitCode.USAGE_ERROR @@ -838,7 +722,6 @@ def test_install_github_release(self, cli_fs_runner) -> None: "plugin", "install", "https://github.com/owner/repo/releases/download/v1.5.0/plugin.whl", - "--force", ], catch_exceptions=False, ) @@ -885,7 +768,6 @@ def test_install_github_artifact(self, cli_fs_runner) -> None: "plugin", "install", "https://github.com/owner/repo/actions/runs/123/artifacts/456", - "--force", ], catch_exceptions=False, ) @@ -895,7 +777,7 @@ def test_install_github_artifact(self, cli_fs_runner) -> None: def test_install_github_artifact_warning(self, cli_fs_runner) -> None: """ - GIVEN a GitHub artifact URL without --force + GIVEN a GitHub artifact URL WHEN running 'ggshield plugin install ' THEN a warning about ephemeral artifacts is displayed """ @@ -970,7 +852,6 @@ def test_install_github_artifact_auth_error(self, cli_fs_runner) -> None: "plugin", "install", "https://github.com/owner/repo/actions/runs/123/artifacts/456", - "--force", ], ) @@ -982,11 +863,6 @@ class TestInstallErrorHandling: """Tests for error handling in various install scenarios.""" def test_install_local_wheel_not_found(self, cli_fs_runner, tmp_path: Path) -> None: - """ - GIVEN a non-existent wheel file path - WHEN running 'ggshield plugin install ' - THEN it shows a file not found error - """ wheel_path = tmp_path / "nonexistent.whl" with mock.patch( @@ -1001,16 +877,16 @@ def test_install_local_wheel_not_found(self, cli_fs_runner, tmp_path: Path) -> N assert result.exit_code == ExitCode.USAGE_ERROR assert "Wheel file not found" in result.output - def test_install_local_wheel_download_error( - self, cli_fs_runner, tmp_path: Path + @pytest.mark.parametrize( + "error", + [ + pytest.param(DownloadError("Invalid wheel"), id="download_error"), + pytest.param(Exception("Unexpected"), id="generic_error"), + ], + ) + def test_install_local_wheel_error( + self, cli_fs_runner, tmp_path: Path, error: Exception ) -> None: - """ - GIVEN a local wheel file that fails to install - WHEN running 'ggshield plugin install ' - THEN it shows an error - """ - from ggshield.core.plugin.downloader import DownloadError - wheel_path = tmp_path / "broken.whl" wheel_path.touch() @@ -1025,30 +901,89 @@ def test_install_local_wheel_download_error( ), ): mock_downloader = mock.MagicMock() - mock_downloader.install_from_wheel.side_effect = DownloadError( - "Invalid wheel" - ) + mock_downloader.install_from_wheel.side_effect = error mock_downloader_class.return_value = mock_downloader result = cli_fs_runner.invoke( cli, - ["plugin", "install", str(wheel_path), "--force"], + ["plugin", "install", str(wheel_path)], ) assert result.exit_code == ExitCode.UNEXPECTED_ERROR assert "Failed to install from wheel" in result.output - def test_install_local_wheel_generic_error( - self, cli_fs_runner, tmp_path: Path + @pytest.mark.parametrize( + "source_type, method, cli_args, error, expected_msg", + [ + pytest.param( + PluginSourceType.URL, + "download_from_url", + ["plugin", "install", "https://example.com/plugin.whl"], + DownloadError("Network error"), + "Failed to install from URL", + id="url-download_error", + ), + pytest.param( + PluginSourceType.URL, + "download_from_url", + ["plugin", "install", "https://example.com/plugin.whl"], + Exception("Unexpected"), + "Failed to install from URL", + id="url-generic_error", + ), + pytest.param( + PluginSourceType.GITHUB_RELEASE, + "download_from_github_release", + [ + "plugin", + "install", + "https://github.com/owner/repo/releases/download/v1/p.whl", + ], + DownloadError("Not found"), + "Failed to install from GitHub release", + id="github_release-download_error", + ), + pytest.param( + PluginSourceType.GITHUB_RELEASE, + "download_from_github_release", + [ + "plugin", + "install", + "https://github.com/owner/repo/releases/download/v1/p.whl", + ], + Exception("Unexpected"), + "Failed to install from GitHub release", + id="github_release-generic_error", + ), + pytest.param( + PluginSourceType.GITHUB_ARTIFACT, + "download_from_github_artifact", + [ + "plugin", + "install", + "https://github.com/owner/repo/actions/runs/123/artifacts/456", + ], + DownloadError("Failed to extract"), + "Failed to install from GitHub artifact", + id="github_artifact-download_error", + ), + pytest.param( + PluginSourceType.GITHUB_ARTIFACT, + "download_from_github_artifact", + [ + "plugin", + "install", + "https://github.com/owner/repo/actions/runs/123/artifacts/456", + ], + Exception("Unexpected"), + "Failed to install from GitHub artifact", + id="github_artifact-generic_error", + ), + ], + ) + def test_install_error( + self, cli_fs_runner, source_type, method, cli_args, error, expected_msg ) -> None: - """ - GIVEN a local wheel file with unexpected error - WHEN running 'ggshield plugin install ' - THEN it shows an error - """ - wheel_path = tmp_path / "problem.whl" - wheel_path.touch() - with ( mock.patch( "ggshield.cmd.plugin.install.PluginDownloader" @@ -1056,68 +991,50 @@ def test_install_local_wheel_generic_error( mock.patch("ggshield.cmd.plugin.install.EnterpriseConfig"), mock.patch( "ggshield.cmd.plugin.install.detect_source_type", - return_value=PluginSourceType.LOCAL_FILE, + return_value=source_type, ), ): mock_downloader = mock.MagicMock() - mock_downloader.install_from_wheel.side_effect = Exception("Unexpected") + getattr(mock_downloader, method).side_effect = error mock_downloader_class.return_value = mock_downloader - result = cli_fs_runner.invoke( - cli, - ["plugin", "install", str(wheel_path), "--force"], - ) + result = cli_fs_runner.invoke(cli, cli_args) assert result.exit_code == ExitCode.UNEXPECTED_ERROR - assert "Failed to install from wheel" in result.output - - def test_install_url_checksum_mismatch(self, cli_fs_runner) -> None: - """ - GIVEN a URL with wrong checksum - WHEN running 'ggshield plugin install --sha256 ' - THEN it shows a checksum error - """ - from ggshield.core.plugin.downloader import ChecksumMismatchError - - with ( - mock.patch( - "ggshield.cmd.plugin.install.PluginDownloader" - ) as mock_downloader_class, - mock.patch("ggshield.cmd.plugin.install.EnterpriseConfig"), - mock.patch( - "ggshield.cmd.plugin.install.detect_source_type", - return_value=PluginSourceType.URL, - ), - ): - mock_downloader = mock.MagicMock() - mock_downloader.download_from_url.side_effect = ChecksumMismatchError( - "expected123", "actual456" - ) - mock_downloader_class.return_value = mock_downloader - - result = cli_fs_runner.invoke( - cli, + assert expected_msg in result.output + + @pytest.mark.parametrize( + "source_type, method, cli_args", + [ + pytest.param( + PluginSourceType.URL, + "download_from_url", [ "plugin", "install", "https://example.com/plugin.whl", "--sha256", "wrong", - "--force", ], - ) - - assert result.exit_code == ExitCode.UNEXPECTED_ERROR - assert "Checksum verification failed" in result.output - - def test_install_url_download_error(self, cli_fs_runner) -> None: - """ - GIVEN a URL that fails to download - WHEN running 'ggshield plugin install ' - THEN it shows a download error - """ - from ggshield.core.plugin.downloader import DownloadError - + id="url", + ), + pytest.param( + PluginSourceType.GITHUB_RELEASE, + "download_from_github_release", + [ + "plugin", + "install", + "https://github.com/owner/repo/releases/download/v1/p.whl", + "--sha256", + "wrong", + ], + id="github_release", + ), + ], + ) + def test_install_checksum_mismatch( + self, cli_fs_runner, source_type, method, cli_args + ) -> None: with ( mock.patch( "ggshield.cmd.plugin.install.PluginDownloader" @@ -1125,230 +1042,234 @@ def test_install_url_download_error(self, cli_fs_runner) -> None: mock.patch("ggshield.cmd.plugin.install.EnterpriseConfig"), mock.patch( "ggshield.cmd.plugin.install.detect_source_type", - return_value=PluginSourceType.URL, + return_value=source_type, ), ): mock_downloader = mock.MagicMock() - mock_downloader.download_from_url.side_effect = DownloadError( - "Network error" + getattr(mock_downloader, method).side_effect = ChecksumMismatchError( + "expected123", "actual456" ) mock_downloader_class.return_value = mock_downloader - result = cli_fs_runner.invoke( - cli, - ["plugin", "install", "https://example.com/plugin.whl", "--force"], - ) + result = cli_fs_runner.invoke(cli, cli_args) assert result.exit_code == ExitCode.UNEXPECTED_ERROR - assert "Failed to install from URL" in result.output - - def test_install_url_generic_error(self, cli_fs_runner) -> None: - """ - GIVEN a URL with unexpected error - WHEN running 'ggshield plugin install ' - THEN it shows an error - """ - with ( - mock.patch( - "ggshield.cmd.plugin.install.PluginDownloader" - ) as mock_downloader_class, - mock.patch("ggshield.cmd.plugin.install.EnterpriseConfig"), - mock.patch( - "ggshield.cmd.plugin.install.detect_source_type", - return_value=PluginSourceType.URL, - ), - ): - mock_downloader = mock.MagicMock() - mock_downloader.download_from_url.side_effect = Exception("Unexpected") - mock_downloader_class.return_value = mock_downloader + assert "Checksum verification failed" in result.output - result = cli_fs_runner.invoke( - cli, - ["plugin", "install", "https://example.com/plugin.whl", "--force"], - ) - assert result.exit_code == ExitCode.UNEXPECTED_ERROR - assert "Failed to install from URL" in result.output +class TestSignatureVerificationHandling: + """Tests for signature verification error handling in install commands.""" - def test_install_github_release_checksum_mismatch(self, cli_fs_runner) -> None: + def test_gitguardian_install_signature_error(self, cli_fs_runner) -> None: """ - GIVEN a GitHub release URL with wrong checksum - WHEN running 'ggshield plugin install --sha256 ' - THEN it shows a checksum error + GIVEN a plugin with invalid signature + WHEN installing from GitGuardian API + THEN signature error is shown with --allow-unsigned hint """ - from ggshield.core.plugin.downloader import ChecksumMismatchError + mock_catalog = PluginCatalog( + plan="Enterprise", + plugins=[ + PluginInfo( + name="tokenscanner", + display_name="Token Scanner", + description="Local secret scanning", + available=True, + latest_version="1.0.0", + reason=None, + ), + ], + features={}, + ) + + mock_download_info = PluginDownloadInfo( + download_url="https://example.com/plugin.whl", + filename="tokenscanner-1.0.0.whl", + sha256="abc123", + version="1.0.0", + expires_at="2099-12-31T23:59:59Z", + ) with ( + mock.patch( + "ggshield.cmd.plugin.install.create_client_from_config" + ) as mock_create_client, + mock.patch( + "ggshield.cmd.plugin.install.PluginAPIClient" + ) as mock_plugin_api_client_class, mock.patch( "ggshield.cmd.plugin.install.PluginDownloader" ) as mock_downloader_class, - mock.patch("ggshield.cmd.plugin.install.EnterpriseConfig"), mock.patch( - "ggshield.cmd.plugin.install.detect_source_type", - return_value=PluginSourceType.GITHUB_RELEASE, - ), + "ggshield.cmd.plugin.install.EnterpriseConfig" + ) as mock_config_class, ): + mock_client = mock.MagicMock() + mock_create_client.return_value = mock_client + + mock_plugin_api_client = mock.MagicMock() + mock_plugin_api_client.get_available_plugins.return_value = mock_catalog + mock_plugin_api_client.get_download_info.return_value = mock_download_info + mock_plugin_api_client_class.return_value = mock_plugin_api_client + mock_downloader = mock.MagicMock() - mock_downloader.download_from_github_release.side_effect = ( - ChecksumMismatchError("expected", "actual") + mock_downloader.download_and_install.side_effect = ( + SignatureVerificationError( + SignatureStatus.INVALID, "no trusted identity matched" + ) ) mock_downloader_class.return_value = mock_downloader - result = cli_fs_runner.invoke( - cli, - [ - "plugin", - "install", - "https://github.com/owner/repo/releases/download/v1/p.whl", - "--sha256", - "wrong", - "--force", - ], - ) + mock_config = mock.MagicMock() + mock_config.get_signature_mode.return_value = mock.MagicMock() + mock_config_class.load.return_value = mock_config + + result = cli_fs_runner.invoke(cli, ["plugin", "install", "tokenscanner"]) assert result.exit_code == ExitCode.UNEXPECTED_ERROR - assert "Checksum verification failed" in result.output + assert "Signature verification failed" in result.output + assert "--allow-unsigned" in result.output - def test_install_github_release_download_error(self, cli_fs_runner) -> None: + def test_local_wheel_signature_error(self, cli_fs_runner, tmp_path: Path) -> None: """ - GIVEN a GitHub release URL that fails to download - WHEN running 'ggshield plugin install ' - THEN it shows a download error + GIVEN a local wheel with invalid signature + WHEN installing from local wheel + THEN signature error is shown with --allow-unsigned hint """ - from ggshield.core.plugin.downloader import DownloadError + wheel_path = tmp_path / "plugin.whl" + wheel_path.touch() with ( mock.patch( "ggshield.cmd.plugin.install.PluginDownloader" ) as mock_downloader_class, - mock.patch("ggshield.cmd.plugin.install.EnterpriseConfig"), + mock.patch( + "ggshield.cmd.plugin.install.EnterpriseConfig" + ) as mock_config_class, mock.patch( "ggshield.cmd.plugin.install.detect_source_type", - return_value=PluginSourceType.GITHUB_RELEASE, + return_value=PluginSourceType.LOCAL_FILE, ), ): mock_downloader = mock.MagicMock() - mock_downloader.download_from_github_release.side_effect = DownloadError( - "Not found" + mock_downloader.install_from_wheel.side_effect = SignatureVerificationError( + SignatureStatus.MISSING, "No bundle found" ) mock_downloader_class.return_value = mock_downloader - result = cli_fs_runner.invoke( - cli, + mock_config = mock.MagicMock() + mock_config.get_signature_mode.return_value = mock.MagicMock() + mock_config_class.load.return_value = mock_config + + result = cli_fs_runner.invoke(cli, ["plugin", "install", str(wheel_path)]) + + assert result.exit_code == ExitCode.UNEXPECTED_ERROR + assert "Signature verification failed" in result.output + assert "--allow-unsigned" in result.output + + @pytest.mark.parametrize( + "source_type, method, cli_args", + [ + pytest.param( + PluginSourceType.URL, + "download_from_url", + ["plugin", "install", "https://example.com/plugin.whl"], + id="url", + ), + pytest.param( + PluginSourceType.GITHUB_RELEASE, + "download_from_github_release", [ "plugin", "install", - "https://github.com/owner/repo/releases/download/v1/p.whl", - "--force", + "https://github.com/o/r/releases/download/v1/p.whl", ], - ) - - assert result.exit_code == ExitCode.UNEXPECTED_ERROR - assert "Failed to install from GitHub release" in result.output - - def test_install_github_release_generic_error(self, cli_fs_runner) -> None: - """ - GIVEN a GitHub release URL with unexpected error - WHEN running 'ggshield plugin install ' - THEN it shows an error - """ - with ( - mock.patch( - "ggshield.cmd.plugin.install.PluginDownloader" - ) as mock_downloader_class, - mock.patch("ggshield.cmd.plugin.install.EnterpriseConfig"), - mock.patch( - "ggshield.cmd.plugin.install.detect_source_type", - return_value=PluginSourceType.GITHUB_RELEASE, + id="github_release", ), - ): - mock_downloader = mock.MagicMock() - mock_downloader.download_from_github_release.side_effect = Exception( - "Unexpected" - ) - mock_downloader_class.return_value = mock_downloader - - result = cli_fs_runner.invoke( - cli, + pytest.param( + PluginSourceType.GITHUB_ARTIFACT, + "download_from_github_artifact", [ "plugin", "install", - "https://github.com/owner/repo/releases/download/v1/p.whl", - "--force", + "https://github.com/o/r/actions/runs/1/artifacts/2", ], - ) - - assert result.exit_code == ExitCode.UNEXPECTED_ERROR - assert "Failed to install from GitHub release" in result.output - - def test_install_github_artifact_download_error(self, cli_fs_runner) -> None: - """ - GIVEN a GitHub artifact URL that fails to download - WHEN running 'ggshield plugin install ' - THEN it shows a download error - """ - from ggshield.core.plugin.downloader import DownloadError - + id="github_artifact", + ), + ], + ) + def test_signature_error_all_sources( + self, cli_fs_runner, source_type, method, cli_args + ) -> None: + """Test that SignatureVerificationError is handled for all source types.""" with ( mock.patch( "ggshield.cmd.plugin.install.PluginDownloader" ) as mock_downloader_class, - mock.patch("ggshield.cmd.plugin.install.EnterpriseConfig"), + mock.patch( + "ggshield.cmd.plugin.install.EnterpriseConfig" + ) as mock_config_class, mock.patch( "ggshield.cmd.plugin.install.detect_source_type", - return_value=PluginSourceType.GITHUB_ARTIFACT, + return_value=source_type, ), ): mock_downloader = mock.MagicMock() - mock_downloader.download_from_github_artifact.side_effect = DownloadError( - "Failed to extract" + getattr(mock_downloader, method).side_effect = SignatureVerificationError( + SignatureStatus.INVALID, "bad signature" ) mock_downloader_class.return_value = mock_downloader - result = cli_fs_runner.invoke( - cli, - [ - "plugin", - "install", - "https://github.com/owner/repo/actions/runs/123/artifacts/456", - "--force", - ], - ) + mock_config = mock.MagicMock() + mock_config.get_signature_mode.return_value = mock.MagicMock() + mock_config_class.load.return_value = mock_config + + result = cli_fs_runner.invoke(cli, cli_args) assert result.exit_code == ExitCode.UNEXPECTED_ERROR - assert "Failed to install from GitHub artifact" in result.output + assert "Signature verification failed" in result.output + assert "--allow-unsigned" in result.output - def test_install_github_artifact_generic_error(self, cli_fs_runner) -> None: + def test_allow_unsigned_flag(self, cli_fs_runner, tmp_path: Path) -> None: """ - GIVEN a GitHub artifact URL with unexpected error - WHEN running 'ggshield plugin install ' - THEN it shows an error + GIVEN --allow-unsigned flag + WHEN installing a plugin + THEN signature mode is set to WARN (not STRICT) """ + wheel_path = tmp_path / "plugin.whl" + wheel_path.touch() + with ( mock.patch( "ggshield.cmd.plugin.install.PluginDownloader" ) as mock_downloader_class, - mock.patch("ggshield.cmd.plugin.install.EnterpriseConfig"), + mock.patch( + "ggshield.cmd.plugin.install.EnterpriseConfig" + ) as mock_config_class, mock.patch( "ggshield.cmd.plugin.install.detect_source_type", - return_value=PluginSourceType.GITHUB_ARTIFACT, + return_value=PluginSourceType.LOCAL_FILE, ), ): mock_downloader = mock.MagicMock() - mock_downloader.download_from_github_artifact.side_effect = Exception( - "Unexpected" + mock_downloader.install_from_wheel.return_value = ( + "plugin", + "1.0.0", + wheel_path, ) mock_downloader_class.return_value = mock_downloader + mock_config = mock.MagicMock() + mock_config.get_signature_mode.return_value = mock.MagicMock() + mock_config_class.load.return_value = mock_config + result = cli_fs_runner.invoke( cli, - [ - "plugin", - "install", - "https://github.com/owner/repo/actions/runs/123/artifacts/456", - "--force", - ], + ["plugin", "install", str(wheel_path), "--allow-unsigned"], + catch_exceptions=False, ) - assert result.exit_code == ExitCode.UNEXPECTED_ERROR - assert "Failed to install from GitHub artifact" in result.output + assert result.exit_code == ExitCode.SUCCESS + call_kwargs = mock_downloader.install_from_wheel.call_args[1] + from ggshield.core.plugin.signature import SignatureVerificationMode + + assert call_kwargs["signature_mode"] == SignatureVerificationMode.WARN diff --git a/tests/unit/core/plugin/test_downloader.py b/tests/unit/core/plugin/test_downloader.py index 78a8d8f1f6..d672338fc9 100644 --- a/tests/unit/core/plugin/test_downloader.py +++ b/tests/unit/core/plugin/test_downloader.py @@ -21,7 +21,12 @@ InsecureSourceError, PluginDownloader, get_plugins_dir, + get_signature_label, ) +from ggshield.core.plugin.signature import SignatureInfo, SignatureStatus + + +MOCK_SIG_INFO = SignatureInfo(status=SignatureStatus.SKIPPED) class TestPluginDownloader: @@ -168,10 +173,14 @@ def test_download_and_install_success(self, tmp_path: Path) -> None: "ggshield.core.plugin.downloader.get_plugins_dir", return_value=tmp_path ): with patch("requests.get", return_value=mock_response): - downloader = PluginDownloader() - wheel_path = downloader.download_and_install( - download_info, "testplugin" - ) + with patch( + "ggshield.core.plugin.downloader.verify_wheel_signature", + return_value=MOCK_SIG_INFO, + ): + downloader = PluginDownloader() + wheel_path = downloader.download_and_install( + download_info, "testplugin" + ) assert wheel_path.exists() assert wheel_path.name == "testplugin-1.0.0.whl" @@ -278,17 +287,19 @@ def test_download_and_install_manifest_failure_cleans_temp_file( "ggshield.core.plugin.downloader.get_plugins_dir", return_value=tmp_path ): with patch("requests.get", return_value=mock_response): - downloader = PluginDownloader() - - with patch.object( - downloader, "_write_manifest", side_effect=OSError("disk full") + with patch( + "ggshield.core.plugin.downloader.verify_wheel_signature", + return_value=MOCK_SIG_INFO, ): - with pytest.raises(OSError): - downloader.download_and_install(download_info, "testplugin") + downloader = PluginDownloader() + + with patch.object( + downloader, "_write_manifest", side_effect=OSError("disk full") + ): + with pytest.raises(OSError): + downloader.download_and_install(download_info, "testplugin") - wheel_path = tmp_path / "testplugin" / "testplugin-1.0.0.whl" temp_path = tmp_path / "testplugin" / "testplugin-1.0.0.whl.tmp" - assert not wheel_path.exists() assert not temp_path.exists() def test_is_installed_by_entry_point_name(self, tmp_path: Path) -> None: @@ -503,10 +514,14 @@ def test_install_from_wheel_success(self, tmp_path: Path) -> None: with patch( "ggshield.core.plugin.downloader.get_plugins_dir", return_value=plugins_dir ): - downloader = PluginDownloader() - plugin_name, version, installed_path = downloader.install_from_wheel( - wheel_path - ) + with patch( + "ggshield.core.plugin.downloader.verify_wheel_signature", + return_value=MOCK_SIG_INFO, + ): + downloader = PluginDownloader() + plugin_name, version, installed_path = downloader.install_from_wheel( + wheel_path + ) assert plugin_name == "myplugin" assert version == "2.0.0" @@ -561,11 +576,15 @@ def test_download_from_url_success(self, tmp_path: Path) -> None: "ggshield.core.plugin.downloader.get_plugins_dir", return_value=plugins_dir ): with patch("requests.get", return_value=mock_response): - downloader = PluginDownloader() - plugin_name, version, installed_path = downloader.download_from_url( - "https://example.com/urlplugin-1.0.0.whl", - sha256=sha256, - ) + with patch( + "ggshield.core.plugin.downloader.verify_wheel_signature", + return_value=MOCK_SIG_INFO, + ): + downloader = PluginDownloader() + plugin_name, version, installed_path = downloader.download_from_url( + "https://example.com/urlplugin-1.0.0.whl", + sha256=sha256, + ) assert plugin_name == "urlplugin" assert version == "1.0.0" @@ -640,11 +659,15 @@ def test_github_release_extracts_repo(self, tmp_path: Path) -> None: "ggshield.core.plugin.downloader.get_plugins_dir", return_value=plugins_dir ): with patch("requests.get", return_value=mock_response): - downloader = PluginDownloader() - plugin_name, version, _ = downloader.download_from_github_release( - "https://github.com/owner/repo/releases/download/v1.0.0/ghplugin-1.0.0.whl", - sha256=sha256, - ) + with patch( + "ggshield.core.plugin.downloader.verify_wheel_signature", + return_value=MOCK_SIG_INFO, + ): + downloader = PluginDownloader() + plugin_name, version, _ = downloader.download_from_github_release( + "https://github.com/owner/repo/releases/download/v1.0.0/ghplugin-1.0.0.whl", + sha256=sha256, + ) assert plugin_name == "ghplugin" assert version == "1.0.0" @@ -927,11 +950,15 @@ def test_download_from_url_no_whl_extension(self, tmp_path: Path) -> None: "ggshield.core.plugin.downloader.get_plugins_dir", return_value=plugins_dir ): with patch("requests.get", return_value=mock_response): - downloader = PluginDownloader() - plugin_name, version, _ = downloader.download_from_url( - "https://example.com/download?file=something", - sha256=sha256, - ) + with patch( + "ggshield.core.plugin.downloader.verify_wheel_signature", + return_value=MOCK_SIG_INFO, + ): + downloader = PluginDownloader() + plugin_name, version, _ = downloader.download_from_url( + "https://example.com/download?file=something", + sha256=sha256, + ) assert plugin_name == "testplugin" assert version == "1.0.0" @@ -964,13 +991,17 @@ def test_github_artifact_success(self, tmp_path: Path) -> None: "ggshield.core.plugin.downloader.get_plugins_dir", return_value=plugins_dir ): with patch("requests.get", return_value=mock_response): - with patch.dict("os.environ", {"GITHUB_TOKEN": "test-token"}): - downloader = PluginDownloader() - plugin_name, version, installed_path = ( - downloader.download_from_github_artifact( - "https://github.com/owner/repo/actions/runs/123/artifacts/456" + with patch( + "ggshield.core.plugin.downloader.verify_wheel_signature", + return_value=MOCK_SIG_INFO, + ): + with patch.dict("os.environ", {"GITHUB_TOKEN": "test-token"}): + downloader = PluginDownloader() + plugin_name, version, installed_path = ( + downloader.download_from_github_artifact( + "https://github.com/owner/repo/actions/runs/123/artifacts/456" + ) ) - ) assert plugin_name == "artifactplugin" assert version == "1.0.0" @@ -1097,6 +1128,223 @@ def test_get_gh_token_not_installed(self, tmp_path: Path) -> None: assert token is None +class TestGetSignatureLabel: + """Tests for get_signature_label.""" + + def test_returns_none_when_no_signature(self) -> None: + manifest: dict = {"plugin_name": "test", "version": "1.0.0"} + assert get_signature_label(manifest) is None + + def test_returns_none_when_signature_is_empty(self) -> None: + manifest: dict = {"signature": {}} + assert get_signature_label(manifest) is None + + def test_returns_status_with_identity(self) -> None: + manifest: dict = { + "signature": { + "status": "valid", + "identity": "GitGuardian/satori", + } + } + assert get_signature_label(manifest) == "valid (GitGuardian/satori)" + + def test_returns_status_without_identity(self) -> None: + manifest: dict = {"signature": {"status": "missing"}} + assert get_signature_label(manifest) == "missing" + + def test_returns_unknown_when_no_status(self) -> None: + manifest: dict = {"signature": {"identity": "org/repo"}} + assert get_signature_label(manifest) == "unknown (org/repo)" + + +class TestDownloadBundle: + """Tests for _download_bundle method.""" + + def test_returns_none_when_no_signature_url(self, tmp_path: Path) -> None: + download_info = PluginDownloadInfo( + download_url="https://example.com/plugin.whl", + filename="plugin-1.0.0.whl", + sha256="abc", + version="1.0.0", + expires_at="2025-01-01T00:00:00Z", + signature_url=None, + ) + + with patch( + "ggshield.core.plugin.downloader.get_plugins_dir", return_value=tmp_path + ): + downloader = PluginDownloader() + + result = downloader._download_bundle(download_info, tmp_path) + assert result is None + + def test_downloads_bundle_successfully(self, tmp_path: Path) -> None: + download_info = PluginDownloadInfo( + download_url="https://example.com/plugin.whl", + filename="plugin-1.0.0.whl", + sha256="abc", + version="1.0.0", + expires_at="2025-01-01T00:00:00Z", + signature_url="https://example.com/plugin.whl.sigstore", + ) + + mock_response = MagicMock() + mock_response.iter_content.return_value = [b"bundle-content"] + mock_response.raise_for_status = MagicMock() + + with patch( + "ggshield.core.plugin.downloader.get_plugins_dir", return_value=tmp_path + ): + downloader = PluginDownloader() + + with patch("requests.get", return_value=mock_response): + result = downloader._download_bundle(download_info, tmp_path) + + assert result is not None + assert result.name == "plugin-1.0.0.whl.sigstore" + assert result.read_bytes() == b"bundle-content" + + def test_returns_none_on_network_error(self, tmp_path: Path) -> None: + download_info = PluginDownloadInfo( + download_url="https://example.com/plugin.whl", + filename="plugin-1.0.0.whl", + sha256="abc", + version="1.0.0", + expires_at="2025-01-01T00:00:00Z", + signature_url="https://example.com/plugin.whl.sigstore", + ) + + with patch( + "ggshield.core.plugin.downloader.get_plugins_dir", return_value=tmp_path + ): + downloader = PluginDownloader() + + with patch( + "requests.get", side_effect=requests.RequestException("Network error") + ): + result = downloader._download_bundle(download_info, tmp_path) + + assert result is None + + +class TestCleanupFailedInstall: + """Tests for _cleanup_failed_install method.""" + + def test_removes_wheel_file(self, tmp_path: Path) -> None: + wheel_path = tmp_path / "plugin-1.0.0.whl" + wheel_path.write_bytes(b"wheel") + + with patch( + "ggshield.core.plugin.downloader.get_plugins_dir", return_value=tmp_path + ): + downloader = PluginDownloader() + + downloader._cleanup_failed_install(wheel_path) + assert not wheel_path.exists() + + def test_removes_bundle_files(self, tmp_path: Path) -> None: + wheel_path = tmp_path / "plugin-1.0.0.whl" + wheel_path.write_bytes(b"wheel") + sigstore = tmp_path / "plugin-1.0.0.whl.sigstore" + sigstore.write_bytes(b"bundle") + sigstore_json = tmp_path / "plugin-1.0.0.whl.sigstore.json" + sigstore_json.write_bytes(b"bundle2") + + with patch( + "ggshield.core.plugin.downloader.get_plugins_dir", return_value=tmp_path + ): + downloader = PluginDownloader() + + downloader._cleanup_failed_install(wheel_path) + assert not wheel_path.exists() + assert not sigstore.exists() + assert not sigstore_json.exists() + + def test_handles_nonexistent_files(self, tmp_path: Path) -> None: + wheel_path = tmp_path / "nonexistent.whl" + + with patch( + "ggshield.core.plugin.downloader.get_plugins_dir", return_value=tmp_path + ): + downloader = PluginDownloader() + + # Should not raise + downloader._cleanup_failed_install(wheel_path) + + +class TestWriteManifestWithSignature: + """Tests for _write_manifest with signature_info.""" + + def test_manifest_includes_signature_when_valid(self, tmp_path: Path) -> None: + sig_info = SignatureInfo( + status=SignatureStatus.VALID, + identity="GitGuardian/satori", + ) + + with patch( + "ggshield.core.plugin.downloader.get_plugins_dir", return_value=tmp_path + ): + downloader = PluginDownloader() + + downloader._write_manifest( + plugin_dir=tmp_path, + plugin_name="test", + version="1.0.0", + wheel_filename="test-1.0.0.whl", + sha256="abc", + source=PluginSource(type=PluginSourceType.GITGUARDIAN_API), + signature_info=sig_info, + ) + + manifest = json.loads((tmp_path / "manifest.json").read_text()) + assert manifest["signature"]["status"] == "valid" + assert manifest["signature"]["identity"] == "GitGuardian/satori" + assert "message" not in manifest["signature"] + + def test_manifest_includes_signature_message(self, tmp_path: Path) -> None: + sig_info = SignatureInfo( + status=SignatureStatus.MISSING, + message="No bundle found", + ) + + with patch( + "ggshield.core.plugin.downloader.get_plugins_dir", return_value=tmp_path + ): + downloader = PluginDownloader() + + downloader._write_manifest( + plugin_dir=tmp_path, + plugin_name="test", + version="1.0.0", + wheel_filename="test-1.0.0.whl", + sha256="abc", + source=PluginSource(type=PluginSourceType.GITGUARDIAN_API), + signature_info=sig_info, + ) + + manifest = json.loads((tmp_path / "manifest.json").read_text()) + assert manifest["signature"]["status"] == "missing" + assert manifest["signature"]["message"] == "No bundle found" + + def test_manifest_omits_signature_when_none(self, tmp_path: Path) -> None: + with patch( + "ggshield.core.plugin.downloader.get_plugins_dir", return_value=tmp_path + ): + downloader = PluginDownloader() + + downloader._write_manifest( + plugin_dir=tmp_path, + plugin_name="test", + version="1.0.0", + wheel_filename="test-1.0.0.whl", + sha256="abc", + source=PluginSource(type=PluginSourceType.GITGUARDIAN_API), + ) + + manifest = json.loads((tmp_path / "manifest.json").read_text()) + assert "signature" not in manifest + + def _create_artifact_zip(content: bytes, filename: str) -> bytes: """Helper to create an artifact ZIP file in memory.""" import io diff --git a/tests/unit/core/plugin/test_enterprise_config.py b/tests/unit/core/plugin/test_enterprise_config.py index 123e1837a5..92d0cb6e2c 100644 --- a/tests/unit/core/plugin/test_enterprise_config.py +++ b/tests/unit/core/plugin/test_enterprise_config.py @@ -3,9 +3,8 @@ from pathlib import Path from unittest.mock import patch -import pytest - from ggshield.core.config.enterprise_config import EnterpriseConfig, PluginConfig +from ggshield.core.plugin.signature import SignatureVerificationMode class TestPluginConfig: @@ -58,12 +57,14 @@ def test_enable_existing_plugin(self) -> None: assert config.plugins["test-plugin"].enabled is True assert config.plugins["test-plugin"].version == "1.0.0" - def test_disable_plugin_missing_raises(self) -> None: - """Test disabling a missing plugin raises an error.""" + def test_disable_plugin_missing_creates_config(self) -> None: + """Test disabling a missing plugin creates a disabled config entry.""" config = EnterpriseConfig() - with pytest.raises(ValueError, match="not configured"): - config.disable_plugin("test-plugin") + config.disable_plugin("test-plugin") + + assert "test-plugin" in config.plugins + assert config.plugins["test-plugin"].enabled is False def test_disable_existing_plugin(self) -> None: """Test disabling an already configured plugin.""" @@ -74,11 +75,11 @@ def test_disable_existing_plugin(self) -> None: assert config.plugins["test-plugin"].enabled is False def test_is_plugin_enabled_default(self) -> None: - """Test that plugins are disabled by default.""" + """Test that plugins are enabled by default.""" config = EnterpriseConfig() - # Plugin not in config should be considered disabled - assert config.is_plugin_enabled("nonexistent") is False + # Plugin not in config should be considered enabled by default + assert config.is_plugin_enabled("nonexistent") is True def test_is_plugin_enabled_explicit(self) -> None: """Test checking if plugin is enabled explicitly.""" @@ -152,6 +153,46 @@ def test_load_and_save(self, tmp_path: Path) -> None: assert loaded.plugins["test-plugin"].enabled is True assert loaded.plugins["test-plugin"].version == "1.0.0" + def test_get_signature_mode_default(self) -> None: + """Test default signature mode is strict.""" + config = EnterpriseConfig() + + assert config.get_signature_mode() == SignatureVerificationMode.STRICT + + def test_get_signature_mode_warn(self) -> None: + """Test warn signature mode.""" + config = EnterpriseConfig(plugin_signature_mode="warn") + + assert config.get_signature_mode() == SignatureVerificationMode.WARN + + def test_get_signature_mode_disabled(self) -> None: + """Test disabled signature mode.""" + config = EnterpriseConfig(plugin_signature_mode="disabled") + + assert config.get_signature_mode() == SignatureVerificationMode.DISABLED + + def test_get_signature_mode_invalid_falls_back_to_strict(self) -> None: + """Test that invalid mode falls back to strict.""" + config = EnterpriseConfig(plugin_signature_mode="invalid_value") + + assert config.get_signature_mode() == SignatureVerificationMode.STRICT + + def test_load_and_save_signature_mode(self, tmp_path: Path) -> None: + """Test that plugin_signature_mode is persisted.""" + config_path = tmp_path / "enterprise_config.yaml" + + with patch( + "ggshield.core.config.enterprise_config.get_enterprise_config_filepath" + ) as mock_path: + mock_path.return_value = config_path + + config = EnterpriseConfig(plugin_signature_mode="warn") + config.save() + + loaded = EnterpriseConfig.load() + assert loaded.plugin_signature_mode == "warn" + assert loaded.get_signature_mode() == SignatureVerificationMode.WARN + def test_load_simple_format(self, tmp_path: Path) -> None: """Test loading config with simple boolean format.""" config_path = tmp_path / "enterprise_config.yaml" diff --git a/tests/unit/core/plugin/test_loader.py b/tests/unit/core/plugin/test_loader.py index b17fdaee85..55e5e78460 100644 --- a/tests/unit/core/plugin/test_loader.py +++ b/tests/unit/core/plugin/test_loader.py @@ -14,6 +14,11 @@ parse_entry_point_from_content, ) from ggshield.core.plugin.registry import PluginRegistry +from ggshield.core.plugin.signature import ( + SignatureStatus, + SignatureVerificationError, + SignatureVerificationMode, +) class MockPlugin(GGShieldPlugin): @@ -559,7 +564,7 @@ def test_load_from_wheel_extracts_wheel(self, tmp_path: Path) -> None: import zipfile config = EnterpriseConfig() - loader = PluginLoader(config) + loader = PluginLoader(config, signature_mode=SignatureVerificationMode.DISABLED) # Create a mock wheel with a Python module wheel_path = tmp_path / "test_plugin-1.0.0.whl" @@ -588,6 +593,66 @@ def test_load_from_wheel_extracts_wheel(self, tmp_path: Path) -> None: assert extract_dir.exists() assert (extract_dir / "test_plugin" / "__init__.py").exists() + def test_load_from_wheel_rejects_on_strict_signature_error( + self, tmp_path: Path + ) -> None: + """Test that _load_from_wheel returns None when signature fails in STRICT mode.""" + import zipfile + + config = EnterpriseConfig() + loader = PluginLoader(config, signature_mode=SignatureVerificationMode.STRICT) + + wheel_path = tmp_path / "test-1.0.0.whl" + with zipfile.ZipFile(wheel_path, "w") as zf: + zf.writestr( + "test-1.0.0.dist-info/entry_points.txt", + "[ggshield.plugins]\ntest = test:Plugin\n", + ) + + with patch( + "ggshield.core.plugin.loader.verify_wheel_signature", + side_effect=SignatureVerificationError( + SignatureStatus.MISSING, "No bundle found" + ), + ): + result = loader._load_from_wheel(wheel_path) + + assert result is None + + def test_load_from_wheel_warns_on_missing_signature(self, tmp_path: Path) -> None: + """Test that _load_from_wheel proceeds with warning in WARN mode.""" + import zipfile + + from ggshield.core.plugin.signature import SignatureInfo + + config = EnterpriseConfig() + loader = PluginLoader(config, signature_mode=SignatureVerificationMode.WARN) + + wheel_path = tmp_path / "test_plugin-1.0.0.whl" + with zipfile.ZipFile(wheel_path, "w") as zf: + zf.writestr("test_plugin/__init__.py", "class TestPlugin: pass") + zf.writestr( + "test_plugin-1.0.0.dist-info/entry_points.txt", + "[ggshield.plugins]\ntest = test_plugin:TestPlugin\n", + ) + + with patch( + "ggshield.core.plugin.loader.verify_wheel_signature", + return_value=SignatureInfo( + status=SignatureStatus.MISSING, message="No bundle found" + ), + ): + with patch( + "ggshield.core.plugin.loader.importlib.import_module" + ) as mock_import: + mock_module = MagicMock() + mock_module.TestPlugin = MockPlugin + mock_import.return_value = mock_module + + result = loader._load_from_wheel(wheel_path) + + assert result is not None + def test_load_from_wheel_handles_exception(self, tmp_path: Path) -> None: """Test that _load_from_wheel returns None on exception.""" import zipfile @@ -619,7 +684,7 @@ def test_load_from_wheel_appends_to_sys_path( import zipfile config = EnterpriseConfig() - loader = PluginLoader(config) + loader = PluginLoader(config, signature_mode=SignatureVerificationMode.DISABLED) wheel_path = tmp_path / "test_plugin-1.0.0.whl" with zipfile.ZipFile(wheel_path, "w") as zf: diff --git a/tests/unit/core/plugin/test_signature.py b/tests/unit/core/plugin/test_signature.py new file mode 100644 index 0000000000..a57f6f331b --- /dev/null +++ b/tests/unit/core/plugin/test_signature.py @@ -0,0 +1,285 @@ +"""Tests for plugin signature verification.""" + +from contextlib import contextmanager +from pathlib import Path +from typing import Iterator +from unittest.mock import MagicMock, patch + +import pytest + +from ggshield.core.plugin.signature import ( + SignatureStatus, + SignatureVerificationError, + SignatureVerificationMode, + TrustedIdentity, + get_bundle_path, + verify_wheel_signature, +) + + +class TestGetBundlePath: + """Tests for get_bundle_path().""" + + def test_returns_sigstore_extension(self, tmp_path: Path) -> None: + wheel = tmp_path / "plugin-1.0.0.whl" + bundle = tmp_path / "plugin-1.0.0.whl.sigstore" + bundle.write_bytes(b"bundle") + result = get_bundle_path(wheel) + assert result == bundle + + def test_returns_sigstore_json_extension(self, tmp_path: Path) -> None: + wheel = tmp_path / "plugin-1.0.0.whl" + bundle = tmp_path / "plugin-1.0.0.whl.sigstore.json" + bundle.write_bytes(b"bundle") + result = get_bundle_path(wheel) + assert result == bundle + + def test_prefers_sigstore_over_sigstore_json(self, tmp_path: Path) -> None: + wheel = tmp_path / "plugin-1.0.0.whl" + (tmp_path / "plugin-1.0.0.whl.sigstore").write_bytes(b"bundle1") + (tmp_path / "plugin-1.0.0.whl.sigstore.json").write_bytes(b"bundle2") + result = get_bundle_path(wheel) + assert result == tmp_path / "plugin-1.0.0.whl.sigstore" + + def test_returns_none_when_no_bundle(self, tmp_path: Path) -> None: + wheel = tmp_path / "plugin-1.0.0.whl" + result = get_bundle_path(wheel) + assert result is None + + def test_preserves_parent_directory(self, tmp_path: Path) -> None: + subdir = tmp_path / "subdir" + subdir.mkdir() + wheel = subdir / "plugin.whl" + bundle = subdir / "plugin.whl.sigstore" + bundle.write_bytes(b"bundle") + result = get_bundle_path(wheel) + assert result is not None + assert result.parent == subdir + + +class TestSignatureVerificationModeDisabled: + """Tests for DISABLED mode.""" + + def test_returns_skipped(self, tmp_path: Path) -> None: + wheel = tmp_path / "plugin.whl" + wheel.write_bytes(b"fake wheel") + + result = verify_wheel_signature(wheel, SignatureVerificationMode.DISABLED) + + assert result.status == SignatureStatus.SKIPPED + + +class TestMissingBundle: + """Tests for missing .sigstore bundle.""" + + def test_strict_mode_raises(self, tmp_path: Path) -> None: + wheel = tmp_path / "plugin.whl" + wheel.write_bytes(b"fake wheel") + # No .sigstore file + + with pytest.raises(SignatureVerificationError) as exc_info: + verify_wheel_signature(wheel, SignatureVerificationMode.STRICT) + + assert exc_info.value.status == SignatureStatus.MISSING + + def test_warn_mode_returns_missing(self, tmp_path: Path) -> None: + wheel = tmp_path / "plugin.whl" + wheel.write_bytes(b"fake wheel") + + result = verify_wheel_signature(wheel, SignatureVerificationMode.WARN) + + assert result.status == SignatureStatus.MISSING + + +class TestBundleVerification: + """Tests for bundle verification with mocked sigstore.""" + + def _setup_wheel_and_bundle(self, tmp_path: Path) -> Path: + """Create a fake wheel and bundle file.""" + wheel = tmp_path / "plugin-1.0.0.whl" + wheel.write_bytes(b"fake wheel content") + bundle = tmp_path / "plugin-1.0.0.whl.sigstore" + bundle.write_bytes(b'{"fake": "bundle"}') + return wheel + + @staticmethod + @contextmanager + def _sigstore_modules( + mock_verifier_cls: MagicMock, + mock_bundle_cls: MagicMock, + mock_all_of_cls: MagicMock, + mock_oidc_issuer_cls: MagicMock, + mock_gh_repo_cls: MagicMock, + ) -> Iterator[None]: + """Patch the top-level sigstore imports in the signature module.""" + with ( + patch("ggshield.core.plugin.signature.Bundle", mock_bundle_cls), + patch("ggshield.core.plugin.signature.Verifier", mock_verifier_cls), + patch("ggshield.core.plugin.signature.AllOf", mock_all_of_cls), + patch( + "ggshield.core.plugin.signature.OIDCIssuer", + mock_oidc_issuer_cls, + ), + patch( + "ggshield.core.plugin.signature.GitHubWorkflowRepository", + mock_gh_repo_cls, + ), + ): + yield + + def test_valid_signature(self, tmp_path: Path) -> None: + """Test successful signature verification.""" + wheel = self._setup_wheel_and_bundle(tmp_path) + + mock_verifier_cls = MagicMock() + mock_bundle_cls = MagicMock() + mock_all_of_cls = MagicMock() + mock_oidc_issuer_cls = MagicMock() + mock_gh_repo_cls = MagicMock() + + mock_verifier = MagicMock() + mock_verifier_cls.production.return_value = mock_verifier + mock_verifier.verify_artifact.return_value = None # Success + + mock_bundle = MagicMock() + mock_bundle_cls.from_json.return_value = mock_bundle + + trusted = [ + TrustedIdentity( + repository="GitGuardian/satori", + issuer="https://token.actions.githubusercontent.com", + ) + ] + + with self._sigstore_modules( + mock_verifier_cls, + mock_bundle_cls, + mock_all_of_cls, + mock_oidc_issuer_cls, + mock_gh_repo_cls, + ): + result = verify_wheel_signature( + wheel, SignatureVerificationMode.STRICT, trusted + ) + + assert result.status == SignatureStatus.VALID + assert result.identity == trusted[0].repository + + def test_invalid_signature_strict_raises(self, tmp_path: Path) -> None: + """Test that invalid signature raises in STRICT mode.""" + wheel = self._setup_wheel_and_bundle(tmp_path) + + mock_verifier_cls = MagicMock() + mock_bundle_cls = MagicMock() + mock_all_of_cls = MagicMock() + mock_oidc_issuer_cls = MagicMock() + mock_gh_repo_cls = MagicMock() + + mock_verifier = MagicMock() + mock_verifier_cls.production.return_value = mock_verifier + mock_verifier.verify_artifact.side_effect = Exception("Verification failed") + + mock_bundle = MagicMock() + mock_bundle_cls.from_json.return_value = mock_bundle + + trusted = [ + TrustedIdentity( + repository="GitGuardian/satori", + issuer="https://token.actions.githubusercontent.com", + ) + ] + + with self._sigstore_modules( + mock_verifier_cls, + mock_bundle_cls, + mock_all_of_cls, + mock_oidc_issuer_cls, + mock_gh_repo_cls, + ): + with pytest.raises(SignatureVerificationError) as exc_info: + verify_wheel_signature(wheel, SignatureVerificationMode.STRICT, trusted) + + assert exc_info.value.status == SignatureStatus.INVALID + + def test_invalid_signature_warn_returns_invalid(self, tmp_path: Path) -> None: + """Test that invalid signature returns INVALID in WARN mode.""" + wheel = self._setup_wheel_and_bundle(tmp_path) + + mock_verifier_cls = MagicMock() + mock_bundle_cls = MagicMock() + mock_all_of_cls = MagicMock() + mock_oidc_issuer_cls = MagicMock() + mock_gh_repo_cls = MagicMock() + + mock_verifier = MagicMock() + mock_verifier_cls.production.return_value = mock_verifier + mock_verifier.verify_artifact.side_effect = Exception("Verification failed") + + mock_bundle = MagicMock() + mock_bundle_cls.from_json.return_value = mock_bundle + + trusted = [ + TrustedIdentity( + repository="org/repo", + issuer="https://token.actions.githubusercontent.com", + ) + ] + + with self._sigstore_modules( + mock_verifier_cls, + mock_bundle_cls, + mock_all_of_cls, + mock_oidc_issuer_cls, + mock_gh_repo_cls, + ): + result = verify_wheel_signature( + wheel, SignatureVerificationMode.WARN, trusted + ) + + assert result.status == SignatureStatus.INVALID + + def test_multi_identity_tries_all(self, tmp_path: Path) -> None: + """Test that multiple trusted identities are tried in order.""" + wheel = self._setup_wheel_and_bundle(tmp_path) + + mock_verifier_cls = MagicMock() + mock_bundle_cls = MagicMock() + mock_all_of_cls = MagicMock() + mock_oidc_issuer_cls = MagicMock() + mock_gh_repo_cls = MagicMock() + + mock_verifier = MagicMock() + mock_verifier_cls.production.return_value = mock_verifier + # First identity fails, second succeeds + mock_verifier.verify_artifact.side_effect = [ + Exception("Wrong identity"), + None, # Success + ] + + mock_bundle = MagicMock() + mock_bundle_cls.from_json.return_value = mock_bundle + + trusted = [ + TrustedIdentity( + repository="other/repo", + issuer="https://token.actions.githubusercontent.com", + ), + TrustedIdentity( + repository="GitGuardian/satori", + issuer="https://token.actions.githubusercontent.com", + ), + ] + + with self._sigstore_modules( + mock_verifier_cls, + mock_bundle_cls, + mock_all_of_cls, + mock_oidc_issuer_cls, + mock_gh_repo_cls, + ): + result = verify_wheel_signature( + wheel, SignatureVerificationMode.STRICT, trusted + ) + + assert result.status == SignatureStatus.VALID + assert result.identity == trusted[1].repository