Skip to content

Commit fc54f9d

Browse files
committed
Add alias tracking for self attributes to prevent trust bypasses
1 parent 5cc5267 commit fc54f9d

2 files changed

Lines changed: 140 additions & 0 deletions

File tree

packages/syft-restrict/src/syft_restrict/verifier.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,11 @@ def __init__(self, policy: Policy, scan: FileScan, ranges):
197197
# _mark_annotation_subtrees; exempt from the name/container checks in _check_name and
198198
# _check_container_literal because annotations are never invoked (see visit).
199199
self._annotation_nodes: set[int] = set()
200+
# One {local_name: self_attr_name} dict per enclosing class/function/lambda scope (mirrors
201+
# _scope_stack's push/pop), tracking which local variables currently alias a self.<attr> --
202+
# so `tmp = self.fn; tmp(x)` is checked the same way `self.fn(x)` directly is; see
203+
# _track_self_attr_alias and its use in _check_call.
204+
self._alias_stack: list[dict[str, str]] = []
200205

201206
def report(self, node: ast.AST, code: ViolationCode, message: str) -> None:
202207
self.violations.append(
@@ -212,13 +217,15 @@ def visit(self, node: ast.AST) -> None:
212217
is_scope = isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.Lambda))
213218
if is_scope:
214219
self._scope_stack.append(node)
220+
self._alias_stack.append({})
215221
self._mark_annotation_subtrees(node)
216222

217223
for child in ast.iter_child_nodes(node):
218224
self.visit(child)
219225

220226
if is_scope:
221227
self._scope_stack.pop()
228+
self._alias_stack.pop()
222229

223230
def _enclosing_class(self) -> ast.ClassDef | None:
224231
"""The nearest enclosing ClassDef, skipping any FunctionDef/Lambda frames above it."""
@@ -408,6 +415,18 @@ def _check_call(self, node: ast.Call) -> None:
408415
f"call to {self._resolve(func.id)!r} is not allow-listed",
409416
)
410417
return
418+
aliased_attr = self._current_aliases().get(func.id)
419+
if aliased_attr is not None and not self._self_attr.is_safe(
420+
aliased_attr, self._enclosing_class()
421+
):
422+
self.report(
423+
node,
424+
"attr-on-value",
425+
f"{func.id!r} was assigned from self.{aliased_attr!r}, which isn't an "
426+
f"allow-listed constructor or a locally-defined class; calling it is not "
427+
f"allowed",
428+
)
429+
return
411430
# Otherwise a bare-name call (local var / hidden or visible def / safe builtin) is allowed.
412431
return
413432
if isinstance(func, ast.Attribute):
@@ -608,6 +627,49 @@ def _check_assign_targets(self, node) -> None:
608627
"banned-construct",
609628
"storing a reference to a banned builtin into a container slot is not allowed",
610629
)
630+
self._track_self_attr_alias(node)
631+
632+
def _track_self_attr_alias(self, node) -> None:
633+
# Detect `tmp = self.<attr>` / `tmp = self.<attr>[i]` / `tmp = <already-tracked alias>` so a
634+
# later `tmp(...)` call in _check_call is checked the same way calling self.<attr> directly
635+
# would be -- otherwise reading an unsafe self.<attr> into a local variable trivially
636+
# defeats _SelfAttrTrust (self.<attr> reads are always allowed; calling a local variable is
637+
# always allowed). Any other assignment to a previously-tracked name clears its alias -- it
638+
# no longer refers to that self.<attr>. AugAssign always clears (no value to trace here).
639+
if not self._alias_stack:
640+
return
641+
aliases = self._alias_stack[-1]
642+
if isinstance(node, ast.Assign):
643+
targets, value = node.targets, node.value
644+
elif isinstance(node, ast.AnnAssign):
645+
targets, value = ([node.target] if node.value is not None else []), node.value
646+
else: # AugAssign
647+
targets, value = [node.target], None
648+
for t in targets:
649+
if not isinstance(t, ast.Name):
650+
continue
651+
attr = self._self_attr_source(value) if value is not None else None
652+
if attr is not None:
653+
aliases[t.id] = attr
654+
else:
655+
aliases.pop(t.id, None)
656+
657+
def _self_attr_source(self, value: ast.AST) -> str | None:
658+
"""The self.<attr> a plain expression traces back to: a direct self.<attr> read, a
659+
single-level self.<attr>[i] subscript, or a copy of an already-tracked local alias."""
660+
attr = self_attr_name(value)
661+
if attr is not None:
662+
return attr
663+
if isinstance(value, ast.Subscript) and isinstance(value.value, ast.Attribute):
664+
attr = self_attr_name(value.value)
665+
if attr is not None:
666+
return attr
667+
if isinstance(value, ast.Name):
668+
return self._current_aliases().get(value.id)
669+
return None
670+
671+
def _current_aliases(self) -> dict[str, str]:
672+
return self._alias_stack[-1] if self._alias_stack else {}
611673

