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

@@ -6,8 +6,11 @@ the exact provenance bytes). pack() builds and signs it; verify_artifact() and
verify_dir() re-hash the contents and check the signature against trusted keys.
"""
import contextlib
import hashlib
import json
import shutil
import sys
import zipfile
from pathlib import Path
@@ -16,6 +19,39 @@ from . import signing
ARTIFACT_SUFFIX = '.shopdbplugin'
def strip_bytecode(root) -> None:
"""Remove every __pycache__ dir and .pyc/.pyo under root.
A signed plugin tree must contain only the source that was hashed. Stripping
bytecode before verify + import guarantees the loader cannot execute a
planted or stale cache in place of the verified source.
"""
root = Path(root)
if not root.exists():
return
for path in root.rglob('__pycache__'):
if path.is_dir():
shutil.rmtree(path, ignore_errors=True)
for suffix in ('*.pyc', '*.pyo'):
for path in root.rglob(suffix):
try:
path.unlink()
except OSError:
pass
@contextlib.contextmanager
def no_bytecode():
"""Import/exec without writing .pyc, so a just-stripped tree stays clean and
a later verify does not trip over freshly written bytecode."""
previous = sys.dont_write_bytecode
sys.dont_write_bytecode = True
try:
yield
finally:
sys.dont_write_bytecode = previous
def pack(plugin_dir, private_key, publisher: str = '',
out_dir=None, created: str = None) -> Path:
"""Build a signed artifact from a plugin directory. Returns its path.
@@ -117,12 +153,12 @@ def verify_dir(plugin_dir, public_keys):
for path in plugin_dir.rglob('*'):
if path.is_file():
member_names.add(path.relative_to(plugin_dir).as_posix())
# Drop the noise the packer also excludes, so an on-disk __pycache__ does
# not read as an "unexpected file".
# Ignore only non-executable dev noise. __pycache__/.pyc are NOT ignored -
# a planted bytecode cache must surface as an "unexpected file" so it cannot
# execute while escaping the hash map (finding #1).
member_names = {
m for m in member_names
if not any(part in signing._EXCLUDE_DIRS for part in Path(m).parts)
and Path(m).suffix not in signing._EXCLUDE_SUFFIXES
if not any(part in signing._VERIFY_EXCLUDE_DIRS for part in Path(m).parts)
}
def read_bytes(relpath):