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 01/14] 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 3efc9b5e7fd..d0f4eb40ce4 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 00000000000..9c5293e2294 --- /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 00000000000..fa2138471df --- /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 02/14] 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 ab02cd552e3..b46d7930f57 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 1be61f98abe..012b7443266 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 03/14] [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 9c5293e2294..220cf8f2989 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 04/14] 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 b46d7930f57..12214608000 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 012b7443266..6daf5d45828 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 220cf8f2989..afdad04d278 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 fa2138471df..bf0b0891c45 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 05/14] 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 d0f4eb40ce4..d1a705205ce 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 afdad04d278..e02239d4f4d 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 e7abff3d890..0e49cb406a3 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 06/14] [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 0e49cb406a3..8ec1f8ae007 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 07/14] 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 e02239d4f4d..33fa089a032 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 08/14] 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 33fa089a032..358e964ef21 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 09/14] 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 358e964ef21..48fddfa07fc 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 From 456d069da7d42d05d239db65afa95b677d1ac650 Mon Sep 17 00:00:00 2001 From: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:40:26 +0100 Subject: [PATCH 10/14] Update docs Signed-off-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> --- docs/source/utils.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/source/utils.rst b/docs/source/utils.rst index ae3b476c3e1..958d27337e6 100644 --- a/docs/source/utils.rst +++ b/docs/source/utils.rst @@ -80,3 +80,8 @@ Ordering -------- .. automodule:: monai.utils.ordering :members: + +Safe Evaluation +--------------- +.. automodule:: monai.utils.safeeval + :members: From b722325dbcce858d6bd21082455e3dca39b06a1f Mon Sep 17 00:00:00 2001 From: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:52:50 +0100 Subject: [PATCH 11/14] Experiment with expression rewriting Signed-off-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> --- monai/bundle/scripts.py | 2 +- monai/utils/safeeval.py | 32 ++++++++++++++++++++++++++++++++ tests/utils/test_safe_eval.py | 9 +++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/monai/bundle/scripts.py b/monai/bundle/scripts.py index dba50dacc19..2f2b59161ea 100644 --- a/monai/bundle/scripts.py +++ b/monai/bundle/scripts.py @@ -162,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(safe_eval(i, {"p": p, "n": n})) + ret.append(safe_eval(i, {"p": p, "n": n}, rewrite_np=True)) 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/safeeval.py b/monai/utils/safeeval.py index 48fddfa07fc..395bc5d175c 100644 --- a/monai/utils/safeeval.py +++ b/monai/utils/safeeval.py @@ -15,6 +15,8 @@ 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 @@ -37,11 +39,28 @@ ) +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 @@ -50,11 +69,20 @@ def safe_eval( 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` 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` @@ -70,4 +98,8 @@ def safe_eval( 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) diff --git a/tests/utils/test_safe_eval.py b/tests/utils/test_safe_eval.py index bf0b0891c45..836dcb90b2b 100644 --- a/tests/utils/test_safe_eval.py +++ b/tests/utils/test_safe_eval.py @@ -38,12 +38,21 @@ def test_good_exprs(self, expr, globals_vars, locals_vars, expected): 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] From d336f95c46801bf04917f9d33d3d78f950c41be3 Mon Sep 17 00:00:00 2001 From: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:25:30 +0100 Subject: [PATCH 12/14] Minor fix [skip ci] Signed-off-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> --- monai/utils/safeeval.py | 1 + 1 file changed, 1 insertion(+) diff --git a/monai/utils/safeeval.py b/monai/utils/safeeval.py index 395bc5d175c..dd357601a8c 100644 --- a/monai/utils/safeeval.py +++ b/monai/utils/safeeval.py @@ -41,6 +41,7 @@ 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 From 95e2334eebf6702b8b1ea34142d5b72523ee5e79 Mon Sep 17 00:00:00 2001 From: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:45:06 +0100 Subject: [PATCH 13/14] Minor tweak Signed-off-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> --- monai/bundle/scripts.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/monai/bundle/scripts.py b/monai/bundle/scripts.py index 2f2b59161ea..1eb15451775 100644 --- a/monai/bundle/scripts.py +++ b/monai/bundle/scripts.py @@ -25,6 +25,7 @@ from textwrap import dedent from typing import Any +import numpy as np import torch from torch.cuda import is_available @@ -159,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(safe_eval(i, {"p": p, "n": n}, rewrite_np=True)) + 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) From dea9fa62874851bcfea612d60007a9f55d5b5722 Mon Sep 17 00:00:00 2001 From: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:50:29 +0100 Subject: [PATCH 14/14] Fix Signed-off-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com> --- monai/bundle/scripts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monai/bundle/scripts.py b/monai/bundle/scripts.py index 1eb15451775..94fd0a57e7c 100644 --- a/monai/bundle/scripts.py +++ b/monai/bundle/scripts.py @@ -160,7 +160,7 @@ def _get_fake_spatial_shape(shape: Sequence[str | int], p: int = 1, n: int = 1, if i == "*": ret.append(any) else: - bad_names=set(c for c in _get_var_names(i) if c not in {"p", "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}")