fix(powershell): handle m365 provider execution and logging (#7602)

Co-authored-by: MrCloudSec <hello@mistercloudsec.com>
This commit is contained in:
Hugo Pereira Brito
2025-04-25 16:44:25 +02:00
committed by Pepe Fagoaga
parent aebc89c17c
commit ad25a8fe82
10 changed files with 365 additions and 102 deletions
+66 -17
View File
@@ -4,6 +4,9 @@ import queue
import re
import subprocess
import threading
from typing import Union
from prowler.lib.logger import logger
class PowerShellSession:
@@ -100,7 +103,9 @@ class PowerShellSession:
ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
return ansi_escape.sub("", text)
def execute(self, command: str) -> dict:
def execute(
self, command: str, json_parse: bool = False, timeout: int = 10
) -> Union[str, dict]:
"""
Send a command to PowerShell and retrieve its output.
@@ -120,31 +125,41 @@ class PowerShellSession:
"""
self.process.stdin.write(f"{command}\n")
self.process.stdin.write(f"Write-Output '{self.END}'\n")
return self.json_parse_output(self.read_output())
self.process.stdin.write(f"Write-Error '{self.END}'\n")
return (
self.json_parse_output(self.read_output(timeout=timeout))
if json_parse
else self.read_output(timeout=timeout)
)
def read_output(self, timeout: int = 10, default: str = "") -> str:
"""
Read output from a process with timeout functionality.
This method reads lines from process stdout until it encounters the END marker
or the stream ends. If reading takes longer than the timeout period, the method
returns a default value while allowing the reading to continue in the background.
This method reads lines from process stdout and stderr in separate threads until it encounters
the END marker for each stream. If reading stdout takes longer than the timeout period,
the method returns a default value while allowing the reading to continue in the background.
Any errors from stderr are logged but do not affect the return value.
Args:
timeout (int, optional): Maximum time in seconds to wait for output.
timeout (int, optional): Maximum time in seconds to wait for stdout output.
Defaults to 10.
default (str, optional): Value to return if timeout occurs.
default (str, optional): Value to return if stdout timeout occurs.
Defaults to empty string.
Returns:
str: Concatenated output lines or default value if timeout occurs.
str: The stdout output if available, otherwise the default value.
Errors from stderr are logged but not returned.
Note:
This method uses a daemon thread to read the output asynchronously,
This method uses daemon threads to read stdout and stderr asynchronously,
ensuring that the main thread is not blocked.
"""
output_lines = []
result_queue = queue.Queue()
error_lines = []
error_queue = queue.Queue()
def reader_thread():
try:
@@ -154,17 +169,35 @@ class PowerShellSession:
break
output_lines.append(line)
result_queue.put("\n".join(output_lines))
except Exception as e:
result_queue.put(str(e))
except Exception as error:
result_queue.put(str(error))
def error_reader_thread():
try:
while True:
line = self.remove_ansi(self.process.stderr.readline().strip())
if line == f"Write-Error: {self.END}":
break
error_lines.append(line)
error_queue.put("\n".join(error_lines))
except Exception as error:
error_queue.put(str(error))
thread = threading.Thread(target=reader_thread)
thread.daemon = True
thread.start()
try:
return result_queue.get(timeout=timeout)
except queue.Empty:
return default
error_thread = threading.Thread(target=error_reader_thread)
error_thread.daemon = True
error_thread.start()
result = result_queue.get(timeout=timeout) or default
error_result = error_queue.get(timeout=1)
if error_result:
logger.error(f"PowerShell error output: {error_result}")
return result
def json_parse_output(self, output: str) -> dict:
"""
@@ -179,13 +212,29 @@ class PowerShellSession:
Returns:
dict: Parsed JSON object if found, otherwise an empty dictionary.
Raises:
JSONDecodeError: If the JSON parsing fails.
Example:
>>> json_parse_output('Some text {"key": "value"} more text')
{"key": "value"}
"""
if output == "":
return {}
json_match = re.search(r"(\[.*\]|\{.*\})", output, re.DOTALL)
if json_match:
return json.loads(json_match.group(1))
if not json_match:
logger.error(
f"Unexpected PowerShell output: {output}\n",
)
else:
try:
return json.loads(json_match.group(1))
except json.JSONDecodeError as error:
logger.error(
f"Error parsing PowerShell output as JSON: {str(error)}\n",
)
return {}
def close(self) -> None:
@@ -84,16 +84,14 @@ class M365PowerShell(PowerShellSession):
bool: True if credentials are valid and authentication succeeds, False otherwise.
"""
self.execute(
f'$securePassword = "{credentials.passwd}" | ConvertTo-SecureString\n'
f'$securePassword = "{credentials.passwd}" | ConvertTo-SecureString'
)
self.execute(
f'$credential = New-Object System.Management.Automation.PSCredential("{credentials.user}", $securePassword)\n'
)
self.process.stdin.write(
'Write-Output "$($credential.GetNetworkCredential().Password)"\n'
decrypted_password = self.execute(
'Write-Output "$($credential.GetNetworkCredential().Password)"'
)
self.process.stdin.write(f"Write-Output '{self.END}'\n")
decrypted_password = self.read_output()
app = msal.ConfidentialClientApplication(
client_id=credentials.client_id,
@@ -154,7 +152,9 @@ class M365PowerShell(PowerShellSession):
"AllowGoogleDrive": true
}
"""
return self.execute("Get-CsTeamsClientConfiguration | ConvertTo-Json")
return self.execute(
"Get-CsTeamsClientConfiguration | ConvertTo-Json", json_parse=True
)
def get_global_meeting_policy(self) -> dict:
"""
@@ -172,7 +172,8 @@ class M365PowerShell(PowerShellSession):
}
"""
return self.execute(
"Get-CsTeamsMeetingPolicy -Identity Global | ConvertTo-Json"
"Get-CsTeamsMeetingPolicy -Identity Global | ConvertTo-Json",
json_parse=True,
)
def get_user_settings(self) -> dict:
@@ -190,7 +191,9 @@ class M365PowerShell(PowerShellSession):
"AllowExternalAccess": true
}
"""
return self.execute("Get-CsTenantFederationConfiguration | ConvertTo-Json")
return self.execute(
"Get-CsTenantFederationConfiguration | ConvertTo-Json", json_parse=True
)
def connect_exchange_online(self) -> dict:
"""
@@ -222,7 +225,8 @@ class M365PowerShell(PowerShellSession):
}
"""
return self.execute(
"Get-AdminAuditLogConfig | Select-Object UnifiedAuditLogIngestionEnabled | ConvertTo-Json"
"Get-AdminAuditLogConfig | Select-Object UnifiedAuditLogIngestionEnabled | ConvertTo-Json",
json_parse=True,
)
def get_malware_filter_policy(self) -> dict:
@@ -241,7 +245,7 @@ class M365PowerShell(PowerShellSession):
"Identity": "Default"
}
"""
return self.execute("Get-MalwareFilterPolicy | ConvertTo-Json")
return self.execute("Get-MalwareFilterPolicy | ConvertTo-Json", json_parse=True)
def get_outbound_spam_filter_policy(self) -> dict:
"""
@@ -261,7 +265,9 @@ class M365PowerShell(PowerShellSession):
"NotifyOutboundSpamRecipients": []
}
"""
return self.execute("Get-HostedOutboundSpamFilterPolicy | ConvertTo-Json")
return self.execute(
"Get-HostedOutboundSpamFilterPolicy | ConvertTo-Json", json_parse=True
)
def get_outbound_spam_filter_rule(self) -> dict:
"""
@@ -278,7 +284,9 @@ class M365PowerShell(PowerShellSession):
"State": "Enabled"
}
"""
return self.execute("Get-HostedOutboundSpamFilterRule | ConvertTo-Json")
return self.execute(
"Get-HostedOutboundSpamFilterRule | ConvertTo-Json", json_parse=True
)
def get_antiphishing_policy(self) -> dict:
"""
@@ -303,7 +311,7 @@ class M365PowerShell(PowerShellSession):
"IsDefault": false
}
"""
return self.execute("Get-AntiPhishPolicy | ConvertTo-Json")
return self.execute("Get-AntiPhishPolicy | ConvertTo-Json", json_parse=True)
def get_antiphishing_rules(self) -> dict:
"""
@@ -321,7 +329,7 @@ class M365PowerShell(PowerShellSession):
"State": Enabled,
}
"""
return self.execute("Get-AntiPhishRule | ConvertTo-Json")
return self.execute("Get-AntiPhishRule | ConvertTo-Json", json_parse=True)
def get_organization_config(self) -> dict:
"""
@@ -340,7 +348,7 @@ class M365PowerShell(PowerShellSession):
"AuditDisabled": false
}
"""
return self.execute("Get-OrganizationConfig | ConvertTo-Json")
return self.execute("Get-OrganizationConfig | ConvertTo-Json", json_parse=True)
def get_mailbox_audit_config(self) -> dict:
"""
@@ -359,7 +367,9 @@ class M365PowerShell(PowerShellSession):
"AuditBypassEnabled": false
}
"""
return self.execute("Get-MailboxAuditBypassAssociation | ConvertTo-Json")
return self.execute(
"Get-MailboxAuditBypassAssociation | ConvertTo-Json", json_parse=True
)
def get_external_mail_config(self) -> dict:
"""
@@ -377,7 +387,7 @@ class M365PowerShell(PowerShellSession):
"ExternalMailTagEnabled": true
}
"""
return self.execute("Get-ExternalInOutlook | ConvertTo-Json")
return self.execute("Get-ExternalInOutlook | ConvertTo-Json", json_parse=True)
def get_transport_rules(self) -> dict:
"""
@@ -396,7 +406,7 @@ class M365PowerShell(PowerShellSession):
"SenderDomainIs": ["example.com"]
}
"""
return self.execute("Get-TransportRule | ConvertTo-Json")
return self.execute("Get-TransportRule | ConvertTo-Json", json_parse=True)
def get_connection_filter_policy(self) -> dict:
"""
@@ -415,7 +425,8 @@ class M365PowerShell(PowerShellSession):
}
"""
return self.execute(
"Get-HostedConnectionFilterPolicy -Identity Default | ConvertTo-Json"
"Get-HostedConnectionFilterPolicy -Identity Default | ConvertTo-Json",
json_parse=True,
)
def get_dkim_config(self) -> dict:
@@ -434,7 +445,7 @@ class M365PowerShell(PowerShellSession):
"Enabled": true
}
"""
return self.execute("Get-DkimSigningConfig | ConvertTo-Json")
return self.execute("Get-DkimSigningConfig | ConvertTo-Json", json_parse=True)
def get_inbound_spam_filter_policy(self) -> dict:
"""
@@ -452,4 +463,6 @@ class M365PowerShell(PowerShellSession):
"AllowedSenderDomains": "[]"
}
"""
return self.execute("Get-HostedContentFilterPolicy | ConvertTo-Json")
return self.execute(
"Get-HostedContentFilterPolicy | ConvertTo-Json", json_parse=True
)
@@ -13,5 +13,7 @@ class M365Service:
self.audit_config = provider.audit_config
self.fixer_config = provider.fixer_config
if provider.credentials:
self.powershell = M365PowerShell(provider.credentials)
# Initialize PowerShell client only if credentials are available
self.powershell = (
M365PowerShell(provider.credentials) if provider.credentials else None
)
+5 -2
View File
@@ -100,7 +100,7 @@ class M365Provider(Provider):
_audit_config: dict
_region_config: M365RegionConfig
_mutelist: M365Mutelist
_credentials: M365Credentials
_credentials: M365Credentials = {}
# TODO: this is not optional, enforce for all providers
audit_metadata: Audit_Metadata
@@ -447,8 +447,11 @@ class M365Provider(Provider):
f"M365 Region: {Fore.YELLOW}{self.region_config.name}{Style.RESET_ALL}",
f"M365 Tenant Domain: {Fore.YELLOW}{self._identity.tenant_domain}{Style.RESET_ALL} M365 Tenant ID: {Fore.YELLOW}{self._identity.tenant_id}{Style.RESET_ALL}",
f"M365 Identity Type: {Fore.YELLOW}{self._identity.identity_type}{Style.RESET_ALL} M365 Identity ID: {Fore.YELLOW}{self._identity.identity_id}{Style.RESET_ALL}",
f"M365 User: {Fore.YELLOW}{self.credentials.user}{Style.RESET_ALL}",
]
if self.credentials and self.credentials.user:
report_lines.append(
f"M365 User: {Fore.YELLOW}{self.credentials.user}{Style.RESET_ALL}"
)
report_title = (
f"{Style.BRIGHT}Using the M365 credentials below:{Style.RESET_ALL}"
)
@@ -10,16 +10,26 @@ from prowler.providers.m365.m365_provider import M365Provider
class Defender(M365Service):
def __init__(self, provider: M365Provider):
super().__init__(provider)
self.powershell.connect_exchange_online()
self.malware_policies = self._get_malware_filter_policy()
self.outbound_spam_policies = self._get_outbound_spam_filter_policy()
self.outbound_spam_rules = self._get_outbound_spam_filter_rule()
self.antiphishing_policies = self._get_antiphising_policy()
self.antiphising_rules = self._get_antiphising_rules()
self.inbound_spam_policies = self._get_inbound_spam_filter_policy()
self.connection_filter_policy = self._get_connection_filter_policy()
self.dkim_configurations = self._get_dkim_config()
self.powershell.close()
self.malware_policies = []
self.outbound_spam_policies = {}
self.outbound_spam_rules = {}
self.antiphishing_policies = {}
self.antiphising_rules = {}
self.connection_filter_policy = None
self.dkim_configurations = []
self.inbound_spam_policies = []
if self.powershell:
self.powershell.connect_exchange_online()
self.malware_policies = self._get_malware_filter_policy()
self.outbound_spam_policies = self._get_outbound_spam_filter_policy()
self.outbound_spam_rules = self._get_outbound_spam_filter_rule()
self.antiphishing_policies = self._get_antiphising_policy()
self.antiphising_rules = self._get_antiphising_rules()
self.connection_filter_policy = self._get_connection_filter_policy()
self.dkim_configurations = self._get_dkim_config()
self.inbound_spam_policies = self._get_inbound_spam_filter_policy()
self.powershell.close()
def _get_malware_filter_policy(self):
logger.info("M365 - Getting Defender malware filter policy...")
@@ -10,12 +10,18 @@ from prowler.providers.m365.m365_provider import M365Provider
class Exchange(M365Service):
def __init__(self, provider: M365Provider):
super().__init__(provider)
self.powershell.connect_exchange_online()
self.organization_config = self._get_organization_config()
self.mailboxes_config = self._get_mailbox_audit_config()
self.external_mail_config = self._get_external_mail_config()
self.transport_rules = self._get_transport_rules()
self.powershell.close()
self.organization_config = None
self.mailboxes_config = []
self.external_mail_config = []
self.transport_rules = []
if self.powershell:
self.powershell.connect_exchange_online()
self.organization_config = self._get_organization_config()
self.mailboxes_config = self._get_mailbox_audit_config()
self.external_mail_config = self._get_external_mail_config()
self.transport_rules = self._get_transport_rules()
self.powershell.close()
def _get_organization_config(self):
logger.info("Microsoft365 - Getting Exchange Organization configuration...")
@@ -8,9 +8,12 @@ from prowler.providers.m365.m365_provider import M365Provider
class Purview(M365Service):
def __init__(self, provider: M365Provider):
super().__init__(provider)
self.powershell.connect_exchange_online()
self.audit_log_config = self._get_audit_log_config()
self.powershell.close()
self.audit_log_config = None
if self.powershell:
self.powershell.connect_exchange_online()
self.audit_log_config = self._get_audit_log_config()
self.powershell.close()
def _get_audit_log_config(self):
logger.info("M365 - Getting Admin Audit Log settings...")
@@ -8,11 +8,16 @@ from prowler.providers.m365.m365_provider import M365Provider
class Teams(M365Service):
def __init__(self, provider: M365Provider):
super().__init__(provider)
self.powershell.connect_microsoft_teams()
self.teams_settings = self._get_teams_client_configuration()
self.global_meeting_policy = self._get_global_meeting_policy()
self.user_settings = self._get_user_settings()
self.powershell.close()
self.teams_settings = None
self.global_meeting_policy = None
self.user_settings = None
if self.powershell:
self.powershell.connect_microsoft_teams()
self.teams_settings = self._get_teams_client_configuration()
self.global_meeting_policy = self._get_global_meeting_policy()
self.user_settings = self._get_user_settings()
self.powershell.close()
def _get_teams_client_configuration(self):
logger.info("M365 - Getting Teams settings...")
+136 -14
View File
@@ -58,34 +58,109 @@ class TestPowerShellSession:
@patch("subprocess.Popen")
def test_execute(self, mock_popen):
"""Test the execute method with various scenarios:
- Normal command execution
- JSON parsing enabled
- Timeout handling
- Error handling
"""
# Setup
mock_process = MagicMock()
mock_popen.return_value = mock_process
session = PowerShellSession()
command = "Get-Command"
expected_output = '{"Name": "Get-Command"}'
with patch.object(session, "read_output", return_value=expected_output):
result = session.execute(command)
mock_process.stdin.write.assert_any_call(f"{command}\n")
# Test 1: Normal command execution
mock_process.stdout.readline.side_effect = ["Hello World\n", f"{session.END}\n"]
mock_process.stderr.readline.return_value = f"Write-Error: {session.END}\n"
with patch.object(session, "remove_ansi", side_effect=lambda x: x):
result = session.execute("Get-Command")
assert result == "Hello World"
mock_process.stdin.write.assert_any_call("Get-Command\n")
mock_process.stdin.write.assert_any_call(f"Write-Output '{session.END}'\n")
assert result == {"Name": "Get-Command"}
mock_process.stdin.write.assert_any_call(f"Write-Error '{session.END}'\n")
# Test 2: JSON parsing enabled
mock_process.stdout.readline.side_effect = [
'{"key": "value"}\n',
f"{session.END}\n",
]
mock_process.stderr.readline.return_value = f"Write-Error: {session.END}\n"
with patch.object(session, "remove_ansi", side_effect=lambda x: x):
with patch.object(
session, "json_parse_output", return_value={"key": "value"}
) as mock_json_parse:
result = session.execute("Get-Command", json_parse=True)
assert result == {"key": "value"}
mock_json_parse.assert_called_once_with('{"key": "value"}')
# Test 3: Timeout handling
mock_process.stdout.readline.side_effect = ["test output\n"] # No END marker
mock_process.stderr.readline.return_value = f"Write-Error: {session.END}\n"
result = session.execute("Get-Command", timeout=0.1)
assert result == ""
# Test 4: Error handling
mock_process.stdout.readline.side_effect = ["\n", f"{session.END}\n"]
mock_process.stderr.readline.side_effect = [
"Write-Error: This is an error\n",
f"Write-Error: {session.END}\n",
]
with patch.object(session, "remove_ansi", side_effect=lambda x: x):
with patch("prowler.lib.logger.logger.error") as mock_error:
result = session.execute("Get-Command")
assert result == ""
mock_error.assert_called_once_with(
"PowerShell error output: Write-Error: This is an error"
)
session.close()
@patch("subprocess.Popen")
def test_read_output(self, mock_popen):
"""Test the read_output method with various scenarios:
- Normal stdout output
- Error in stderr
- Timeout in stdout
- Empty output
"""
# Setup
mock_process = MagicMock()
mock_popen.return_value = mock_process
session = PowerShellSession()
# Test normal output
with patch.object(session, "read_output", return_value="test@example.com"):
assert session.read_output() == "test@example.com"
# Test 1: Normal stdout output
mock_process.stdout.readline.side_effect = ["Hello World\n", f"{session.END}\n"]
mock_process.stderr.readline.return_value = f"Write-Error: {session.END}\n"
with patch.object(session, "remove_ansi", side_effect=lambda x: x):
result = session.read_output()
assert result == "Hello World"
# Test 2: Error in stderr
mock_process.stdout.readline.side_effect = ["\n", f"{session.END}\n"]
mock_process.stderr.readline.side_effect = [
"Write-Error: This is an error\n",
f"Write-Error: {session.END}\n",
]
with patch.object(session, "remove_ansi", side_effect=lambda x: x):
with patch("prowler.lib.logger.logger.error") as mock_error:
result = session.read_output()
assert result == ""
mock_error.assert_called_once_with(
"PowerShell error output: Write-Error: This is an error"
)
# Test 3: Timeout in stdout
mock_process.stdout.readline.side_effect = ["test output\n"] # No END marker
mock_process.stderr.readline.return_value = f"Write-Error: {session.END}\n"
result = session.read_output(timeout=0.1, default="timeout")
assert result == "timeout"
# Test 4: Empty output
mock_process.stdout.readline.side_effect = [f"{session.END}\n"]
mock_process.stderr.readline.return_value = f"Write-Error: {session.END}\n"
result = session.read_output()
assert result == ""
# Test timeout
mock_process.stdout.readline.return_value = "test output\n"
with patch.object(session, "remove_ansi", return_value="test output"):
assert session.read_output(timeout=0.1, default="") == ""
session.close()
@patch("subprocess.Popen")
@@ -112,6 +187,53 @@ class TestPowerShellSession:
assert result == expected
session.close()
@patch("subprocess.Popen")
def test_json_parse_output_logging(self, mock_popen):
mock_process = MagicMock()
mock_popen.return_value = mock_process
session = PowerShellSession()
# Test warning for non-JSON output
with patch("prowler.lib.logger.logger.error") as mock_error:
result = session.json_parse_output("some text without json")
assert result == {}
mock_error.assert_called_once_with(
"Unexpected PowerShell output: some text without json\n"
)
session.close()
@patch("subprocess.Popen")
def test_json_parse_output_with_text_around_json(self, mock_popen):
mock_process = MagicMock()
mock_popen.return_value = mock_process
session = PowerShellSession()
# Test JSON extraction from text with surrounding content
result = session.json_parse_output('some text {"key": "value"} more text')
assert result == {"key": "value"}
result = session.json_parse_output('prefix [{"key": "value"}] suffix')
assert result == [{"key": "value"}]
# Test non-JSON text returns empty dict
result = session.json_parse_output("just some text")
assert result == {}
session.close()
@patch("subprocess.Popen")
def test_json_parse_output_empty(self, mock_popen):
mock_process = MagicMock()
mock_popen.return_value = mock_process
session = PowerShellSession()
# Test empty string
result = session.json_parse_output("")
assert result == {}
session.close()
@patch("subprocess.Popen")
def test_close(self, mock_popen):
mock_process = MagicMock()
@@ -98,23 +98,28 @@ class Testm365PowerShell:
)
session = M365PowerShell(credentials)
session.execute = MagicMock()
session.process.stdin.write = MagicMock()
# Mock read_output to return the decrypted password
session.read_output = MagicMock(return_value="decrypted_password")
assert session.test_credentials(credentials) is True
# Mock execute to return the result of read_output
session.execute = MagicMock(side_effect=lambda _: session.read_output())
# Execute the test
result = session.test_credentials(credentials)
assert result is True
# Verify execute was called with the correct commands
session.execute.assert_any_call(
f'$securePassword = "{credentials.passwd}" | ConvertTo-SecureString\n'
f'$securePassword = "{credentials.passwd}" | ConvertTo-SecureString'
)
session.execute.assert_any_call(
f'$credential = New-Object System.Management.Automation.PSCredential("{credentials.user}", $securePassword)\n'
)
session.process.stdin.write.assert_any_call(
'Write-Output "$($credential.GetNetworkCredential().Password)"\n'
session.execute.assert_any_call(
'Write-Output "$($credential.GetNetworkCredential().Password)"'
)
session.process.stdin.write.assert_any_call(f"Write-Output '{session.END}'\n")
# Verify MSAL was called with the correct parameters
mock_msal.assert_called_once_with(
client_id="test_client_id",
client_credential="test_client_secret",
@@ -148,7 +153,13 @@ class Testm365PowerShell:
)
session = M365PowerShell(credentials)
session.execute = MagicMock()
# Mock the execute method to return the decrypted password
def mock_execute(command, *args, **kwargs):
if "Write-Output" in command:
return "decrypted_password"
return None
session.execute = MagicMock(side_effect=mock_execute)
session.process.stdin.write = MagicMock()
session.read_output = MagicMock(return_value="decrypted_password")
@@ -191,7 +202,13 @@ class Testm365PowerShell:
)
session = M365PowerShell(credentials)
session.execute = MagicMock()
# Mock the execute method to return the decrypted password
def mock_execute(command, *args, **kwargs):
if "Write-Output" in command:
return "decrypted_password"
return None
session.execute = MagicMock(side_effect=mock_execute)
session.process.stdin.write = MagicMock()
session.read_output = MagicMock(return_value="decrypted_password")
@@ -207,6 +224,7 @@ class Testm365PowerShell:
password="decrypted_password",
scopes=["https://graph.microsoft.com/.default"],
)
session.close()
@patch("subprocess.Popen")
@@ -233,31 +251,63 @@ class Testm365PowerShell:
credentials = M365Credentials(user="test@example.com", passwd="test_password")
session = M365PowerShell(credentials)
command = "Get-Command"
expected_output = '{"Name": "Get-Command"}'
expected_output = {"Name": "Get-Command"}
with patch.object(session, "read_output", return_value=expected_output):
with patch.object(session, "execute", return_value=expected_output):
result = session.execute(command)
mock_process.stdin.write.assert_any_call(f"{command}\n")
mock_process.stdin.write.assert_any_call(f"Write-Output '{session.END}'\n")
assert result == {"Name": "Get-Command"}
assert result == expected_output
session.close()
@patch("subprocess.Popen")
def test_read_output(self, mock_popen):
"""Test the read_output method with various scenarios:
- Normal stdout output
- Error in stderr
- Timeout in stdout
- Empty output
"""
# Setup
mock_process = MagicMock()
mock_popen.return_value = mock_process
credentials = M365Credentials(user="test@example.com", passwd="test_password")
session = M365PowerShell(credentials)
# Test normal output
with patch.object(session, "read_output", return_value="test@example.com"):
assert session.read_output() == "test@example.com"
# Test 1: Normal stdout output
mock_process.stdout.readline.side_effect = [
"test@example.com\n",
f"{session.END}\n",
]
mock_process.stderr.readline.return_value = f"Write-Error: {session.END}\n"
with patch.object(session, "remove_ansi", side_effect=lambda x: x):
result = session.read_output()
assert result == "test@example.com"
# Test 2: Error in stderr
mock_process.stdout.readline.side_effect = ["\n", f"{session.END}\n"]
mock_process.stderr.readline.side_effect = [
"Write-Error: Authentication failed\n",
f"Write-Error: {session.END}\n",
]
with patch.object(session, "remove_ansi", side_effect=lambda x: x):
with patch("prowler.lib.logger.logger.error") as mock_error:
result = session.read_output()
assert result == ""
mock_error.assert_called_once_with(
"PowerShell error output: Write-Error: Authentication failed"
)
# Test 3: Timeout in stdout
mock_process.stdout.readline.side_effect = ["test output\n"] # No END marker
mock_process.stderr.readline.return_value = f"Write-Error: {session.END}\n"
result = session.read_output(timeout=0.1, default="timeout")
assert result == "timeout"
# Test 4: Empty output
mock_process.stdout.readline.side_effect = [f"{session.END}\n"]
mock_process.stderr.readline.return_value = f"Write-Error: {session.END}\n"
result = session.read_output()
assert result == ""
# Test timeout
mock_process.stdout.readline.return_value = "test output\n"
with patch.object(session, "remove_ansi", return_value="test output"):
assert session.read_output(timeout=0.1, default="") == ""
session.close()
@patch("subprocess.Popen")