ADR-013 Phase 2: guard hash-gates a .py sibling of an init-less dir

Fourth review found the last import-path bypass: the is_dir() branch returned
None for a name whose dir has no __init__.py, without checking a same-name
sibling file. FileFinder loads a file over an init-less namespace dir, so an
attacker could overwrite a signed foo.py with malicious bytes, mkdir an empty
foo/ next to it (PROVENANCE untouched, still verifies), and any import of that
name ran the unverified foo.py - RCE with only plugins/ write access.

Fix: the dir-with-no-__init__.py branch no longer returns early; it falls
through to the leaf .py hash gate and the non-source refuse check. Invariant:
find_spec returns None for a plugins.* name ONLY where FileFinder would also
find nothing on the same __path__.

Everything else was confirmed sound this round: the owned plugins root, exec of
exact verified bytes (never .pyc/.so), the extension/bytecode refusal, plugin.py
read-once, the provenance signature gate, dev-exemption scoping, and #3/#4.
Symlink, suffix-ordering, cache-lifecycle, and loader-internal angles cleared.
2 regression tests (tampered .py + sibling dir; unsigned .py + sibling dir). All
13 bundled plugins still load under enforcement; 1067 pass, naming green.
This commit is contained in:
cproudlock
2026-07-18 22:28:33 -04:00
parent c59d2dab56
commit beea6c0c9f
2 changed files with 38 additions and 6 deletions

View File

@@ -157,17 +157,19 @@ class PluginImportGuard(importlib.abc.MetaPathFinder):
init = base / '__init__.py'
if init.exists():
return self._source_spec(name, fullname, init, True, [str(base)])
# Regular package with no __init__.py: refuse a planted
# __init__.<ext>; otherwise treat as a namespace package (no body).
# Dir with no __init__.py: refuse a planted __init__.<ext>. Do NOT
# return yet - FileFinder loads a same-name sibling FILE over an
# init-less dir, so a foo.py next to foo/ must still be hash-gated
# (round 4). Fall through to the leaf resolution below.
self._refuse_if_unverifiable_present(base, '__init__', name)
return None
# Leaf module (or a dir-with-no-__init__ that has a sibling .py): a .py
# here must match the signed hash; a planted non-source candidate is
# refused; only a genuinely-absent name defers to normal import.
# Invariant: return None only where FileFinder would also find nothing.
source = base.with_suffix('.py')
if source.exists():
return self._source_spec(name, fullname, source, False, None)
# No .py for this name: refuse a planted .so / sourceless .pyc, else defer
# (a genuinely-absent module -> normal ModuleNotFoundError).
self._refuse_if_unverifiable_present(base.parent, base.name, name)
return None

View File

@@ -167,6 +167,36 @@ def test_planted_sourceless_pyc_refused(guarded):
guard.find_spec(f'plugins.{name}.models.evil')
def test_tampered_py_with_sibling_dir_refused(guarded):
"""Round 4: a signed foo.py tampered + an empty foo/ dir next to it must be
refused. FileFinder would load the file over the init-less dir, so the guard
must hash-gate the sibling .py instead of returning None for the dir."""
guard, plugins_dir, priv, name = guarded
pdir = _write_pkg_plugin(plugins_dir, name)
(pdir / 'models' / 'device.py').write_text('MARKER = "signed"\n')
_stamp(pdir, priv, name)
# attacker tampers the signed device.py and adds an empty sibling device/
(pdir / 'models' / 'device.py').write_text('MARKER = "evil"\n')
(pdir / 'models' / 'device').mkdir()
with pytest.raises(PluginVerificationError):
guard.find_spec(f'plugins.{name}.models.device')
def test_unsigned_py_with_sibling_dir_refused(guarded):
"""Symmetric variant: a never-signed evil.py next to evil/ is refused too."""
guard, plugins_dir, priv, name = guarded
pdir = _write_pkg_plugin(plugins_dir, name)
_stamp(pdir, priv, name)
(pdir / 'models' / 'evil.py').write_text('MARKER = "evil"\n')
(pdir / 'models' / 'evil').mkdir()
with pytest.raises(PluginVerificationError):
guard.find_spec(f'plugins.{name}.models.evil')
def test_absent_module_defers(guarded):
"""A genuinely-absent module returns None (normal ModuleNotFoundError)."""
guard, plugins_dir, priv, name = guarded