Files
shopdb-flask/tests/test_plugin_shelf.py
cproudlock 55a6f1b8d3 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.
2026-07-18 21:06:27 -04:00

216 lines
7.9 KiB
Python

"""Signed shelf index + adopt-mechanics tests (ADR-013 Phase 2).
Covers: a signed index round-trips and a tampered/wrong-key index is rejected;
serial anti-rollback state; revocation; version resolution; and unpack_verified
placing only a fully verified tree (refusing a tampered artifact).
"""
import json
import zipfile
import pytest
from shopdb.plugins import signing, packaging, shelf
def _write_plugin(plugin_dir, name='demo', version='1.0.0', tier=None):
plugin_dir.mkdir(parents=True, exist_ok=True)
manifest = {
'name': name, 'version': version, 'description': 'demo',
'dependencies': [], 'core_version': '>=0.1.0,<1.0.0',
'api_prefix': f'/api/{name}',
}
if tier:
manifest['tier'] = tier
(plugin_dir / 'manifest.json').write_text(json.dumps(manifest))
(plugin_dir / 'plugin.py').write_text('# demo\n')
return plugin_dir
@pytest.fixture
def keypair(tmp_path):
private_pem, public_pem = signing.generate_keypair()
return (signing.load_private_key(private_pem),
signing.load_public_key(public_pem))
def _build_shelf(tmp_path, private_key, specs, serial=1, revoked=None):
"""specs: list of (name, version). Packs each into a shelf dir + index."""
shelf_dir = tmp_path / 'shelf'
shelf_dir.mkdir(parents=True, exist_ok=True)
for name, version in specs:
src = tmp_path / 'src' / f'{name}-{version}'
_write_plugin(src, name=name, version=version)
packaging.pack(src, private_key, out_dir=shelf_dir)
shelf.build_index(shelf_dir, private_key, serial, revoked=revoked)
return shelf_dir
# --- index sign/verify ------------------------------------------------------
def test_index_round_trip(tmp_path, keypair):
priv, pub = keypair
shelf_dir = _build_shelf(tmp_path, priv, [('demo', '1.0.0')])
index, errors = shelf.load_index(shelf_dir, [pub])
assert errors == []
assert index['serial'] == 1
assert 'demo' in index['plugins']
def test_tampered_index_rejected(tmp_path, keypair):
priv, pub = keypair
shelf_dir = _build_shelf(tmp_path, priv, [('demo', '1.0.0')])
# change the index bytes but keep the old signature
path = shelf_dir / shelf.INDEX_NAME
data = json.loads(path.read_text())
data['serial'] = 999
path.write_bytes(signing.serialize_provenance(data))
_, errors = shelf.load_index(shelf_dir, [pub])
assert any('signature does not match' in e for e in errors)
def test_index_wrong_key_rejected(tmp_path, keypair):
priv, _ = keypair
shelf_dir = _build_shelf(tmp_path, priv, [('demo', '1.0.0')])
wrong_pub = signing.load_public_key(signing.generate_keypair()[1])
_, errors = shelf.load_index(shelf_dir, [wrong_pub])
assert any('signature does not match' in e for e in errors)
def test_index_no_keys_unverifiable(tmp_path, keypair):
priv, _ = keypair
shelf_dir = _build_shelf(tmp_path, priv, [('demo', '1.0.0')])
_, errors = shelf.load_index(shelf_dir, [])
assert any('unverifiable' in e for e in errors)
# --- anti-rollback state ----------------------------------------------------
def test_serial_state_round_trip(tmp_path):
assert shelf.read_last_serial(tmp_path) == -1
shelf.write_last_serial(tmp_path, 5)
assert shelf.read_last_serial(tmp_path) == 5
# --- revocation + version resolution ----------------------------------------
def test_resolve_highest_non_revoked(tmp_path, keypair):
priv, pub = 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, 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):
priv, pub = keypair
shelf_dir = _build_shelf(
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')
assert version == '1.0.0'
def test_resolve_explicit_revoked_raises(tmp_path, keypair):
priv, pub = keypair
shelf_dir = _build_shelf(
tmp_path, priv, [('demo', '1.0.0')],
revoked=[{'name': 'demo', 'version': '1.0.0'}])
index, _ = shelf.load_index(shelf_dir, [pub])
with pytest.raises(ValueError):
shelf.resolve_version(index, 'demo', '1.0.0')
def test_resolve_unknown_raises(tmp_path, keypair):
priv, pub = keypair
shelf_dir = _build_shelf(tmp_path, priv, [('demo', '1.0.0')])
index, _ = shelf.load_index(shelf_dir, [pub])
with pytest.raises(KeyError):
shelf.resolve_version(index, 'nope')
# --- verified atomic unpack -------------------------------------------------
def test_unpack_verified_places_tree(tmp_path, keypair):
priv, pub = keypair
src = _write_plugin(tmp_path / 'src' / 'demo')
artifact = packaging.pack(src, priv, out_dir=tmp_path / 'dist')
plugins_dir = tmp_path / 'live'
plugins_dir.mkdir()
target = shelf.unpack_verified(artifact, [pub], plugins_dir, 'demo')
assert target == plugins_dir / 'demo'
assert (target / 'manifest.json').exists()
assert (target / signing.PROVENANCE_NAME).exists()
# staging cleaned up
assert not (plugins_dir / shelf.STAGING_DIR / 'demo').exists()
def test_unpack_verified_refuses_tampered(tmp_path, keypair):
priv, pub = keypair
src = _write_plugin(tmp_path / 'src' / 'demo')
artifact = packaging.pack(src, priv, out_dir=tmp_path / 'dist')
# tamper a member, keep provenance + sig
with zipfile.ZipFile(artifact) as zin:
members = {n: zin.read(n) for n in zin.namelist()}
members['manifest.json'] += b'tampered'
with zipfile.ZipFile(artifact, 'w', zipfile.ZIP_DEFLATED) as zout:
for name, data in members.items():
zout.writestr(name, data)
plugins_dir = tmp_path / 'live'
plugins_dir.mkdir()
with pytest.raises(ValueError):
shelf.unpack_verified(artifact, [pub], plugins_dir, 'demo')
# nothing placed
assert not (plugins_dir / 'demo').exists()
def test_unpack_verified_wrong_name_refused(tmp_path, keypair):
priv, pub = keypair
src = _write_plugin(tmp_path / 'src' / 'demo')
artifact = packaging.pack(src, priv, out_dir=tmp_path / 'dist')
plugins_dir = tmp_path / 'live'
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()