From a8551243ea149fdf621d34bcef3bd447a07dfdfc Mon Sep 17 00:00:00 2001 From: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> Date: Sat, 20 Jun 2026 00:54:58 -0400 Subject: [PATCH 1/9] Add safe_eval Function Signed-off-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> --- monai/utils/__init__.py | 1 + monai/utils/safe_eval.py | 75 +++++++++++++++++++++++++++++++++++ tests/utils/test_safe_eval.py | 55 +++++++++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 monai/utils/safe_eval.py create mode 100644 tests/utils/test_safe_eval.py diff --git a/monai/utils/__init__.py b/monai/utils/__init__.py index 3efc9b5e7f..d0f4eb40ce 100644 --- a/monai/utils/__init__.py +++ b/monai/utils/__init__.py @@ -137,6 +137,7 @@ torch_profiler_time_cpu_gpu, torch_profiler_time_end_to_end, ) +from .safe_eval import SAFE_TYPES, safe_eval from .state_cacher import StateCacher from .tf32 import detect_default_tf32, has_ampere_or_later from .type_conversion import ( diff --git a/monai/utils/safe_eval.py b/monai/utils/safe_eval.py new file mode 100644 index 0000000000..9c5293e229 --- /dev/null +++ b/monai/utils/safe_eval.py @@ -0,0 +1,75 @@ +# 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. + +import ast +from typing import Any, Mapping, Sequence + +__all__ = ["SAFE_TYPES", "safe_eval"] + +# default set of safe AST node types +SAFE_TYPES = ( + ast.Expression, + ast.Name, + ast.Load, + ast.Constant, + ast.BinOp, + ast.UnaryOp, + ast.Add, + ast.Sub, + ast.Mult, + ast.Div, + ast.FloorDiv, + ast.Pow, + ast.Mod, + ast.USub, + ast.UAdd, +) + + +def safe_eval( + expr: str, + globals: Mapping[str, Any] | None = None, + locals: Mapping[str, object] | None = None, + allowed_types: Sequence[type] = SAFE_TYPES, +): + """ + 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. + + Args: + expr: expression to evaluate, this will be stripped before parsing to avoid indentation complaints + globals: global variable mapping + locals: local variable mapping + allows_types: sequence of allowed AST types which can be found in `expr` when parsed + + 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` and `locals` + """ + parsed = ast.parse(expr.strip(), mode="eval") + + def _disallowed_node(n): + return not any(isinstance(n, at) for at in allowed_types) + + disallowed = list(filter(_disallowed_node, ast.walk(parsed))) + + if disallowed: + disallowed_strs = list(map(ast.unparse, disallowed)) + raise ValueError( + f"Unsafe expression `{expr}` cannot be evaluated, contains disallowed components: {disallowed_strs}" + ) + + return eval(expr, globals, locals) diff --git a/tests/utils/test_safe_eval.py b/tests/utils/test_safe_eval.py new file mode 100644 index 0000000000..fa2138471d --- /dev/null +++ b/tests/utils/test_safe_eval.py @@ -0,0 +1,55 @@ +# 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. + +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, locals, expected): + """Test valid expressions with globals/locals evaluate to correct values.""" + result = safe_eval(expr, globals, locals) + 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) + + 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() From 58e721e7971b0f325c398ad227135d104022ba56 Mon Sep 17 00:00:00 2001 From: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> Date: Sat, 20 Jun 2026 01:03:26 -0400 Subject: [PATCH 2/9] Remove eval where possible Signed-off-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> --- monai/bundle/scripts.py | 3 ++- monai/utils/ordering.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/monai/bundle/scripts.py b/monai/bundle/scripts.py index ab02cd552e..b46d7930f5 100644 --- a/monai/bundle/scripts.py +++ b/monai/bundle/scripts.py @@ -51,6 +51,7 @@ min_version, optional_import, pprint_edges, + safe_eval ) validate, _ = optional_import("jsonschema", name="validate") @@ -161,7 +162,7 @@ def _get_fake_spatial_shape(shape: Sequence[str | int], p: int = 1, n: int = 1, 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})) + ret.append(safe_eval(i, {"p": p, "n": n})) else: raise ValueError(f"spatial shape items must be int or string, but got: {type(i)} {i}.") return tuple(ret) diff --git a/monai/utils/ordering.py b/monai/utils/ordering.py index 1be61f98ab..012b744326 100644 --- a/monai/utils/ordering.py +++ b/monai/utils/ordering.py @@ -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]) From dd97330cd0852f62919c27b03acb9c79bc3d0430 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 05:05:30 +0000 Subject: [PATCH 3/9] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- monai/utils/safe_eval.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/monai/utils/safe_eval.py b/monai/utils/safe_eval.py index 9c5293e229..220cf8f298 100644 --- a/monai/utils/safe_eval.py +++ b/monai/utils/safe_eval.py @@ -10,7 +10,8 @@ # limitations under the License. import ast -from typing import Any, Mapping, Sequence +from typing import Any +from collections.abc import Mapping, Sequence __all__ = ["SAFE_TYPES", "safe_eval"] @@ -43,7 +44,7 @@ def safe_eval( """ 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 + 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. From f329f15bc080056fa95aafef6e9cd1781e014fe1 Mon Sep 17 00:00:00 2001 From: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> Date: Sat, 20 Jun 2026 01:13:41 -0400 Subject: [PATCH 4/9] Fixes Signed-off-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> --- monai/bundle/scripts.py | 2 +- monai/utils/ordering.py | 2 +- monai/utils/safe_eval.py | 12 +++++++----- tests/utils/test_safe_eval.py | 7 +++++-- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/monai/bundle/scripts.py b/monai/bundle/scripts.py index b46d7930f5..1221460800 100644 --- a/monai/bundle/scripts.py +++ b/monai/bundle/scripts.py @@ -51,7 +51,7 @@ min_version, optional_import, pprint_edges, - safe_eval + safe_eval, ) validate, _ = optional_import("jsonschema", name="validate") diff --git a/monai/utils/ordering.py b/monai/utils/ordering.py index 012b744326..6daf5d4582 100644 --- a/monai/utils/ordering.py +++ b/monai/utils/ordering.py @@ -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 = getattr(self,f"{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]) diff --git a/monai/utils/safe_eval.py b/monai/utils/safe_eval.py index 220cf8f298..afdad04d27 100644 --- a/monai/utils/safe_eval.py +++ b/monai/utils/safe_eval.py @@ -9,9 +9,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations + import ast -from typing import Any from collections.abc import Mapping, Sequence +from typing import Any __all__ = ["SAFE_TYPES", "safe_eval"] @@ -37,8 +39,8 @@ def safe_eval( expr: str, - globals: Mapping[str, Any] | None = None, - locals: Mapping[str, object] | None = None, + globals_vars: Mapping[str, Any] | None = None, + locals_vars: Mapping[str, object] | None = None, allowed_types: Sequence[type] = SAFE_TYPES, ): """ @@ -52,7 +54,7 @@ def safe_eval( expr: expression to evaluate, this will be stripped before parsing to avoid indentation complaints globals: global variable mapping locals: local variable mapping - allows_types: sequence of allowed AST types which can be found in `expr` when parsed + allowed_types: sequence of allowed AST types which can be found in `expr` when parsed Raises: ValueError: raised when any node in the AST parsed from `expr` has a type not in `allowed_types` @@ -73,4 +75,4 @@ def _disallowed_node(n): f"Unsafe expression `{expr}` cannot be evaluated, contains disallowed components: {disallowed_strs}" ) - return eval(expr, globals, locals) + return eval(expr, globals_vars, locals_vars) diff --git a/tests/utils/test_safe_eval.py b/tests/utils/test_safe_eval.py index fa2138471d..bf0b0891c4 100644 --- a/tests/utils/test_safe_eval.py +++ b/tests/utils/test_safe_eval.py @@ -9,8 +9,11 @@ # 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 @@ -30,9 +33,9 @@ class TestSafeEval(unittest.TestCase): @parameterized.expand(GOOD_EXPRS) - def test_good_exprs(self, expr, globals, locals, expected): + 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, locals) + result = safe_eval(expr, globals_vars, locals_vars) self.assertEqual(result, expected) @parameterized.expand(BAD_EXPRS) From 420d6102e7e0b9ad63259a07042374ac8f795880 Mon Sep 17 00:00:00 2001 From: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:31:49 -0400 Subject: [PATCH 5/9] Minor fixes and renaming module to avoid alias test fail Signed-off-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> --- monai/utils/__init__.py | 2 +- monai/utils/{safe_eval.py => safeeval.py} | 23 +++++++++-------------- tests/utils/test_alias.py | 5 ++++- 3 files changed, 14 insertions(+), 16 deletions(-) rename monai/utils/{safe_eval.py => safeeval.py} (80%) diff --git a/monai/utils/__init__.py b/monai/utils/__init__.py index d0f4eb40ce..d1a705205c 100644 --- a/monai/utils/__init__.py +++ b/monai/utils/__init__.py @@ -137,7 +137,7 @@ torch_profiler_time_cpu_gpu, torch_profiler_time_end_to_end, ) -from .safe_eval import SAFE_TYPES, safe_eval +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 ( diff --git a/monai/utils/safe_eval.py b/monai/utils/safeeval.py similarity index 80% rename from monai/utils/safe_eval.py rename to monai/utils/safeeval.py index afdad04d27..e02239d4f4 100644 --- a/monai/utils/safe_eval.py +++ b/monai/utils/safeeval.py @@ -18,7 +18,7 @@ __all__ = ["SAFE_TYPES", "safe_eval"] # default set of safe AST node types -SAFE_TYPES = ( +SAFE_TYPES: Sequence[ast.AST] = ( ast.Expression, ast.Name, ast.Load, @@ -42,7 +42,7 @@ def safe_eval( globals_vars: Mapping[str, Any] | None = None, locals_vars: Mapping[str, object] | None = None, allowed_types: Sequence[type] = SAFE_TYPES, -): +) -> 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 @@ -52,27 +52,22 @@ def safe_eval( Args: expr: expression to evaluate, this will be stripped before parsing to avoid indentation complaints - globals: global variable mapping - locals: local variable mapping + globals_vars: global variable mapping + locals_vars: local variable mapping allowed_types: sequence of allowed AST types which can be found in `expr` when parsed 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` and `locals` + The evaluated expression value, using `eval` with `globals_vars` and `locals_vars` """ parsed = ast.parse(expr.strip(), mode="eval") - def _disallowed_node(n): - return not any(isinstance(n, at) for at in allowed_types) - - disallowed = list(filter(_disallowed_node, ast.walk(parsed))) + # 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: - disallowed_strs = list(map(ast.unparse, disallowed)) - raise ValueError( - f"Unsafe expression `{expr}` cannot be evaluated, contains disallowed components: {disallowed_strs}" - ) + raise ValueError(f"Unsafe expression `{expr}` not evaluated, contains disallowed components: {disallowed}") - return eval(expr, globals_vars, locals_vars) + return eval(expr, dict(globals_vars), locals_vars) diff --git a/tests/utils/test_alias.py b/tests/utils/test_alias.py index e7abff3d89..0e49cb406a 100644 --- a/tests/utils/test_alias.py +++ b/tests/utils/test_alias.py @@ -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) From 2f083a9744eb080354962aca29dd47a84a326a2d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 17:32:08 +0000 Subject: [PATCH 6/9] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/utils/test_alias.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/utils/test_alias.py b/tests/utils/test_alias.py index 0e49cb406a..8ec1f8ae00 100644 --- a/tests/utils/test_alias.py +++ b/tests/utils/test_alias.py @@ -25,7 +25,7 @@ class TestModuleAlias(unittest.TestCase): """ 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. + as a member of that module (or any other) which is imported in a `__init__.py` file. """ def test_files(self): From 23f9b250b3b667ee31c9ec623023f49717d8519f Mon Sep 17 00:00:00 2001 From: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:41:06 -0400 Subject: [PATCH 7/9] Type tweak Signed-off-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> --- monai/utils/safeeval.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/monai/utils/safeeval.py b/monai/utils/safeeval.py index e02239d4f4..33fa089a03 100644 --- a/monai/utils/safeeval.py +++ b/monai/utils/safeeval.py @@ -52,7 +52,7 @@ def safe_eval( Args: expr: expression to evaluate, this will be stripped before parsing to avoid indentation complaints - globals_vars: global variable mapping + globals_vars: global variable mapping, this will be treated as read-only for this function, unlike `eval` locals_vars: local variable mapping allowed_types: sequence of allowed AST types which can be found in `expr` when parsed @@ -70,4 +70,4 @@ def safe_eval( if disallowed: raise ValueError(f"Unsafe expression `{expr}` not evaluated, contains disallowed components: {disallowed}") - return eval(expr, dict(globals_vars), locals_vars) + return eval(expr, dict(globals_vars) if globals_vars else None, locals_vars) From 274c880e59741f1e2e071481c0184e6cf4bce118 Mon Sep 17 00:00:00 2001 From: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:57:06 -0400 Subject: [PATCH 8/9] Type fix Signed-off-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> --- monai/utils/safeeval.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monai/utils/safeeval.py b/monai/utils/safeeval.py index 33fa089a03..358e964ef2 100644 --- a/monai/utils/safeeval.py +++ b/monai/utils/safeeval.py @@ -41,7 +41,7 @@ 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, + allowed_types: Sequence[ast.AST] = SAFE_TYPES, ) -> Any: """ Evaluate the Python expression `expr` using `eval`, but only if it is a safe expression in that its parsed AST From 25d302bd4fbf2df1dc0fbb2dc0a33600a9f23aac Mon Sep 17 00:00:00 2001 From: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> Date: Sat, 20 Jun 2026 15:15:06 -0400 Subject: [PATCH 9/9] Picky typing issue Signed-off-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> --- monai/utils/safeeval.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/monai/utils/safeeval.py b/monai/utils/safeeval.py index 358e964ef2..48fddfa07f 100644 --- a/monai/utils/safeeval.py +++ b/monai/utils/safeeval.py @@ -18,7 +18,7 @@ __all__ = ["SAFE_TYPES", "safe_eval"] # default set of safe AST node types -SAFE_TYPES: Sequence[ast.AST] = ( +SAFE_TYPES: Sequence[type] = ( ast.Expression, ast.Name, ast.Load, @@ -41,7 +41,7 @@ def safe_eval( expr: str, globals_vars: Mapping[str, Any] | None = None, locals_vars: Mapping[str, object] | None = None, - allowed_types: Sequence[ast.AST] = SAFE_TYPES, + allowed_types: Sequence[type] = SAFE_TYPES, ) -> Any: """ Evaluate the Python expression `expr` using `eval`, but only if it is a safe expression in that its parsed AST