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

@@ -8,6 +8,7 @@ ADOPT always re-verifies the artifact's own signed manifest, never trusting the
index for anything security-bearing.
"""
import hashlib
import json
import shutil
import zipfile
@@ -17,6 +18,14 @@ from packaging.version import Version, InvalidVersion
from . import signing, packaging
def _sha256_file(path) -> str:
digest = hashlib.sha256()
with open(path, 'rb') as handle:
for chunk in iter(lambda: handle.read(65536), b''):
digest.update(chunk)
return digest.hexdigest()
INDEX_NAME = 'shelf-index.json'
INDEX_SIG = 'shelf-index.sig'
STATE_NAME = 'shelf-state.json'
@@ -41,6 +50,10 @@ def build_index(shelf_dir, private_key, serial: int,
'tier': manifest.get('tier', 'optional'),
'core_version': manifest.get('core_version', ''),
'artifact': artifact.name,
# Bind the entry to the artifact CONTENT, not just a filename, so an
# attacker who can rewrite the shelf folder cannot serve different
# (e.g. revoked) bytes under a live filename (finding #4).
'sha256': _sha256_file(artifact),
})
index = {
@@ -111,10 +124,10 @@ def _safe_version(value):
def resolve_version(index: dict, name: str, version: str = None):
"""Pick the artifact filename for name[==version] from the index.
"""Pick the artifact for name[==version] from the index.
With no version, picks the highest non-revoked version. Returns
(version, artifact_filename) or raises KeyError/ValueError.
(version, artifact_filename, sha256_or_None) or raises KeyError/ValueError.
"""
candidates = index.get('plugins', {}).get(name)
if not candidates:
@@ -125,31 +138,46 @@ def resolve_version(index: dict, name: str, version: str = None):
if entry['version'] == version:
if is_revoked(index, name, version):
raise ValueError(f'{name} {version} is revoked')
return version, entry['artifact']
return version, entry['artifact'], entry.get('sha256')
raise KeyError(f'{name} {version} not on the shelf')
live = [e for e in candidates if not is_revoked(index, name, e['version'])]
if not live:
raise ValueError(f'every published version of {name} is revoked')
best = max(live, key=lambda e: (_safe_version(e['version']) or Version('0'),))
return best['version'], best['artifact']
return best['version'], best['artifact'], best.get('sha256')
def unpack_verified(artifact_path, public_keys, plugins_dir, name: str):
def unpack_verified(artifact_path, public_keys, plugins_dir, name: str,
expected_version: str = None, expected_sha256: str = None):
"""Verify an artifact, then atomically place it at plugins_dir/name.
Verifies signature + every file hash in a staging area BEFORE it can be
imported; only a fully verified tree is moved into place. Returns the final
plugin directory. Raises ValueError with all problems on any failure.
Binds the delivered bytes to what the signed index resolved: the file digest
must match expected_sha256 (when given) and the artifact's OWN signed
manifest must carry name (and expected_version, when given). This stops a
revoked/other version being served under a live filename (finding #4).
Verifies signature + every file hash in staging BEFORE anything can import;
only a fully verified tree is moved into place. Raises ValueError on any
failure.
"""
plugins_dir = Path(plugins_dir)
if expected_sha256 is not None:
actual_sha = _sha256_file(artifact_path)
if actual_sha != expected_sha256:
raise ValueError(
'artifact bytes do not match the signed index digest')
manifest, errors = packaging.verify_artifact(artifact_path, public_keys)
if errors:
raise ValueError('; '.join(errors))
if manifest.get('name') != name:
raise ValueError(
f"artifact manifest name '{manifest.get('name')}' != '{name}'")
if expected_version is not None and manifest.get('version') != expected_version:
raise ValueError(
f"artifact version '{manifest.get('version')}' != resolved "
f"'{expected_version}' (bytes served under wrong filename?)")
staging = plugins_dir / STAGING_DIR
staging.mkdir(parents=True, exist_ok=True)