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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user