From beea6c0c9f248dbbe025318bbdb3fa64a85e7239 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Sat, 18 Jul 2026 22:28:33 -0400 Subject: [PATCH] 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. --- shopdb/plugins/importguard.py | 14 ++++++++------ tests/test_plugin_importguard.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) 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