Skip to content

Commit 8c83361

Browse files
committed
fix: harden pickle source resolution precedence
1 parent 2b799db commit 8c83361

2 files changed

Lines changed: 179 additions & 22 deletions

File tree

packages/modelaudit-picklescan/src/modelaudit_picklescan/call_graph.py

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import ast
6+
import fnmatch
67
import hashlib
78
import os
89
import sys
@@ -46,9 +47,7 @@
4647
_MAX_ASSIGNMENT_ALIASES = 128
4748
_MAX_ASSIGNMENT_ALIAS_PASSES = 256
4849
_MAX_FUNCTION_INSTANCE_ALIASES = 32
49-
_TRUSTED_META_PATH_FINDERS = tuple(sys.meta_path)
5050
_TRUSTED_PATH_HOOKS = tuple(sys.path_hooks)
51-
_TRUSTED_PATH_IMPORTERS = tuple(finder for finder in sys.path_importer_cache.values() if finder is not None)
5251
_MAX_CLASS_INSTANCE_ALIASES = 128
5352
_MAX_INHERITED_CLASS_METHODS = 128
5453
_MAX_WILDCARD_IMPORTS = 16
@@ -1047,17 +1046,17 @@ def _find_module_spec_without_imports(module_name: str) -> ModuleSpec | None:
10471046
if isinstance(loaded_spec, ModuleSpec):
10481047
return loaded_spec
10491048

1050-
if not _untrusted_meta_path_finder_precedes(BuiltinImporter):
1049+
if not _untrusted_meta_path_finder_precedes(BuiltinImporter, module_name):
10511050
builtin_spec = BuiltinImporter.find_spec(module_name)
10521051
if builtin_spec is not None:
10531052
return builtin_spec
10541053

1055-
if not _untrusted_meta_path_finder_precedes(FrozenImporter):
1054+
if not _untrusted_meta_path_finder_precedes(FrozenImporter, module_name):
10561055
frozen_spec = FrozenImporter.find_spec(module_name)
10571056
if frozen_spec is not None:
10581057
return frozen_spec
10591058

1060-
if _untrusted_meta_path_finder_precedes(PathFinder) or _has_untrusted_path_hook():
1059+
if _untrusted_meta_path_finder_precedes(PathFinder, module_name) or _has_untrusted_path_hook():
10611060
return None
10621061

10631062
return _find_standard_filesystem_spec(module_name)
@@ -1084,40 +1083,58 @@ def _find_standard_filesystem_spec(module_name: str) -> ModuleSpec | None:
10841083
return spec
10851084

10861085

1087-
def _untrusted_meta_path_finder_precedes(target: object) -> bool:
1086+
def _matches_loaded_finder_type(finder: object, module_name: str, type_name: str) -> bool:
1087+
module = sys.modules.get(module_name)
1088+
finder_type = getattr(module, type_name, None) if module is not None else None
1089+
return isinstance(finder_type, type) and type(finder) is finder_type
1090+
1091+
1092+
def _known_meta_path_finder_cannot_handle(finder: object, module_name: str) -> bool:
1093+
root_name = module_name.split(".", maxsplit=1)[0]
1094+
if _matches_loaded_finder_type(finder, "_distutils_hack", "DistutilsMetaFinder"):
1095+
return root_name not in {"distutils", "pip", "test"}
1096+
1097+
if _matches_loaded_finder_type(finder, "_virtualenv", "_Finder"):
1098+
virtualenv_module = sys.modules.get("_virtualenv")
1099+
patched_modules = getattr(virtualenv_module, "_DISTUTILS_PATCH", ()) if virtualenv_module is not None else ()
1100+
return module_name not in patched_modules
1101+
1102+
if _matches_loaded_finder_type(finder, "_pytest.assertion.rewrite", "AssertionRewritingHook"):
1103+
if module_name == "conftest":
1104+
return False
1105+
must_rewrite = getattr(finder, "_must_rewrite", ())
1106+
if any(module_name == name or module_name.startswith(f"{name}.") for name in must_rewrite):
1107+
return False
1108+
patterns = getattr(finder, "fnpats", ())
1109+
module_filename = f"{module_name.rsplit('.', maxsplit=1)[-1]}.py"
1110+
return all(not fnmatch.fnmatchcase(module_filename, pattern) for pattern in patterns)
1111+
1112+
return False
1113+
1114+
1115+
def _untrusted_meta_path_finder_precedes(target: object, module_name: str) -> bool:
10881116
for finder in sys.meta_path:
10891117
if finder is target:
10901118
return False
10911119
if finder is BuiltinImporter or finder is FrozenImporter or finder is PathFinder:
10921120
continue
1093-
if any(finder is trusted_finder for trusted_finder in _TRUSTED_META_PATH_FINDERS):
1121+
if _known_meta_path_finder_cannot_handle(finder, module_name):
10941122
continue
10951123
return True
10961124
return True
10971125

