Skip to content

Commit 786a608

Browse files
Split shorten_code() formatting from dedup tracking (GHSA-cffv-grgg-g429)
AnalysisContext.shorten_code() mutated a shared reported_shortened_code set as a side effect of formatting, even though most callers only wanted the formatted string. It could then shadow findings from future analysis runs. For instance, UnsafeImportsML processed every import and would leave MLAllowlist with an already-reported state for all of them. Any import outside ML_ALLOWLIST would not be flagged by the second run. Split shorten_code() into a pure formatter and an explicit mark_reported() helper. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 91a40fc commit 786a608

3 files changed

Lines changed: 47 additions & 21 deletions

File tree

fickling/analysis.py

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -78,19 +78,25 @@ def analyze(self, analysis: Analysis) -> list[AnalysisResult]:
7878
def results(self) -> AnalysisResults:
7979
return AnalysisResults(pickled=self.pickled, results=self.previous_results)
8080

81-
def shorten_code(self, ast_node) -> tuple[str, bool]:
81+
def shorten_code(self, ast_node) -> str:
82+
"""Return a short, human-readable form of an AST node for use in
83+
analysis messages. Pure formatter — does not touch dedup state.
84+
"""
8285
code = unparse(ast_node).strip()
8386
if len(code) > 32:
8487
cutoff = code.find("(")
85-
if code[cutoff] == "(":
86-
shortened_code = f"{code[: code.find('(')].strip()}(...)"
87-
else:
88-
shortened_code = code
89-
else:
90-
shortened_code = code
91-
was_already_reported = shortened_code in self.reported_shortened_code
92-
self.reported_shortened_code.add(shortened_code)
93-
return shortened_code, was_already_reported
88+
if cutoff >= 0:
89+
return f"{code[:cutoff].strip()}(...)"
90+
return code
91+
92+
def mark_reported(self, shortened: str) -> bool:
93+
"""Mark a shortened code fragment as reported. Returns True if
94+
this was the first mark, False if a prior call already marked it.
95+
"""
96+
if shortened in self.reported_shortened_code:
97+
return False
98+
self.reported_shortened_code.add(shortened)
99+
return True
94100

95101

96102
class Analyzer(metaclass=AnalyzerMeta):
@@ -255,8 +261,8 @@ def analyze(self, context: AnalysisContext) -> Iterator[AnalysisResult]:
255261
class NonStandardImports(Analysis):
256262
def analyze(self, context: AnalysisContext) -> Iterator[AnalysisResult]:
257263
for node in context.pickled.non_standard_imports():
258-
shortened, already_reported = context.shorten_code(node)
259-
if not already_reported:
264+
shortened = context.shorten_code(node)
265+
if context.mark_reported(shortened):
260266
yield AnalysisResult(
261267
Severity.LIKELY_UNSAFE,
262268
f"`{shortened}` imports a Python module that is not a part of "
@@ -324,7 +330,7 @@ class UnsafeImportsML(Analysis):
324330

325331
def analyze(self, context: AnalysisContext) -> Iterator[AnalysisResult]:
326332
for node in context.pickled.properties.imports:
327-
shortened, _ = context.shorten_code(node)
333+
shortened = context.shorten_code(node)
328334
all_modules = [
329335
node.module.rsplit(".", i)[0] for i in range(0, node.module.count(".") + 1)
330336
]
@@ -377,7 +383,7 @@ class BadCalls(Analysis):
377383

378384
def analyze(self, context: AnalysisContext) -> Iterator[AnalysisResult]:
379385
for node in context.pickled.properties.calls:
380-
shortened, _already_reported = context.shorten_code(node)
386+
shortened = context.shorten_code(node)
381387
if any(shortened.startswith(f"{c}(") for c in self.BAD_CALLS):
382388
yield AnalysisResult(
383389
Severity.OVERTLY_MALICIOUS,
@@ -397,7 +403,7 @@ def analyze(self, context: AnalysisContext) -> Iterator[AnalysisResult]:
397403
# if the call is to a constructor of an object imported from the Python
398404
# standard library, it's probably okay
399405
continue
400-
shortened, already_reported = context.shorten_code(node)
406+
shortened = context.shorten_code(node)
401407
if (
402408
shortened.startswith("eval(")
403409
or shortened.startswith("exec(")
@@ -413,7 +419,7 @@ def analyze(self, context: AnalysisContext) -> Iterator[AnalysisResult]:
413419
"OvertlyBadEval",
414420
trigger=shortened,
415421
)
416-
elif not already_reported:
422+
elif context.mark_reported(shortened):
417423
yield AnalysisResult(
418424
Severity.LIKELY_UNSAFE,
419425
f"Call to `{shortened}` can execute arbitrary code and is inherently unsafe",
@@ -429,7 +435,7 @@ def analyze(self, context: AnalysisContext) -> Iterator[AnalysisResult]:
429435
n.name in SAFE_BUILTINS for n in node.names
430436
):
431437
continue
432-
shortened, _ = context.shorten_code(node)
438+
shortened = context.shorten_code(node)
433439
yield AnalysisResult(
434440
Severity.LIKELY_OVERTLY_MALICIOUS,
435441
f"`{shortened}` is suspicious and indicative of an overtly malicious pickle file",
@@ -447,7 +453,7 @@ def analyze(self, context: AnalysisContext) -> Iterator[AnalysisResult]:
447453
# Malformed pickle or resource exhaustion - dedicated analyses will report this
448454
return
449455
for varname, asmt in unused.items():
450-
shortened, _ = context.shorten_code(asmt.value)
456+
shortened = context.shorten_code(asmt.value)
451457
yield AnalysisResult(
452458
Severity.SUSPICIOUS,
453459
f"Variable `{varname}` is assigned value `{shortened}` but unused afterward; "

fickling/ml.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -295,9 +295,7 @@ def __init__(self):
295295

296296
def analyze(self, context: AnalysisContext) -> Iterator[AnalysisResult]:
297297
for node in context.pickled.properties.imports:
298-
shortened, already_reported = context.shorten_code(node)
299-
if already_reported:
300-
continue
298+
shortened = context.shorten_code(node)
301299

302300
if isinstance(node, ast.ImportFrom):
303301
# from module import x

test/test_bypasses.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,28 @@ def test_missing_osx_support(self):
683683
"from _osx_support import _find_build_tool",
684684
)
685685

686+
# https://github.com/trailofbits/fickling/security/advisories/GHSA-cffv-grgg-g429
687+
def test_ml_allowlist_not_shadowed_by_unsafe_imports_ml(self):
688+
"""MLAllowlist must flag imports outside ML_ALLOWLIST even when another
689+
analysis (UnsafeImportsML) has already iterated the same import.
690+
"""
691+
pickled = Pickled(
692+
[
693+
op.Proto.create(4),
694+
op.ShortBinUnicode("ast"),
695+
op.ShortBinUnicode("parse"),
696+
op.StackGlobal(),
697+
op.Stop(),
698+
]
699+
)
700+
res = check_safety(pickled)
701+
self.assertGreater(res.severity, Severity.LIKELY_SAFE)
702+
detailed = res.detailed_results().get("AnalysisResult", {})
703+
self.assertIsNotNone(
704+
detailed.get("MLAllowlist"),
705+
"MLAllowlist did not produce a finding for `from ast import parse`",
706+
)
707+
686708

687709
class TestUnsafeModuleCoverage(TestCase):
688710
"""Verify every entry in UNSAFE_MODULES and UNSAFE_IMPORTS triggers detection."""

0 commit comments

Comments
 (0)