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:
@@ -577,6 +577,7 @@ def stamp_bundled(key_path, publisher, name):
|
||||
from .signing import (load_private_key, build_provenance,
|
||||
serialize_provenance, sign,
|
||||
PROVENANCE_NAME, PROVENANCE_SIG)
|
||||
from .packaging import strip_bytecode
|
||||
|
||||
pm = current_app.extensions.get('plugin_manager')
|
||||
if not pm:
|
||||
@@ -589,6 +590,9 @@ def stamp_bundled(key_path, publisher, name):
|
||||
|
||||
for plugin_name in names:
|
||||
plugin_dir = plugins_dir / plugin_name
|
||||
# Stamp a clean tree: no bytecode, so verify-at-load never trips on a
|
||||
# cache the provenance does not cover.
|
||||
strip_bytecode(plugin_dir)
|
||||
manifest = pm.loader.load_manifest(plugin_name)
|
||||
provenance = build_provenance(
|
||||
plugin_dir, manifest['name'], manifest['version'], publisher)
|
||||
@@ -743,7 +747,8 @@ def adopt_plugin(spec, pubkeys, force_downgrade):
|
||||
raise SystemExit(1)
|
||||
|
||||
try:
|
||||
resolved_version, artifact_name = resolve_version(index, name, version)
|
||||
resolved_version, artifact_name, expected_sha = resolve_version(
|
||||
index, name, version)
|
||||
except (KeyError, ValueError) as e:
|
||||
click.echo(click.style(f"{e}", fg='red'))
|
||||
raise SystemExit(1)
|
||||
@@ -763,7 +768,9 @@ def adopt_plugin(spec, pubkeys, force_downgrade):
|
||||
plugins_dir = Path(pm.loader.plugins_dir)
|
||||
click.echo(f"Verifying + unpacking {name} {resolved_version} ...")
|
||||
try:
|
||||
unpack_verified(artifact_path, keys, plugins_dir, name)
|
||||
unpack_verified(artifact_path, keys, plugins_dir, name,
|
||||
expected_version=resolved_version,
|
||||
expected_sha256=expected_sha)
|
||||
except ValueError as e:
|
||||
click.echo(click.style(f" verification failed: {e}", fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
Reference in New Issue
Block a user