10981126

10991127
def _is_standard_path_hook(hook: object) -> bool:
1100-
if hook is zipimporter:
1101-
return True
1102-
return getattr(hook, "__module__", None) == "_frozen_importlib_external" and getattr(
1103-
hook, "__qualname__", ""
1104-
).endswith("FileFinder.path_hook.<locals>.path_hook_for_FileFinder")
1128+
return hook is zipimporter or any(hook is trusted_hook for trusted_hook in _TRUSTED_PATH_HOOKS)
11051129

11061130

11071131
def _has_untrusted_path_hook() -> bool:
1108-
if any(
1109-
not _is_standard_path_hook(hook) and not any(hook is trusted_hook for trusted_hook in _TRUSTED_PATH_HOOKS)
1110-
for hook in sys.path_hooks
1111-
):
1132+
if any(not _is_standard_path_hook(hook) for hook in sys.path_hooks):
11121133
return True
11131134
for entry in sys.path:
11141135
cache_key = entry or os.getcwd()
11151136
finder = sys.path_importer_cache.get(cache_key)
1116-
if (
1117-
finder is not None
1118-
and not isinstance(finder, (FileFinder, zipimporter))
1119-
and not any(finder is trusted_finder for trusted_finder in _TRUSTED_PATH_IMPORTERS)
1120-
):
1137+
if finder is not None and not isinstance(finder, (FileFinder, zipimporter)):
11211138
return True
11221139
return False
11231140

@@ -1130,6 +1147,13 @@ def _find_standard_path_spec(module_name: str, search_path: list[str]) -> Module
11301147
(SourcelessFileLoader, BYTECODE_SUFFIXES),
11311148
)
11321149
for entry in search_path:
1150+
try:
1151+
zip_spec = zipimporter(entry).find_spec(module_name)
1152+
except ImportError:
1153+
zip_spec = None
1154+
if zip_spec is not None:
1155+
return zip_spec
1156+
11331157
finder = FileFinder(entry, *loader_details)
11341158
spec = finder.find_spec(module_name)
11351159
if spec is None:
@@ -3577,7 +3601,7 @@ def _resolve_module_source(module_name: str) -> Path | None:
35773601
return loaded_source_path
35783602
if loaded_spec.origin not in {"built-in", "frozen"}:
35793603
return None
3580-
elif _untrusted_meta_path_finder_precedes(PathFinder) or _has_untrusted_path_hook():
3604+
elif _untrusted_meta_path_finder_precedes(PathFinder, module_name) or _has_untrusted_path_hook():
35813605
return None
35823606

35833607
spec = _find_standard_filesystem_spec(module_name)

