Compare commits

...
Author SHA1 Message Date
Hugo P.Brito a1afab7ece fix(m365): enforce required Exchange module version
- Detect the pinned module version explicitly
- Install and import the compatible version when needed
- Cover existing incompatible versions with a regression test
2026-09-02 11:58:52 +01:00
Hugo P.Brito c4a29defd9 fix(m365): pin Exchange Online module version
- Pin installation and import to a PowerShell 7.5-compatible release
- Add regression coverage for the required module version
- Document the M365 SDK fix
2026-09-01 15:38:01 +01:00
3 changed files with 97 additions and 9 deletions
@@ -0,0 +1 @@
`ExchangeOnlineManagement` pinned to version 3.9.2 for compatibility with Prowler's Linux PowerShell 7.5 runtime
@@ -1148,18 +1148,36 @@ def initialize_m365_powershell_modules():
"MicrosoftTeams",
"MSAL.PS",
]
REQUIRED_MODULE_VERSIONS = {"ExchangeOnlineManagement": "3.9.2"}
pwsh = PowerShellSession()
try:
for module in REQUIRED_MODULES:
try:
required_version = REQUIRED_MODULE_VERSIONS.get(module)
version_parameter = (
f" -RequiredVersion '{required_version}'"
if required_version
else ""
)
# Check if module is already installed
result = pwsh.execute(f"Get-Module -ListAvailable {module}", timeout=5)
availability_command = (
"Get-Module -ListAvailable -FullyQualifiedName "
f"@{{ ModuleName = '{module}'; RequiredVersion = '{required_version}' }}"
if required_version
else f"Get-Module -ListAvailable -Name '{module}'"
)
result = pwsh.execute(availability_command, timeout=5)
# Install module if not installed
if not result:
install_command = (
f"Install-Module -Name '{module}'{version_parameter} "
"-Force -AllowClobber -Scope CurrentUser"
)
install_result = pwsh.execute(
f"Install-Module {module} -Force -AllowClobber -Scope CurrentUser",
install_command,
timeout=60,
)
if install_result:
@@ -1169,8 +1187,12 @@ def initialize_m365_powershell_modules():
else:
logger.info(f"Successfully installed module {module}")
if not result or required_version:
# Import module
pwsh.execute(f'Import-Module "{module}" -Force', timeout=1)
pwsh.execute(
f"Import-Module -Name '{module}'{version_parameter} -Force",
timeout=1,
)
except Exception as error:
logger.error(f"Failed to initialize module {module}: {str(error)}")
@@ -409,7 +409,7 @@ class Testm365PowerShell:
mock_popen.return_value = mock_process
# Mock the execute method to simulate successful module installation
def mock_execute(command, *args, **kwargs):
def mock_execute(command, *_args, **_kwargs):
if "Get-Module" in command:
return None # Module not installed
elif "Install-Module" in command:
@@ -436,12 +436,77 @@ class Testm365PowerShell:
assert (
mock_execute_obj.call_count == 3 * 3
) # number of modules * 3 commands each
mock_execute_obj.assert_any_call(
(
"Install-Module -Name 'ExchangeOnlineManagement' "
"-RequiredVersion '3.9.2' "
"-Force -AllowClobber -Scope CurrentUser"
),
timeout=60,
)
mock_execute_obj.assert_any_call(
(
"Import-Module -Name 'ExchangeOnlineManagement' "
"-RequiredVersion '3.9.2' -Force"
),
timeout=1,
)
# Verify success messages were logged
mock_info.assert_any_call(
"Successfully installed module ExchangeOnlineManagement"
)
mock_info.assert_any_call("Successfully installed module MicrosoftTeams")
@patch("subprocess.Popen")
def test_initialize_m365_powershell_modules_installs_required_exchange_version(
self, mock_popen
):
"""Install the required Exchange module when only another version exists."""
mock_popen.return_value = MagicMock()
# Given: 3.10.0 satisfies the old name-only query, while the exact
# required-version query correctly reports that 3.9.2 is absent.
exchange_availability = {
"Get-Module -ListAvailable ExchangeOnlineManagement": "3.10.0",
(
"Get-Module -ListAvailable -FullyQualifiedName "
"@{ ModuleName = 'ExchangeOnlineManagement'; "
"RequiredVersion = '3.9.2' }"
): None,
}
def mock_execute(command, *_args, **_kwargs):
if command in exchange_availability:
return exchange_availability[command]
if "Get-Module" in command:
return "installed"
return None
with patch.object(
PowerShellSession, "execute", side_effect=mock_execute
) as mock_execute_obj:
from prowler.providers.m365.lib.powershell.m365_powershell import (
initialize_m365_powershell_modules,
)
result = initialize_m365_powershell_modules()
assert result is True
mock_execute_obj.assert_any_call(
(
"Install-Module -Name 'ExchangeOnlineManagement' "
"-RequiredVersion '3.9.2' -Force -AllowClobber -Scope CurrentUser"
),
timeout=60,
)
mock_execute_obj.assert_any_call(
(
"Import-Module -Name 'ExchangeOnlineManagement' "
"-RequiredVersion '3.9.2' -Force"
),
timeout=1,
)
@patch("subprocess.Popen")
def test_initialize_m365_powershell_modules_failure(self, mock_popen):
"""Test initialize_m365_powershell_modules when module initialization fails"""
@@ -449,7 +514,7 @@ class Testm365PowerShell:
mock_popen.return_value = mock_process
# Mock the execute method to simulate installation failure
def mock_execute(command, *args, **kwargs):
def mock_execute(command, *_args, **_kwargs):
if "Get-Module" in command:
return None # Module not installed
elif "Install-Module" in command:
@@ -484,7 +549,7 @@ class Testm365PowerShell:
mock_popen.return_value = mock_process
# Mock the execute method to simulate successful module installation
def mock_execute(command, *args, **kwargs):
def mock_execute(command, *_args, **_kwargs):
if "Get-Module" in command:
return None # Module not installed
elif "Install-Module" in command:
@@ -522,7 +587,7 @@ class Testm365PowerShell:
mock_popen.return_value = mock_process
# Mock the execute method to simulate installation failure
def mock_execute(command, *args, **kwargs):
def mock_execute(command, *_args, **_kwargs):
if "Get-Module" in command:
return None # Module not installed
elif "Install-Module" in command:
@@ -670,7 +735,7 @@ class Testm365PowerShell:
session = M365PowerShell(credentials, identity)
# Mock execute to return valid responses
def mock_execute(command, *args, **kwargs):
def mock_execute(command, *_args, **_kwargs):
if "Write-Output $exchangeToken" in command:
return "valid_exchange_token"
return None
@@ -720,7 +785,7 @@ class Testm365PowerShell:
session = M365PowerShell(credentials, identity)
# Mock execute to return valid token but decode returns no permissions
def mock_execute(command, *args, **kwargs):
def mock_execute(command, *_args, **_kwargs):
if "Write-Output $exchangeToken" in command:
return "valid_exchange_token"
return None