98eb1d0d89
Check Cross-Plugin Imports / check (push) Has been cancelled
Phase 1: Contracts konsequent nutzen - 12 neue contracts.py erstellt (alle 19 Plugins haben jetzt contracts) - 4 bestehende contracts.py an zentrale ContractRegistry angepasst - Alle 19 Plugins haben on_deactivate mit Contract-Unregister - 0 echte problematische INTER-Plugin Imports Phase 2: Hooks/Filters-System - app/core/hooks.py (HookRegistry mit actions + filters) - 15 Hook-Punkte in Core-Services (contact, auth, mail, calendar, user, dms) - BasePlugin.on_deactivate meldet alle Hooks ab Phase 3: Plugin-Isolation - scripts/check_cross_plugin_imports.py (Linting-Regel) - .github/workflows/check-cross-plugin-imports.yml (CI/CD) - .pre-commit-cross-plugin.yaml (Pre-commit Hook) - 155 Dateien geprueft, 0 Verstoesse Phase 4: Plugin-Versioning - app/plugins/semver.py (SemVer mit Parse, Compare, Pre-release) - migration_runner.py erweitert: run_migration_down, rollback_to_version - manifest.py: min_app_version Feld - registry.py: App-Version-Compatibility-Check bei Installation - GET /api/v1/plugins/updates Endpoint Phase 5: Marketplace-Vorbereitung - app/plugins/signature.py (Ed25519 Signatur-Validierung) - app/plugins/quarantine.py (Plugin-Quarantine mit Validierung) - app/models/plugin_allowlist.py + Migration 0046 - manifest.py: author, license, homepage, icon, screenshots, changelog, marketplace_tags, price - registry.py: discover_external(), discover_all() - POST /api/v1/plugins/install-marketplace (deaktiviert) Phase 6: Manifest-Anpassung - manifest.py: 12 neue Felder + SemVer/Hook-Name Validierung - MANIFEST_SCHEMA_DOC aktualisiert - Alle 19 Plugin-Manifeste aktualisiert - Frontend PluginUiManifest Typ erweitert Zusaetzliche Bug-Fixes: - test_sample-Modul erstellt - conftest.py Deadlock-Prevention - SESSION_COOKIE_SECURE=true - dump.rdb aus Git entfernt + .gitignore - backup.py datetime.utcnow -> func.now() - system_settings.py JSONB-Import nach oben - tax.py Mapped[float] -> Mapped[Decimal] - notification.py type_key-Laengen vereinheitlicht Tests: 91 neue Tests, alle bestanden
216 lines
6.9 KiB
Python
216 lines
6.9 KiB
Python
"""Plugin quarantine — extract, validate, and install external plugins safely.
|
|
|
|
Workflow:
|
|
1. Extract ZIP to a temporary directory
|
|
2. Validate manifest exists and is valid
|
|
3. Check for dangerous imports
|
|
4. Validate migration SQL
|
|
5. Verify signature (if provided)
|
|
6. If all checks pass: move to plugins/ directory
|
|
7. If any check fails: delete temp directory and raise error
|
|
|
|
Usage::
|
|
|
|
from app.plugins.quarantine import quarantine_plugin
|
|
|
|
plugin_dir = await quarantine_plugin(
|
|
zip_path=Path("plugin.zip"),
|
|
signature=b"...",
|
|
public_key=b"...",
|
|
)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import tempfile
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
from app.plugins.signature import PluginSignature
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Maximum plugin ZIP size (50 MB)
|
|
MAX_PLUGIN_SIZE = 50 * 1024 * 1024
|
|
|
|
# Dangerous patterns in plugin source code
|
|
DANGEROUS_PATTERNS = [
|
|
(r"\bos\.system\b", "os.system call"),
|
|
(r"\bsubprocess\.", "subprocess module"),
|
|
(r"\beval\s*\(", "eval() call"),
|
|
(r"\bexec\s*\(", "exec() call"),
|
|
(r"\b__import__\s*\(", "__import__() call"),
|
|
(r"\bcompile\s*\(", "compile() call"),
|
|
(r"\bopen\s*\([^)]*['\"]w['\"]", "file write outside DMS"),
|
|
]
|
|
|
|
|
|
class QuarantineError(Exception):
|
|
"""Raised when a plugin fails quarantine validation."""
|
|
|
|
|
|
def _validate_manifest(plugin_dir: Path) -> dict:
|
|
"""Validate that the plugin has a valid manifest.
|
|
|
|
Returns the parsed manifest data.
|
|
"""
|
|
plugin_py = plugin_dir / "plugin.py"
|
|
init_py = plugin_dir / "__init__.py"
|
|
|
|
if not plugin_py.exists() and not init_py.exists():
|
|
raise QuarantineError("Plugin must have plugin.py or __init__.py")
|
|
|
|
# Read source and look for manifest
|
|
source_file = plugin_py if plugin_py.exists() else init_py
|
|
source = source_file.read_text(encoding="utf-8")
|
|
|
|
if "PluginManifest" not in source:
|
|
raise QuarantineError("Plugin source must define a PluginManifest")
|
|
|
|
if "BasePlugin" not in source:
|
|
raise QuarantineError("Plugin source must inherit from BasePlugin")
|
|
|
|
return {"source_file": str(source_file), "has_manifest": True}
|
|
|
|
|
|
def _check_dangerous_imports(plugin_dir: Path) -> list[str]:
|
|
"""Check plugin source for dangerous imports/patterns.
|
|
|
|
Returns a list of dangerous patterns found (empty if safe).
|
|
"""
|
|
found: list[str] = []
|
|
|
|
for py_file in plugin_dir.rglob("*.py"):
|
|
source = py_file.read_text(encoding="utf-8")
|
|
for pattern, description in DANGEROUS_PATTERNS:
|
|
if re.search(pattern, source):
|
|
found.append(f"{py_file.name}: {description}")
|
|
|
|
return found
|
|
|
|
|
|
def _check_migration_sql(plugin_dir: Path) -> list[str]:
|
|
"""Validate migration SQL files in the plugin.
|
|
|
|
Returns a list of issues found (empty if OK).
|
|
"""
|
|
issues: list[str] = []
|
|
migrations_dir = plugin_dir / "migrations"
|
|
|
|
if not migrations_dir.exists():
|
|
return issues # No migrations is OK
|
|
|
|
for sql_file in migrations_dir.glob("*.sql"):
|
|
content = sql_file.read_text(encoding="utf-8")
|
|
# Check for tenant_id in CREATE TABLE
|
|
if "CREATE TABLE" in content.upper() and "tenant_id" not in content.lower():
|
|
issues.append(
|
|
f"{sql_file.name}: CREATE TABLE without tenant_id column"
|
|
)
|
|
# Check for DROP DATABASE / DROP SCHEMA
|
|
if "DROP DATABASE" in content.upper() or "DROP SCHEMA" in content.upper():
|
|
issues.append(f"{sql_file.name}: Contains DROP DATABASE/SCHEMA")
|
|
|
|
return issues
|
|
|
|
|
|
async def quarantine_plugin(
|
|
zip_path: Path,
|
|
signature: bytes | None = None,
|
|
public_key: bytes | None = None,
|
|
plugins_dir: Path | None = None,
|
|
) -> Path:
|
|
"""Extract, validate, and install a plugin from a ZIP file.
|
|
|
|
Args:
|
|
zip_path: Path to the plugin ZIP file.
|
|
signature: Optional Ed25519 signature bytes.
|
|
public_key: Optional Ed25519 public key bytes.
|
|
plugins_dir: Target directory for external plugins (default: plugins/).
|
|
|
|
Returns:
|
|
Path to the installed plugin directory.
|
|
|
|
Raises:
|
|
QuarantineError: If any validation check fails.
|
|
"""
|
|
# Check file size
|
|
file_size = zip_path.stat().st_size
|
|
if file_size > MAX_PLUGIN_SIZE:
|
|
raise QuarantineError(
|
|
f"Plugin ZIP too large: {file_size} bytes (max {MAX_PLUGIN_SIZE})"
|
|
)
|
|
|
|
# Verify signature if provided
|
|
if signature and public_key:
|
|
if not PluginSignature.verify_signature(zip_path, signature, public_key):
|
|
raise QuarantineError("Signature verification failed")
|
|
|
|
# Create temp directory for extraction
|
|
temp_dir = Path(tempfile.mkdtemp(prefix="plugin_quarantine_"))
|
|
|
|
try:
|
|
# Extract ZIP
|
|
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
# Check for path traversal in ZIP entries
|
|
for entry in zf.namelist():
|
|
if entry.startswith("/") or ".." in entry:
|
|
raise QuarantineError(f"Unsafe ZIP entry: {entry}")
|
|
zf.extractall(temp_dir)
|
|
|
|
# Find the plugin directory (might be nested)
|
|
plugin_dir = temp_dir
|
|
if not (plugin_dir / "plugin.py").exists() and not (plugin_dir / "__init__.py").exists():
|
|
# Look for a single subdirectory
|
|
subdirs = [d for d in plugin_dir.iterdir() if d.is_dir() and not d.name.startswith("_")]
|
|
if len(subdirs) == 1:
|
|
plugin_dir = subdirs[0]
|
|
else:
|
|
raise QuarantineError("Could not find plugin root directory in ZIP")
|
|
|
|
# 1. Validate manifest
|
|
manifest_info = _validate_manifest(plugin_dir)
|
|
logger.info("Manifest validated for plugin in %s", plugin_dir.name)
|
|
|
|
# 2. Check dangerous imports
|
|
dangerous = _check_dangerous_imports(plugin_dir)
|
|
if dangerous:
|
|
raise QuarantineError(
|
|
f"Dangerous patterns found in plugin: {', '.join(dangerous)}"
|
|
)
|
|
|
|
# 3. Check migration SQL
|
|
sql_issues = _check_migration_sql(plugin_dir)
|
|
if sql_issues:
|
|
raise QuarantineError(
|
|
f"Migration SQL issues: {', '.join(sql_issues)}"
|
|
)
|
|
|
|
# 4. All checks passed — move to plugins directory
|
|
target_dir = plugins_dir or Path(os.environ.get("EXTERNAL_PLUGINS_PATH", "plugins"))
|
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
plugin_name = plugin_dir.name
|
|
final_dir = target_dir / plugin_name
|
|
|
|
if final_dir.exists():
|
|
raise QuarantineError(f"Plugin directory already exists: {final_dir}")
|
|
|
|
shutil.copytree(plugin_dir, final_dir)
|
|
logger.info("Plugin installed to %s", final_dir)
|
|
|
|
return final_dir
|
|
|
|
except Exception:
|
|
# Clean up temp directory on any error
|
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
raise
|
|
finally:
|
|
# Always clean up temp directory
|
|
if temp_dir.exists():
|
|
shutil.rmtree(temp_dir, ignore_errors=True)
|