packages/modelaudit-picklescan/tests/test_call_graph_import_statements.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2048,6 +2048,58 @@ def find_spec(
20482048
assert not marker.exists()
20492049

20502050

2051+
def test_call_graph_fails_closed_for_meta_path_finder_installed_before_import(tmp_path: Path) -> None:
2052+
module_dir = tmp_path / "modules"
2053+
module_dir.mkdir()
2054+
module_name = "modelaudit_tp_preimport_meta_path_probe"
2055+
(module_dir / f"{module_name}.py").write_text(
2056+
"def invoke(command):\n return command\n",
2057+
encoding="utf-8",
2058+
)
2059+
marker = tmp_path / "preimport_meta_path_called"
2060+
child_code = """
2061+
import sys
2062+
from importlib.machinery import ModuleSpec
2063+
from pathlib import Path
2064+
2065+
module_dir = Path(sys.argv[1])
2066+
marker = Path(sys.argv[2])
2067+
module_name = sys.argv[3]
2068+
2069+
class PreexistingMetaPathFinder:
2070+
@staticmethod
2071+
def find_spec(fullname, path=None, target=None):
2072+
del path, target
2073+
if fullname == module_name:
2074+
marker.write_text(fullname, encoding="utf-8")
2075+
return ModuleSpec(fullname, loader=None, origin="custom://module")
2076+
return None
2077+
2078+
sys.path.insert(0, str(module_dir))
2079+
sys.meta_path.insert(0, PreexistingMetaPathFinder())
2080+
from modelaudit_picklescan.call_graph import _call_graph_source_unavailable_reason
2081+
2082+
reason = _call_graph_source_unavailable_reason(module_name)
2083+
if reason != "source_unavailable":
2084+
raise SystemExit(f"unexpected source reason: {reason!r}")
2085+
if marker.exists():
2086+
raise SystemExit("pre-existing finder was invoked")
2087+
"""
2088+
2089+
result = subprocess.run(
2090+
[sys.executable, "-c", child_code, str(module_dir), str(marker), module_name],
2091+
cwd=str(tmp_path),
2092+
env=dict(os.environ),
2093+
check=False,
2094+
capture_output=True,
2095+
text=True,
2096+
timeout=30,
2097+
)
2098+
2099+
assert result.returncode == 0, result.stderr
2100+
assert not marker.exists()
2101+
2102+
20512103
def test_scan_bytes_analyzes_stdlib_source_modules_without_custom_meta_path_finders(
20522104
tmp_path: Path,
20532105
monkeypatch: pytest.MonkeyPatch,
@@ -2305,6 +2357,84 @@ def custom_path_hook(path: str) -> object:
23052357
assert not marker.exists()
23062358

23072359

2360+
def test_call_graph_rejects_path_hook_with_spoofed_standard_metadata(
2361+
tmp_path: Path,
2362+
monkeypatch: pytest.MonkeyPatch,
2363+
) -> None:
2364+
module_name = "modelaudit_tp_spoofed_path_hook_probe"
2365+
module_dir = tmp_path / "modules"
2366+
module_dir.mkdir()
2367+
(module_dir / f"{module_name}.py").write_text("def invoke():\n return None\n", encoding="utf-8")
2368+
marker = tmp_path / "spoofed_path_hook_called"
2369+
2370+
def spoofed_path_hook(path: str) -> object:
2371+
marker.write_text(path, encoding="utf-8")
2372+
raise ImportError
2373+
2374+
spoofed_path_hook.__module__ = "_frozen_importlib_external"
2375+
spoofed_path_hook.__qualname__ = "FileFinder.path_hook.<locals>.path_hook_for_FileFinder"
2376+
monkeypatch.syspath_prepend(str(module_dir))
2377+
monkeypatch.setattr(sys, "path_hooks", [spoofed_path_hook, *sys.path_hooks])
2378+
sys.path_importer_cache.pop(str(module_dir), None)
2379+
_clear_call_graph_caches()
2380+
2381+
try:
2382+
assert call_graph._call_graph_source_unavailable_reason(module_name) == "source_unavailable"
2383+
finally:
2384+
_clear_call_graph_caches()
2385+
2386+
assert not marker.exists()
2387+
2388+
2389+
def test_call_graph_honors_zipimport_precedence_over_later_extension(
2390+
tmp_path: Path,
2391+
monkeypatch: pytest.MonkeyPatch,
2392+
) -> None:
2393+
module_name = "modelaudit_tp_zip_shadowing_extension"
2394+
zip_path = tmp_path / "modules.zip"
2395+
extension_dir = tmp_path / "extension"
2396+
extension_dir.mkdir()
2397+
with zipfile.ZipFile(zip_path, "w") as archive:
2398+
archive.writestr(f"{module_name}.py", "def invoke(command):\n return command\n")
2399+
(extension_dir / f"{module_name}{EXTENSION_SUFFIXES[0]}").write_bytes(b"not imported")
2400+
monkeypatch.setattr(sys, "path", [str(zip_path), str(extension_dir), *sys.path])
2401+
importlib.invalidate_caches()
2402+
_clear_call_graph_caches()
2403+
2404+
try:
2405+
report = scan_bytes(
2406+
_global_call_payload(module_name, "invoke", _unicode_operand("echo hidden")),
2407+
source="zip-shadowing-extension.pkl",
2408+
)
2409+
finally:
2410+
_clear_call_graph_caches()
2411+
2412+
assert report.status == ScanStatus.INCONCLUSIVE
2413+
assert report.verdict == SafetyVerdict.UNKNOWN
2414+
assert _has_call_graph_source_unavailable_notice(report, module_name, "invoke", "source_unavailable")
2415+
2416+
2417+
def test_call_graph_keeps_extension_precedence_over_later_zipimport(
2418+
tmp_path: Path,
2419+
monkeypatch: pytest.MonkeyPatch,
2420+
) -> None:
2421+
module_name = "modelaudit_tp_extension_shadowing_zip"
2422+
extension_dir = tmp_path / "extension"
2423+
extension_dir.mkdir()
2424+
zip_path = tmp_path / "modules.zip"
2425+
(extension_dir / f"{module_name}{EXTENSION_SUFFIXES[0]}").write_bytes(b"not imported")
2426+
with zipfile.ZipFile(zip_path, "w") as archive:
2427+
archive.writestr(f"{module_name}.py", "def invoke(command):\n return command\n")
2428+
monkeypatch.setattr(sys, "path", [str(extension_dir), str(zip_path), *sys.path])
2429+
importlib.invalidate_caches()
2430+
_clear_call_graph_caches()
2431+
2432+
try:
2433+
assert call_graph._call_graph_source_unavailable_reason(module_name) is None
2434+
finally:
2435+
_clear_call_graph_caches()
2436+
2437+
23082438
def test_scan_bytes_marks_bytecode_only_invoked_call_graph_source_unavailable(
23092439
tmp_path: Path,
23102440
monkeypatch: pytest.MonkeyPatch,
@@ -2614,6 +2744,7 @@ def test_scan_bytes_analyzes_shadowed_torch_extension_callable_invocation(
26142744
"import os\n\ndef device(command):\n os.system(command)\n return command\n",
26152745
encoding="utf-8",
26162746
)
2747+
monkeypatch.delitem(sys.modules, "torch", raising=False)
26172748
monkeypatch.syspath_prepend(str(module_dir))
26182749
importlib.invalidate_caches()
26192750
_clear_call_graph_caches()
@@ -2658,6 +2789,7 @@ def __call__(self, command):
26582789
""",
26592790
encoding="utf-8",
26602791
)
2792+
monkeypatch.delitem(sys.modules, "torch", raising=False)
26612793
monkeypatch.syspath_prepend(str(module_dir))
26622794
importlib.invalidate_caches()
26632795
_clear_call_graph_caches()
@@ -2745,6 +2877,7 @@ def test_call_graph_analyzes_shadowed_torch_storage_persistent_id_reference(
27452877
"import os\n\ndef __getattr__(name):\n os.system(name)\n raise AttributeError(name)\n",
27462878
encoding="utf-8",
27472879
)
2880+
monkeypatch.delitem(sys.modules, "torch", raising=False)
27482881
monkeypatch.syspath_prepend(str(module_dir))
27492882
importlib.invalidate_caches()
27502883
_clear_call_graph_caches()

0 commit comments

Comments
 (0)