diff --git a/shopdb/plugins/importguard.py b/shopdb/plugins/importguard.py index ad91b45..d8a4ea9 100644 --- a/shopdb/plugins/importguard.py +++ b/shopdb/plugins/importguard.py @@ -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__.; otherwise treat as a namespace package (no body). + # Dir with no __init__.py: refuse a planted __init__.. 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 diff --git a/tests/test_plugin_importguard.py b/tests/test_plugin_importguard.py index 7914ba7..0cdead7 100644 --- a/tests/test_plugin_importguard.py +++ b/tests/test_plugin_importguard.py @@ -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