Files
leocrm/app/plugins/quarantine.py
T
Agent Zero abbe7a18fc fix(audit): P0-P3 audit fixes — 838 ruff errors → 0, 30 F821 bugs fixed, 118 files changed
- P0: hooks.py 3-tuple fix, trigger_dispatcher Contract, contacts/plugin unregister_actions_by_owner
- P0: 5 test files — check_permission mocks removed, hardcoded DB credential → env var
- P1: attachment_service DmsFile via Contract helper, restore_registry/history_hooks dedup
- P1: mail/plugin restore unregister, mcp_client datetime.now(UTC), saved_views/filters patterns
- P1: ProtectedRoute fail-closed, 13 test assertion fixes (bcrypt, DB-URLs, SECRET_KEYs)
- P2: deprecated notifications → post_system_message (3 files), forgejo Base, report_generator lazy import
- P2: webhooks permissions, deps.py/roles.py plugin perms removed, import_export default
- P2: address/tags/entity_links patterns removed, worker.py Contract-Umgehungen fixed
- P2: 28 frontend TODOs (hardcoded constants, deprecated notification API)
- P3: dead code, duplicates, deprecated imports, private attr, __import__ inline
- P3: 8 frontend TODOs (LucideIcons, inline styles, XSS, i18n)
- ruff: 838 → 0 (612 auto-fix + 246 manual + 27 F821 regression fix)
- F821: 30 → 0 (AutomationDefinition, DmsFile, user_id, Path, Any, String)
- Contract-Umgehungen: 2 neue gefunden (worker.py:169, worker.py:280) und gefixt
2026-08-16 01:17:18 +02:00

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 # noqa: ASYNC240
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
_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(): # noqa: ASYNC240
shutil.rmtree(temp_dir, ignore_errors=True)