fix: harden syft-restrict against verifier and obfuscator bypasses#9436
Conversation
…or f-string conversions
… scope checks Fix ast.comprehension check relying on unavailable lineno, therefore unreachable code.
…handling and range errors
0213862 to
fc54f9d
Compare
There was a problem hiding this comment.
Pull request overview
This PR strengthens syft-restrict’s static verification and obfuscation layers to close multiple identified bypass techniques, adds regression tests for each bypass class, and refreshes the documentation to match the evolved threat model and implementation.
Changes:
- Harden verifier rules around call-target resolution, aliasing,
self/clstrust, and f-string interpolation to prevent reflection/dynamic-escape bypasses. - Update obfuscation to correctly blank f-string literal text under Python 3.12+ (PEP 701 tokenization), plus refactor shared AST/range utilities into
astutil.py. - Add targeted regression tests (bypasses + range validation) and restructure docs/README for clarity.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/syft-restrict/tests/verify/test_whitelist.py | Updates whitelist expectations for f-strings to reflect new interpolation rejection behavior. |
| packages/syft-restrict/tests/verify/test_ranges.py | Adds regression test ensuring malformed private ranges raise instead of silently passing. |
| packages/syft-restrict/tests/verify/test_bypasses.py | Adds comprehensive regression coverage for verifier bypass classes (aliasing, reflection, f-strings, self/cls trust, etc.). |
| packages/syft-restrict/tests/obfuscate/test_obfuscate.py | Adds a test ensuring f-string literal text is blanked across tokenizer variants (incl. Python 3.12+). |
| packages/syft-restrict/src/syft_restrict/verifier.py | Major verifier hardening + refactor: central violation codes, stronger call/name checks, self-attr trust tracking, and f-string interpolation rejection. |
| packages/syft-restrict/src/syft_restrict/runner.py | Switches to shared astutil helpers for range normalization and scanning. |
| packages/syft-restrict/src/syft_restrict/policy.py | Expands banned builtins and improves documentation/comments; minor cleanup. |
| packages/syft-restrict/src/syft_restrict/obfuscator.py | Refactors passes; adds PEP 701 f-string token handling; uses shared astutil. |
| packages/syft-restrict/src/syft_restrict/astutil.py | New shared helper module for AST and range utilities (scan, dotted paths, range normalization). |
| packages/syft-restrict/src/syft_restrict/init.py | Updates package-level docs/pointers to new docs structure. |
| packages/syft-restrict/README.md | Simplifies and reorients README toward usage + docs pointers. |
| packages/syft-restrict/docs/verify.md | New/expanded explanation of verifier behavior, edge cases, and limitations. |
| packages/syft-restrict/docs/code-layout.md | New short guide to module responsibilities. |
| packages/syft-restrict/docs/blacklist.md | New consolidated “what is rejected” doc with violation codes. |
| packages/syft-restrict/disallowed-ast-examples.md | Removes legacy table-style doc (replaced by new docs structure). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
| | ----------------- | ---------------------------------------------------------- | | ||
| | Definitions | `def`, `class`, `lambda`, `return` | | ||
| | Names & constants | variables, numbers, strings (not f-strings) | | ||
| | Assignment | `=`, `+=`, annotated assigns | |
There was a problem hiding this comment.
is this always allowed? also reassigning self for instance?
There was a problem hiding this comment.
Assignment is always allowed as a construct, it's not banned in itself, but name and target are checked separately from the assignment. The name must not be reserved, the target must be allowed, and the assignment resets the later call-trust of the target.
I will add a line clarifying that too.
| | Definitions | `def`, `class`, `lambda`, `return` | | ||
| | Names & constants | variables, numbers, strings (not f-strings) | | ||
| | Assignment | `=`, `+=`, annotated assigns | | ||
| | Containers | `list`, `tuple`, `dict`, `set`, comprehensions | |
There was a problem hiding this comment.
I guess the note here is that they can only nest allowed syntax?
There was a problem hiding this comment.
Yes. An allowed outer construct does not grant permission to its inner pieces. Each node is verified independently. I will make that clearer in the document.
|
|
||
| --- | ||
|
|
||
| ## Per-file knobs |
There was a problem hiding this comment.
maybe its me, but I am not familiar with the term "knob"
There was a problem hiding this comment.
Good point. I'll change the section title to "Per-file policy configuration", matching the terms used in the rest of the project.
| | Private defs/classes | `Attention`, `helper` | | ||
| | Safe builtins | `list`, `range` | | ||
| | `self` / `cls` | only as the real first parameter of a method | | ||
|
|
There was a problem hiding this comment.
how about the result of x =transpose(y), are we allowed to reassign x?
There was a problem hiding this comment.
Yes. Ordinary locals can be reassigned at will. Only reserved names are protected.
However, the verifier still tracks the source to attempt to resolve and reject if the name is used as a callable.
I'll add a line to clarify that too.
There was a problem hiding this comment.
I think reassignment is tricky though. Because of control flow we never know whether something actually was reassignment. So instead of tracking the "current" value of something (which we cant), we should track all assignments and check if they are all safe.
There was a problem hiding this comment.
Yes, we can't track dynamic branching, but we still block everything at the source. It gets rejected in some way.
For example:
if c: g = safe
else: g = unsafe
g()
unsafe is visited last, so in theory this case gets rejected with call-unresolved.
But if we flip it:
if c: g = unsafe
else: g = safe
g()
In this safe is visited last, so in theory it's not rejected, even though it should have.
However, the false negative is not exploitable anyway because defining unsafe with something like a banned name or other unsafe construct would have triggered another error at the origin, so while the assignment tracking theoretically fails in this case, it's inconsequential in practice.
|
|
||
| ```python | ||
| class Net(nn.Module): # base must be allow-listed (e.g. flax.linen.Module) | ||
| def setup(self): # often public (data owner can read wiring) |
There was a problem hiding this comment.
Now that I read this, perhaps this is the weakest part of our setup. We assume that setup acts like a init function, something a variable is assigned here and never reassigned. But that is very jax specific, and how do we even know that setup is always called (if it wouldnt be jax)
There was a problem hiding this comment.
That's a matter of correctness to be resolved at runtime, not security. In the static analysis, the self.attr() calls in __call__ only pass if they were assigned a safe target somewhere in the class, or if they were not assigned anywhere and therefore presumed to come from the base class.
Order doesn't matter. The verifier collects all assignments first and requires all of them to be vetted. If another method reassigns them between setup and __call__, that reassignment needs to be vetted too. If setup doesn't run before __call__, it's a runtime AttributeError, not a bypass. It's not the verifier's job to check for that.
We can flip the rule to default-deny any self.attr that wasn't explicitly assigned in the class, so the assumption that undefined attributes come from the base class no longer holds. It's a stricter rule, but breaks access to base class attributes.
There was a problem hiding this comment.
yes I was thinking about something similar, or we could let the user pass in certain fields that can be used but not reassigned or something like that. Perhaps out of scope for this PR
| | ---------------------------------------------------------------------------- | ----------------------------------------------------- | | ||
| | Bases that resolve to an allow-listed path (e.g. `nn.Module`) | `object`, random private bases, non-allow-listed libs | | ||
| | Decorators: `nn.compact`, `jax.jit`, `jax.named_scope`, `flax.linen.compact` | `@property`, `@staticmethod`, arbitrary functions | | ||
| | Defining `setup`, `__call__`, `__post_init__` | `__getattr__`, `__reduce__`, other magic methods | |
There was a problem hiding this comment.
do we need post_init?
There was a problem hiding this comment.
We already block __init__. If we block __post_init__ too, there's no hook for initializing dataclasses at construction time. I think there's a case for leaving it in, as convenience.
| | Allowed | Not allowed | | ||
| | ---------------------------------------------------------------------------- | ----------------------------------------------------- | | ||
| | Bases that resolve to an allow-listed path (e.g. `nn.Module`) | `object`, random private bases, non-allow-listed libs | | ||
| | Decorators: `nn.compact`, `jax.jit`, `jax.named_scope`, `flax.linen.compact` | `@property`, `@staticmethod`, arbitrary functions | |
There was a problem hiding this comment.
I think in the end we are not using any of these anymore. perhaps we should just disallow them?
There was a problem hiding this comment.
Yes. I'll just ban the decorator node entirely, since those were the only decorators allowed.
Anyone who wants decorator behavior should make an explicit call instead. I'll document that.
|
|
||
| ```python | ||
| # library path (imports public, use private) | ||
| import jax.numpy as jnp |
There was a problem hiding this comment.
only if allowlisted
| @@ -0,0 +1,352 @@ | |||
| # How verification works | |||
There was a problem hiding this comment.
this is high quality now! It helps me reason over the logic and find edge cases we may not have covered. Great job!
|
|
||
|
|
||
| class Block(nn.Module): | ||
| cfg: dict |
There was a problem hiding this comment.
I didnt think about this yet, is this allowed? is it secure?
There was a problem hiding this comment.
It's secure, keeping the documented limitations in mind. Malicious public wrappers still depend on human review.
However, it's still a gap because the verifier doesn't catch potential abuse of a declared field like that. It's not a security issue, since you still need malicious public code to abuse it, but it's inconsistent when compared to how we handle similar implicit assignments.
I will close this gap too. self.cfg() in that case shouldn't be trusted.
| _MARKER_RE = re.compile(r"^#\s*syft-restrict:\s*(obfuscate|hide)(?:-(start|end))?\s*$") | ||
|
|
||
|
|
||
| def parse_markers(source: str) -> tuple[list[tuple[int, int]], list[tuple[int, int]]]: |
There was a problem hiding this comment.
some comments here would help me or otherwise split it up in functions
There was a problem hiding this comment.
I modified the structure to use a stack to handle the start/end and nesting and simplified the implementation. I think it's easier to understand now.
Rename the method to a clearer enforcement verb and extract named booleans (name_reserved/name_rebound) in both branches for readability.
| return | ||
|
|
||
| # methods are not bare-call targets and therefore not shadowed; shared names like `setup`` are fine | ||
| if isinstance(node, ast.FunctionDef) and id(node) in self.scan.method_ids: |
There was a problem hiding this comment.
perhaps nitpick and not dangerous, but it looks like you might be able to define method x on class y twice because we are not checking that
There was a problem hiding this comment.
Yes. It's not exploitable in any way that matters for the security model, but they might be confusing in obfuscated code. I'll fix it.
|
|
||
| # The only dunder/hook methods a model class may *define* (approach-B §3.1 #6). | ||
| # The only dunder/hook methods a model class may *define* (docs/verify.md#allow_functions--paths-callable-by-name). | ||
| ALLOWED_DUNDER_DEFS: frozenset[str] = frozenset({"__call__", "setup", "__post_init__"}) |
There was a problem hiding this comment.
I think we can remove post init
| # access (`block.w`), the same attribute-access-hook class banned in §3.1 #6, so default-deny | ||
| # access (`block.w`), the same attribute-access-hook class banned for dunder defs, so default-deny | ||
| # rejects it. Pure inference needs only setup/__call__. | ||
| ALLOWED_DECORATORS: frozenset[str] = frozenset( |
There was a problem hiding this comment.
we are not using these, remove
…d add corresponding test
| root = path.split(".")[0] | ||
| if root in ("self", "cls"): | ||
| # a single self.<name>/cls.<name> read is fine; a deeper chain is not | ||
| if self_attr_name(node) is not None: |
There was a problem hiding this comment.
I think we may want to consider making this more strict. I think its only fine to call that if we defined it ourselves, so we know the getattr doesnt create side effects
There was a problem hiding this comment.
That will prevent using attrs inherited from the base class. It's the same question I asked above.
| attr = self_attr_name(value.value) | ||
| if attr is not None: | ||
| return self._self_attr.is_safe(attr, self._enclosing_class()) | ||
| return _all_leaves_safe(value, self._is_safe_local_leaf) |
There was a problem hiding this comment.
I dont have an answer to this question yet, but do we need any asignment that not an attribute?
There was a problem hiding this comment.
No, we don't strictly need it. It's a trade-off between convenience and attack surface. If we remove that, we lose any legitimate alias-then-call pattern, like this:
block = self.layers[0]
return block(x)
Because block(x) is trusted only because block was tracked as safe.
Without local assignments, you'd have to do:
return self.layers[0](x)
I don't think the trade-off is worth it. The reduced attack surface here is minimal. The local aliased call must still resolve to a safe callable.
| else: | ||
| safe.pop(t.id, None) | ||
|
|
||
| def _is_safe_local_source(self, value: ast.AST) -> bool: |
There was a problem hiding this comment.
we are calling this function from _track_safe_local after we already skipped anything that is not an ast.Name, like ast.Attribute, so in that context value here can only be an ast.Name. However, we are calling functions that sound like they should be applied to an attribute. (self_attr_name), this is confusing me a bit
There was a problem hiding this comment.
No, there's target and value. The Name filter is for the target name, the variable being assigned to. The value is the one being assigned and need to be tracked as safe.
I think it could be confusing because we have to iterate possible chained assignments, like x = y = value. I will add some comments clarifying that.
Summary
Closes a series of bypasses in
syft-restrict's static verifier and obfuscator, found throughiterative adversarial review. Update documentation.
Changes
type,__build_class__,print, and thedunder-proxy builtins
repr/str/ascii/format/bytes.from X import name, homoglyph identifiers, andcommon identifier-aliasing patterns that evaded the call-target checks.
!r/!s/!a), the{x=}debug form, and plaininterpolation (
f"{x}") all invoke__format__/__repr__/__str__with noCallnode — allare now rejected instead of only the conversion-flag forms.
self/clsparameter vetting: reassigningself/cls, reusing them as an unrelatedparameter name, and non-first-parameter/nested-function/lambda cases no longer grant the
self.<name>trust exemption.self.<attr>trust bypasses: tuple-unpack and for-loop assignment targets are now trackedby the safety table, and local-variable aliasing (including multi-hop copies) is tracked so
tmp = self.fn; tmp(x)is checked the same wayself.fn(x)is.def/classstatements whose name shadows a trusted import alias or visible wrapper name(previously only rebinding via assignment was checked).
tokenizer (previously left un-obfuscated on 3.12+).
docs/disallowed-ast-examples.mdas per-construct subheadings instead of a wide table;add
docs/code-layout.md; simplifyREADME.md.astutil.pyand refactorverifier.py/obfuscator.pyforreadability (central violation-code registry,
_SelfAttrTrusthelper, split obfuscator passes),with no behavior change.
Testing
tests/verify/test_bypasses.pywith a regression test per bypass classtests/verify/test_ranges.pyfor the range-validation fix.Asana task
https://app.asana.com/1/1185126988600652/project/1216249688888494/task/1216266561077080?focus=true