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:
@@ -157,17 +157,19 @@ class PluginImportGuard(importlib.abc.MetaPathFinder):
|
|||||||
init = base / '__init__.py'
|
init = base / '__init__.py'
|
||||||
if init.exists():
|
if init.exists():
|
||||||
return self._source_spec(name, fullname, init, True, [str(base)])
|
return self._source_spec(name, fullname, init, True, [str(base)])
|
||||||
# Regular package with no __init__.py: refuse a planted
|
# Dir with no __init__.py: refuse a planted __init__.<ext>. Do NOT
|
||||||
# __init__.<ext>; otherwise treat as a namespace package (no body).
|
# 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)
|
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')
|
source = base.with_suffix('.py')
|
||||||
if source.exists():
|
if source.exists():
|
||||||
return self._source_spec(name, fullname, source, False, None)
|
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)
|
self._refuse_if_unverifiable_present(base.parent, base.name, name)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -167,6 +167,36 @@ def test_planted_sourceless_pyc_refused(guarded):
|
|||||||
guard.find_spec(f'plugins.{name}.models.evil')
|
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):
|
def test_absent_module_defers(guarded):
|
||||||
"""A genuinely-absent module returns None (normal ModuleNotFoundError)."""
|
"""A genuinely-absent module returns None (normal ModuleNotFoundError)."""
|
||||||
guard, plugins_dir, priv, name = guarded
|
guard, plugins_dir, priv, name = guarded
|
||||||
|
|||||||
Reference in New Issue
Block a user