Skip to content

Commit e0bd528

Browse files
moonbox3MAF Dashboard BotCopilot
authored
Python: Default Dapr module allowlist to semantic_kernel prefix (#13596)
### Motivation and Context Follow-up to #13499. The previous PR added the `allowed_module_prefixes` parameter but defaulted it to `None`, which meant the module restriction was only active if developers discovered and configured it. Secure-by-default is the right posture here — restrict first, let developers widen as needed. - Change `allowed_module_prefixes` default from `None` to `("semantic_kernel.",)` across Dapr runtime step loading - Non-SK step classes now require developers to explicitly add their module prefix (e.g. `("semantic_kernel.", "myapp.steps.")`) - Developers can pass `None` to opt out entirely, but the secure default is now enforced - The Dapr runtime code is experimental, so this is a non-breaking change per our stability guarantees <!-- Thank you for your contribution to the semantic-kernel repo! Please help reviewers and future users, providing the following information: 1. Why is this change required? 2. What problem does it solve? 3. What scenario does it contribute to? 4. If it fixes an open issue, please link to the issue here. --> <!-- Describe your changes, the overall approach, the underlying design. These notes will help understanding how your code works. Thanks! --> ### Contribution Checklist <!-- Before submitting this PR, please make sure: --> - [X] The code builds clean without any errors or warnings - [X] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [X] All unit tests pass, and I have added new tests where possible - [ ] I didn't break anyone 😄 --------- Co-authored-by: MAF Dashboard Bot <maf-dashboard-bot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5069b41 commit e0bd528

File tree

9 files changed

+175
-43
lines changed

9 files changed

+175
-43
lines changed

python/semantic_kernel/processes/dapr_runtime/actors/step_actor.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from semantic_kernel.processes.process_message_factory import ProcessMessageFactory
3939
from semantic_kernel.processes.process_types import get_generic_state_type
4040
from semantic_kernel.processes.step_utils import (
41+
DEFAULT_ALLOWED_MODULE_PREFIXES,
4142
find_input_channels,
4243
get_fully_qualified_name,
4344
get_step_class_from_qualified_name,
@@ -57,7 +58,7 @@ def __init__(
5758
actor_id: ActorId,
5859
kernel: Kernel,
5960
factories: dict[str, Callable],
60-
allowed_module_prefixes: Sequence[str] | None = None,
61+
allowed_module_prefixes: Sequence[str] | None = DEFAULT_ALLOWED_MODULE_PREFIXES,
6162
):
6263
"""Initializes a new instance of StepActor.
6364
@@ -66,9 +67,10 @@ def __init__(
6667
actor_id: The unique ID for the actor.
6768
kernel: The Kernel dependency to be injected.
6869
factories: The factory dictionary to use for creating the step.
69-
allowed_module_prefixes: Optional sequence of module prefixes that are allowed
70-
for step class loading. If provided, step classes must come from modules
71-
starting with one of these prefixes.
70+
allowed_module_prefixes: Sequence of module prefixes that are allowed
71+
for step class loading. Step classes must come from modules starting
72+
with one of these prefixes. Defaults to ("semantic_kernel.",). Pass
73+
None to allow any module (not recommended for production).
7274
"""
7375
super().__init__(ctx, actor_id)
7476
self.kernel = kernel

python/semantic_kernel/processes/dapr_runtime/dapr_kernel_process_context.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Copyright (c) Microsoft. All rights reserved.
22

33
import uuid
4+
from collections.abc import Sequence
45

56
from dapr.actor import ActorId, ActorProxy
67

@@ -9,6 +10,7 @@
910
from semantic_kernel.processes.dapr_runtime.interfaces.process_interface import ProcessInterface
1011
from semantic_kernel.processes.kernel_process.kernel_process import KernelProcess
1112
from semantic_kernel.processes.kernel_process.kernel_process_event import KernelProcessEvent
13+
from semantic_kernel.processes.step_utils import DEFAULT_ALLOWED_MODULE_PREFIXES
1214
from semantic_kernel.utils.feature_stage_decorator import experimental
1315

1416

@@ -20,13 +22,21 @@ class DaprKernelProcessContext:
2022
process: KernelProcess
2123
max_supersteps: int = 100
2224

23-
def __init__(self, process: KernelProcess, max_supersteps: int | None = None) -> None:
25+
def __init__(
26+
self,
27+
process: KernelProcess,
28+
max_supersteps: int | None = None,
29+
allowed_module_prefixes: Sequence[str] | None = DEFAULT_ALLOWED_MODULE_PREFIXES,
30+
) -> None:
2431
"""Initialize a new instance of DaprKernelProcessContext.
2532
2633
Args:
2734
process: The kernel process to start.
2835
max_supersteps: The maximum number of supersteps. This is the total number of times process steps will run.
2936
Defaults to None, and thus the process will run its steps 100 times.
37+
allowed_module_prefixes: Sequence of module prefixes that are allowed
38+
for step class loading. Defaults to ("semantic_kernel.",). Pass
39+
None to allow any module (not recommended for production).
3040
"""
3141
if process.state.name is None:
3242
raise ValueError("Process state name must not be None")
@@ -36,6 +46,7 @@ def __init__(self, process: KernelProcess, max_supersteps: int | None = None) ->
3646
if max_supersteps is not None:
3747
self.max_supersteps = max_supersteps
3848

49+
self.allowed_module_prefixes = allowed_module_prefixes
3950
self.process = process
4051
process_id = ActorId(process.state.id)
4152
self.dapr_process = ActorProxy.create( # type: ignore
@@ -76,4 +87,6 @@ async def get_state(self) -> KernelProcess:
7687
"""
7788
raw_process_info = await self.dapr_process.get_process_info()
7889
dapr_process_info = DaprProcessInfo.model_validate(raw_process_info)
79-
return dapr_process_info.to_kernel_process()
90+
return dapr_process_info.to_kernel_process(
91+
allowed_module_prefixes=self.allowed_module_prefixes,
92+
)

python/semantic_kernel/processes/dapr_runtime/dapr_process_info.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from semantic_kernel.processes.kernel_process.kernel_process import KernelProcess
1111
from semantic_kernel.processes.kernel_process.kernel_process_state import KernelProcessState
1212
from semantic_kernel.processes.kernel_process.kernel_process_step_info import KernelProcessStepInfo
13+
from semantic_kernel.processes.step_utils import DEFAULT_ALLOWED_MODULE_PREFIXES
1314
from semantic_kernel.utils.feature_stage_decorator import experimental
1415

1516

@@ -20,13 +21,17 @@ class DaprProcessInfo(DaprStepInfo):
2021
type: Literal["DaprProcessInfo"] = "DaprProcessInfo" # type: ignore
2122
steps: MutableSequence["DaprStepInfo | DaprProcessInfo"] = Field(default_factory=list)
2223

23-
def to_kernel_process(self, allowed_module_prefixes: Sequence[str] | None = None) -> KernelProcess:
24+
def to_kernel_process(
25+
self, allowed_module_prefixes: Sequence[str] | None = DEFAULT_ALLOWED_MODULE_PREFIXES
26+
) -> KernelProcess:
2427
"""Converts the Dapr process info to a kernel process.
2528
2629
Args:
27-
allowed_module_prefixes: Optional list of module prefixes that are allowed
28-
for step class loading. If provided, step classes must come from modules
29-
starting with one of these prefixes.
30+
allowed_module_prefixes: Sequence of module prefixes that are allowed
31+
for step class loading. Defaults to DEFAULT_ALLOWED_MODULE_PREFIXES
32+
("semantic_kernel.",). Pass None to disable the allowlist and allow
33+
any module (not recommended for production). An empty sequence blocks
34+
all modules.
3035
"""
3136
if not isinstance(self.state, KernelProcessState):
3237
raise ValueError("State must be a kernel process state")

python/semantic_kernel/processes/dapr_runtime/dapr_step_info.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@
1111
from semantic_kernel.processes.kernel_process.kernel_process_state import KernelProcessState
1212
from semantic_kernel.processes.kernel_process.kernel_process_step_info import KernelProcessStepInfo
1313
from semantic_kernel.processes.kernel_process.kernel_process_step_state import KernelProcessStepState
14-
from semantic_kernel.processes.step_utils import get_fully_qualified_name, get_step_class_from_qualified_name
14+
from semantic_kernel.processes.step_utils import (
15+
DEFAULT_ALLOWED_MODULE_PREFIXES,
16+
get_fully_qualified_name,
17+
get_step_class_from_qualified_name,
18+
)
1519
from semantic_kernel.utils.feature_stage_decorator import experimental
1620

1721

@@ -25,14 +29,16 @@ class DaprStepInfo(KernelBaseModel):
2529
edges: dict[str, list[KernelProcessEdge]] = Field(default_factory=dict)
2630

2731
def to_kernel_process_step_info(
28-
self, allowed_module_prefixes: Sequence[str] | None = None
32+
self, allowed_module_prefixes: Sequence[str] | None = DEFAULT_ALLOWED_MODULE_PREFIXES
2933
) -> KernelProcessStepInfo:
3034
"""Converts the Dapr step info to a kernel process step info.
3135
3236
Args:
33-
allowed_module_prefixes: Optional list of module prefixes that are allowed
34-
for step class loading. If provided, step classes must come from modules
35-
starting with one of these prefixes.
37+
allowed_module_prefixes: Sequence of module prefixes that are allowed
38+
for step class loading. Defaults to DEFAULT_ALLOWED_MODULE_PREFIXES
39+
("semantic_kernel.",). Pass None to disable the allowlist and allow
40+
any module (not recommended for production). An empty sequence blocks
41+
all modules.
3642
"""
3743
inner_step_type = get_step_class_from_qualified_name(
3844
self.inner_step_python_type,

python/semantic_kernel/processes/step_utils.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
from semantic_kernel.processes.kernel_process.kernel_process_step_context import KernelProcessStepContext
1313
from semantic_kernel.utils.feature_stage_decorator import experimental
1414

15+
DEFAULT_ALLOWED_MODULE_PREFIXES: tuple[str, ...] = ("semantic_kernel.",)
16+
1517

1618
@experimental
1719
def find_input_channels(
@@ -47,7 +49,7 @@ def get_fully_qualified_name(cls) -> str:
4749
@experimental
4850
def get_step_class_from_qualified_name(
4951
full_class_name: str,
50-
allowed_module_prefixes: Sequence[str] | None = None,
52+
allowed_module_prefixes: Sequence[str] | None = DEFAULT_ALLOWED_MODULE_PREFIXES,
5153
) -> type[KernelProcessStep]:
5254
"""Loads and validates a KernelProcessStep class from a fully qualified name.
5355
@@ -58,11 +60,12 @@ def get_step_class_from_qualified_name(
5860
full_class_name: The fully qualified class name in Python import notation
5961
(e.g., 'mypackage.mymodule.MyStep'). The module must be importable
6062
from the current Python environment.
61-
allowed_module_prefixes: Optional list of module prefixes that are allowed
62-
to be imported. If provided, the module must start with one of these
63-
prefixes. This check is performed BEFORE import to prevent execution
64-
of module-level code in unauthorized modules. If None or empty, any
65-
module is allowed.
63+
allowed_module_prefixes: Sequence of module prefixes that are allowed
64+
to be imported. The module must start with one of these prefixes.
65+
This check is performed BEFORE import to prevent execution of
66+
module-level code in unauthorized modules. Defaults to
67+
("semantic_kernel.",). Pass None to allow any module (not
68+
recommended for production). An empty sequence blocks all modules.
6669
6770
Returns:
6871
The validated class type that is a subclass of KernelProcessStep
@@ -90,7 +93,12 @@ def get_step_class_from_qualified_name(
9093
)
9194

9295
# Check module allowlist BEFORE import to prevent module-level code execution
93-
if allowed_module_prefixes and not any(module_name.startswith(prefix) for prefix in allowed_module_prefixes):
96+
if allowed_module_prefixes is not None and not any(
97+
module_name.startswith(prefix)
98+
if prefix.endswith(".")
99+
else (module_name == prefix or module_name.startswith(prefix + "."))
100+
for prefix in allowed_module_prefixes
101+
):
94102
raise ProcessInvalidConfigurationException(
95103
f"Module '{module_name}' is not in the allowed module prefixes: {allowed_module_prefixes}. "
96104
f"Step class '{full_class_name}' cannot be loaded."

python/tests/conftest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
from unittest.mock import MagicMock
88
from uuid import uuid4
99

10-
import pandas as pd
1110
from pydantic import BaseModel
1211
from pytest import fixture
1312

@@ -355,6 +354,8 @@ def definition(
355354

356355
@fixture
357356
def definition_pandas(index_kind: str, distance_function: str, vector_property_type: str, dimensions: int) -> object:
357+
import pandas as pd
358+
358359
return VectorStoreCollectionDefinition(
359360
fields=[
360361
VectorStoreField(

python/tests/unit/processes/dapr_runtime/test_dapr_kernel_process_context.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@ def process_context():
4141
mock_dapr_process = AsyncMock(spec=ProcessInterface)
4242
mock_actor_proxy_create.return_value = mock_dapr_process
4343

44-
context = DaprKernelProcessContext(process=process)
44+
context = DaprKernelProcessContext(
45+
process=process,
46+
allowed_module_prefixes=("semantic_kernel.", DummyInnerStepType.__module__),
47+
)
4548

4649
yield context, mock_dapr_process
4750

python/tests/unit/processes/dapr_runtime/test_step_class_loading.py

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ class NotAStep:
2222
def test_valid_step_class_loads_successfully():
2323
"""Test that a valid KernelProcessStep subclass loads correctly."""
2424
full_name = f"{MockValidStep.__module__}.{MockValidStep.__name__}"
25-
result = get_step_class_from_qualified_name(full_name)
25+
result = get_step_class_from_qualified_name(
26+
full_name,
27+
allowed_module_prefixes=[MockValidStep.__module__],
28+
)
2629
assert result is MockValidStep
2730
assert issubclass(result, KernelProcessStep)
2831

@@ -60,7 +63,7 @@ def test_none_like_empty_raises_exception():
6063
def test_nonexistent_module_raises_exception():
6164
"""Test that a non-existent module raises ProcessInvalidConfigurationException."""
6265
with pytest.raises(ProcessInvalidConfigurationException, match="Unable to import module"):
63-
get_step_class_from_qualified_name("nonexistent_module_xyz123.SomeClass")
66+
get_step_class_from_qualified_name("nonexistent_module_xyz123.SomeClass", allowed_module_prefixes=None)
6467

6568

6669
def test_nonexistent_class_in_valid_module_raises_exception():
@@ -79,25 +82,25 @@ def test_non_step_class_raises_exception():
7982
"""Test that a class not inheriting from KernelProcessStep raises exception."""
8083
full_name = f"{NotAStep.__module__}.{NotAStep.__name__}"
8184
with pytest.raises(ProcessInvalidConfigurationException, match="must be a subclass of KernelProcessStep"):
82-
get_step_class_from_qualified_name(full_name)
85+
get_step_class_from_qualified_name(full_name, allowed_module_prefixes=[NotAStep.__module__])
8386

8487

8588
def test_builtin_class_raises_exception():
86-
"""Test that built-in classes like str raise exception."""
89+
"""Test that built-in classes like str raise exception (bypassing prefix check to test subclass validation)."""
8790
with pytest.raises(ProcessInvalidConfigurationException, match="must be a subclass of KernelProcessStep"):
88-
get_step_class_from_qualified_name("builtins.str")
91+
get_step_class_from_qualified_name("builtins.str", allowed_module_prefixes=None)
8992

9093

9194
def test_os_system_prevented():
92-
"""Test that os.system (a dangerous function, not a class) is prevented."""
95+
"""Test that os.system is prevented (bypassing prefix check to test type validation)."""
9396
with pytest.raises(ProcessInvalidConfigurationException, match="is not a class type"):
94-
get_step_class_from_qualified_name("os.system")
97+
get_step_class_from_qualified_name("os.system", allowed_module_prefixes=None)
9598

9699

97100
def test_arbitrary_class_prevented():
98-
"""Test that arbitrary classes like subprocess.Popen are prevented."""
101+
"""Test that arbitrary classes like subprocess.Popen are prevented (bypassing prefix check)."""
99102
with pytest.raises(ProcessInvalidConfigurationException, match="must be a subclass of KernelProcessStep"):
100-
get_step_class_from_qualified_name("subprocess.Popen")
103+
get_step_class_from_qualified_name("subprocess.Popen", allowed_module_prefixes=None)
101104

102105

103106
def test_kernel_class_prevented():
@@ -139,31 +142,51 @@ def test_allowlist_blocks_dangerous_module():
139142
)
140143

141144

142-
def test_empty_allowlist_allows_all():
143-
"""Test that an empty allowlist allows any module (current behavior)."""
145+
def test_empty_allowlist_blocks_all():
146+
"""Test that an empty allowlist blocks all modules."""
144147
full_name = f"{MockValidStep.__module__}.{MockValidStep.__name__}"
145-
result = get_step_class_from_qualified_name(full_name, allowed_module_prefixes=[])
146-
assert result is MockValidStep
148+
with pytest.raises(ProcessInvalidConfigurationException, match="is not in the allowed module prefixes"):
149+
get_step_class_from_qualified_name(full_name, allowed_module_prefixes=[])
147150

148151

149152
def test_none_allowlist_allows_all():
150-
"""Test that None allowlist allows any module (default behavior)."""
153+
"""Test that None allowlist allows any module (explicit opt-out)."""
151154
full_name = f"{MockValidStep.__module__}.{MockValidStep.__name__}"
152155
result = get_step_class_from_qualified_name(full_name, allowed_module_prefixes=None)
153156
assert result is MockValidStep
154157

155158

159+
def test_default_allowlist_blocks_non_sk_modules():
160+
"""Test that the default allowlist only permits semantic_kernel modules."""
161+
with pytest.raises(ProcessInvalidConfigurationException, match="is not in the allowed module prefixes"):
162+
get_step_class_from_qualified_name("subprocess.Popen")
163+
164+
165+
def test_default_allowlist_permits_sk_modules():
166+
"""Test that the default allowlist permits semantic_kernel modules."""
167+
full_name = "semantic_kernel.processes.kernel_process.kernel_process_step.KernelProcessStep"
168+
result = get_step_class_from_qualified_name(full_name)
169+
assert result is KernelProcessStep
170+
171+
156172
def test_allowlist_prefix_matching():
157-
"""Test that allowlist uses prefix matching correctly."""
173+
"""Test that allowlist uses boundary-aware prefix matching correctly."""
158174
full_name = f"{MockValidStep.__module__}.{MockValidStep.__name__}"
159-
# Use a prefix of the actual module name
160-
module_prefix = MockValidStep.__module__[:4] # First 4 chars as prefix
175+
# Exact module name match works
161176
result = get_step_class_from_qualified_name(
162177
full_name,
163-
allowed_module_prefixes=[module_prefix],
178+
allowed_module_prefixes=[MockValidStep.__module__],
164179
)
165180
assert result is MockValidStep
166181

182+
# Arbitrary substring prefix without dot boundary does not match
183+
short_prefix = MockValidStep.__module__[:4] # e.g. "test"
184+
with pytest.raises(ProcessInvalidConfigurationException, match="is not in the allowed module prefixes"):
185+
get_step_class_from_qualified_name(
186+
full_name,
187+
allowed_module_prefixes=[short_prefix],
188+
)
189+
167190

168191
def test_allowlist_multiple_prefixes():
169192
"""Test that multiple allowed prefixes work correctly."""

0 commit comments

Comments
 (0)