From 68c4f8d0bc2c103bf17a8dc4be4a70080b3121bc Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 18 Apr 2026 00:31:04 +0800 Subject: [PATCH 1/3] Harden SSH/API network security and bump to 1.0.19 Add shared URL validation helper (pybreeze/utils/network), route AI code review and diagram download through it, disable redirect following on requests.*, switch SSH/SFTP to WarningPolicy with system host keys, and expand CLAUDE.md security checklist. --- CLAUDE.md | 49 +++++++++++++++++-- pybreeze/__main__.py | 2 + .../python_task_process_manager.py | 10 ++-- .../test_pioneer_process_manager.py | 2 - .../extend_multi_language/extend_english.py | 2 + .../extend_traditional_chinese.py | 2 + .../update_language_dict.py | 2 + .../connect_gui/ssh/ssh_command_widget.py | 8 ++- .../connect_gui/ssh/ssh_file_viewer_widget.py | 16 +++--- .../connect_gui/ssh/ssh_login_widget.py | 2 + .../connect_gui/ssh/ssh_main_widget.py | 2 + .../connect_gui/url/ai_code_review_gui.py | 19 +++++-- .../diagram_editor/diagram_editor_widget.py | 8 ++- .../diagram_editor/diagram_net_utils.py | 35 ++----------- .../diagram_editor/diagram_scene.py | 6 +-- .../editor_main/file_tree_context_menu.py | 6 ++- pybreeze/pybreeze_ui/editor_main/main_ui.py | 4 +- .../extend_ai_gui/ai_gui_global_variable.py | 2 + .../code_review/code_review_thread.py | 6 ++- .../code_review/cot_code_review_gui.py | 10 ++-- .../code_smell_detector.py | 2 + .../first_code_review.py | 2 + .../first_summary_prompt.py | 2 + .../global_rule.py | 2 + .../cot_code_review_prompt_templates/judge.py | 2 + .../judge_single_review.py | 2 + .../linter.py | 2 + .../step_by_step_analysis.py | 2 + .../total_summary.py | 2 + .../cot_prompt_editor_widget.py | 2 + .../skills_prompt_editor_widget.py | 2 + .../skills_prompt_templates/code_explainer.py | 2 + .../skills_prompt_templates/code_review.py | 2 + .../extend_ai_gui/skills/skills_send_gui.py | 6 ++- .../jupyter_lab_gui/jupyter_lab_thread.py | 5 +- .../jupyter_lab_gui/jupyter_lab_widget.py | 2 + .../automation_menu_factory.py | 2 +- .../menu/plugin_menu/build_plugin_menu.py | 20 +++----- .../menu/plugin_menu/build_run_with_menu.py | 2 +- .../show_code_window/code_window.py | 2 + pybreeze/pybreeze_ui/syntax/syntax_keyword.py | 2 + pybreeze/utils/exception/exception_tags.py | 2 + pybreeze/utils/exception/exceptions.py | 2 + pybreeze/utils/json_format/json_process.py | 4 +- pybreeze/utils/logging/logger.py | 2 + .../package_manager/package_manager_class.py | 2 + pybreeze/utils/network/__init__.py | 0 pybreeze/utils/network/url_validation.py | 47 ++++++++++++++++++ pyproject.toml | 2 +- 49 files changed, 234 insertions(+), 87 deletions(-) create mode 100644 pybreeze/utils/network/__init__.py create mode 100644 pybreeze/utils/network/url_validation.py diff --git a/CLAUDE.md b/CLAUDE.md index 8f73d4f..f0dc94b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,6 +36,7 @@ pybreeze/ ├── logging/ # pybreeze_logger ├── file_process/ # File/directory utilities ├── json_format/ # JSON processing + ├── network/ # URL validation (SSRF prevention) └── manager/package_manager/ # PackageManager class ``` @@ -95,14 +96,37 @@ All code must follow secure-by-default principles. Review every change against t - Never use `subprocess.Popen(..., shell=True)` — always pass argument lists - Never log or display secrets, tokens, passwords, or API keys - Use `json.loads()` / `json.dumps()` for serialisation — never pickle +- Never use `yaml.load()` — always use `yaml.safe_load()` - Validate all user input at system boundaries (file dialogs, URL inputs, network data) +- Handle exceptions without leaking stack traces, file paths, or internal state to the user ### Network requests (SSRF prevention) -- All outbound HTTP requests must go through `diagram_net_utils.safe_download_image()` or equivalent guards -- Only `http://` and `https://` schemes are allowed — block `file://`, `ftp://`, `data:`, `gopher://` -- Resolved IP addresses must be checked against private/loopback/link-local ranges (`ipaddress.is_private`, `is_loopback`, `is_link_local`, `is_reserved`) -- Enforce download size limits (default: 20 MB) and connection timeouts (default: 15s) -- Never pass user-supplied URLs directly to `urlopen()` without validation +- **All** outbound HTTP requests to user-specified URLs must validate the target before connecting: + 1. Only `http://` and `https://` schemes — block `file://`, `ftp://`, `data:`, `gopher://` + 2. Resolve the hostname and check IPs against private/loopback/link-local/reserved ranges (`ipaddress.is_private`, `is_loopback`, `is_link_local`, `is_reserved`) + 3. Enforce connection timeouts (default: 15 s for downloads, 30 s for API calls) + 4. Enforce response size limits where applicable (default: 20 MB for binary downloads) +- Reference implementation: `diagram_net_utils._validate_url()` and `safe_download_image()` +- For API-style requests (`requests.get/post`): create or reuse a URL validation helper that performs scheme + IP checks, then call it before every `requests.*` call +- Disable automatic redirect following (`allow_redirects=False`) or re-validate the redirect target to prevent redirect-based SSRF +- Never pass user-supplied URLs directly to `urlopen()` or `requests.*` without validation + +### Network requests (TLS / SSH) +- All HTTPS requests must use default TLS verification — never set `verify=False` +- SSH connections: `paramiko.AutoAddPolicy()` accepts any host key and is vulnerable to MITM. Document it as a known limitation in the SSH GUI. Prefer `paramiko.RejectPolicy()` or `paramiko.WarningPolicy()` when non-interactive verification is possible; at minimum, warn the user on first connection to an unknown host + +### Subprocess execution +- Always pass argument lists to `subprocess.Popen` / `subprocess.run` — never `shell=True` +- Explicitly set `shell=False` for clarity in new code +- Never interpolate user input into command strings — pass as separate list elements +- Set `timeout` on all `subprocess.run()` calls to prevent hangs +- The IDE intentionally runs user-authored scripts; this is trusted local execution, not arbitrary remote code. Subprocess hardening protects against accidental shell injection, not against malicious local files + +### JupyterLab integration +- The embedded JupyterLab server binds to `localhost` only and is intended for local development +- `--ServerApp.token=` and `--ServerApp.password=` are deliberately empty to enable seamless embedding — this is safe only because the server is localhost-only +- Do not change `--ServerApp.ip` to `0.0.0.0` or any externally-reachable address +- `--ServerApp.disable_check_xsrf=True` is required for the embedded QWebEngineView; do not expose the server externally with XSRF disabled ### File I/O - File read/write paths from user dialogs (`QFileDialog`) are trusted (user-initiated) @@ -110,10 +134,25 @@ All code must follow secure-by-default principles. Review every change against t - Local paths: check `path.is_file()` and verify extension is in an allowlist - URLs: pass through the same SSRF validation as user-entered URLs - Never construct file paths by string concatenation with user input — use `pathlib.Path` with validation +- When writing to data directories (`.pybreeze/`), create the directory with `os.makedirs(exist_ok=True)` and always use `encoding="utf-8"` +- Never follow symlinks from untrusted sources — use `Path.resolve(strict=True)` and verify the resolved path is still within expected boundaries ### Qt / UI - `QGraphicsTextItem` with `TextEditorInteraction` must not be enabled by default — use double-click-to-edit pattern to prevent unintended text selection issues in themed environments - Plugin loading (`jeditor_plugins/`) uses auto-discovery — only load `.py` files, skip files starting with `_` or `.` +- `QWebEngineView.setUrl()` must only load trusted URLs (localhost or user-confirmed external URLs) — never load untrusted HTML or URLs without user consent +- Never call `QWebEngineView.setHtml()` with unsanitised content — this enables XSS within the embedded browser + +### Secrets and credentials +- SSH passwords and private key passphrases are held in memory only during the session — never persist to disk or logs +- Password fields must use `QLineEdit.EchoMode.Password` +- API endpoint URLs may contain embedded tokens — treat URL strings with the same care as credentials (do not log full URLs) +- Environment variables (`PYBREEZE_LOG_MAX_BYTES`, etc.) must never contain secrets; use dedicated secure stores for credentials + +### Dependency security +- Pin dependencies to exact versions in `requirements.txt` / `dev_requirements.txt` +- Do not add new dependencies without reviewing their security posture (maintained? known CVEs?) +- Avoid transitive dependency bloat — prefer stdlib solutions when the alternative is a single-function dependency ## Commit & PR rules diff --git a/pybreeze/__main__.py b/pybreeze/__main__.py index a465590..408a814 100644 --- a/pybreeze/__main__.py +++ b/pybreeze/__main__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from pybreeze import start_editor start_editor() diff --git a/pybreeze/extend/process_executor/python_task_process_manager.py b/pybreeze/extend/process_executor/python_task_process_manager.py index 2e4e5f8..baddfc9 100644 --- a/pybreeze/extend/process_executor/python_task_process_manager.py +++ b/pybreeze/extend/process_executor/python_task_process_manager.py @@ -5,7 +5,7 @@ import subprocess import sys import threading -import typing +from typing import Callable from pathlib import Path from queue import Queue from threading import Thread @@ -42,8 +42,8 @@ class TaskProcessManager: def __init__( self, main_window: CodeWindow, - task_done_trigger_function: typing.Callable | None = None, - error_trigger_function: typing.Callable | None = None, + task_done_trigger_function: Callable | None = None, + error_trigger_function: Callable | None = None, program_buffer_size: int = 1024, program_encoding: str = "utf-8" ): @@ -60,8 +60,8 @@ def __init__( self.run_error_queue: Queue = Queue() self.process: subprocess.Popen | None = None - self.task_done_trigger_function: typing.Callable = task_done_trigger_function - self.error_trigger_function: typing.Callable = error_trigger_function + self.task_done_trigger_function: Callable = task_done_trigger_function + self.error_trigger_function: Callable = error_trigger_function self.program_buffer_size = program_buffer_size def renew_path(self) -> None: diff --git a/pybreeze/extend/process_executor/test_pioneer/test_pioneer_process_manager.py b/pybreeze/extend/process_executor/test_pioneer/test_pioneer_process_manager.py index cbc5399..7684c6e 100644 --- a/pybreeze/extend/process_executor/test_pioneer/test_pioneer_process_manager.py +++ b/pybreeze/extend/process_executor/test_pioneer/test_pioneer_process_manager.py @@ -10,7 +10,6 @@ from PySide6.QtCore import QTimer from PySide6.QtGui import QTextCharFormat -from PySide6.QtWidgets import QWidget from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import actually_color_dict from je_editor.utils.venv_check.check_venv import check_and_choose_venv @@ -31,7 +30,6 @@ def __init__( encoding: str = "utf-8", ): self._main_window: PyBreezeMainWindow = main_window - self._widget: QWidget = main_window.tab_widget.currentWidget() # Code window init self._code_window = CodeWindow() self._main_window.current_run_code_window.append(self._code_window) diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index cc54210..1468953 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from je_editor import english_word_dict # PyBreeze-specific English translations diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index a88d7e4..78a35cd 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from je_editor import traditional_chinese_word_dict # PyBreeze-specific Traditional Chinese translations diff --git a/pybreeze/extend_multi_language/update_language_dict.py b/pybreeze/extend_multi_language/update_language_dict.py index a25c004..84d0fa2 100644 --- a/pybreeze/extend_multi_language/update_language_dict.py +++ b/pybreeze/extend_multi_language/update_language_dict.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from pybreeze.extend_multi_language.extend_english import update_english_word_dict from pybreeze.extend_multi_language.extend_traditional_chinese import \ update_traditional_chinese_word_dict diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py index c6e041c..f74548d 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import os import re @@ -137,7 +139,11 @@ def connect_ssh(self): try: self.ssh_client = paramiko.SSHClient() - self.ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + self.ssh_client.load_system_host_keys() + self.ssh_client.set_missing_host_key_policy(paramiko.WarningPolicy()) + pybreeze_logger.warning( + f"SSH connecting to {host}:{port} — host key will be accepted without verification" + ) if use_key: if not os.path.exists(key_path): diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py index c79727b..e59417f 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py @@ -1,4 +1,7 @@ +from __future__ import annotations + import os +import stat from pathlib import Path import paramiko @@ -10,6 +13,7 @@ from je_editor import language_wrapper from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_login_widget import LoginWidget +from pybreeze.utils.logging.logger import pybreeze_logger class SFTPClientWrapper: @@ -32,7 +36,11 @@ def connect(self, host: str, port: int, username: str, password: str, """ self.close() self._ssh = paramiko.SSHClient() - self._ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + self._ssh.load_system_host_keys() + self._ssh.set_missing_host_key_policy(paramiko.WarningPolicy()) + pybreeze_logger.warning( + f"SFTP connecting to {host}:{port} — host key will be accepted without verification" + ) if use_key and key_path: pkey = None for KeyType in (paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey): @@ -96,8 +104,6 @@ def is_dir(self, path: str) -> bool: )) try: st = self._sftp.stat(path) - # S_ISDIR check via stat.S_ISDIR - import stat return stat.S_ISDIR(st.st_mode) except OSError: return False @@ -294,7 +300,6 @@ def populate_children(self, parent_item: QTreeWidgetItem): try: entries = self.client.list_dir(path) # Sort: dirs first, then files - import stat dirs = [] files = [] for e in entries: @@ -307,10 +312,9 @@ def populate_children(self, parent_item: QTreeWidgetItem): else: files.append((name, e)) for name, e in dirs + files: - import stat as _stat full_path = os.path.join(path if path != "/" else "", name) full_path = full_path if full_path.startswith("/") else f"/{full_path}" - typ = "dir" if _stat.S_ISDIR(e.st_mode) else "file" + typ = "dir" if stat.S_ISDIR(e.st_mode) else "file" size = e.st_size if typ == "file" else 0 child = self.make_item(name, typ, size, full_path) parent_item.addChild(child) diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_login_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_login_widget.py index c1ca6a9..071238a 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_login_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_login_widget.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from PySide6.QtWidgets import ( QWidget, QHBoxLayout, QVBoxLayout, QLabel, QLineEdit, QSpinBox, QCheckBox, QPushButton diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_main_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_main_widget.py index f7a2e13..f82f382 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_main_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_main_widget.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import sys from PySide6.QtCore import Qt diff --git a/pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py b/pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py index 72152e4..6dc6f3a 100644 --- a/pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py +++ b/pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import sys import requests import os @@ -7,6 +9,8 @@ ) from je_editor import language_wrapper +from pybreeze.utils.network.url_validation import UnsafeURLError, validate_url + class AICodeReviewClient(QWidget): def __init__(self): @@ -115,6 +119,13 @@ def send_request(self): self.word_dict.get("ai_code_review_gui_message_enter_valid_url")) return + try: + validate_url(url) + except UnsafeURLError as e: + self.response_panel.setPlainText( + f"{self.word_dict.get('ai_code_review_gui_message_error')}: {e}") + return + # 檢查 URL 是否已紀錄 if os.path.exists(self.url_file): with open(self.url_file, encoding="utf-8") as f: @@ -133,13 +144,13 @@ def send_request(self): try: if method == "GET": - response = requests.get(url, timeout=30) + response = requests.get(url, timeout=30, allow_redirects=False) elif method == "POST": - response = requests.post(url, data={"code": code_content}, timeout=30) + response = requests.post(url, data={"code": code_content}, timeout=30, allow_redirects=False) elif method == "PUT": - response = requests.put(url, data={"code": code_content}, timeout=30) + response = requests.put(url, data={"code": code_content}, timeout=30, allow_redirects=False) elif method == "DELETE": - response = requests.delete(url, timeout=30) + response = requests.delete(url, timeout=30, allow_redirects=False) else: self.response_panel.setPlainText( self.word_dict.get("ai_code_review_gui_message_unsupported_http_method")) diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py index f06e082..0ebba53 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py @@ -4,7 +4,7 @@ from pathlib import Path from PySide6.QtCore import QMarginsF, QRectF, QSizeF, Qt -from PySide6.QtGui import QImage, QKeySequence, QPainter, QShortcut +from PySide6.QtGui import QImage, QKeySequence, QPainter, QPixmap, QShortcut from PySide6.QtSvg import QSvgGenerator from PySide6.QtWidgets import ( QCheckBox, @@ -12,6 +12,7 @@ QFileDialog, QFrame, QHBoxLayout, + QInputDialog, QLabel, QMenu, QMessageBox, @@ -25,6 +26,7 @@ from je_editor import language_wrapper from pybreeze.pybreeze_ui.diagram_editor.diagram_mermaid_parser import parse_mermaid +from pybreeze.pybreeze_ui.diagram_editor.diagram_net_utils import safe_download_image from pybreeze.pybreeze_ui.diagram_editor.diagram_property_panel import DiagramPropertyPanel from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene, ToolMode from pybreeze.pybreeze_ui.diagram_editor.diagram_view import DiagramView @@ -516,7 +518,6 @@ def _add_image_from_file(self) -> None: ) if not path: return - from PySide6.QtGui import QPixmap pix = QPixmap(path) if pix.isNull(): QMessageBox.warning(self, _lang("diagram_editor_error_title", "Error"), @@ -525,7 +526,6 @@ def _add_image_from_file(self) -> None: self._scene.add_image(pix, path) def _add_image_from_url(self) -> None: - from PySide6.QtWidgets import QInputDialog url, ok = QInputDialog.getText( self, _lang("diagram_editor_dialog_image_url", "Image URL"), @@ -535,9 +535,7 @@ def _add_image_from_url(self) -> None: return url = url.strip() try: - from pybreeze.pybreeze_ui.diagram_editor.diagram_net_utils import safe_download_image data = safe_download_image(url) - from PySide6.QtGui import QPixmap pix = QPixmap() pix.loadFromData(data) if pix.isNull(): diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_net_utils.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_net_utils.py index 3671748..b378f08 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_net_utils.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_net_utils.py @@ -8,14 +8,12 @@ """ from __future__ import annotations -import ipaddress -import socket -from urllib.parse import urlparse from urllib.request import Request, urlopen +from pybreeze.utils.network.url_validation import UnsafeURLError, validate_url + MAX_DOWNLOAD_BYTES = 20 * 1024 * 1024 # 20 MB TIMEOUT_SECONDS = 15 -_ALLOWED_SCHEMES = {"http", "https"} class ImageDownloadError(Exception): @@ -24,33 +22,10 @@ class ImageDownloadError(Exception): def _validate_url(url: str) -> str: """Validate URL scheme and resolve hostname to block private/loopback IPs.""" - parsed = urlparse(url) - - # Scheme check - if parsed.scheme.lower() not in _ALLOWED_SCHEMES: - raise ImageDownloadError( - f"Scheme '{parsed.scheme}' is not allowed. Use http or https." - ) - - # Hostname check - hostname = parsed.hostname - if not hostname: - raise ImageDownloadError("URL has no hostname.") - - # Resolve and check for private/loopback IPs (SSRF prevention) try: - infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM) - except socket.gaierror as e: - raise ImageDownloadError(f"Cannot resolve hostname '{hostname}': {e}") from e - - for family, _, _, _, sockaddr in infos: - ip = ipaddress.ip_address(sockaddr[0]) - if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: - raise ImageDownloadError( - f"Access to private/internal address {ip} is blocked." - ) - - return url + return validate_url(url) + except UnsafeURLError as exc: + raise ImageDownloadError(str(exc)) from exc def safe_download_image(url: str) -> bytes: diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py index 624c7c3..fa83c04 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py @@ -2,12 +2,15 @@ from contextlib import contextmanager from enum import Enum, auto +from pathlib import Path from PySide6.QtCore import QPointF, Qt, Signal from PySide6.QtGui import QColor, QPen, QPixmap, QUndoStack from PySide6.QtWidgets import QGraphicsLineItem, QGraphicsScene, QMenu +from je_editor import language_wrapper from pybreeze.pybreeze_ui.diagram_editor.diagram_commands import DiagramSnapshotCommand +from pybreeze.pybreeze_ui.diagram_editor.diagram_net_utils import safe_download_image from pybreeze.pybreeze_ui.diagram_editor.diagram_items import ( ConnectionStyle, DiagramConnection, @@ -247,7 +250,6 @@ def keyPressEvent(self, event) -> None: super().keyPressEvent(event) def contextMenuEvent(self, event) -> None: - from je_editor import language_wrapper menu = QMenu() item = self._node_at(event.scenePos()) conn = self._connection_at(event.scenePos()) @@ -578,7 +580,6 @@ def _try_load_image_source(self, img: DiagramImage, source: str) -> None: Local paths are restricted to existing image files. URLs are validated and size-limited via ``safe_download_image``. """ - from pathlib import Path path = Path(source) # Only load if the file actually exists and has an image extension _IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".bmp", ".gif", ".svg", ".webp", ".ico"} @@ -589,7 +590,6 @@ def _try_load_image_source(self, img: DiagramImage, source: str) -> None: return if source.startswith(("http://", "https://")): try: - from pybreeze.pybreeze_ui.diagram_editor.diagram_net_utils import safe_download_image data = safe_download_image(source) pix = QPixmap() pix.loadFromData(data) diff --git a/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py b/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py index 692bc2d..b5ee9be 100644 --- a/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py +++ b/pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py @@ -1,5 +1,9 @@ +from __future__ import annotations + import os import shutil +import subprocess +import sys from pathlib import Path from PySide6.QtCore import Qt, QModelIndex @@ -248,8 +252,6 @@ def _action_copy_path(tree_view: QTreeView, path: Path | None, relative: bool = def _action_reveal_in_explorer(path: Path | None) -> None: if path is None: return - import subprocess - import sys target = path if path.is_dir() else path.parent if sys.platform == "win32": os.startfile(str(target)) diff --git a/pybreeze/pybreeze_ui/editor_main/main_ui.py b/pybreeze/pybreeze_ui/editor_main/main_ui.py index 86baee5..2655281 100644 --- a/pybreeze/pybreeze_ui/editor_main/main_ui.py +++ b/pybreeze/pybreeze_ui/editor_main/main_ui.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import os import sys from os import environ @@ -16,6 +18,7 @@ from pybreeze.pybreeze_ui.menu.build_menubar import add_menu_to_menubar from pybreeze.pybreeze_ui.syntax.syntax_extend import \ syntax_extend_package +from pybreeze.utils.logging.logger import pybreeze_logger EDITOR_EXTEND_TAB: dict[str, type[QWidget]] = { @@ -108,7 +111,6 @@ def start_editor(debug_mode: bool = False, theme: str = "dark_amber.xml", **kwar try: window.startup_setting() except Exception as error: - from pybreeze.utils.logging.logger import pybreeze_logger pybreeze_logger.error(f"Startup setting error: {error}") ret = new_ide.exec() os._exit(ret) diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/ai_gui_global_variable.py b/pybreeze/pybreeze_ui/extend_ai_gui/ai_gui_global_variable.py index d91be02..b4984d3 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/ai_gui_global_variable.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/ai_gui_global_variable.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_code_review_prompt_templates.code_smell_detector import \ CODE_SMELL_DETECTOR_TEMPLATE from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_code_review_prompt_templates.first_code_review import \ diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/code_review/code_review_thread.py b/pybreeze/pybreeze_ui/extend_ai_gui/code_review/code_review_thread.py index d935445..e553f53 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/code_review/code_review_thread.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/code_review/code_review_thread.py @@ -1,3 +1,5 @@ +from __future__ import annotations + # Worker Thread 負責傳送資料 import requests from PySide6.QtCore import QThread, Signal @@ -6,6 +8,7 @@ from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import COT_TEMPLATE_RELATION from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_code_review_prompt_templates.global_rule import \ build_global_rule_template +from pybreeze.utils.network.url_validation import validate_url class SenderThread(QThread): @@ -18,6 +21,7 @@ def __init__(self, files: list, code: str, url: str): self.url = url def run(self): + validate_url(self.url) code = self.code first_code_review_result = None first_summary_result = None @@ -61,7 +65,7 @@ def run(self): try: # 傳送到指定 URL - resp = requests.post(self.url, json={"prompt": prompt}, timeout=60) + resp = requests.post(self.url, json={"prompt": prompt}, timeout=60, allow_redirects=False) reply_text = resp.text match file: case "first_summary_prompt.md": diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_code_review_gui.py b/pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_code_review_gui.py index f1e6861..9857fde 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_code_review_gui.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_code_review_gui.py @@ -1,4 +1,4 @@ -from urllib.parse import urlparse +from __future__ import annotations from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QTextEdit, QComboBox, QPushButton, \ QMessageBox @@ -6,6 +6,7 @@ from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import COT_TEMPLATE_FILES from pybreeze.pybreeze_ui.extend_ai_gui.code_review.code_review_thread import SenderThread +from pybreeze.utils.network.url_validation import UnsafeURLError, validate_url class CoTCodeReviewGUI(QWidget): @@ -69,9 +70,10 @@ def start_sending(self): if not url: QMessageBox.warning(self, "Warning", language_wrapper.language_word_dict.get("cot_gui_error_no_url")) return - parsed = urlparse(url) - if not parsed.scheme or not parsed.netloc: - QMessageBox.warning(self, "Warning", language_wrapper.language_word_dict.get("cot_gui_error_no_url")) + try: + validate_url(url) + except UnsafeURLError as e: + QMessageBox.warning(self, "Warning", str(e)) return # 啟動傳送 Thread diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/code_smell_detector.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/code_smell_detector.py index 41b6274..80ca31c 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/code_smell_detector.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/code_smell_detector.py @@ -1,3 +1,5 @@ +from __future__ import annotations + CODE_SMELL_DETECTOR_TEMPLATE = """ You are a senior software engineer specializing in code quality reviews. Carefully analyze the following code and identify all possible **code smells**. diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/first_code_review.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/first_code_review.py index d884a5b..845eae4 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/first_code_review.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/first_code_review.py @@ -1,3 +1,5 @@ +from __future__ import annotations + FIRST_CODE_REVIEW_TEMPLATE = """ # Code Review Template diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/first_summary_prompt.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/first_summary_prompt.py index 07d3e90..72abc8c 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/first_summary_prompt.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/first_summary_prompt.py @@ -1,3 +1,5 @@ +from __future__ import annotations + FIRST_SUMMARY_TEMPLATE = """ # PR Summary Template diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/global_rule.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/global_rule.py index ba0392a..9da0630 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/global_rule.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/global_rule.py @@ -1,3 +1,5 @@ +from __future__ import annotations + GLOBAL_RULE_TEMPLATE = """ Please conduct a code review according to the following global rules: diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/judge.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/judge.py index 51c8b18..69e2c0e 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/judge.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/judge.py @@ -1,3 +1,5 @@ +from __future__ import annotations + JUDGE_TEMPLATE = """ # Code Review Comment Evaluation Template (Enhanced) diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/judge_single_review.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/judge_single_review.py index 90a888a..b606fb7 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/judge_single_review.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/judge_single_review.py @@ -1,3 +1,5 @@ +from __future__ import annotations + JUDGE_SINGLE_REVIEW_TEMPLATE = """ # Code Review Comment Evaluation Template (Enhanced) diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/linter.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/linter.py index f02c698..51fab82 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/linter.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/linter.py @@ -1,3 +1,5 @@ +from __future__ import annotations + LINTER_TEMPLATE = """ You are a strict code linter. Your task is to analyze the given source code and produce structured linter_messages. diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/step_by_step_analysis.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/step_by_step_analysis.py index 51f18c5..1000f39 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/step_by_step_analysis.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/step_by_step_analysis.py @@ -1,3 +1,5 @@ +from __future__ import annotations + STEP_BY_STEP_ANALYSIS_TEMPLATE = """ You are a code quality reviewer. Analyze the code smell, and linter message step by step. diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/total_summary.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/total_summary.py index 60be5ee..56c6591 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/total_summary.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_code_review_prompt_templates/total_summary.py @@ -1,3 +1,5 @@ +from __future__ import annotations + TOTAL_SUMMARY_TEMPLATE = """ # PR Total Summary Instructions diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_prompt_editor_widget.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_prompt_editor_widget.py index 60b4d75..af3b832 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_prompt_editor_widget.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/cot_prompt_editor_widget.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import os from PySide6.QtCore import QFileSystemWatcher diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/skills_prompt_editor_widget.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/skills_prompt_editor_widget.py index c124311..5d54914 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/skills_prompt_editor_widget.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/skills_prompt_editor_widget.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import os from PySide6.QtCore import QFileSystemWatcher diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/skills_prompt_templates/code_explainer.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/skills_prompt_templates/code_explainer.py index 33487fb..a4cb285 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/skills_prompt_templates/code_explainer.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/skills_prompt_templates/code_explainer.py @@ -1,3 +1,5 @@ +from __future__ import annotations + CODE_EXPLAINER_TEMPLATE = """ You are a senior software engineer and educator. Explain the following code clearly and in detail. diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/skills_prompt_templates/code_review.py b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/skills_prompt_templates/code_review.py index 889be30..43214cf 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/skills_prompt_templates/code_review.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/skills_prompt_templates/code_review.py @@ -1,3 +1,5 @@ +from __future__ import annotations + CODE_REVIEW_SKILL_TEMPLATE = """ You are an expert software reviewer. Your task is to analyze one or more code diffs from a Pull Request (PR). The input may contain multiple `code_diff` sections placed in different positions. diff --git a/pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py b/pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py index 2e6cc76..fd9c81f 100644 --- a/pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py +++ b/pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import requests from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QLineEdit, @@ -7,6 +9,7 @@ from je_editor import language_wrapper from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import SKILLS_TEMPLATE_FILES +from pybreeze.utils.network.url_validation import validate_url class RequestThread(QThread): @@ -20,7 +23,8 @@ def __init__(self, api_url, code_text): def run(self): try: - response = requests.post(self.api_url, json={"code": self.code_text}, timeout=30) + validate_url(self.api_url) + response = requests.post(self.api_url, json={"code": self.code_text}, timeout=30, allow_redirects=False) if response.ok: self.finished.emit(response.text) elif response.is_redirect: diff --git a/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py b/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py index 7b945b1..d1b319f 100644 --- a/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py +++ b/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import os import socket import subprocess @@ -49,6 +51,7 @@ def is_jupyter_installed(python_exe: str) -> bool: result = subprocess.run( [python_exe, "-m", "pip", "show", "jupyterlab"], capture_output=True, + timeout=30, ) return result.returncode == 0 @@ -77,7 +80,7 @@ def run(self): "install", "jupyterlab", "-U" - ], capture_output=True, text=True) + ], capture_output=True, text=True, timeout=300) if result.returncode != 0: raise RuntimeError(result.stderr) diff --git a/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_widget.py b/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_widget.py index 0e668b0..540d9e1 100644 --- a/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_widget.py +++ b/pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_widget.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import sys from PySide6.QtCore import QUrl diff --git a/pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py b/pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py index b25f92c..b038abd 100644 --- a/pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py +++ b/pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py @@ -1,5 +1,6 @@ from __future__ import annotations +import importlib from typing import TYPE_CHECKING, Callable from PySide6.QtGui import QAction @@ -102,7 +103,6 @@ def safe_create_project(import_name: str) -> Callable: """Create a safe project creation function that handles ImportError.""" def _create(): try: - import importlib package = importlib.import_module(import_name) if package is not None: package.create_project_dir() diff --git a/pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py b/pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py index 83bd20a..b6b3e99 100644 --- a/pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py +++ b/pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py @@ -1,5 +1,6 @@ from __future__ import annotations +from pathlib import Path from typing import TYPE_CHECKING from PySide6.QtGui import QAction @@ -7,6 +8,13 @@ from je_editor import language_wrapper from je_editor.plugins import get_all_plugin_metadata +from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget +from je_editor.pyside_ui.main_ui.plugin_browser.plugin_browser_widget import PluginBrowserWidget +from je_editor.pyside_ui.dialog.file_dialog.save_file_dialog import choose_file_get_save_file_path +from je_editor.utils.file.save.save_file import write_file + +from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess +from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow if TYPE_CHECKING: from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow @@ -117,8 +125,6 @@ def _open_plugin_browser(ui_we_want_to_set: PyBreezeMainWindow) -> None: 開啟插件瀏覽器分頁。 Open plugin browser tab. """ - from je_editor.pyside_ui.main_ui.plugin_browser.plugin_browser_widget import PluginBrowserWidget - tab_name = language_wrapper.language_word_dict.get("plugin_browser_tab_name", "Plugin Browser") ui_we_want_to_set.tab_widget.addTab( PluginBrowserWidget(), @@ -151,15 +157,6 @@ def _make_run_callback(ui_we_want_to_set: PyBreezeMainWindow, run_config: dict, Uses PyBreeze's FileRunnerProcess and CodeWindow. """ def callback(): - from pathlib import Path - from PySide6.QtWidgets import QMessageBox - - from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget - from je_editor.utils.file.save.save_file import write_file - - from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess - from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow - widget = ui_we_want_to_set.tab_widget.currentWidget() if not isinstance(widget, EditorWidget): return @@ -169,7 +166,6 @@ def callback(): write_file(widget.current_file, widget.code_edit.toPlainText()) file_path = widget.current_file else: - from je_editor.pyside_ui.dialog.file_dialog.save_file_dialog import choose_file_get_save_file_path if not choose_file_get_save_file_path(ui_we_want_to_set): return file_path = widget.current_file diff --git a/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py b/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py index 13c28b9..8e98860 100644 --- a/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py +++ b/pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py @@ -13,6 +13,7 @@ from je_editor import language_wrapper from je_editor.plugins import get_all_plugin_run_configs +from je_editor.pyside_ui.dialog.file_dialog.save_file_dialog import choose_file_get_save_file_path from je_editor.pyside_ui.main_ui.editor.editor_widget import EditorWidget from je_editor.utils.file.save.save_file import write_file @@ -35,7 +36,6 @@ def _get_current_file(main_window: PyBreezeMainWindow) -> str | None: return widget.current_file # No file yet — ask user to save first - from je_editor.pyside_ui.dialog.file_dialog.save_file_dialog import choose_file_get_save_file_path if choose_file_get_save_file_path(main_window): return widget.current_file return None diff --git a/pybreeze/pybreeze_ui/show_code_window/code_window.py b/pybreeze/pybreeze_ui/show_code_window/code_window.py index 2831c90..439fca6 100644 --- a/pybreeze/pybreeze_ui/show_code_window/code_window.py +++ b/pybreeze/pybreeze_ui/show_code_window/code_window.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from PySide6.QtGui import QGuiApplication from PySide6.QtWidgets import QWidget, QGridLayout, QTextEdit, QScrollArea diff --git a/pybreeze/pybreeze_ui/syntax/syntax_keyword.py b/pybreeze/pybreeze_ui/syntax/syntax_keyword.py index 60344f4..782b711 100644 --- a/pybreeze/pybreeze_ui/syntax/syntax_keyword.py +++ b/pybreeze/pybreeze_ui/syntax/syntax_keyword.py @@ -1,3 +1,5 @@ +from __future__ import annotations + api_testka_keys: list = [ "AT_test_api_method", "AT_delegate_async_httpx", "AT_test_api_method_httpx", "AT_generate_html", "AT_generate_html_report", "AT_generate_json", diff --git a/pybreeze/utils/exception/exception_tags.py b/pybreeze/utils/exception/exception_tags.py index 463b902..7150c39 100644 --- a/pybreeze/utils/exception/exception_tags.py +++ b/pybreeze/utils/exception/exception_tags.py @@ -1,3 +1,5 @@ +from __future__ import annotations + # add command exception add_command_type_exception_tag: str = "command execute_return_value type must be a method or function" add_command_not_allow_package_exception_tag: str = "chosen command package is not allowed" diff --git a/pybreeze/utils/exception/exceptions.py b/pybreeze/utils/exception/exceptions.py index 4a3b023..72b88a1 100644 --- a/pybreeze/utils/exception/exceptions.py +++ b/pybreeze/utils/exception/exceptions.py @@ -1,3 +1,5 @@ +from __future__ import annotations + class ITEException(Exception): pass diff --git a/pybreeze/utils/json_format/json_process.py b/pybreeze/utils/json_format/json_process.py index d9bcc1e..3cd8351 100644 --- a/pybreeze/utils/json_format/json_process.py +++ b/pybreeze/utils/json_format/json_process.py @@ -1,4 +1,6 @@ -import json.decoder +from __future__ import annotations + +import json from json import dumps from json import loads diff --git a/pybreeze/utils/logging/logger.py b/pybreeze/utils/logging/logger.py index 2dee1e8..15171aa 100644 --- a/pybreeze/utils/logging/logger.py +++ b/pybreeze/utils/logging/logger.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging import os from logging.handlers import RotatingFileHandler diff --git a/pybreeze/utils/manager/package_manager/package_manager_class.py b/pybreeze/utils/manager/package_manager/package_manager_class.py index 61c31b6..19616fd 100644 --- a/pybreeze/utils/manager/package_manager/package_manager_class.py +++ b/pybreeze/utils/manager/package_manager/package_manager_class.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import os diff --git a/pybreeze/utils/network/__init__.py b/pybreeze/utils/network/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pybreeze/utils/network/url_validation.py b/pybreeze/utils/network/url_validation.py new file mode 100644 index 0000000..1445785 --- /dev/null +++ b/pybreeze/utils/network/url_validation.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import ipaddress +import socket +from urllib.parse import urlparse + +_ALLOWED_SCHEMES = frozenset({"http", "https"}) + + +class UnsafeURLError(Exception): + """Raised when a URL fails security validation.""" + + +def validate_url(url: str) -> str: + """Validate a user-supplied URL against SSRF rules. + + Checks: + 1. Scheme must be ``http`` or ``https`` + 2. Hostname must be present + 3. Resolved IP must not be private, loopback, link-local or reserved + + Returns the original *url* on success; raises ``UnsafeURLError`` on failure. + """ + parsed = urlparse(url) + + if parsed.scheme.lower() not in _ALLOWED_SCHEMES: + raise UnsafeURLError( + f"Scheme '{parsed.scheme}' is not allowed. Use http or https." + ) + + hostname = parsed.hostname + if not hostname: + raise UnsafeURLError("URL has no hostname.") + + try: + infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM) + except socket.gaierror as exc: + raise UnsafeURLError(f"Cannot resolve hostname '{hostname}': {exc}") from exc + + for _family, _type, _proto, _canonname, sockaddr in infos: + ip = ipaddress.ip_address(sockaddr[0]) + if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: + raise UnsafeURLError( + f"Access to private/internal address {ip} is blocked." + ) + + return url diff --git a/pyproject.toml b/pyproject.toml index 60b6293..0003d72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" [project] name = "pybreeze" -version = "1.0.18" +version = "1.0.19" authors = [ { name = "JE-Chen", email = "jechenmailman@gmail.com" }, ] From 656c5ff1f2108e72e809c7d8bc7943691329562c Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 18 Apr 2026 12:52:42 +0800 Subject: [PATCH 2/3] Add SonarQube/Codacy conventions and fix silent except --- CLAUDE.md | 72 +++++++++++++++++++ .../diagram_editor/diagram_scene.py | 10 ++- 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f0dc94b..84a516c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,6 +154,78 @@ All code must follow secure-by-default principles. Review every change against t - Do not add new dependencies without reviewing their security posture (maintained? known CVEs?) - Avoid transitive dependency bloat — prefer stdlib solutions when the alternative is a single-function dependency +## Code quality (SonarQube / Codacy compliance) + +All code must satisfy common static-analysis rules enforced by SonarQube and Codacy. Review each change against the checklist below. + +### Complexity & size +- Cyclomatic complexity per function: ≤ 15 (hard cap 20). Break large branches into helpers +- Cognitive complexity per function: ≤ 15. Flatten nested `if`/`for`/`try` chains with early returns or guard clauses +- Function length: ≤ 75 lines of code (excluding docstring / blank lines). Extract helpers past that +- Parameter count: ≤ 7 per function/method. Use a dataclass or typed dict when more are needed +- Nesting depth: ≤ 4 levels of `if`/`for`/`while`/`try`. Refactor with early returns instead of pyramids +- File length: ≤ 1000 lines — split modules past that +- Class `__init__`: keep attribute count reasonable; if a class has > 15 instance attributes, split responsibilities + +### Exception handling +- Never use bare `except:` — always specify exception types +- Avoid catching `Exception` or `BaseException` unless immediately re-raising or logging and re-raising with context +- Never `pass` silently inside `except` — log the error via `pybreeze_logger` (at minimum `.debug()`) with context +- Do not `return` / `break` / `continue` inside a `finally` block — it swallows exceptions +- Custom exceptions must inherit from `ITEException`; never `raise Exception(...)` directly +- Use `raise ... from err` (or `raise ... from None`) when re-raising to preserve / suppress the chain explicitly + +### Pythonic correctness +- Compare with `None` using `is` / `is not`, never `==` / `!=` +- Type checks use `isinstance(obj, T)`, never `type(obj) == T` +- Never use mutable default arguments (`def f(x=[])`) — use `None` and initialise inside +- Prefer f-strings over `%` formatting or `str.format()` +- Use context managers (`with open(...) as f:`) for every file / socket / lock — never leave resources to GC +- Use `enumerate()` instead of `range(len(...))` when the index is needed alongside the item +- Use `dict.get(key, default)` instead of `key in dict and dict[key]` patterns +- Use set / dict comprehensions when clearer than manual loops; avoid comprehensions with side effects + +### Naming & style (PEP 8) +- `snake_case` for functions, methods, variables, module names +- `PascalCase` for classes +- `UPPER_SNAKE_CASE` for module-level constants +- `_leading_underscore` for protected / internal members; never use `__dunder__` for custom attributes +- No single-letter names except loop indices (`i`, `j`) or conventional math (`x`, `y`) +- Do not shadow built-ins (`id`, `type`, `list`, `dict`, `input`, `file`, `open`, etc.) — rename the local variable + +### Duplication & dead code +- String literal used 3+ times in the same module → extract a module-level constant +- Identical 6+ line blocks in 2+ places → extract a helper function +- Remove unused imports, unused parameters, unused local variables, unreachable code after `return` / `raise` +- No commented-out code blocks — delete them (git history is the archive) +- No `TODO` / `FIXME` / `XXX` without an accompanying issue reference (`# TODO(#123): ...`) + +### Logging, printing, assertions +- Never use `print()` for diagnostics in library / runtime code — use `pybreeze_logger` +- Use lazy logging (`logger.debug("x=%s", x)`) — avoid eager f-string formatting inside log calls on hot paths +- Never use `assert` for runtime validation (Python strips assertions with `-O`). Use explicit `if … raise …` instead; `assert` is only for test code + +### Hardcoded values & secrets +- No hardcoded passwords, tokens, API keys, or secrets — use env vars or a config file excluded from VCS +- No hardcoded IP addresses or hostnames outside of `localhost` / documented loopback — use config +- Magic numbers (except 0, 1, -1) should be named constants when repeated or non-obvious + +### Boolean & return hygiene +- Replace `if cond: return True else: return False` with `return bool(cond)` or `return cond` +- Replace `if x == True` / `if x == False` with `if x` / `if not x` +- A function should have a consistent return type — never mix `return value` and bare `return` (returns `None`) on meaningful paths unless explicitly documented +- Do not return inside a generator function (`yield` + `return value` is a syntax pitfall) + +### Imports +- One import per line for `import` statements; grouped `from x import a, b` is fine +- Order: stdlib → third-party → first-party (`pybreeze.*`) — separated by blank lines +- No wildcard imports (`from x import *`) outside of `__init__.py` re-exports +- No relative imports beyond one level (`from ..pkg import x` OK, `from ...pkg import x` avoid) + +### Running the linters +- Before committing any non-trivial change, run `ruff check pybreeze/` locally to catch these rules — `ruff` covers the majority of SonarQube/Codacy Python rules +- When adding a new rule exception, justify it in a `# noqa: RULE` comment with a short reason — never blanket-disable + ## Commit & PR rules - Commit messages: short imperative sentence (e.g., "Update stable version", "Fix github actions") diff --git a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py index fa83c04..c80afc8 100644 --- a/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py +++ b/pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py @@ -10,7 +10,6 @@ from je_editor import language_wrapper from pybreeze.pybreeze_ui.diagram_editor.diagram_commands import DiagramSnapshotCommand -from pybreeze.pybreeze_ui.diagram_editor.diagram_net_utils import safe_download_image from pybreeze.pybreeze_ui.diagram_editor.diagram_items import ( ConnectionStyle, DiagramConnection, @@ -19,6 +18,11 @@ NodeShape, ResizeHandle, ) +from pybreeze.pybreeze_ui.diagram_editor.diagram_net_utils import ( + ImageDownloadError, + safe_download_image, +) +from pybreeze.utils.logging.logger import pybreeze_logger class ToolMode(Enum): @@ -595,8 +599,8 @@ def _try_load_image_source(self, img: DiagramImage, source: str) -> None: pix.loadFromData(data) if not pix.isNull(): img.set_pixmap(pix, source) - except Exception: - pass + except (ImageDownloadError, OSError) as err: + pybreeze_logger.debug("safe_download_image failed: %s", err) def load_from_dict(self, data: dict) -> None: self._clear_items() From ccb73564c3918903878b4fdeb4b141d5a50cc415 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 18 Apr 2026 13:00:44 +0800 Subject: [PATCH 3/3] Replace WarningPolicy with interactive TOFU SSH host key verification --- CLAUDE.md | 2 +- .../extend_multi_language/extend_english.py | 7 ++ .../extend_traditional_chinese.py | 7 ++ .../connect_gui/ssh/ssh_command_widget.py | 8 +- .../connect_gui/ssh/ssh_file_viewer_widget.py | 13 +- .../connect_gui/ssh/ssh_host_key_policy.py | 111 ++++++++++++++++++ 6 files changed, 135 insertions(+), 13 deletions(-) create mode 100644 pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py diff --git a/CLAUDE.md b/CLAUDE.md index 84a516c..91fdc12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,7 +113,7 @@ All code must follow secure-by-default principles. Review every change against t ### Network requests (TLS / SSH) - All HTTPS requests must use default TLS verification — never set `verify=False` -- SSH connections: `paramiko.AutoAddPolicy()` accepts any host key and is vulnerable to MITM. Document it as a known limitation in the SSH GUI. Prefer `paramiko.RejectPolicy()` or `paramiko.WarningPolicy()` when non-interactive verification is possible; at minimum, warn the user on first connection to an unknown host +- SSH connections: never use `paramiko.AutoAddPolicy()` or `paramiko.WarningPolicy()` — both silently accept unknown host keys and are vulnerable to MITM. Use `InteractiveHostKeyPolicy` from `pybreeze.pybreeze_ui.connect_gui.ssh.ssh_host_key_policy` (via `apply_host_key_policy(client, parent_widget)`), which prompts the user with the SHA256 fingerprint on first connection and persists confirmed keys to `~/.pybreeze/ssh_known_hosts` ### Subprocess execution - Always pass argument lists to `subprocess.Popen` / `subprocess.run` — never `shell=True` diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index 1468953..36abc3e 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -101,6 +101,13 @@ "test_pioneer_create_template_label": "Create TestPioneer Yaml template", "test_pioneer_run_yaml": "Execute Test Pioneer Yaml", "test_pioneer_not_choose_yaml": "Please choose a Yaml file", + # SSH host key verification + "ssh_host_key_policy_dialog_title_verify_host": "Verify SSH host key", + "ssh_host_key_policy_dialog_message_verify_host": ( + "The authenticity of host '{host}' cannot be established.\n" + "{key_type} key fingerprint is {fingerprint}.\n\n" + "Do you want to trust this host and continue connecting?" + ), # SSH command widget "ssh_command_widget_window_title_ssh_command_widget": "SSH Command Widget", "ssh_command_widget_button_label_send_command": "Send", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index 78a35cd..0d12eb7 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -101,6 +101,13 @@ "test_pioneer_create_template_label": "建立 TestPioneer Yaml 模板", "test_pioneer_run_yaml": "執行 Test Pioneer Yaml", "test_pioneer_not_choose_yaml": "請選擇 Yaml 檔案", + # SSH host key verification + "ssh_host_key_policy_dialog_title_verify_host": "驗證 SSH 主機金鑰", + "ssh_host_key_policy_dialog_message_verify_host": ( + "無法驗證主機 '{host}' 的真實性。\n" + "{key_type} 金鑰指紋為 {fingerprint}。\n\n" + "是否信任此主機並繼續連線?" + ), # SSH command widget "ssh_command_widget_window_title_ssh_command_widget": "SSH 指令介面", "ssh_command_widget_button_label_send_command": "送出", diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py index f74548d..44f8a76 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py @@ -12,6 +12,7 @@ ) from je_editor import language_wrapper +from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_host_key_policy import apply_host_key_policy from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_login_widget import LoginWidget from pybreeze.utils.logging.logger import pybreeze_logger @@ -139,11 +140,8 @@ def connect_ssh(self): try: self.ssh_client = paramiko.SSHClient() - self.ssh_client.load_system_host_keys() - self.ssh_client.set_missing_host_key_policy(paramiko.WarningPolicy()) - pybreeze_logger.warning( - f"SSH connecting to {host}:{port} — host key will be accepted without verification" - ) + apply_host_key_policy(self.ssh_client, self) + pybreeze_logger.info("SSH connecting to %s:%s", host, port) if use_key: if not os.path.exists(key_path): diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py index e59417f..a02531a 100644 --- a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py @@ -12,6 +12,7 @@ ) from je_editor import language_wrapper +from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_host_key_policy import apply_host_key_policy from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_login_widget import LoginWidget from pybreeze.utils.logging.logger import pybreeze_logger @@ -29,18 +30,16 @@ def __init__(self): self.root_path: str = "/" def connect(self, host: str, port: int, username: str, password: str, - use_key: bool = False, key_path: str = ""): + use_key: bool = False, key_path: str = "", + parent_widget: QWidget | None = None): """ Establish SSH + SFTP connection. 建立 SSH + SFTP 連線。 """ self.close() self._ssh = paramiko.SSHClient() - self._ssh.load_system_host_keys() - self._ssh.set_missing_host_key_policy(paramiko.WarningPolicy()) - pybreeze_logger.warning( - f"SFTP connecting to {host}:{port} — host key will be accepted without verification" - ) + apply_host_key_policy(self._ssh, parent_widget) + pybreeze_logger.info("SFTP connecting to %s:%s", host, port) if use_key and key_path: pkey = None for KeyType in (paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey): @@ -216,7 +215,7 @@ def _connect(self): self.word_dict.get("ssh_file_viewer_dialog_message_missing_input")) return try: - self.client.connect(host, port, user, pwd, use_key, key_path) + self.client.connect(host, port, user, pwd, use_key, key_path, parent_widget=self) self.load_root("/") except Exception as e: QMessageBox.critical( diff --git a/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py new file mode 100644 index 0000000..0a6571a --- /dev/null +++ b/pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py @@ -0,0 +1,111 @@ +"""Interactive SSH host key policy with persistent trust-on-first-use (TOFU). + +Replaces the MITM-prone ``paramiko.AutoAddPolicy`` / ``paramiko.WarningPolicy``: +unknown host keys are shown to the user via a Qt dialog with their SHA256 +fingerprint, and only accepted on explicit confirmation. Confirmed hosts are +persisted to ``~/.pybreeze/ssh_known_hosts`` so subsequent connections verify +automatically. +""" +from __future__ import annotations + +import base64 +import hashlib +from pathlib import Path +from typing import TYPE_CHECKING + +import paramiko +from je_editor import language_wrapper +from PySide6.QtWidgets import QMessageBox + +from pybreeze.utils.logging.logger import pybreeze_logger + +if TYPE_CHECKING: + from PySide6.QtWidgets import QWidget + + +def _known_hosts_path() -> Path: + """Return the PyBreeze-managed known_hosts file path, ensuring the parent dir exists.""" + home_dir = Path.home() / ".pybreeze" + home_dir.mkdir(parents=True, exist_ok=True) + return home_dir / "ssh_known_hosts" + + +def _fingerprint_sha256(key: paramiko.PKey) -> str: + """Return an OpenSSH-style SHA256 fingerprint (``SHA256:base64`` without padding).""" + digest = hashlib.sha256(key.asbytes()).digest() + return "SHA256:" + base64.b64encode(digest).rstrip(b"=").decode("ascii") + + +class InteractiveHostKeyPolicy(paramiko.MissingHostKeyPolicy): + """Policy that prompts the user to verify unknown host keys. + + Accepted keys are persisted so later connections pass through ``RejectPolicy``-like + strictness automatically. Declined keys abort the connection with ``SSHException``. + """ + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__() + self._parent = parent + self._word_dict = language_wrapper.language_word_dict + + def missing_host_key( + self, + client: paramiko.SSHClient, + hostname: str, + key: paramiko.PKey, + ) -> None: + fingerprint = _fingerprint_sha256(key) + key_type = key.get_name() + + title = self._word_dict.get( + "ssh_host_key_policy_dialog_title_verify_host", + "Verify SSH host key", + ) + message_template = self._word_dict.get( + "ssh_host_key_policy_dialog_message_verify_host", + "The authenticity of host '{host}' cannot be established.\n" + "{key_type} key fingerprint is {fingerprint}.\n\n" + "Do you want to trust this host and continue connecting?", + ) + message = message_template.format( + host=hostname, key_type=key_type, fingerprint=fingerprint + ) + + box = QMessageBox(self._parent) + box.setIcon(QMessageBox.Icon.Warning) + box.setWindowTitle(title) + box.setText(message) + box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) + box.setDefaultButton(QMessageBox.StandardButton.No) + response = box.exec() + + if response != QMessageBox.StandardButton.Yes: + pybreeze_logger.warning( + "SSH host key for %s rejected by user (%s)", hostname, fingerprint + ) + raise paramiko.SSHException( + f"Host key for {hostname} rejected by user." + ) + + client.get_host_keys().add(hostname, key_type, key) + try: + client.save_host_keys(str(_known_hosts_path())) + except OSError as err: + pybreeze_logger.warning( + "Failed to persist SSH host key for %s: %s", hostname, err + ) + pybreeze_logger.info( + "SSH host key for %s accepted and stored (%s)", hostname, fingerprint + ) + + +def apply_host_key_policy(client: paramiko.SSHClient, parent: QWidget | None) -> None: + """Load known hosts and attach the interactive TOFU policy to *client*.""" + client.load_system_host_keys() + known_hosts = _known_hosts_path() + if known_hosts.is_file(): + try: + client.load_host_keys(str(known_hosts)) + except OSError as err: + pybreeze_logger.warning("Failed to load PyBreeze known_hosts: %s", err) + client.set_missing_host_key_policy(InteractiveHostKeyPolicy(parent))