612674
def _check_reserved_target(self, target: ast.AST) -> None:
613675
# Guards against: rebinding self/cls (which would forge the identifier-based trust — see

packages/syft-restrict/tests/verify/test_bypasses.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,3 +568,81 @@ def test_self_attr_for_loop_target_must_not_grant_trust(verify_all):
568568
" return x",
569569
]
570570
assert "attr-on-value" in error_codes(verify_all("\n".join(src)))
571+
572+
573+
def test_self_attr_local_alias_must_not_grant_trust(verify_all):
574+
# Copying self.<attr> into a local variable before calling it must not defeat _SelfAttrTrust --
575+
# reading self.<attr> is unconditionally allowed, and calling a local variable is
576+
# unconditionally allowed, so without alias tracking this trivially bypasses the same check
577+
# that correctly rejects calling self.fn directly.
578+
src = [
579+
"class M(object):",
580+
" def setup(self):",
581+
" self.fn = lambda x: x + 1",
582+
" def __call__(self, x):",
583+
" tmp = self.fn",
584+
" return tmp(x)",
585+
]
586+
assert "attr-on-value" in error_codes(verify_all("\n".join(src)))
587+
588+
589+
def test_self_attr_subscript_local_alias_must_not_grant_trust(verify_all):
590+
# Same bypass via the Flax self.layer[i](x) idiom: copy the element to a local first. Uses an
591+
# opaque parameter (not a BANNED_NAMES literal like `open`) so the only thing standing between
592+
# this and a violation is the self-attr trust check the alias is meant to defeat.
593+
src = [
594+
"class M(object):",
595+
" def setup(self, cb):",
596+
" self.layer = [cb]",
597+
" def __call__(self, x):",
598+
" tmp = self.layer[0]",
599+
" return tmp(x)",
600+
]
601+
assert "attr-on-value" in error_codes(verify_all("\n".join(src)))
602+
603+
604+
def test_self_attr_two_hop_local_alias_must_not_grant_trust(verify_all):
605+
# A second-hop copy (tmp2 = tmp) must not escape alias tracking either, mirroring the
606+
# multi-hop rigor already applied to BANNED_NAMES aliasing (test_homoglyph_two_hop_alias).
607+
src = [
608+
"class M(object):",
609+
" def setup(self):",
610+
" self.fn = lambda x: x + 1",
611+
" def __call__(self, x):",
612+
" tmp = self.fn",
613+
" tmp2 = tmp",
614+
" return tmp2(x)",
615+
]
616+
assert "attr-on-value" in error_codes(verify_all("\n".join(src)))
617+
618+
619+
def test_self_attr_reassigned_local_alias_is_not_stuck_tainted(verify_all):
620+
# Reassigning a variable that once aliased an unsafe self.<attr> to something else entirely
621+
# must clear the taint -- tracking must reflect the CURRENT value, not the first one ever seen.
622+
src = [
623+
"class M(object):",
624+
" def setup(self):",
625+
" self.fn = lambda x: x + 1",
626+
" def __call__(self, x):",
627+
" tmp = self.fn",
628+
" tmp = x",
629+
" return tmp",
630+
]
631+
result = verify_all("\n".join(src))
632+
assert result.ok, [(v.code, v.message) for v in result.violations]
633+
634+
635+
def test_self_attr_local_alias_of_safe_source_is_still_allowed(verify_all):
636+
# A local alias of a self.<attr> that WAS assigned a vetted-safe source (or is presumed
637+
# inherited, since the class never assigns it) must still be callable -- alias tracking must
638+
# track safety, not blanket-ban aliasing self.<attr> outright (see
639+
# test_calling_a_value_is_allowed in test_whitelist.py, which already covers this shape and
640+
# must keep passing).
641+
src = [
642+
"class M(object):",
643+
" def __call__(self, x):",
644+
" layer = self.layers[0]",
645+
" return layer(x)",
646+
]
647+
result = verify_all("\n".join(src))
648+
assert result.ok, [(v.code, v.message) for v in result.violations]

0 commit comments

Comments
 (0)