Harden module installation and imports

This commit is contained in:
2026-07-11 18:37:51 +02:00
parent 9a0c467d55
commit b9badc9153
13 changed files with 194 additions and 38 deletions

View File

@@ -30,10 +30,14 @@ _TABLE_RENAMES = (
("governance_templates", "admin_governance_templates"), ("governance_templates", "admin_governance_templates"),
("governance_template_assignments", "admin_governance_template_assignments"), ("governance_template_assignments", "admin_governance_template_assignments"),
) )
_KNOWN_TABLE_NAMES = {name for pair in _TABLE_RENAMES for name in pair}
def _row_count(bind: sa.Connection, table_name: str) -> int: def _row_count(bind: sa.Connection, table_name: str) -> int:
return int(bind.execute(sa.text(f"SELECT COUNT(*) FROM {table_name}")).scalar_one()) if table_name not in _KNOWN_TABLE_NAMES:
raise RuntimeError(f"Unexpected table name: {table_name}")
quoted = bind.dialect.identifier_preparer.quote(table_name)
return int(bind.execute(sa.text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one()) # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
def _rename_tables(renames: tuple[tuple[str, str], ...]) -> None: def _rename_tables(renames: tuple[tuple[str, str], ...]) -> None:

View File

@@ -19,11 +19,14 @@ LEGACY_SCOPE_TABLE = "tenancy_tenants"
CORE_SCOPE_TABLE = "core_scopes" CORE_SCOPE_TABLE = "core_scopes"
LEGACY_SLUG_INDEX = "ix_tenancy_tenants_slug" LEGACY_SLUG_INDEX = "ix_tenancy_tenants_slug"
CORE_SLUG_INDEX = "ix_core_scopes_slug" CORE_SLUG_INDEX = "ix_core_scopes_slug"
_KNOWN_SCOPE_TABLES = {LEGACY_SCOPE_TABLE, CORE_SCOPE_TABLE}
def _row_count(bind: sa.Connection, table_name: str) -> int: def _row_count(bind: sa.Connection, table_name: str) -> int:
if table_name not in _KNOWN_SCOPE_TABLES:
raise RuntimeError(f"Unexpected scope table name: {table_name}")
quoted = bind.dialect.identifier_preparer.quote(table_name) quoted = bind.dialect.identifier_preparer.quote(table_name)
return int(bind.execute(sa.text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one()) return int(bind.execute(sa.text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one()) # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
def _scope_tables(bind: sa.Connection) -> set[str]: def _scope_tables(bind: sa.Connection) -> set[str]:

View File

@@ -16,6 +16,7 @@ revision = "9d0e1f2a3b4c"
down_revision = "8c9d0e1f2a3b" down_revision = "8c9d0e1f2a3b"
branch_labels = None branch_labels = None
depends_on = None depends_on = None
_RECONCILE_CREATE_ALL_TABLES = ("admin_governance_template_assignments", "admin_governance_templates", "core_system_settings")
def _now() -> datetime: def _now() -> datetime:
@@ -28,9 +29,10 @@ def upgrade() -> None:
tables = set(inspector.get_table_names()) tables = set(inspector.get_table_names())
# Reconcile only the empty create_all shape for the newly introduced tables. # Reconcile only the empty create_all shape for the newly introduced tables.
for table_name in ("admin_governance_template_assignments", "admin_governance_templates", "core_system_settings"): for table_name in _RECONCILE_CREATE_ALL_TABLES:
if table_name in tables: if table_name in tables:
count = bind.execute(sa.text(f"SELECT COUNT(*) FROM {table_name}")).scalar_one() quoted = bind.dialect.identifier_preparer.quote(table_name)
count = bind.execute(sa.text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one() # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
if count: if count:
raise RuntimeError(f"Cannot reconcile non-empty create_all table {table_name}") raise RuntimeError(f"Cannot reconcile non-empty create_all table {table_name}")
op.drop_table(table_name) op.drop_table(table_name)

View File

@@ -49,7 +49,7 @@ def upgrade() -> None:
placeholders = ", ".join(f":action_{index}" for index, _ in enumerate(SYSTEM_ACTIONS)) placeholders = ", ".join(f":action_{index}" for index, _ in enumerate(SYSTEM_ACTIONS))
params = {f"action_{index}": action for index, action in enumerate(SYSTEM_ACTIONS)} params = {f"action_{index}": action for index, action in enumerate(SYSTEM_ACTIONS)}
bind.execute( bind.execute(
sa.text(f"UPDATE audit_log SET scope = 'system' WHERE action IN ({placeholders})"), sa.text(f"UPDATE audit_log SET scope = 'system' WHERE action IN ({placeholders})"), # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
params, params,
) )
bind.execute(sa.text("UPDATE audit_log SET scope = 'tenant' WHERE scope IS NULL OR scope NOT IN ('tenant', 'system')")) bind.execute(sa.text("UPDATE audit_log SET scope = 'tenant' WHERE scope IS NULL OR scope NOT IN ('tenant', 'system')"))

View File

@@ -690,7 +690,7 @@ def _read_trusted_keys_url(url: str) -> str:
raise ValueError("Trusted configuration catalog key URL must use http:// or https://.") raise ValueError("Trusted configuration catalog key URL must use http:// or https://.")
cache_path = _configured_trusted_keys_cache_path() cache_path = _configured_trusted_keys_cache_path()
try: try:
with urllib.request.urlopen(url, timeout=15) as response: with urllib.request.urlopen(url, timeout=15) as response: # noqa: S310 - validated configuration key HTTP(S) URL. # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
body = response.read().decode("utf-8") body = response.read().decode("utf-8")
if cache_path is not None: if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True) cache_path.parent.mkdir(parents=True, exist_ok=True)
@@ -714,7 +714,7 @@ def _read_catalog_payload_with_metadata(source: Path | str | None) -> tuple[obje
return {"packages": []}, metadata return {"packages": []}, metadata
if isinstance(source, str) and _is_http_url(source): if isinstance(source, str) and _is_http_url(source):
try: try:
with urllib.request.urlopen(source, timeout=15) as response: with urllib.request.urlopen(source, timeout=15) as response: # noqa: S310 - validated configuration catalog HTTP(S) URL. # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
body = response.read().decode("utf-8") body = response.read().decode("utf-8")
if cache_path is not None: if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True) cache_path.parent.mkdir(parents=True, exist_ok=True)

View File

@@ -11,6 +11,7 @@ import json
import os import os
from pathlib import Path from pathlib import Path
import re import re
import shlex
import shutil import shutil
import sqlite3 import sqlite3
import subprocess import subprocess
@@ -2611,7 +2612,7 @@ def _run_restart_command(command: str) -> dict[str, object]:
"stdout": "", "stdout": "",
"stderr": "", "stderr": "",
} }
result = subprocess.run(command, shell=True, text=True, capture_output=True, check=False) result = _run_operator_command(command)
return { return {
"command": command, "command": command,
"return_code": result.returncode, "return_code": result.returncode,
@@ -2685,13 +2686,16 @@ def _python_package_name_from_ref(value: str) -> str | None:
def _wait_for_health_url(url: str, *, timeout_seconds: float, interval_seconds: float) -> dict[str, object]: def _wait_for_health_url(url: str, *, timeout_seconds: float, interval_seconds: float) -> dict[str, object]:
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.username or parsed.password:
return {"ok": False, "attempts": 0, "error": "health URL must be an absolute HTTP(S) URL without embedded credentials"}
deadline = time.monotonic() + max(timeout_seconds, 0.1) deadline = time.monotonic() + max(timeout_seconds, 0.1)
attempts = 0 attempts = 0
last_error = "" last_error = ""
while time.monotonic() < deadline: while time.monotonic() < deadline:
attempts += 1 attempts += 1
try: try:
with urllib.request.urlopen(url, timeout=min(max(interval_seconds, 0.5), 10.0)) as response: with urllib.request.urlopen(url, timeout=min(max(interval_seconds, 0.5), 10.0)) as response: # noqa: S310 - validated module health HTTP(S) URL. # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
status = int(getattr(response, "status", 0)) status = int(getattr(response, "status", 0))
if 200 <= status < 400: if 200 <= status < 400:
return {"ok": True, "status": status, "attempts": attempts} return {"ok": True, "status": status, "attempts": attempts}
@@ -3217,15 +3221,53 @@ def _run_database_hook(
if pgtools_url: if pgtools_url:
env["GOVOPLAN_DATABASE_URL_PGTOOLS"] = pgtools_url env["GOVOPLAN_DATABASE_URL_PGTOOLS"] = pgtools_url
env.setdefault("DATABASE_URL", database_url) env.setdefault("DATABASE_URL", database_url)
return _run_operator_command(command, cwd=run_dir, env=env)
def _run_operator_command(
command: str,
*,
cwd: Path | None = None,
env: Mapping[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
try:
argv = _operator_command_argv(command)
except ValueError as exc:
return subprocess.CompletedProcess(args=command, returncode=2, stdout="", stderr=str(exc))
try:
return subprocess.run( return subprocess.run(
command, argv,
cwd=run_dir, cwd=cwd,
env=env, env=dict(env) if env is not None else None,
shell=True,
text=True, text=True,
capture_output=True, capture_output=True,
check=False, check=False,
) )
except OSError as exc:
return subprocess.CompletedProcess(args=argv, returncode=127, stdout="", stderr=str(exc))
def _operator_command_argv(command: str) -> tuple[str, ...]:
try:
argv = tuple(shlex.split(command))
except ValueError as exc:
raise ValueError(f"Invalid command syntax: {exc}") from exc
if not argv:
raise ValueError("Command is empty")
if any(_operator_command_part_uses_shell(part) for part in argv):
raise ValueError("Shell operators, substitutions, and redirects are not supported in installer hooks")
if _looks_like_env_assignment(argv[0]):
raise ValueError("Environment assignments must be configured through installer hook environment, not shell syntax")
return argv
def _operator_command_part_uses_shell(value: str) -> bool:
return value in {"|", "||", "&", "&&", ";", "<", ">", ">>", "2>", "2>>"} or "$(" in value or "`" in value
def _looks_like_env_assignment(value: str) -> bool:
key, separator, _rest = value.partition("=")
return bool(separator) and bool(re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", key))
def _database_url_for_pgtools(database_url: str) -> str | None: def _database_url_for_pgtools(database_url: str) -> str | None:

View File

@@ -8,6 +8,7 @@ import os
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import urllib.error import urllib.error
import urllib.parse
import urllib.request import urllib.request
from cryptography.exceptions import InvalidSignature from cryptography.exceptions import InvalidSignature
@@ -257,11 +258,12 @@ def _configured_trusted_keys_cache_path() -> Path | None:
def _read_trusted_keys_url(url: str) -> str: def _read_trusted_keys_url(url: str) -> str:
if not url.startswith(("https://", "http://")): parsed = urllib.parse.urlparse(url)
raise ValueError("Trusted license key URL must use http:// or https://.") if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.username or parsed.password:
raise ValueError("Trusted license key URL must be an absolute HTTP(S) URL without embedded credentials.")
cache_path = _configured_trusted_keys_cache_path() cache_path = _configured_trusted_keys_cache_path()
try: try:
with urllib.request.urlopen(url, timeout=15) as response: with urllib.request.urlopen(url, timeout=15) as response: # noqa: S310 - validated license key HTTP(S) URL. # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
body = response.read().decode("utf-8") body = response.read().decode("utf-8")
if cache_path is not None: if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True) cache_path.parent.mkdir(parents=True, exist_ok=True)

View File

@@ -354,7 +354,7 @@ def _read_trusted_keys_url(url: str) -> str:
raise ValueError("Trusted catalog key URL must use http:// or https://.") raise ValueError("Trusted catalog key URL must use http:// or https://.")
cache_path = _configured_trusted_keys_cache_path() cache_path = _configured_trusted_keys_cache_path()
try: try:
with urllib.request.urlopen(url, timeout=15) as response: with urllib.request.urlopen(url, timeout=15) as response: # noqa: S310 - validated catalog key HTTP(S) URL. # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
body = response.read().decode("utf-8") body = response.read().decode("utf-8")
if cache_path is not None: if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True) cache_path.parent.mkdir(parents=True, exist_ok=True)
@@ -421,7 +421,7 @@ def _read_catalog_url(url: str) -> str:
def _read_catalog_url_with_metadata(url: str) -> tuple[str, bool]: def _read_catalog_url_with_metadata(url: str) -> tuple[str, bool]:
cache_path = _configured_catalog_cache_path() cache_path = _configured_catalog_cache_path()
try: try:
with urllib.request.urlopen(url, timeout=15) as response: with urllib.request.urlopen(url, timeout=15) as response: # noqa: S310 - validated module catalog HTTP(S) URL. # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
body = response.read().decode("utf-8") body = response.read().decode("utf-8")
if cache_path is not None: if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True) cache_path.parent.mkdir(parents=True, exist_ok=True)
@@ -935,7 +935,8 @@ def _catalog_source_type(source: Path | str | None) -> str | None:
def _is_http_url(value: str) -> bool: def _is_http_url(value: str) -> bool:
return value.startswith(("https://", "http://")) parsed = urllib.parse.urlparse(value)
return parsed.scheme in {"http", "https"} and bool(parsed.netloc) and not parsed.username and not parsed.password
def _invalid_catalog_result( def _invalid_catalog_result(

View File

@@ -5,6 +5,7 @@ from dataclasses import dataclass, replace
import json import json
import os import os
from pathlib import Path from pathlib import Path
import re
from typing import Any from typing import Any
from alembic import command from alembic import command
@@ -44,6 +45,7 @@ MIGRATION_TASK_PHASES = (*PRE_MIGRATION_TASK_PHASES, *POST_MIGRATION_TASK_PHASES
MIGRATION_TRACK_RELEASE = "release" MIGRATION_TRACK_RELEASE = "release"
MIGRATION_TRACK_DEV = "dev" MIGRATION_TRACK_DEV = "dev"
MIGRATION_TRACKS = (MIGRATION_TRACK_RELEASE, MIGRATION_TRACK_DEV) MIGRATION_TRACKS = (MIGRATION_TRACK_RELEASE, MIGRATION_TRACK_DEV)
_SQL_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
_NAMESPACE_TABLE_RENAMES = ( _NAMESPACE_TABLE_RENAMES = (
("tenants", "tenancy_tenants"), ("tenants", "tenancy_tenants"),
@@ -521,19 +523,25 @@ def _backfill_user_lock_state_for_create_all_schema(database_url: str) -> None:
def _row_count(connection, table_name: str) -> int: def _row_count(connection, table_name: str) -> int:
quoted = connection.dialect.identifier_preparer.quote(table_name) quoted = _quoted_table_name(connection, table_name)
return int(connection.execute(text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one()) return int(connection.execute(text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one()) # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
def _drop_table(connection, table_name: str) -> None: def _drop_table(connection, table_name: str) -> None:
quoted = connection.dialect.identifier_preparer.quote(table_name) quoted = _quoted_table_name(connection, table_name)
connection.execute(text(f"DROP TABLE {quoted}")) connection.execute(text(f"DROP TABLE {quoted}")) # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
def _rename_table(connection, old_name: str, new_name: str) -> None: def _rename_table(connection, old_name: str, new_name: str) -> None:
quoted_old = connection.dialect.identifier_preparer.quote(old_name) quoted_old = _quoted_table_name(connection, old_name)
quoted_new = connection.dialect.identifier_preparer.quote(new_name) quoted_new = _quoted_table_name(connection, new_name)
connection.execute(text(f"ALTER TABLE {quoted_old} RENAME TO {quoted_new}")) connection.execute(text(f"ALTER TABLE {quoted_old} RENAME TO {quoted_new}")) # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
def _quoted_table_name(connection, table_name: str) -> str:
if not _SQL_IDENTIFIER_RE.fullmatch(table_name):
raise ValueError(f"Unsafe table identifier: {table_name!r}")
return connection.dialect.identifier_preparer.quote(table_name)
def _reconcile_scope_table_names(connection, tables: set[str]) -> bool: def _reconcile_scope_table_names(connection, tables: set[str]) -> bool:

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import re
from collections.abc import Callable, Iterable, Mapping, Sequence from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass, field from dataclasses import dataclass, field
from importlib import import_module from importlib import import_module
@@ -15,6 +16,10 @@ from govoplan_core.server.fastapi import LifespanFactory
ManifestFactory = Callable[[], ModuleManifest] ManifestFactory = Callable[[], ModuleManifest]
RouterEnabled = Callable[[object | None, PlatformRegistry], bool] RouterEnabled = Callable[[object | None, PlatformRegistry], bool]
AppConfigurator = Callable[[FastAPI, PlatformRegistry, object | None], None] AppConfigurator = Callable[[FastAPI, PlatformRegistry, object | None], None]
DEFAULT_TRUSTED_IMPORT_PREFIXES = ("govoplan_core", "govoplan_", "app")
TRUSTED_IMPORT_PREFIXES_ENV = "GOVOPLAN_TRUSTED_IMPORT_PREFIXES"
_MODULE_PATH_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$")
_ATTRIBUTE_PATH_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)*$")
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -49,16 +54,52 @@ class GovoplanServerConfig:
def import_object(path: str) -> Any: def import_object(path: str) -> Any:
module_name, separator, attribute = path.partition(":") module_name, attribute = validate_object_path(path)
if not separator: _validate_trusted_import_prefix(module_name, trusted_import_prefixes())
raise ValueError(f"Object path must use module:attribute syntax: {path!r}") module = import_module(module_name) # nosemgrep: python.lang.security.audit.non-literal-import.non-literal-import
module = import_module(module_name)
value: Any = module value: Any = module
for part in attribute.split("."): for part in attribute.split("."):
value = getattr(value, part) value = getattr(value, part)
return value return value
def validate_object_path(path: str) -> tuple[str, str]:
module_name, separator, attribute = path.strip().partition(":")
if not separator:
raise ValueError(f"Object path must use module:attribute syntax: {path!r}")
if not _MODULE_PATH_RE.fullmatch(module_name):
raise ValueError(f"Object path module is not a valid Python module path: {path!r}")
if not _ATTRIBUTE_PATH_RE.fullmatch(attribute) or any(part.startswith("_") for part in attribute.split(".")):
raise ValueError(f"Object path attribute is not a public attribute path: {path!r}")
return module_name, attribute
def trusted_import_prefixes() -> tuple[str, ...]:
configured = os.getenv(TRUSTED_IMPORT_PREFIXES_ENV, "").strip()
if not configured:
return DEFAULT_TRUSTED_IMPORT_PREFIXES
prefixes = tuple(item.strip() for item in configured.split(",") if item.strip())
return prefixes or DEFAULT_TRUSTED_IMPORT_PREFIXES
def _validate_trusted_import_prefix(module_name: str, prefixes: Iterable[str]) -> None:
if any(_module_matches_prefix(module_name, prefix) for prefix in prefixes):
return
raise ValueError(
f"Object path module {module_name!r} is outside trusted import prefixes. "
f"Set {TRUSTED_IMPORT_PREFIXES_ENV} to allow custom deployment config modules."
)
def _module_matches_prefix(module_name: str, prefix: str) -> bool:
cleaned = prefix.strip()
if not cleaned:
return False
if cleaned.endswith((".", "_")):
return module_name.startswith(cleaned)
return module_name == cleaned or module_name.startswith(f"{cleaned}.")
def load_server_config(path: str | None = None) -> GovoplanServerConfig: def load_server_config(path: str | None = None) -> GovoplanServerConfig:
config_path = path or os.getenv("GOVOPLAN_SERVER_CONFIG") or "govoplan_core.server.default_config:get_server_config" config_path = path or os.getenv("GOVOPLAN_SERVER_CONFIG") or "govoplan_core.server.default_config:get_server_config"
try: try:

View File

@@ -6,6 +6,7 @@ import importlib
from govoplan_core.core.discovery import discover_module_manifests from govoplan_core.core.discovery import discover_module_manifests
from govoplan_core.core.modules import ModuleManifest from govoplan_core.core.modules import ModuleManifest
from govoplan_core.core.registry import PlatformRegistry, RegistryError from govoplan_core.core.registry import PlatformRegistry, RegistryError
from govoplan_core.server.config import validate_object_path
ManifestFactory = Callable[[], ModuleManifest] ManifestFactory = Callable[[], ModuleManifest]
@@ -74,8 +75,10 @@ def build_platform_registry(enabled_modules: str | Iterable[str], *, manifest_fa
def _load_builtin_manifest(module_id: str) -> ModuleManifest: def _load_builtin_manifest(module_id: str) -> ModuleManifest:
target = _BUILTIN_MANIFESTS[module_id] target = _BUILTIN_MANIFESTS[module_id]
module_name, function_name = target.split(":", 1) module_name, function_name = validate_object_path(target)
module = importlib.import_module(module_name) if module_name != _BUILTIN_MANIFESTS[module_id].split(":", 1)[0]:
raise RegistryError(f"Built-in module manifest target changed unexpectedly: {module_id}")
module = importlib.import_module(module_name) # nosemgrep: python.lang.security.audit.non-literal-import.non-literal-import
factory = getattr(module, function_name) factory = getattr(module, function_name)
manifest = factory() manifest = factory()
if not isinstance(manifest, ModuleManifest): if not isinstance(manifest, ModuleManifest):

View File

@@ -0,0 +1,50 @@
from __future__ import annotations
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from govoplan_core.server.config import import_object, validate_object_path
from govoplan_core.server.registry import available_module_manifests
class ImportTrustBoundaryTests(unittest.TestCase):
def test_object_path_validation_rejects_invalid_shapes(self) -> None:
with self.assertRaisesRegex(ValueError, "module:attribute"):
validate_object_path("govoplan_core.server.default_config")
with self.assertRaisesRegex(ValueError, "valid Python module"):
validate_object_path("os;system:path")
with self.assertRaisesRegex(ValueError, "public attribute"):
validate_object_path("govoplan_core.server.default_config:_private")
def test_import_object_allows_default_govoplan_config_path(self) -> None:
value = import_object("govoplan_core.server.default_config:get_server_config")
self.assertTrue(callable(value))
def test_import_object_rejects_untrusted_stdlib_module(self) -> None:
with self.assertRaisesRegex(ValueError, "outside trusted import prefixes"):
import_object("os:path")
def test_import_object_allows_explicit_custom_prefix(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-config-import-") as directory:
root = Path(directory)
(root / "custom_config.py").write_text("value = 42\n", encoding="utf-8")
sys.path.insert(0, str(root))
try:
with patch.dict(os.environ, {"GOVOPLAN_TRUSTED_IMPORT_PREFIXES": "custom_config"}):
self.assertEqual(import_object("custom_config:value"), 42)
finally:
sys.path.remove(str(root))
sys.modules.pop("custom_config", None)
def test_builtin_manifest_imports_are_loaded_from_fixed_targets(self) -> None:
manifests = available_module_manifests(enabled_modules=("access",), ignore_load_errors=True)
if "access" in manifests:
self.assertEqual(manifests["access"].id, "access")
if __name__ == "__main__":
unittest.main()

View File

@@ -138,7 +138,7 @@ alreadyLoaded: PlatformWebModule[] = resolveInstalledWebModules(platformModules)
let promise = remoteModuleCache.get(cacheKey); let promise = remoteModuleCache.get(cacheKey);
if (!promise) { if (!promise) {
promise = loadRemoteWebModule(module).catch((error) => { promise = loadRemoteWebModule(module).catch((error) => {
console.warn(`GovOPlaN remote module ${module.id} was not loaded:`, error); console.warn("GovOPlaN remote module was not loaded:", module.id, error);
return null; return null;
}); });
remoteModuleCache.set(cacheKey, promise); remoteModuleCache.set(cacheKey, promise);