Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions fickling/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,24 @@ def analyze(self, context: AnalysisContext) -> Iterator[AnalysisResult]:
)


class ShadowedStdlibImports(Analysis):
def analyze(self, context: AnalysisContext) -> Iterator[AnalysisResult]:
for node in context.pickled.shadowed_stdlib_imports():
shortened = context.shorten_code(node)
if context.mark_reported(shortened):
yield AnalysisResult(
Severity.SUSPICIOUS,
f"`{shortened}` imports a module that was removed from the "
"Python standard library in a recent version; on modern "
"interpreters this name resolves to a third-party package "
"of the same name, which can execute arbitrary code. "
"Treat as unsafe unless explicitly allowlisted "
"(fickling.fickle.SHADOWED_STDLIB_IMPORT_ALLOWLIST)",
"ShadowedStdlibImports",
trigger=shortened,
)


class UnsafeImportsML(Analysis):
# ML-specific unsafe modules only; general-purpose modules (os, subprocess,
# socket, pickle, etc.) are already covered by fickle.py's UNSAFE_IMPORTS
Expand Down
56 changes: 52 additions & 4 deletions fickling/fickle.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
overload,
)

from fickling import stdlib_names
from fickling.exception import ExpansionAttackError, ResourceExhaustionError, WrongMethodError

T = TypeVar("T")
Expand Down Expand Up @@ -56,8 +57,6 @@ def __post_init__(self) -> None:
GenericSequence = Sequence[T]
make_constant = ast.Constant

BUILTIN_STDLIB_MODULE_NAMES: frozenset[str] = sys.stdlib_module_names

OPCODES_BY_NAME: dict[str, type[Opcode]] = {}
OPCODE_INFO_BY_NAME: dict[str, OpcodeInfo] = {opcode.name: opcode for opcode in opcodes}

Expand Down Expand Up @@ -279,15 +278,36 @@ def __post_init__(self) -> None:


def is_std_module(module_name: str) -> bool:
return module_name.partition(".")[0] in BUILTIN_STDLIB_MODULE_NAMES
# Looked up through the module so runtime retargeting via
# stdlib_names.use_stdlib_of() takes effect (issue #311).
return module_name.partition(".")[0] in stdlib_names.STDLIB_MODULE_NAMES


def is_private_or_dunder_stdlib_module(name: str) -> bool:
"""A stdlib module with a leading underscore (`_socket`, `_pickle`,
`__future__`, …): internal or non-public, so it should never appear in a
pickle. Benign exceptions are handled in _is_allowed_private_import.
"""
return name.startswith("_") and name in BUILTIN_STDLIB_MODULE_NAMES
return name.startswith("_") and name in stdlib_names.STDLIB_MODULE_NAMES


# User extension point: add top-level module names here (e.g. `distutils` for
# a scanner that knows its targets run setuptools' shim) to treat imports of
# removed-and-shadowable stdlib names as plain stdlib again — use only when
# you know no third-party package can shadow them on the loading interpreter.
SHADOWED_STDLIB_IMPORT_ALLOWLIST: frozenset[str] = frozenset()


def is_shadowed_stdlib_module(module_name: str) -> bool:
"""True for names stdlib somewhere in the support matrix but removed from
recent releases, where third-party PyPI packages of the same name can
shadow them (see fickling.stdlib_names.SHADOWED_STDLIB_MODULE_NAMES).
"""
root = module_name.partition(".")[0]
return (
root in stdlib_names.current_shadowed_names()
and root not in SHADOWED_STDLIB_IMPORT_ALLOWLIST
)


def import_name_components(node: ast.Import | ast.ImportFrom) -> Iterator[str]:
Expand Down Expand Up @@ -689,6 +709,7 @@ def _process_import(self, node: ast.Import | ast.ImportFrom):
isinstance(node, ast.ImportFrom)
and node.module is not None
and is_std_module(node.module)
and not is_shadowed_stdlib_module(node.module)
and (
not any(is_private_or_dunder_stdlib_module(c) for c in node.module.split("."))
or _is_allowed_private_import(node)
Expand Down Expand Up @@ -1344,6 +1365,33 @@ def private_stdlib_imports(self) -> Iterator[ast.Import | ast.ImportFrom]:
if any(is_private_or_dunder_stdlib_module(c) for c in import_name_components(node)):
yield node

def shadowed_stdlib_imports(self) -> Iterator[ast.Import | ast.ImportFrom]:
"""Imports of modules removed from the stdlib in a recent version.

These names can be shadowed by third-party PyPI packages on modern
interpreters, so they are reported separately (ShadowedStdlibImports)
instead of being treated as plain stdlib.
"""
for node in self.properties.imports:
if isinstance(node, ast.ImportFrom):
if node.module is None:
continue
root = node.module.partition(".")[0]
if (
root in stdlib_names.current_shadowed_names()
and root not in SHADOWED_STDLIB_IMPORT_ALLOWLIST
):
yield node
else:
for alias in node.names:
root = alias.name.partition(".")[0]
if (
root in stdlib_names.current_shadowed_names()
and root not in SHADOWED_STDLIB_IMPORT_ALLOWLIST
):
yield node
break

@property
def ast(self) -> ast.Module:
if self._ast is None:
Expand Down
Loading