##// END OF EJS Templates
Increase coverage of `guard_eval`
krassowski -
Show More
@@ -996,7 +996,8 b' class Completer(Configurable):'
996 996
997 997 - ``forbidden``: no evaluation of code is permitted,
998 998 - ``minimal``: evaluation of literals and access to built-in namespace;
999 no item/attribute evaluation nor access to locals/globals,
999 no item/attribute evaluationm no access to locals/globals,
1000 no evaluation of any operations or comparisons.
1000 1001 - ``limited``: access to all namespaces, evaluation of hard-coded methods
1001 1002 (for example: :any:`dict.keys`, :any:`object.__getattr__`,
1002 1003 :any:`object.__getitem__`) on allow-listed objects (for example:
@@ -108,6 +108,7 b' class EvaluationPolicy:'
108 108 return True
109 109
110 110 owner_method = _unbind_method(func)
111
111 112 if owner_method and owner_method in self.allowed_calls:
112 113 return True
113 114
@@ -127,6 +128,10 b' def _has_original_dunder_external('
127 128 value_type = type(value)
128 129 if type(value) == member_type:
129 130 return True
131 if method_name == "__getattribute__":
132 # we have to short-circuit here due to an unresolved issue in
133 # `isinstance` implementation: https://bugs.python.org/issue32683
134 return False
130 135 if isinstance(value, member_type):
131 136 method = getattr(value_type, method_name, None)
132 137 member_method = getattr(member_type, method_name, None)
@@ -149,7 +154,7 b' def _has_original_dunder('
149 154
150 155 method = getattr(value_type, method_name, None)
151 156
152 if not method:
157 if method is None:
153 158 return None
154 159
155 160 if method in allowed_methods:
@@ -193,6 +198,7 b' class SelectivePolicy(EvaluationPolicy):'
193 198 allowed_external=self.allowed_getattr_external,
194 199 method_name="__getattr__",
195 200 )
201
196 202 # Many objects do not have `__getattr__`, this is fine
197 203 if has_original_attr is None and has_original_attribute:
198 204 return True
@@ -200,10 +206,6 b' class SelectivePolicy(EvaluationPolicy):'
200 206 # Accept objects without modifications to `__getattr__` and `__getattribute__`
201 207 return has_original_attr and has_original_attribute
202 208
203 def get_attr(self, value, attr):
204 if self.can_get_attr(value, attr):
205 return getattr(value, attr)
206
207 209 def can_get_item(self, value, item):
208 210 """Allow accessing `__getiitem__` of allow-listed instances unless it was not modified."""
209 211 return _has_original_dunder(
@@ -215,20 +217,24 b' class SelectivePolicy(EvaluationPolicy):'
215 217 )
216 218
217 219 def can_operate(self, dunders: Tuple[str, ...], a, b=None):
220 objects = [a]
221 if b is not None:
222 objects.append(b)
218 223 return all(
219 224 [
220 225 _has_original_dunder(
221 a,
226 obj,
222 227 allowed_types=self.allowed_operations,
223 allowed_methods=self._dunder_methods(dunder),
228 allowed_methods=self._operator_dunder_methods(dunder),
224 229 allowed_external=self.allowed_operations_external,
225 230 method_name=dunder,
226 231 )
227 232 for dunder in dunders
233 for obj in objects
228 234 ]
229 235 )
230 236
231 def _dunder_methods(self, dunder: str) -> Set[Callable]:
237 def _operator_dunder_methods(self, dunder: str) -> Set[Callable]:
232 238 if dunder not in self._operation_methods_cache:
233 239 self._operation_methods_cache[dunder] = self._safe_get_methods(
234 240 self.allowed_operations, dunder
@@ -257,7 +263,7 b' class SelectivePolicy(EvaluationPolicy):'
257 263
258 264
259 265 class _DummyNamedTuple(NamedTuple):
260 pass
266 """Used internally to retrieve methods of named tuple instance."""
261 267
262 268
263 269 class EvaluationContext(NamedTuple):
@@ -451,12 +457,15 b' def eval_node(node: Union[ast.AST, None], context: EvaluationContext):'
451 457 f"not allowed in {context.evaluation} mode",
452 458 )
453 459 else:
454 raise ValueError(f"Comparison `{dunder}` not supported")
460 raise ValueError(
461 f"Comparison `{dunder}` not supported"
462 ) # pragma: no cover
455 463 return all_true
456 464 if isinstance(node, ast.Constant):
457 465 return node.value
458 466 if isinstance(node, ast.Index):
459 return eval_node(node.value, context)
467 # deprecated since Python 3.9
468 return eval_node(node.value, context) # pragma: no cover
460 469 if isinstance(node, ast.Tuple):
461 470 return tuple(eval_node(e, context) for e in node.elts)
462 471 if isinstance(node, ast.List):
@@ -477,7 +486,8 b' def eval_node(node: Union[ast.AST, None], context: EvaluationContext):'
477 486 eval_node(node.step, context),
478 487 )
479 488 if isinstance(node, ast.ExtSlice):
480 return tuple([eval_node(dim, context) for dim in node.dims])
489 # deprecated since Python 3.9
490 return tuple([eval_node(dim, context) for dim in node.dims]) # pragma: no cover
481 491 if isinstance(node, ast.UnaryOp):
482 492 value = eval_node(node.operand, context)
483 493 dunders = _find_dunder(node.op, UNARY_OP_DUNDERS)
@@ -490,7 +500,6 b' def eval_node(node: Union[ast.AST, None], context: EvaluationContext):'
490 500 type(value),
491 501 f"not allowed in {context.evaluation} mode",
492 502 )
493 raise ValueError("Unhandled unary operation:", node.op)
494 503 if isinstance(node, ast.Subscript):
495 504 value = eval_node(node.value, context)
496 505 slice_ = eval_node(node.slice, context)
@@ -507,14 +516,14 b' def eval_node(node: Union[ast.AST, None], context: EvaluationContext):'
507 516 if policy.allow_globals_access and node.id in context.globals:
508 517 return context.globals[node.id]
509 518 if policy.allow_builtins_access and hasattr(builtins, node.id):
510 # note: do not use __builtins__, it is implementation detail of Python
519 # note: do not use __builtins__, it is implementation detail of cPython
511 520 return getattr(builtins, node.id)
512 521 if not policy.allow_globals_access and not policy.allow_locals_access:
513 522 raise GuardRejection(
514 523 f"Namespace access not allowed in {context.evaluation} mode"
515 524 )
516 525 else:
517 raise NameError(f"{node.id} not found in locals nor globals")
526 raise NameError(f"{node.id} not found in locals, globals, nor builtins")
518 527 if isinstance(node, ast.Attribute):
519 528 value = eval_node(node.value, context)
520 529 if policy.can_get_attr(value, node.attr):
@@ -540,7 +549,7 b' def eval_node(node: Union[ast.AST, None], context: EvaluationContext):'
540 549 func, # not joined to avoid calling `repr`
541 550 f"not allowed in {context.evaluation} mode",
542 551 )
543 raise ValueError("Unhandled node", node)
552 raise ValueError("Unhandled node", ast.dump(node))
544 553
545 554
546 555 SUPPORTED_EXTERNAL_GETITEM = {
@@ -552,6 +561,7 b' SUPPORTED_EXTERNAL_GETITEM = {'
552 561 ("numpy", "void"),
553 562 }
554 563
564
555 565 BUILTIN_GETITEM: Set[InstancesHaveGetItem] = {
556 566 dict,
557 567 str,
@@ -583,6 +593,8 b' set_non_mutating_methods = set(dir(set)) & set(dir(frozenset))'
583 593 dict_keys: Type[collections.abc.KeysView] = type({}.keys())
584 594 method_descriptor: Any = type(list.copy)
585 595
596 NUMERICS = {int, float, complex}
597
586 598 ALLOWED_CALLS = {
587 599 bytes,
588 600 *_list_methods(bytes),
@@ -600,6 +612,8 b' ALLOWED_CALLS = {'
600 612 *_list_methods(str),
601 613 tuple,
602 614 *_list_methods(tuple),
615 *NUMERICS,
616 *[method for numeric_cls in NUMERICS for method in _list_methods(numeric_cls)],
603 617 collections.deque,
604 618 *_list_methods(collections.deque, list_non_mutating_methods),
605 619 collections.defaultdict,
@@ -624,12 +638,13 b' BUILTIN_GETATTR: Set[MayHaveGetattr] = {'
624 638 frozenset,
625 639 object,
626 640 type, # `type` handles a lot of generic cases, e.g. numbers as in `int.real`.
641 *NUMERICS,
627 642 dict_keys,
628 643 method_descriptor,
629 644 }
630 645
631 646
632 BUILTIN_OPERATIONS = {int, float, complex, *BUILTIN_GETATTR}
647 BUILTIN_OPERATIONS = {*BUILTIN_GETATTR}
633 648
634 649 EVALUATION_POLICIES = {
635 650 "minimal": EvaluationPolicy(
@@ -1,4 +1,5 b''
1 1 from typing import NamedTuple
2 from functools import partial
2 3 from IPython.core.guarded_eval import (
3 4 EvaluationContext,
4 5 GuardRejection,
@@ -9,12 +10,19 b' from IPython.testing import decorators as dec'
9 10 import pytest
10 11
11 12
12 def limited(**kwargs):
13 return EvaluationContext(locals=kwargs, globals={}, evaluation="limited")
13 def create_context(evaluation: str, **kwargs):
14 return EvaluationContext(locals=kwargs, globals={}, evaluation=evaluation)
14 15
15 16
16 def unsafe(**kwargs):
17 return EvaluationContext(locals=kwargs, globals={}, evaluation="unsafe")
17 forbidden = partial(create_context, "forbidden")
18 minimal = partial(create_context, "minimal")
19 limited = partial(create_context, "limited")
20 unsafe = partial(create_context, "unsafe")
21 dangerous = partial(create_context, "dangerous")
22
23 LIMITED_OR_HIGHER = [limited, unsafe, dangerous]
24
25 MINIMAL_OR_HIGHER = [minimal, *LIMITED_OR_HIGHER]
18 26
19 27
20 28 @dec.skip_without("pandas")
@@ -142,7 +150,7 b' def test_set_literal():'
142 150 assert guarded_eval('{"a"}', context) == {"a"}
143 151
144 152
145 def test_if_expression():
153 def test_evaluates_if_expression():
146 154 context = limited()
147 155 assert guarded_eval("2 if True else 3", context) == 2
148 156 assert guarded_eval("4 if False else 5", context) == 5
@@ -178,7 +186,7 b' def test_method_descriptor():'
178 186 [{"a": 1}, "data.keys().isdisjoint({})", "data.update()", True],
179 187 ],
180 188 )
181 def test_calls(data, good, bad, expected):
189 def test_evaluates_calls(data, good, bad, expected):
182 190 context = limited(data=data)
183 191 assert guarded_eval(good, context) == expected
184 192
@@ -194,9 +202,26 b' def test_calls(data, good, bad, expected):'
194 202 ["list(range(20))[3:-2:3]", [3, 6, 9, 12, 15]],
195 203 ],
196 204 )
197 def test_literals(code, expected):
198 context = limited()
199 assert guarded_eval(code, context) == expected
205 @pytest.mark.parametrize("context", LIMITED_OR_HIGHER)
206 def test_evaluates_complex_cases(code, expected, context):
207 assert guarded_eval(code, context()) == expected
208
209
210 @pytest.mark.parametrize(
211 "code,expected",
212 [
213 ["1", 1],
214 ["1.0", 1.0],
215 ["0xdeedbeef", 0xDEEDBEEF],
216 ["True", True],
217 ["None", None],
218 ["{}", {}],
219 ["[]", []],
220 ],
221 )
222 @pytest.mark.parametrize("context", MINIMAL_OR_HIGHER)
223 def test_evaluates_literals(code, expected, context):
224 assert guarded_eval(code, context()) == expected
200 225
201 226
202 227 @pytest.mark.parametrize(
@@ -207,9 +232,9 b' def test_literals(code, expected):'
207 232 ["~5", -6],
208 233 ],
209 234 )
210 def test_unary_operations(code, expected):
211 context = limited()
212 assert guarded_eval(code, context) == expected
235 @pytest.mark.parametrize("context", LIMITED_OR_HIGHER)
236 def test_evaluates_unary_operations(code, expected, context):
237 assert guarded_eval(code, context()) == expected
213 238
214 239
215 240 @pytest.mark.parametrize(
@@ -228,9 +253,9 b' def test_unary_operations(code, expected):'
228 253 ["1 & 2", 0],
229 254 ],
230 255 )
231 def test_binary_operations(code, expected):
232 context = limited()
233 assert guarded_eval(code, context) == expected
256 @pytest.mark.parametrize("context", LIMITED_OR_HIGHER)
257 def test_evaluates_binary_operations(code, expected, context):
258 assert guarded_eval(code, context()) == expected
234 259
235 260
236 261 @pytest.mark.parametrize(
@@ -262,16 +287,152 b' def test_binary_operations(code, expected):'
262 287 ["True is True", True],
263 288 ["False is False", True],
264 289 ["True is False", False],
290 ["True is not True", False],
291 ["False is not True", True],
265 292 ],
266 293 )
267 def test_comparisons(code, expected):
268 context = limited()
269 assert guarded_eval(code, context) == expected
294 @pytest.mark.parametrize("context", LIMITED_OR_HIGHER)
295 def test_evaluates_comparisons(code, expected, context):
296 assert guarded_eval(code, context()) == expected
297
298
299 def test_guards_comparisons():
300 class GoodEq(int):
301 pass
302
303 class BadEq(int):
304 def __eq__(self, other):
305 assert False
306
307 context = limited(bad=BadEq(1), good=GoodEq(1))
308
309 with pytest.raises(GuardRejection):
310 guarded_eval("bad == 1", context)
311
312 with pytest.raises(GuardRejection):
313 guarded_eval("bad != 1", context)
314
315 with pytest.raises(GuardRejection):
316 guarded_eval("1 == bad", context)
317
318 with pytest.raises(GuardRejection):
319 guarded_eval("1 != bad", context)
320
321 assert guarded_eval("good == 1", context) is True
322 assert guarded_eval("good != 1", context) is False
323 assert guarded_eval("1 == good", context) is True
324 assert guarded_eval("1 != good", context) is False
325
326
327 def test_guards_unary_operations():
328 class GoodOp(int):
329 pass
330
331 class BadOpInv(int):
332 def __inv__(self, other):
333 assert False
334
335 class BadOpInverse(int):
336 def __inv__(self, other):
337 assert False
338
339 context = limited(good=GoodOp(1), bad1=BadOpInv(1), bad2=BadOpInverse(1))
340
341 with pytest.raises(GuardRejection):
342 guarded_eval("~bad1", context)
343
344 with pytest.raises(GuardRejection):
345 guarded_eval("~bad2", context)
346
347
348 def test_guards_binary_operations():
349 class GoodOp(int):
350 pass
270 351
352 class BadOp(int):
353 def __add__(self, other):
354 assert False
271 355
272 def test_access_builtins():
356 context = limited(good=GoodOp(1), bad=BadOp(1))
357
358 with pytest.raises(GuardRejection):
359 guarded_eval("1 + bad", context)
360
361 with pytest.raises(GuardRejection):
362 guarded_eval("bad + 1", context)
363
364 assert guarded_eval("good + 1", context) == 2
365 assert guarded_eval("1 + good", context) == 2
366
367
368 def test_guards_attributes():
369 class GoodAttr(float):
370 pass
371
372 class BadAttr1(float):
373 def __getattr__(self, key):
374 assert False
375
376 class BadAttr2(float):
377 def __getattribute__(self, key):
378 assert False
379
380 context = limited(good=GoodAttr(0.5), bad1=BadAttr1(0.5), bad2=BadAttr2(0.5))
381
382 with pytest.raises(GuardRejection):
383 guarded_eval("bad1.as_integer_ratio", context)
384
385 with pytest.raises(GuardRejection):
386 guarded_eval("bad2.as_integer_ratio", context)
387
388 assert guarded_eval("good.as_integer_ratio()", context) == (1, 2)
389
390
391 @pytest.mark.parametrize("context", MINIMAL_OR_HIGHER)
392 def test_access_builtins(context):
393 assert guarded_eval("round", context()) == round
394
395
396 def test_access_builtins_fails():
273 397 context = limited()
274 assert guarded_eval("round", context) == round
398 with pytest.raises(NameError):
399 guarded_eval("this_is_not_builtin", context)
400
401
402 def test_rejects_forbidden():
403 context = forbidden()
404 with pytest.raises(GuardRejection):
405 guarded_eval("1", context)
406
407
408 def test_guards_locals_and_globals():
409 context = EvaluationContext(
410 locals={"local_a": "a"}, globals={"global_b": "b"}, evaluation="minimal"
411 )
412
413 with pytest.raises(GuardRejection):
414 guarded_eval("local_a", context)
415
416 with pytest.raises(GuardRejection):
417 guarded_eval("global_b", context)
418
419
420 def test_access_locals_and_globals():
421 context = EvaluationContext(
422 locals={"local_a": "a"}, globals={"global_b": "b"}, evaluation="limited"
423 )
424 assert guarded_eval("local_a", context) == "a"
425 assert guarded_eval("global_b", context) == "b"
426
427
428 @pytest.mark.parametrize(
429 "code",
430 ["def func(): pass", "class C: pass", "x = 1", "x += 1", "del x", "import ast"],
431 )
432 @pytest.mark.parametrize("context", [minimal(), limited(), unsafe()])
433 def test_rejects_side_effect_syntax(code, context):
434 with pytest.raises(SyntaxError):
435 guarded_eval(code, context)
275 436
276 437
277 438 def test_subscript():
General Comments 0
You need to be logged in to leave comments. Login now