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