Skip to content

Commit ebc7c2a

Browse files
authored
fix: track assignment aliases in component code scanner (#14041)
fix: bind loop and comprehension aliases
1 parent f53b2f1 commit ebc7c2a

2 files changed

Lines changed: 105 additions & 0 deletions

File tree

src/backend/base/langflow/agentic/helpers/code_security.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,27 @@ def _bind_assignment_target(self, target: ast.AST, value: ast.AST) -> None:
306306
for target_element in target.elts:
307307
self._bind_assignment_target(target_element, value)
308308

309+
def _bind_iterated_target(self, target: ast.AST, iterable: ast.AST) -> None:
310+
"""Bind a loop target to every statically visible iterable value."""
311+
if isinstance(iterable, (ast.List, ast.Tuple, ast.Set)):
312+
values = iterable.elts
313+
elif isinstance(iterable, ast.Dict):
314+
values = [key for key in iterable.keys if key is not None]
315+
else:
316+
values = [iterable]
317+
318+
before_iteration = self._snapshot_alias_state()
319+
iteration_states: list[_AliasState] = []
320+
for value in values:
321+
self._restore_alias_state(before_iteration)
322+
self._bind_assignment_target(target, value)
323+
iteration_states.append(self._snapshot_alias_state())
324+
325+
if iteration_states:
326+
self._merge_alias_states(iteration_states)
327+
else:
328+
self._bind_assignment_target(target, iterable)
329+
309330
def _snapshot_alias_state(self) -> _AliasState:
310331
return self.module_aliases.copy(), self.shadowed_aliases.copy()
311332

@@ -314,6 +335,28 @@ def _restore_alias_state(self, state: _AliasState) -> None:
314335
self.module_aliases = aliases.copy()
315336
self.shadowed_aliases = shadowed.copy()
316337

338+
def _restore_target_names(self, target_names: set[str], state: _AliasState) -> None:
339+
aliases, shadowed = state
340+
for name in target_names:
341+
if name in aliases:
342+
self.module_aliases[name] = aliases[name]
343+
self.shadowed_aliases.discard(name)
344+
elif name in shadowed:
345+
self.module_aliases.pop(name, None)
346+
self.shadowed_aliases.add(name)
347+
else:
348+
self.module_aliases.pop(name, None)
349+
self.shadowed_aliases.discard(name)
350+
351+
def _assignment_target_names(self, target: ast.AST) -> set[str]:
352+
if isinstance(target, ast.Name):
353+
return {target.id}
354+
if isinstance(target, ast.Starred):
355+
return self._assignment_target_names(target.value)
356+
if isinstance(target, (ast.Tuple, ast.List)):
357+
return set().union(*(self._assignment_target_names(element) for element in target.elts))
358+
return set()
359+
317360
def _merge_alias_states(self, states: list[_AliasState]) -> None:
318361
"""Conservatively retain every module value reachable from a branch."""
319362
names = set().union(*(set(aliases) | shadowed for aliases, shadowed in states))
@@ -414,6 +457,7 @@ def _visit_loop(self, node: ast.For | ast.AsyncFor | ast.While) -> None:
414457
else:
415458
self.visit(node.iter)
416459
self.visit(node.target)
460+
self._bind_iterated_target(node.target, node.iter)
417461

418462
for statement in node.body:
419463
self.visit(statement)
@@ -437,6 +481,35 @@ def visit_AsyncFor(self, node: ast.AsyncFor):
437481
def visit_While(self, node: ast.While):
438482
self._visit_loop(node)
439483

484+
def _visit_comprehension_expression(
485+
self, generators: list[ast.comprehension], expressions: tuple[ast.AST, ...]
486+
) -> None:
487+
"""Visit comprehensions in evaluation order with isolated target bindings."""
488+
enclosing_state = self._snapshot_alias_state()
489+
target_names: set[str] = set()
490+
for generator in generators:
491+
self.visit(generator.iter)
492+
self.visit(generator.target)
493+
self._bind_iterated_target(generator.target, generator.iter)
494+
target_names.update(self._assignment_target_names(generator.target))
495+
for condition in generator.ifs:
496+
self.visit(condition)
497+
for expression in expressions:
498+
self.visit(expression)
499+
self._restore_target_names(target_names, enclosing_state)
500+
501+
def visit_ListComp(self, node: ast.ListComp):
502+
self._visit_comprehension_expression(node.generators, (node.elt,))
503+
504+
def visit_SetComp(self, node: ast.SetComp):
505+
self._visit_comprehension_expression(node.generators, (node.elt,))
506+
507+
def visit_DictComp(self, node: ast.DictComp):
508+
self._visit_comprehension_expression(node.generators, (node.key, node.value))
509+
510+
def visit_GeneratorExp(self, node: ast.GeneratorExp):
511+
self._visit_comprehension_expression(node.generators, (node.elt,))
512+
440513
def _shadow_arguments(self, arguments: ast.arguments) -> None:
441514
positional = [*arguments.posonlyargs, *arguments.args, *arguments.kwonlyargs]
442515
for argument in positional:

src/backend/tests/unit/agentic/helpers/test_code_security.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,38 @@ def test_should_allow_parameter_shadowing_module_name(self):
620620
result = scan_code_security("import os\ndef use_safe_object(os):\n os.system('not the os module')")
621621
assert result.is_safe is True
622622

623+
@pytest.mark.parametrize(
624+
"code",
625+
[
626+
"import os\nfor module in (os,):\n module.system('id')",
627+
"import os\nasync def run():\n async for module in (os,):\n module.system('id')",
628+
"import os\n[module.system('id') for module in (os,)]",
629+
"import os\n{module.system('id') for module in (os,)}",
630+
"import os\n{module: module.system('id') for module in (os,)}",
631+
"import os\n(module.system('id') for module in (os,))",
632+
],
633+
)
634+
def test_should_detect_iterated_module_alias_call(self, code):
635+
result = scan_code_security(code)
636+
assert result.is_safe is False
637+
assert any("os.system()" in violation for violation in result.violations)
638+
639+
def test_should_detect_destructured_loop_target_alias_call(self):
640+
result = scan_code_security("import os\nfor (module,) in ((os,),):\n module.system('id')")
641+
assert result.is_safe is False
642+
assert any("os.system()" in violation for violation in result.violations)
643+
644+
def test_should_not_leak_comprehension_target_alias(self):
645+
code = "import os\nmodule = object()\n[module for module in (os,)]\nmodule.system('not the os module')"
646+
result = scan_code_security(code)
647+
assert result.is_safe is True
648+
649+
def test_should_preserve_named_expression_alias_from_comprehension(self):
650+
code = "import os\n[(module := os) for _ in (None,)]\nmodule.system('id')"
651+
result = scan_code_security(code)
652+
assert result.is_safe is False
653+
assert any("os.system()" in violation for violation in result.violations)
654+
623655
def test_should_detect_alias_after_zero_iteration_for_loop(self):
624656
code = """
625657
import os

0 commit comments

Comments
 (0)