Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
9feb7bc
fix: terminate call-graph alias fixpoint on oscillating rebinds
mldangelo May 22, 2026
467e66b
Merge remote-tracking branch 'origin/main' into mdangelo/codex/review…
mldangelo-oai May 22, 2026
cebd962
fix: preserve propagation when bounding alias cycles
mldangelo-oai May 22, 2026
d78c997
fix: bound assignment alias propagation work
mldangelo-oai May 22, 2026
26655ad
fix: fail closed on cyclic alias propagation
mldangelo-oai May 22, 2026
cbbbd10
fix: converge stable alias rebind states
mldangelo-oai May 22, 2026
21fcf1a
Merge remote-tracking branch 'origin/main' into mdangelo/codex/review…
mldangelo-oai May 23, 2026
c617e8e
fix: preserve findings on incomplete call-graph analysis
mldangelo-oai May 23, 2026
3572995
fix: preserve startup-hook findings on analysis limits
mldangelo-oai May 23, 2026
42e7702
Merge remote-tracking branch 'origin/main' into mdangelo/codex/review…
mldangelo-oai May 23, 2026
3acc6a8
fix: fail closed on conditional alias rebinding
mldangelo-oai May 23, 2026
f670f86
fix: retain aliases and findings across analysis limits
mldangelo-oai May 23, 2026
dbcdffb
fix: preserve deterministic loop-else alias results
mldangelo-oai May 23, 2026
6cd7de3
fix: track ambiguous alias reads before overwrites
mldangelo-oai May 23, 2026
78eb56d
fix: fail closed during torch reference filtering
mldangelo-oai May 23, 2026
e37bce1
fix: preserve findings across alias ambiguity limits
mldangelo-oai May 23, 2026
8f2c0b4
fix: propagate ambiguous aliases in installed packages
mldangelo-oai May 23, 2026
183869a
fix: preserve deterministic alias findings across limits
mldangelo-oai May 23, 2026
95642a4
fix: resolve deterministic alias alternatives safely
mldangelo-oai May 23, 2026
2d00183
fix: retain deterministic aliases before epilogues
mldangelo-oai May 23, 2026
b054f0a
Merge remote-tracking branch 'origin/main' into mdangelo/codex/review…
mldangelo-oai May 23, 2026
8ca3b53
fix: track ambiguous alias calls before overwrites
mldangelo-oai May 23, 2026
3bbeb4d
test: stabilize generic zip raw-scan fixture
mldangelo-oai May 23, 2026
71925b1
fix: preserve same-line terminal alias ordering
mldangelo-oai May 23, 2026
06b7a3c
fix: resolve deterministic terminal alias branches
mldangelo-oai May 23, 2026
66049f2
fix: handle one-sided terminal alias paths
mldangelo-oai May 23, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -1664,6 +1664,12 @@ def _collect_assignment_aliases(
) -> dict[str, str]:
node_list = tuple(nodes)
assignment_aliases: dict[str, str] = {}
# Track every (target, resolved) pair already applied. A target can be
# reassigned across passes (e.g. distinct `if`/`else` branches both bind
# the same name), so the fixpoint may oscillate without ever growing the
# dict. Re-applying a previously seen pair must not count as progress, or
# the loop never terminates (see imaplib/http.server/nntplib stdlib hangs).
seen_assignments: set[tuple[str, str]] = set()

changed = True
while changed and len(assignment_aliases) < _MAX_ASSIGNMENT_ALIASES:
Expand All @@ -1684,7 +1690,9 @@ def _collect_assignment_aliases(
if assignment_aliases.get(target_name) == resolved:
continue
assignment_aliases[target_name] = resolved
changed = True
if (target_name, resolved) not in seen_assignments:
seen_assignments.add((target_name, resolved))
changed = True
Comment thread
mldangelo-oai marked this conversation as resolved.
Outdated
if len(assignment_aliases) >= _MAX_ASSIGNMENT_ALIASES:
break
return assignment_aliases
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Regression: assignment-alias fixpoint must terminate on oscillating binds.

A module that binds the same name in both branches of an ``if``/``else``
(e.g. the ``imaplib``/``http.server``/``nntplib`` stdlib ``__main__`` blocks)
makes ``_collect_assignment_aliases`` oscillate the alias between two values
without ever growing the dict. The dict-size guard never trips, so the
fixpoint loop spins forever and the whole scan hangs (GitHub issue #1247).
"""

from __future__ import annotations

import ast
import threading
from importlib.util import find_spec

import pytest

from modelaudit_picklescan import PickleReport, Severity, scan_bytes
from modelaudit_picklescan.api import _RUST_EXTENSION_MODULE
from modelaudit_picklescan.call_graph import (
_collect_assignment_aliases,
_collect_local_defs,
_module_level_statements,
)

_OSCILLATING_MODULE_SOURCE = """\
class A:
pass


class B:
pass


if cond:
m = A()
else:
m = B()
"""


def _run_with_timeout(target: object, timeout: float = 10.0) -> None:
thread = threading.Thread(target=target) # type: ignore[arg-type]
thread.daemon = True
thread.start()
thread.join(timeout)
if thread.is_alive():
pytest.fail(f"call-graph analysis did not terminate within {timeout}s")


def test_collect_assignment_aliases_terminates_on_branch_rebind() -> None:
tree = ast.parse(_OSCILLATING_MODULE_SOURCE)
statements = _module_level_statements(tree)
local_defs = _collect_local_defs(statements)
local_class_targets = {"testmod.A", "testmod.B"}

result: dict[str, dict[str, str]] = {}

def _collect() -> None:
result["aliases"] = _collect_assignment_aliases(
statements,
"testmod",
{},
local_defs,
local_class_targets,
)

_run_with_timeout(_collect)

aliases = result["aliases"]
# The rebinding name still resolves to one of the two local classes; the
# exact branch is order-dependent, but the loop must converge.
assert aliases.get("m") in {"testmod.A", "testmod.B"}


def _stack_global_payload(module: str, name: str) -> bytes:
def _operand(value: str) -> bytes:
data = value.encode()
return b"\x8c" + bytes([len(data)]) + data

return b"\x80\x04" + _operand(module) + _operand(name) + b"\x93" + _operand("proof_of_bypass") + b"\x85R."


@pytest.mark.skipif(
find_spec(_RUST_EXTENSION_MODULE) is None,
reason="Rust picklescan extension is not built",
)
@pytest.mark.skipif(
find_spec("imaplib") is None,
reason="imaplib stdlib module is unavailable",
)
def test_scan_imaplib_reference_terminates_and_flags() -> None:
"""The issue #1247 proof-of-concept must finish and stay flagged."""
payload = _stack_global_payload("imaplib", "test")
result: dict[str, PickleReport] = {}

def _scan() -> None:
result["report"] = scan_bytes(payload)

_run_with_timeout(_scan)

report = result["report"]
severities = {finding.severity for finding in report.findings}
assert Severity.CRITICAL in severities
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ def pytest_runtest_setup(item):
"test_call_graph_local_imports.py", # standalone picklescan function-local import RCE regressions
"test_call_graph_six.py", # standalone picklescan six.moves alias RCE regressions
"test_call_graph_tkinter.py", # standalone picklescan Tcl call-graph RCE regressions
"test_call_graph_assignment_alias_cycle.py", # standalone picklescan alias fixpoint termination regressions
"test_dill_joblib_enhanced.py", # Dill/joblib pickle routing regression tests
"test_pickle_context_filtering.py", # Pickle context filtering regression tests
"test_xdist_status.py", # xdist worker progress reporting tests
Expand Down
Loading