Harden sandbox exec/eval analysis: assigned-container sinks; shadowed fold helpers; namespace-dict writes; caller-alias payloads; unknown PATH vars
Close five bypasses Codex found on the round-49 branch (all in the static
exec/eval analyzer, plus one PATH case).
- assigned-container sink: a subscript into a container bound to a single-
assignment NAME (d = {'e': exec}; d['e'](payload), xs = [eval]; xs[0](...))
reached exec/eval, but the container resolvers only handled INLINE literals, so
the Name form returned None and the payload was never scanned. Resolve a Name
container through the const-prop env in the exec / deserialize / shell-sink
resolvers (the last also caught d = [os.system]; d[0]('rm -rf /')).
- shadowed fold helper: the constant folder called the real builtin / stdlib
module even when the snippet rebinds the name, so
str = lambda _: "__import__('os').system('touch /tmp/x')"; eval(str(1)) folded
through the real str and was marked safe. Track names rebound away from their
canonical builtin / module (assignment, def, param, from-import, aliased import)
and refuse to fold them, leaving the payload opaque -> eval/exec fails closed. A
plain `import base64` keeps the canonical module and still folds.
- namespace-dict write: const-prop only tracked Name stores, so
x = '2+2'; globals()['x'] = BAD; eval(x) folded x as the safe literal. Invalidate
a name written through globals()/vars()/locals()[key] = ... (constant key), and
fail closed on a dynamic key or a bulk update()/setdefault()/__setitem__.
- caller-alias payload: exec()/eval() run in the CALLER namespace, but the payload
was scanned as a fresh module, so import os; f = os.system; exec("f('rm -rf /')")
saw f as unknown and passed. When a payload FREE name resolves, in the caller
scope, to a shell / exec / deserialize / import alias, fail closed. A payload that
references only builtins (exec("print(1)")) or binds its own names stays allowed.
- unknown PATH variable: in the sandbox an unset $VAR expands to EMPTY, so
PATH=$EVIL is an empty component that makes the shell search the cwd; a snippet
can drop a local executable and run os.system('PATH=$EVIL evil'). Model an
unknown/unset $VAR in a PATH entry as empty and fail closed when the entry then
collapses to an empty or relative path; $PATH and an entry that stays absolute
($CONDA_PREFIX/bin -> /bin) are still trusted.
Regression coverage: TestRound50Bypasses in tests/test_sandbox_tools.py (assigned
container exec/eval + shell sinks, shadowed str/chr fold, globals/vars/update
invalidation, exec/eval caller alias, PATH=$UNKNOWN) plus a round50 benign-allowed
set (safe container callees, normal builtin/module folds, a namespace read, a safe
caller alias, and PATH with trusted absolute entries).
This commit is contained in:
parent
afc84773dc
commit
01f96315ca
2 changed files with 297 additions and 3 deletions
|
|
@ -393,6 +393,22 @@ def _path_var_resolves_unsafe(var, assignments):
|
|||
return False
|
||||
|
||||
|
||||
def _path_var_is_unknown_external(var, assignments):
|
||||
"""True when ``var`` is neither a workdir alias (HOME/PWD), the trusted inherited PATH, nor a
|
||||
variable assigned earlier in the same command. In the sandbox such a variable is UNSET, so its
|
||||
expansion is EMPTY -- not a trusted absolute path."""
|
||||
return var not in ("HOME", "PWD", "PATH") and not (assignments and var in assignments)
|
||||
|
||||
|
||||
def _path_entry_empty_expansion_unsafe(entry: str, var: str) -> bool:
|
||||
"""Model an unknown/unset ``$var`` in a PATH ENTRY as EMPTY (the sandbox reality) and report
|
||||
whether the entry then collapses to an empty or RELATIVE path (both search the cwd). A bare
|
||||
``$EVIL`` -> ``''`` and ``${X}bin`` -> ``bin`` are unsafe; ``$CONDA_PREFIX/bin`` -> ``/bin``
|
||||
stays absolute and is safe."""
|
||||
blanked = re.sub(r"\$\{?" + re.escape(var) + r"\}?", "", entry)
|
||||
return blanked == "" or not blanked.startswith(("/", "%"))
|
||||
|
||||
|
||||
def _path_value_is_unsafe(value: str, assignments = None) -> bool:
|
||||
"""True when a PATH search list would let a BARE (no-slash) command resolve to a workdir
|
||||
executable: any entry that is ``.``, empty (``:`` = cwd), a relative directory, or one that
|
||||
|
|
@ -426,12 +442,25 @@ def _path_value_is_unsafe(value: str, assignments = None) -> bool:
|
|||
var = re.split(r"[/}]", inner, maxsplit = 1)[0]
|
||||
if _path_var_resolves_unsafe(var, assignments):
|
||||
return True
|
||||
if _path_var_is_unknown_external(var, assignments) and _path_entry_empty_expansion_unsafe(
|
||||
e, var
|
||||
):
|
||||
return True
|
||||
continue
|
||||
if e.startswith("$"):
|
||||
m = re.match(r"\$([A-Za-z_][A-Za-z0-9_]*)", e)
|
||||
if m and _path_var_resolves_unsafe(m.group(1), assignments):
|
||||
return True
|
||||
continue # $PATH / $CONDA_PREFIX / $1: assume a trusted absolute expansion
|
||||
# An unknown/unset $VAR expands to EMPTY in the sandbox, so a bare `$EVIL` (or one that
|
||||
# leaves a relative remainder, `${X}bin`) collapses the entry to the cwd; only an entry
|
||||
# that stays ABSOLUTE with the var blanked ($CONDA_PREFIX/bin -> /bin) is trusted.
|
||||
if (
|
||||
m
|
||||
and _path_var_is_unknown_external(m.group(1), assignments)
|
||||
and _path_entry_empty_expansion_unsafe(e, m.group(1))
|
||||
):
|
||||
return True
|
||||
continue # $PATH / $CONDA_PREFIX/bin / $1: a trusted absolute expansion
|
||||
if e.startswith(("/", "%")):
|
||||
continue
|
||||
return True # a relative directory (relbin, ./tools)
|
||||
|
|
@ -3618,14 +3647,27 @@ _FOLD_MAXINT = 1 << 64
|
|||
_UNKNOWN = object() # sentinel: "not statically decidable"
|
||||
|
||||
|
||||
class _ConstEnv(dict):
|
||||
"""A const-prop env (name -> RHS node) that also carries the set of names REBOUND away from
|
||||
their canonical builtin / stdlib module in the snippet, so the folder can refuse to fold a
|
||||
shadowed helper (str = lambda _: '...'; eval(str(1))) as the real builtin."""
|
||||
|
||||
__slots__ = ("shadowed",)
|
||||
|
||||
def __init__(self, *a, shadowed = None, **k):
|
||||
super().__init__(*a, **k)
|
||||
self.shadowed = shadowed or frozenset()
|
||||
|
||||
|
||||
class _FoldState:
|
||||
"""Shared op counter + single-assignment const-prop environment."""
|
||||
|
||||
__slots__ = ("ops", "names")
|
||||
__slots__ = ("ops", "names", "shadowed")
|
||||
|
||||
def __init__(self, names = None):
|
||||
self.ops = 0
|
||||
self.names = names or {}
|
||||
self.shadowed = getattr(names, "shadowed", None) or frozenset()
|
||||
|
||||
|
||||
def _fold_cap(value):
|
||||
|
|
@ -4001,6 +4043,10 @@ def _fold_call(node, _state, _depth):
|
|||
name = f.id
|
||||
if name not in _FOLD_PURE_BUILTINS:
|
||||
return None
|
||||
# A snippet that rebinds the builtin name (str = lambda _: '...'; eval(str(1))) makes the
|
||||
# real-builtin fold diverge from runtime; refuse so the payload stays opaque (fail closed).
|
||||
if name in _state.shadowed:
|
||||
return None
|
||||
try:
|
||||
if name == "chr":
|
||||
if len(args) == 1 and isinstance(args[0], int) and 0 <= args[0] <= 0x10FFFF:
|
||||
|
|
@ -4069,6 +4115,10 @@ def _fold_call(node, _state, _depth):
|
|||
return None
|
||||
if isinstance(owner, ast.Name):
|
||||
mod = owner.id
|
||||
# A rebound module receiver (base64 = <fake>; eval(base64.b64decode('...'))) would fold
|
||||
# through the real stdlib module while runtime uses the user binding; refuse the fold.
|
||||
if mod in _state.shadowed:
|
||||
return None
|
||||
try:
|
||||
if mod == "base64" and attr in _FOLD_B64_FUNCS and len(args) >= 1:
|
||||
return _fold_cap(getattr(base64, attr)(args[0]))
|
||||
|
|
@ -4203,6 +4253,45 @@ def _build_const_prop_env(tree):
|
|||
if extra is not None:
|
||||
disqualified.add(extra.arg)
|
||||
|
||||
# A write THROUGH the namespace dict (globals()['x'] = BAD, vars()['x'] = BAD, locals()[...]
|
||||
# = ..., or globals().update(...) / .setdefault(...) / .__setitem__(...)) mutates a module
|
||||
# variable with NO Name Store, so a folded constant would be stale and the recovered exec/eval
|
||||
# payload wrong. Invalidate the affected name (constant key) or, for a dynamic key / bulk
|
||||
# update, every recorded name -- the snippet is manipulating the namespace opaquely.
|
||||
def _is_namespace_call(nv):
|
||||
return (
|
||||
isinstance(nv, ast.Call)
|
||||
and isinstance(nv.func, ast.Name)
|
||||
and nv.func.id in ("globals", "vars", "locals")
|
||||
)
|
||||
|
||||
_ns_write_all = False
|
||||
_ns_write_names: set[str] = set()
|
||||
for n in ast.walk(tree):
|
||||
# globals()[key] = ... (Assign target or AugAssign target).
|
||||
_subs = []
|
||||
if isinstance(n, ast.Assign):
|
||||
_subs = [t for t in n.targets if isinstance(t, ast.Subscript)]
|
||||
elif isinstance(n, (ast.AugAssign, ast.AnnAssign)):
|
||||
if isinstance(getattr(n, "target", None), ast.Subscript):
|
||||
_subs = [n.target]
|
||||
for _t in _subs:
|
||||
if not _is_namespace_call(_t.value):
|
||||
continue
|
||||
_key = _t.slice.value if isinstance(_t.slice, ast.Constant) else None
|
||||
if isinstance(_key, str):
|
||||
_ns_write_names.add(_key)
|
||||
else:
|
||||
_ns_write_all = True
|
||||
# globals().update(...) / .setdefault(...) / .__setitem__(...) -- an opaque bulk write.
|
||||
if (
|
||||
isinstance(n, ast.Call)
|
||||
and isinstance(n.func, ast.Attribute)
|
||||
and n.func.attr in ("update", "setdefault", "__setitem__", "pop", "clear")
|
||||
and _is_namespace_call(n.func.value)
|
||||
):
|
||||
_ns_write_all = True
|
||||
|
||||
# Count how many module-level stores each recorded name really has; if more
|
||||
# than one Store target references it anywhere, drop it.
|
||||
store_counts: dict[str, int] = {}
|
||||
|
|
@ -4210,7 +4299,61 @@ def _build_const_prop_env(tree):
|
|||
if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Store):
|
||||
store_counts[n.id] = store_counts.get(n.id, 0) + 1
|
||||
|
||||
env = {}
|
||||
# Names REBOUND to something OTHER than their canonical builtin / stdlib module: a fold that
|
||||
# calls the real builtin (str(...), len(...)) or hard-coded module (base64.b64decode(...))
|
||||
# would diverge from runtime, which calls the user binding. A plain `import name` keeps the
|
||||
# canonical module (NOT shadowing); every other binding -- assignment, def/class, param,
|
||||
# from-import, an aliased import that rebinds the name, or a loop/with/except/comprehension
|
||||
# target -- is. The folder consults this set before folding a Name builtin / module receiver.
|
||||
shadowed: set[str] = set()
|
||||
|
||||
def _shadow_targets(t):
|
||||
for nn in ast.walk(t):
|
||||
if isinstance(nn, ast.Name) and isinstance(nn.ctx, (ast.Store, ast.Del)):
|
||||
shadowed.add(nn.id)
|
||||
|
||||
for n in ast.walk(tree):
|
||||
if isinstance(n, ast.Assign):
|
||||
for t in n.targets:
|
||||
_shadow_targets(t)
|
||||
elif isinstance(n, (ast.AugAssign, ast.AnnAssign)):
|
||||
if getattr(n, "target", None) is not None:
|
||||
_shadow_targets(n.target)
|
||||
elif isinstance(n, ast.NamedExpr):
|
||||
_shadow_targets(n.target)
|
||||
elif isinstance(n, (ast.For, ast.AsyncFor)):
|
||||
_shadow_targets(n.target)
|
||||
elif isinstance(n, ast.comprehension):
|
||||
_shadow_targets(n.target)
|
||||
elif isinstance(n, ast.withitem):
|
||||
if n.optional_vars is not None:
|
||||
_shadow_targets(n.optional_vars)
|
||||
elif isinstance(n, ast.ExceptHandler):
|
||||
if n.name:
|
||||
shadowed.add(n.name)
|
||||
elif isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||||
shadowed.add(n.name)
|
||||
_a = getattr(n, "args", None)
|
||||
if _a is not None:
|
||||
for _p in list(_a.args) + list(_a.posonlyargs) + list(_a.kwonlyargs):
|
||||
shadowed.add(_p.arg)
|
||||
for _extra in (_a.vararg, _a.kwarg):
|
||||
if _extra is not None:
|
||||
shadowed.add(_extra.arg)
|
||||
elif isinstance(n, ast.ImportFrom):
|
||||
for _al in n.names:
|
||||
shadowed.add(_al.asname or _al.name)
|
||||
elif isinstance(n, ast.Import):
|
||||
for _al in n.names:
|
||||
# import os as base64 rebinds `base64` to a different module; a plain
|
||||
# `import base64` (asname None) keeps the canonical module and does not shadow.
|
||||
if _al.asname is not None and _al.asname != _al.name:
|
||||
shadowed.add(_al.asname)
|
||||
|
||||
env = _ConstEnv(shadowed = frozenset(shadowed))
|
||||
if _ns_write_all:
|
||||
return env # an opaque namespace mutation could rebind any recorded constant
|
||||
disqualified |= _ns_write_names
|
||||
for name, rhs in assigned_once.items():
|
||||
if name in disqualified:
|
||||
continue
|
||||
|
|
@ -6199,6 +6342,35 @@ def _check_signal_escape_patterns(
|
|||
_const_env = {}
|
||||
_scope_idx = _ScopeAliasIndex(tree)
|
||||
|
||||
def _payload_free_name_hits_caller_alias(src, mode, node):
|
||||
"""A FREE name in an exec/eval payload that resolves, in the CALLER's scope at ``node``, to
|
||||
a shell / exec-builtin / deserialize / import alias -- exec/eval run in the caller
|
||||
namespace, so ``f`` in ``exec('f(...)')`` is the caller's ``f = os.system``. Returns the
|
||||
offending name or None. Builtins / undefined names never resolve, so ``exec('print(1)')``
|
||||
and ``exec('x = 1')`` stay allowed."""
|
||||
try:
|
||||
inner = ast.parse(src, mode = "eval" if mode == "eval" else "exec")
|
||||
except Exception:
|
||||
return None
|
||||
bound: set[str] = set()
|
||||
loaded: set[str] = set()
|
||||
for n in ast.walk(inner):
|
||||
if isinstance(n, ast.Name):
|
||||
if isinstance(n.ctx, (ast.Store, ast.Del)):
|
||||
bound.add(n.id)
|
||||
elif isinstance(n.ctx, ast.Load):
|
||||
loaded.add(n.id)
|
||||
elif isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||||
bound.add(n.name)
|
||||
elif isinstance(n, (ast.Import, ast.ImportFrom)):
|
||||
for _al in n.names:
|
||||
bound.add((_al.asname or _al.name).split(".")[0])
|
||||
for nm in loaded - bound:
|
||||
for _kind in ("shell", "execb", "deser", "impf"):
|
||||
if _scope_idx.resolve(nm, node, _kind):
|
||||
return nm
|
||||
return None
|
||||
|
||||
def _analyze_exec_call(node, func_id):
|
||||
"""Stage 2 driver: recover + recurse a foldable payload, else dynamic policy."""
|
||||
try:
|
||||
|
|
@ -6225,6 +6397,23 @@ def _check_signal_escape_patterns(
|
|||
),
|
||||
}
|
||||
)
|
||||
return
|
||||
# exec()/eval() run in the CALLER namespace, so the payload -- scanned above as a
|
||||
# fresh module -- can reference a caller-scope alias the inner pass cannot see
|
||||
# (import os; f = os.system; exec("f('rm -rf /')")). Fail closed when a payload
|
||||
# FREE name resolves to a shell / exec / deserialize / import alias at this call.
|
||||
_alias = _payload_free_name_hits_caller_alias(src, mode, node)
|
||||
if _alias is not None:
|
||||
dynamic_exec.append(
|
||||
{
|
||||
"type": "dynamic_exec",
|
||||
"line": getattr(node, "lineno", -1),
|
||||
"description": (
|
||||
f"{func_id}() payload references caller alias {_alias!r} "
|
||||
"bound to a shell / exec / deserialize sink"
|
||||
),
|
||||
}
|
||||
)
|
||||
return
|
||||
if parsed_kind == "BOUND_HIT":
|
||||
dynamic_exec.append(
|
||||
|
|
@ -6762,6 +6951,11 @@ def _check_signal_escape_patterns(
|
|||
return None
|
||||
|
||||
container = sub.value
|
||||
# Resolve a single-assignment NAME container (d = [os.system]; d[0]('rm -rf /')) to its
|
||||
# literal, mirroring the exec-container resolver -- a shell sink hidden in an assigned
|
||||
# container was otherwise missed because the callee is an indexed Name.
|
||||
if isinstance(container, ast.Name) and container.id in _const_env:
|
||||
container = _const_env[container.id]
|
||||
ci = _const_fold(sub.slice, _const_env)
|
||||
if isinstance(container, (ast.List, ast.Tuple)) and isinstance(ci, int):
|
||||
if -len(container.elts) <= ci < len(container.elts):
|
||||
|
|
@ -6795,6 +6989,12 @@ def _check_signal_escape_patterns(
|
|||
return None
|
||||
|
||||
container = sub.value
|
||||
# Resolve a subscript into a container bound to a single-assignment NAME
|
||||
# (d = {'e': exec}; d['e'](...), xs = [eval]; xs[0](...)) to the literal container,
|
||||
# so the exec/eval sink hidden inside it is not missed just because the callee is an
|
||||
# indexed Name rather than an inline literal.
|
||||
if isinstance(container, ast.Name) and container.id in _const_env:
|
||||
container = _const_env[container.id]
|
||||
ci = _const_fold(sub.slice, _const_env)
|
||||
if isinstance(container, (ast.List, ast.Tuple)) and isinstance(ci, int):
|
||||
if -len(container.elts) <= ci < len(container.elts):
|
||||
|
|
@ -6832,6 +7032,10 @@ def _check_signal_escape_patterns(
|
|||
return None
|
||||
|
||||
container = sub.value
|
||||
# Resolve a single-assignment NAME container (d = {'k': pickle.loads}; d['k'](payload))
|
||||
# to its literal, mirroring the exec-container resolver above.
|
||||
if isinstance(container, ast.Name) and container.id in _const_env:
|
||||
container = _const_env[container.id]
|
||||
ci = _const_fold(sub.slice, _const_env)
|
||||
if isinstance(container, (ast.List, ast.Tuple)) and isinstance(ci, int):
|
||||
if -len(container.elts) <= ci < len(container.elts):
|
||||
|
|
|
|||
|
|
@ -4910,3 +4910,93 @@ class TestRound49Bypasses:
|
|||
)
|
||||
def test_round49_benign_allowed(self, code):
|
||||
_ok(code)
|
||||
|
||||
|
||||
class TestRound50Bypasses:
|
||||
"""Fiftieth-round Codex findings: the static exec/eval analyzer defeated by aliasing /
|
||||
shadowing, and an unknown PATH variable. A subscript into an ASSIGNED container hiding an
|
||||
exec/eval (or shell) sink, a rebound builtin/module used in the constant fold, a namespace-dict
|
||||
write invalidating a folded constant, an exec/eval payload referencing a caller alias, and a
|
||||
PATH entry that is an unset $VAR (empty -> cwd search)."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
# d = {'e': exec}; d['e'](payload) / xs = [eval]; xs[0](payload): the container is an
|
||||
# assigned Name, not an inline literal, so the sink was missed.
|
||||
"d = {'e': exec}\nd['e'](\"import os\\nos.system('touch /tmp/x')\")",
|
||||
"xs = [eval]\nxs[0](\"__import__('os').system('touch /tmp/x')\")",
|
||||
# same, but a shell sink hidden in an assigned container.
|
||||
"import os\nd = [os.system]\nd[0]('touch /tmp/x')",
|
||||
"import os\nd = {'k': os.system}\nd['k']('touch /tmp/x')",
|
||||
],
|
||||
)
|
||||
def test_assigned_container_sink_blocked(self, code):
|
||||
assert _check_code_safety(code) is not None, code
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
# A rebound builtin / module used in the fold diverges from runtime; refuse to fold ->
|
||||
# opaque payload -> eval/exec fails closed.
|
||||
"str = lambda _: \"__import__('os').system('touch /tmp/x')\"\neval(str(1))",
|
||||
"def chr(_):\n return \"__import__('os').system('touch /tmp/x')\"\neval(chr(0))",
|
||||
],
|
||||
)
|
||||
def test_shadowed_fold_helper_blocked(self, code):
|
||||
assert _check_code_safety(code) is not None, code
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
# A write through the namespace dict invalidates the folded constant.
|
||||
"x = '2+2'\nglobals()['x'] = \"__import__('os').system('touch /tmp/x')\"\neval(x)",
|
||||
"x = '2+2'\nvars()['x'] = \"__import__('os').system('touch /tmp/x')\"\neval(x)",
|
||||
"x = '2+2'\nglobals().update({'x': \"__import__('os').system('touch /tmp/x')\"})\neval(x)",
|
||||
],
|
||||
)
|
||||
def test_namespace_write_invalidates_constant_blocked(self, code):
|
||||
assert _check_code_safety(code) is not None, code
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
# exec/eval run in the caller namespace, so the payload reaches a caller alias.
|
||||
"import os\nf = os.system\nexec(\"f('touch /tmp/x')\")",
|
||||
"import os\ns = os.system\neval(\"s('touch /tmp/x')\")",
|
||||
],
|
||||
)
|
||||
def test_exec_payload_caller_alias_blocked(self, code):
|
||||
assert _check_code_safety(code) is not None, code
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
# An unknown/unset $VAR is an EMPTY PATH component -> the shell searches the cwd.
|
||||
"import os\nos.system('PATH=$EVIL evil')",
|
||||
"import os\nos.system('PATH=${EVIL} run')",
|
||||
],
|
||||
)
|
||||
def test_unknown_path_variable_blocked(self, code):
|
||||
assert _check_code_safety(code) is not None, code
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
# An assigned container to a SAFE callee, normal builtin/module folds, a namespace
|
||||
# READ, an eval of a safe literal via a var, an exec whose payload references only a
|
||||
# SAFE caller alias or a builtin, and a PATH with trusted absolute entries stay allowed.
|
||||
"d = {'e': print}\nd['e']('hi')",
|
||||
"xs = [len]\nprint(xs[0]([1, 2]))",
|
||||
"import base64\nprint(base64.b64decode('aGk='))",
|
||||
"s = str(42)\nprint(s)",
|
||||
"g = globals()\nprint(len(g))",
|
||||
"x = '1 + 1'\neval(x)",
|
||||
"f = print\nexec(\"f(1)\")",
|
||||
"exec(\"y = 5\")",
|
||||
"import os\nos.system('PATH=/usr/local/bin:$PATH ls')",
|
||||
"import os\nos.system('ls -la')",
|
||||
],
|
||||
)
|
||||
def test_round50_benign_allowed(self, code):
|
||||
_ok(code)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue