ADR-013 Phase 2: fix four bypasses found by adversarial review
An adversarial security review of the Phase 2 trust model found four real bypasses (two remote-triggerable to in-process code execution). Root cause for three: the set of bytes verification covered was smaller than the set that determined execution. Fixes: 1. Bytecode-cache blind spot (CRITICAL). verify_dir excluded __pycache__/.pyc, so a planted cache ran while escaping the hash map. verify_dir now flags any bytecode as an unexpected file; the loader strips bytecode before verify and imports under sys.dont_write_bytecode, so only verified source executes. 2. Unauthenticated verify-at-load bypass (CRITICAL). load_plugin_class imported plugin.py with no gate, reachable via discover_available / an anonymous GET /api/plugins. The verify+strip gate moved INTO load_plugin_class - the single import choke point every path flows through - so an unsigned/tampered plugin is never imported. discover_available skips a refused plugin instead of 500. 3. Ungated migration entrypoints (HIGH). downgrade_plugin and get_current_head (ScriptDirectory imports version modules) ran plugin code with no check. All alembic-invoking methods now pass through _verify_ok (strip + verify) first and run under no-bytecode. 4. Revocation/content bypass (HIGH). The signed index bound a filename, not content; adopt did not bind the delivered bytes to the resolved version, so revoked bytes could be served under a live filename. The index now records a per-artifact SHA-256; adopt verifies the on-disk digest and requires the artifact's own signed manifest version to equal the resolved version. Enforcement stays default-off; strip/no-bytecode run only under enforcement, so the unsigned path is unchanged. 6 regression tests (planted bytecode, the discover import path, downgrade gate, version-swap). 1054 pass, naming green.
This commit is contained in:
@@ -152,19 +152,38 @@ class PluginLoader:
|
||||
if name in self._plugin_classes:
|
||||
return self._plugin_classes[name]
|
||||
|
||||
plugin_module_path = self.plugins_dir / name / 'plugin.py'
|
||||
plugin_dir = self.plugins_dir / name
|
||||
plugin_module_path = plugin_dir / 'plugin.py'
|
||||
if not plugin_module_path.exists():
|
||||
raise PluginNotFoundError(
|
||||
f'Plugin {name} plugin.py not found at {plugin_module_path}',
|
||||
plugin_name=name,
|
||||
)
|
||||
|
||||
# THE import gate. Every import path reaches here, so an unsigned or
|
||||
# tampered plugin never executes under enforcement (findings #1, #2).
|
||||
# When enforcing: strip any bytecode (so only verified source can run),
|
||||
# verify the tree, and import without writing new bytecode.
|
||||
import contextlib
|
||||
from .packaging import strip_bytecode, no_bytecode
|
||||
enforcing = self.verifier is not None and self.verifier.require_signed
|
||||
if enforcing:
|
||||
strip_bytecode(plugin_dir)
|
||||
if self.verifier is not None:
|
||||
ok, reason = self.verifier.check(name)
|
||||
if not ok:
|
||||
raise PluginContractError(
|
||||
f'Plugin {name} failed signature verification: {reason}',
|
||||
plugin_name=name,
|
||||
)
|
||||
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f'plugins.{name}.plugin', plugin_module_path,
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
with (no_bytecode() if enforcing else contextlib.nullcontext()):
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f'plugins.{name}.plugin', plugin_module_path,
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
except Exception as e:
|
||||
raise PluginContractError(
|
||||
f'Plugin {name} import failed: {e}',
|
||||
@@ -200,16 +219,9 @@ class PluginLoader:
|
||||
manifest = self.load_manifest(name)
|
||||
self.check_contract_version(name, __contract_version__)
|
||||
|
||||
# verify-at-load: refuse to import plugin.py unless the tree matches
|
||||
# a trusted signature (when the site enforces it). Runs BEFORE any
|
||||
# plugin code is imported, so a tampered/unsigned plugin never runs.
|
||||
if self.verifier is not None:
|
||||
ok, reason = self.verifier.check(name)
|
||||
if not ok:
|
||||
raise PluginError(
|
||||
f'Plugin {name} failed signature verification: {reason}',
|
||||
plugin_name=name,
|
||||
)
|
||||
# verify-at-load is enforced inside load_plugin_class (the single
|
||||
# import choke point), so EVERY path that imports plugin code -
|
||||
# load_plugin, discover_available, CLI introspection - fails closed.
|
||||
|
||||
for dep in manifest.get('dependencies', []):
|
||||
if not self.registry.is_enabled(dep):
|
||||
|
||||
Reference in New Issue
Block a user