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:
cproudlock
2026-07-18 21:06:27 -04:00
parent 5b19f3b554
commit 55a6f1b8d3
10 changed files with 273 additions and 50 deletions

View File

@@ -158,6 +158,19 @@ def test_loader_loads_signed_when_enforced(tmp_path, app, db, keypair):
assert plugin.meta.name == 'demo'
def test_load_plugin_class_refuses_unsigned_when_enforced(tmp_path):
"""Finding #2: the gate is IN load_plugin_class, so the discover_available /
GET /api/plugins import path (which calls it directly, not via load_plugin)
also fails closed - an unsigned plugin.py is never imported."""
plugins_dir = tmp_path / 'plugins'
_write_loadable_plugin(plugins_dir)
loader = PluginLoader(plugins_dir, PluginRegistry(tmp_path / 'plugins.json'))
loader.verifier = PluginVerifier(plugins_dir, require_signed=True,
trusted_key_paths=[])
with pytest.raises(PluginError):
loader.load_plugin_class('demo')
def test_migrate_refused_when_unverified(tmp_path):
plugins_dir = tmp_path / 'plugins'
_write_loadable_plugin(plugins_dir)
@@ -165,3 +178,45 @@ def test_migrate_refused_when_unverified(tmp_path):
manager.verifier = PluginVerifier(plugins_dir, require_signed=True,
trusted_key_paths=[])
assert manager.run_plugin_migrations('demo') is False
def test_downgrade_refused_when_unverified(tmp_path):
"""Finding #3: the downgrade path must fail closed like upgrade."""
plugins_dir = tmp_path / 'plugins'
_write_loadable_plugin(plugins_dir)
manager = PluginMigrationManager(plugins_dir, 'sqlite://')
manager.verifier = PluginVerifier(plugins_dir, require_signed=True,
trusted_key_paths=[])
assert manager.downgrade_plugin('demo') is False
def test_planted_bytecode_refused(tmp_path, app, db, keypair):
"""Finding #1: a signed tree with an extra planted __pycache__/*.pyc must be
refused - the loader strips it before verify, and verify flags any bytecode
that survives, so only the signed source can run."""
private_key, pub_path = keypair
plugins_dir = tmp_path / 'plugins'
pdir = _write_loadable_plugin(plugins_dir)
_stamp(pdir, private_key)
# plant bytecode AFTER signing; provenance does not cover it
cache = pdir / '__pycache__'
cache.mkdir()
(cache / 'plugin.cpython-311.pyc').write_bytes(b'\x00malicious')
verifier = PluginVerifier(plugins_dir, require_signed=True,
trusted_key_paths=[pub_path])
# verify BEFORE any strip: the planted bytecode is flagged as an extra file
ok, reason = verifier.check('demo')
assert not ok
assert 'unexpected file' in reason
# the loader strips bytecode first, then verifies the clean source, so a
# legitimately signed plugin still loads
from shopdb.plugins.loader import PluginLoader
loader = PluginLoader(plugins_dir, PluginRegistry(tmp_path / 'plugins.json'))
loader.verifier = verifier
plugin = loader.load_plugin('demo', app, db)
assert plugin is not None
assert not (pdir / '__pycache__').exists()