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:
@@ -21,6 +21,23 @@ class PluginMigrationManager:
|
||||
# Set by PluginManager.init_app; a PluginVerifier or None (no policy).
|
||||
self.verifier = None
|
||||
|
||||
def _verify_ok(self, plugin_name: str) -> bool:
|
||||
"""Strip bytecode + verify the tree before ANY alembic import.
|
||||
|
||||
Alembic imports env.py and every versions/*.py module body (running
|
||||
their top-level code) with full DB rights, so every method that reaches
|
||||
alembic for a plugin must pass through here first (finding #3).
|
||||
"""
|
||||
if self.verifier is None or not self.verifier.require_signed:
|
||||
return True
|
||||
from .packaging import strip_bytecode
|
||||
strip_bytecode(self.plugins_dir / plugin_name)
|
||||
ok, reason = self.verifier.check(plugin_name)
|
||||
if not ok:
|
||||
logger.error(
|
||||
"Refusing plugin migration code for %s: %s", plugin_name, reason)
|
||||
return ok
|
||||
|
||||
def get_migrations_dir(self, plugin_name: str) -> Optional[Path]:
|
||||
"""Get migrations directory for a plugin."""
|
||||
migrations_dir = self.plugins_dir / plugin_name / 'migrations'
|
||||
@@ -38,15 +55,10 @@ class PluginMigrationManager:
|
||||
|
||||
Uses flask db upgrade with the plugin's migrations directory.
|
||||
"""
|
||||
# verify-at-migrate: never run a plugin's DDL (full DB rights) from an
|
||||
# unverified tree when the site enforces signing.
|
||||
if self.verifier is not None:
|
||||
ok, reason = self.verifier.check(plugin_name)
|
||||
if not ok:
|
||||
logger.error(
|
||||
"Refusing migrations for %s: signature verification failed "
|
||||
"(%s)", plugin_name, reason)
|
||||
return False
|
||||
# verify-at-migrate: never run a plugin's migration code (full DB rights)
|
||||
# from an unverified tree when the site enforces signing.
|
||||
if not self._verify_ok(plugin_name):
|
||||
return False
|
||||
|
||||
migrations_dir = self.get_migrations_dir(plugin_name)
|
||||
|
||||
@@ -58,6 +70,7 @@ class PluginMigrationManager:
|
||||
# Use alembic directly with plugin's migrations
|
||||
from alembic.config import Config
|
||||
from alembic import command
|
||||
from .packaging import no_bytecode
|
||||
|
||||
config = Config()
|
||||
config.set_main_option('script_location', str(migrations_dir))
|
||||
@@ -69,7 +82,8 @@ class PluginMigrationManager:
|
||||
f'alembic_version_{plugin_name}'
|
||||
)
|
||||
|
||||
command.upgrade(config, revision)
|
||||
with no_bytecode():
|
||||
command.upgrade(config, revision)
|
||||
logger.info(f"Migrations completed for {plugin_name}")
|
||||
return True
|
||||
|
||||
@@ -88,6 +102,9 @@ class PluginMigrationManager:
|
||||
revision: str = 'head'
|
||||
) -> bool:
|
||||
"""Run migrations via subprocess as fallback."""
|
||||
if not self._verify_ok(plugin_name):
|
||||
return False
|
||||
|
||||
migrations_dir = self.get_migrations_dir(plugin_name)
|
||||
if not migrations_dir:
|
||||
return True
|
||||
@@ -95,7 +112,7 @@ class PluginMigrationManager:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable, '-m', 'alembic',
|
||||
sys.executable, '-B', '-m', 'alembic',
|
||||
'-c', str(migrations_dir / 'alembic.ini'),
|
||||
'upgrade', revision
|
||||
],
|
||||
@@ -103,7 +120,8 @@ class PluginMigrationManager:
|
||||
text=True,
|
||||
env={
|
||||
**dict(__import__('os').environ),
|
||||
'DATABASE_URL': self.database_url
|
||||
'DATABASE_URL': self.database_url,
|
||||
'PYTHONDONTWRITEBYTECODE': '1',
|
||||
}
|
||||
)
|
||||
|
||||
@@ -125,6 +143,11 @@ class PluginMigrationManager:
|
||||
"""
|
||||
Downgrade/rollback plugin migrations.
|
||||
"""
|
||||
# Downgrade runs the same env.py + version module code as upgrade, so it
|
||||
# gets the same fail-closed verification (finding #3: this path had none).
|
||||
if not self._verify_ok(plugin_name):
|
||||
return False
|
||||
|
||||
migrations_dir = self.get_migrations_dir(plugin_name)
|
||||
|
||||
if not migrations_dir:
|
||||
@@ -133,6 +156,7 @@ class PluginMigrationManager:
|
||||
try:
|
||||
from alembic.config import Config
|
||||
from alembic import command
|
||||
from .packaging import no_bytecode
|
||||
|
||||
config = Config()
|
||||
config.set_main_option('script_location', str(migrations_dir))
|
||||
@@ -142,7 +166,8 @@ class PluginMigrationManager:
|
||||
f'alembic_version_{plugin_name}'
|
||||
)
|
||||
|
||||
command.downgrade(config, revision)
|
||||
with no_bytecode():
|
||||
command.downgrade(config, revision)
|
||||
logger.info(f"Downgrade completed for {plugin_name}")
|
||||
return True
|
||||
|
||||
@@ -151,7 +176,14 @@ class PluginMigrationManager:
|
||||
return False
|
||||
|
||||
def get_current_revision(self, plugin_name: str) -> Optional[str]:
|
||||
"""Get current migration revision for a plugin."""
|
||||
"""Get current migration revision for a plugin.
|
||||
|
||||
ScriptDirectory imports every versions/*.py to build the revision map,
|
||||
so this reads (executes) plugin code and must verify first (finding #3).
|
||||
"""
|
||||
if not self._verify_ok(plugin_name):
|
||||
return None
|
||||
|
||||
migrations_dir = self.get_migrations_dir(plugin_name)
|
||||
if not migrations_dir:
|
||||
return None
|
||||
@@ -159,12 +191,14 @@ class PluginMigrationManager:
|
||||
try:
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
from .packaging import no_bytecode
|
||||
|
||||
config = Config()
|
||||
config.set_main_option('script_location', str(migrations_dir))
|
||||
|
||||
script = ScriptDirectory.from_config(config)
|
||||
return script.get_current_head()
|
||||
with no_bytecode():
|
||||
script = ScriptDirectory.from_config(config)
|
||||
return script.get_current_head()
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user