Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/source/utils.rst
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,8 @@ Ordering
--------
.. automodule:: monai.utils.ordering
:members:

Safe Evaluation
---------------
.. automodule:: monai.utils.safeeval
:members:
12 changes: 8 additions & 4 deletions monai/bundle/scripts.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from textwrap import dedent
from typing import Any

import numpy as np
import torch
from torch.cuda import is_available

Expand All @@ -51,6 +52,7 @@
min_version,
optional_import,
pprint_edges,
safe_eval,
)

validate, _ = optional_import("jsonschema", name="validate")
Expand Down Expand Up @@ -158,10 +160,12 @@ def _get_fake_spatial_shape(shape: Sequence[str | int], p: int = 1, n: int = 1,
if i == "*":
ret.append(any)
else:
for c in _get_var_names(i):
if c not in ["p", "n"]:
raise ValueError(f"only support variables 'p' and 'n' so far, but got: {c}.")
ret.append(eval(i, {"p": p, "n": n}))
bad_names = set(c for c in _get_var_names(i) if c not in {"p", "n"})
if bad_names:
raise ValueError(f"Only variables `p` and `n` currently supported. Invalid names: {bad_names}")

# evaluate using Numpy types to prevent slow Python DoS attacks
ret.append(int(safe_eval(i, {"p": np.int32(p), "n": np.int32(n)}, rewrite_np=True)))
else:
raise ValueError(f"spatial shape items must be int or string, but got: {type(i)} {i}.")
return tuple(ret)
Expand Down
1 change: 1 addition & 0 deletions monai/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@
torch_profiler_time_cpu_gpu,
torch_profiler_time_end_to_end,
)
from .safeeval import SAFE_TYPES, safe_eval
from .state_cacher import StateCacher
from .tf32 import detect_default_tf32, has_ampere_or_later
from .type_conversion import (
Expand Down
2 changes: 1 addition & 1 deletion monai/utils/ordering.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ def _order_template(self, template: np.ndarray) -> np.ndarray:
else:
rows, columns, depths = (template.shape[0], template.shape[1], template.shape[2])

sequence = eval(f"self.{self.ordering_type}_idx")(rows, columns, depths)
sequence = getattr(self, f"{self.ordering_type}_idx")(rows, columns, depths)

ordering = np.array([template[tuple(e)] for e in sequence])

Expand Down
106 changes: 106 additions & 0 deletions monai/utils/safeeval.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Copyright (c) MONAI Consortium
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import annotations

import ast
from collections.abc import Mapping, Sequence
from typing import Any

import numpy as np

__all__ = ["SAFE_TYPES", "safe_eval"]

# default set of safe AST node types
SAFE_TYPES: Sequence[type] = (
ast.Expression,
ast.Name,
ast.Load,
ast.Constant,
ast.BinOp,
ast.UnaryOp,
ast.Add,
ast.Sub,
ast.Mult,
ast.Div,
ast.FloorDiv,
ast.Pow,
Comment thread
ericspod marked this conversation as resolved.
ast.Mod,
ast.USub,
ast.UAdd,
)


class _RewriteConstNp(ast.NodeTransformer):
"""Replaces int and float constants in the tree with those wrapped in Numpy types."""

def __init__(self, int_type_str: str, float_type_str: str):
self.int_type_str = int_type_str
self.float_type_str = float_type_str

def visit_Constant(self, node):
if isinstance(node.value, (int, float)):
type_str = self.int_type_str if isinstance(node.value, int) else self.float_type_str
return ast.parse(f"{type_str}({node.value})")

return node


def safe_eval(
expr: str,
globals_vars: Mapping[str, Any] | None = None,
locals_vars: Mapping[str, object] | None = None,
allowed_types: Sequence[type] = SAFE_TYPES,
rewrite_np: bool = False,
int_type_str: str = "np.int32",
float_type_str: str = "np.float32",
) -> Any:
"""
Evaluate the Python expression `expr` using `eval`, but only if it is a safe expression in that its parsed AST
contains nodes whose types are given in `allowed_types`. This ensures unsafe node types are excluded, if these
are present in the AST a ValueError is raised. The default set of such types in `SAFE_TYPES` ensures only
expressions with constants and names can be evaluated, so excludes attribute access, indexing, and calls. Code
injection is infeasible through such expressions, so this is a safe and secure way of evaluating simple expressions.

If `rewrite_np` is True, int and float constants in the given expression will be wrapped with Numpy types as given
by `int_type_str` and `float_type_str`. These are expected to be constructor names prefixed with `np.` as Numpy
will be present in the expression global variables under that name. The values can be changed to other types if
needed, such as "int64". One advantage of doing this is to avoid denial-of-service attacks by attempting to evaluate
an expressoini which is incredibly slow under native Python but fast (though potentially erroneous) under Numpy.

Args:
expr: expression to evaluate, this will be stripped before parsing to avoid indentation complaints
globals_vars: global variable mapping, this will be treated as read-only for this function, unlike `eval`
Comment thread
ericspod marked this conversation as resolved.
locals_vars: local variable mapping
allowed_types: sequence of allowed AST types which can be found in `expr` when parsed
rewrite_np: if True, wrap int or float literals in Numpy types
int_type_str: int Numpy wrapping type string
float_type_str: float Numpy wrapping type string

Raises:
ValueError: raised when any node in the AST parsed from `expr` has a type not in `allowed_types`

Returns:
The evaluated expression value, using `eval` with `globals_vars` and `locals_vars`
"""
parsed = ast.parse(expr.strip(), mode="eval")

# collect nodes in the AST which aren't permitted and unparse them for inclusion in the exception message
disallowed = [ast.unparse(n) for n in ast.walk(parsed) if not isinstance(n, tuple(allowed_types))]

if disallowed:
raise ValueError(f"Unsafe expression `{expr}` not evaluated, contains disallowed components: {disallowed}")

if rewrite_np:
parsed = _RewriteConstNp(int_type_str, float_type_str).visit(parsed)
locals_vars = {"np": np, **(locals_vars or {})}

return eval(expr, dict(globals_vars) if globals_vars else None, locals_vars)
Comment thread
ericspod marked this conversation as resolved.
5 changes: 4 additions & 1 deletion tests/utils/test_alias.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@


class TestModuleAlias(unittest.TestCase):
"""check that 'import monai.xx.file_name' returns a module"""
"""
Check that 'import monai.xx.file_name' returns a module. Note that this test will fail if a module has the same name
as a member of that module (or any other) which is imported in a `__init__.py` file.
"""

def test_files(self):
src_dir = os.path.dirname(TESTS_PATH)
Expand Down
67 changes: 67 additions & 0 deletions tests/utils/test_safe_eval.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Copyright (c) MONAI Consortium
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import annotations

import ast
import unittest

from parameterized import parameterized

from monai.utils import safe_eval

GOOD_EXPRS = [
("1+2", None, None, 3),
(" 1 + 2 ", None, None, 3),
("1+2+x", {"x": 4}, None, 7),
("1+2+x", None, {"x": 4}, 7),
("1*2+x", {"x": 4}, None, 6),
("(1+2)*3", None, None, 9),
("foo+bar", {"foo": 1030}, {"bar": 204}, 1234),
]

BAD_EXPRS = [("foo()",), ("foo.bar",), ("foo[123]",), ("(1,2)",), ("[3,4]",), ("int.__class__.__init__.__globals__",)]


class TestSafeEval(unittest.TestCase):
@parameterized.expand(GOOD_EXPRS)
def test_good_exprs(self, expr, globals_vars, locals_vars, expected):
"""Test valid expressions with globals/locals evaluate to correct values."""
result = safe_eval(expr, globals_vars, locals_vars)
self.assertEqual(result, expected)

@parameterized.expand(GOOD_EXPRS)
def test_good_exprs_np(self, expr, globals_vars, locals_vars, expected):
"""Test valid expressions with globals/locals evaluate to correct values with Numpy wrapping."""
result = safe_eval(expr, globals_vars, locals_vars, rewrite_np=True)
self.assertEqual(result, expected)

@parameterized.expand(BAD_EXPRS)
def test_bad_exprs(self, expr):
"""Test bad expressions correctly raise ValueError."""
with self.assertRaises(ValueError):
safe_eval(expr)

with self.assertRaises(ValueError):
safe_eval(expr, rewrite_np=True)

def test_allowed_types(self):
"""Test restricting the allowed list of types."""
allowed = [ast.Expression, ast.Constant, ast.BinOp, ast.Add]
result = safe_eval("1+2", allowed_types=allowed)
self.assertEqual(result, 3)

with self.assertRaises(ValueError):
safe_eval("1*2", allowed_types=allowed)


if __name__ == "__main__":
unittest.main()
Loading