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:
@@ -144,6 +144,10 @@ def test_verify_dir_round_trip(tmp_path, keypair):
|
||||
(plugin_dir / signing.PROVENANCE_SIG).write_bytes(
|
||||
signing.sign(priv, prov_bytes))
|
||||
|
||||
# A real adopted/loaded tree carries no bytecode (packer excludes it, loader
|
||||
# strips it); verify flags any that is present, so clear the test's planted
|
||||
# __pycache__ before asserting a clean verify.
|
||||
packaging.strip_bytecode(plugin_dir)
|
||||
_, errors = packaging.verify_dir(plugin_dir, [pub])
|
||||
assert errors == []
|
||||
|
||||
|
||||
@@ -99,9 +99,10 @@ def test_resolve_highest_non_revoked(tmp_path, keypair):
|
||||
shelf_dir = _build_shelf(
|
||||
tmp_path, priv, [('demo', '1.0.0'), ('demo', '1.2.0')])
|
||||
index, _ = shelf.load_index(shelf_dir, [pub])
|
||||
version, artifact = shelf.resolve_version(index, 'demo')
|
||||
version, artifact, sha = shelf.resolve_version(index, 'demo')
|
||||
assert version == '1.2.0'
|
||||
assert artifact == 'demo-1.2.0.shopdbplugin'
|
||||
assert sha and len(sha) == 64
|
||||
|
||||
|
||||
def test_resolve_skips_revoked(tmp_path, keypair):
|
||||
@@ -110,7 +111,7 @@ def test_resolve_skips_revoked(tmp_path, keypair):
|
||||
tmp_path, priv, [('demo', '1.0.0'), ('demo', '1.2.0')],
|
||||
revoked=[{'name': 'demo', 'version': '1.2.0'}])
|
||||
index, _ = shelf.load_index(shelf_dir, [pub])
|
||||
version, _ = shelf.resolve_version(index, 'demo')
|
||||
version, _, _ = shelf.resolve_version(index, 'demo')
|
||||
assert version == '1.0.0'
|
||||
|
||||
|
||||
@@ -178,3 +179,37 @@ def test_unpack_verified_wrong_name_refused(tmp_path, keypair):
|
||||
plugins_dir.mkdir()
|
||||
with pytest.raises(ValueError):
|
||||
shelf.unpack_verified(artifact, [pub], plugins_dir, 'somethingelse')
|
||||
|
||||
|
||||
def test_revoked_bytes_under_live_filename_refused(tmp_path, keypair):
|
||||
"""Finding #4: an attacker swaps the genuinely-signed bytes of a revoked
|
||||
1.0.0 into the file named for the live 2.0.0. Adopt must refuse because the
|
||||
artifact's own signed version (and content digest) do not match the resolved
|
||||
version."""
|
||||
priv, pub = keypair
|
||||
shelf_dir = tmp_path / 'shelf'
|
||||
shelf_dir.mkdir()
|
||||
# pack both versions
|
||||
for version in ('1.0.0', '2.0.0'):
|
||||
src = _write_plugin(tmp_path / 'src' / version, version=version)
|
||||
packaging.pack(src, priv, out_dir=shelf_dir)
|
||||
# index revokes 1.0.0, records true digests for 2.0.0
|
||||
shelf.build_index(shelf_dir, priv, 1,
|
||||
revoked=[{'name': 'demo', 'version': '1.0.0'}])
|
||||
index, _ = shelf.load_index(shelf_dir, [pub])
|
||||
|
||||
resolved_version, artifact_name, expected_sha = shelf.resolve_version(
|
||||
index, 'demo')
|
||||
assert resolved_version == '2.0.0'
|
||||
|
||||
# attacker overwrites the 2.0.0 file with the signed 1.0.0 bytes
|
||||
live = shelf_dir / artifact_name
|
||||
(shelf_dir / 'demo-1.0.0.shopdbplugin').replace(live)
|
||||
|
||||
plugins_dir = tmp_path / 'live'
|
||||
plugins_dir.mkdir()
|
||||
with pytest.raises(ValueError):
|
||||
shelf.unpack_verified(live, [pub], plugins_dir, 'demo',
|
||||
expected_version=resolved_version,
|
||||
expected_sha256=expected_sha)
|
||||
assert not (plugins_dir / 'demo').exists()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user