From fb9eda208eac0f7882d71fe5cb47d9250ce91fa2 Mon Sep 17 00:00:00 2001 From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> Date: Fri, 7 Nov 2025 13:19:37 +0100 Subject: [PATCH] fix(powershell): depth truncation and parsing error (#9181) --- prowler/CHANGELOG.md | 1 + prowler/lib/powershell/powershell.py | 23 +-- .../m365/lib/powershell/m365_powershell.py | 160 +++++++++++++----- tests/lib/powershell/powershell_test.py | 8 + .../lib/powershell/m365_powershell_test.py | 17 +- 5 files changed, 146 insertions(+), 63 deletions(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index eb33889546..7ee85b6607 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -48,6 +48,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### Fixed - Check `check_name` has no `resource_name` error for GCP provider [(#9169)](https://github.com/prowler-cloud/prowler/pull/9169) +- Depth Truncation and parsing error in PowerShell queries [(#9181)](https://github.com/prowler-cloud/prowler/pull/9181) --- diff --git a/prowler/lib/powershell/powershell.py b/prowler/lib/powershell/powershell.py index 034d28fa16..8142fa8e45 100644 --- a/prowler/lib/powershell/powershell.py +++ b/prowler/lib/powershell/powershell.py @@ -220,18 +220,19 @@ class PowerShellSession: if output == "": return {} - json_match = re.search(r"(\[.*\]|\{.*\})", output, re.DOTALL) - if not json_match: - logger.error( - f"Unexpected PowerShell output: {output}\n", - ) - else: + decoder = json.JSONDecoder() + for index, character in enumerate(output): + if character not in ("{", "["): + continue 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", - ) + parsed_json, _ = decoder.raw_decode(output[index:]) + return parsed_json + except json.JSONDecodeError: + continue + + logger.error( + f"Unexpected PowerShell output: {output}\n", + ) return {} diff --git a/prowler/providers/m365/lib/powershell/m365_powershell.py b/prowler/providers/m365/lib/powershell/m365_powershell.py index ef3c72cc71..e3d729275f 100644 --- a/prowler/providers/m365/lib/powershell/m365_powershell.py +++ b/prowler/providers/m365/lib/powershell/m365_powershell.py @@ -1,4 +1,5 @@ import os +from typing import Optional from prowler.lib.logger import logger from prowler.lib.powershell.powershell import PowerShellSession @@ -11,6 +12,7 @@ from prowler.providers.m365.models import M365Credentials, M365IdentityInfo class M365PowerShell(PowerShellSession): + CONNECT_TIMEOUT = 15 """ Microsoft 365 specific PowerShell session management implementation. @@ -123,6 +125,23 @@ class M365PowerShell(PowerShellSession): '$graphToken = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$tenantID/oauth2/v2.0/token" -Method POST -Body $graphtokenBody | Select-Object -ExpandProperty Access_Token' ) + def _execute_connect_command( + self, command: str, timeout: Optional[int] = None + ) -> str: + """ + Execute a PowerShell connect command ensuring empty responses surface as timeouts. + + Args: + command (str): PowerShell connect command to run. + timeout (Optional[int]): Timeout in seconds for the command execution. + + Returns: + str: Command output or 'Timeout' if the command produced no output. + """ + effective_timeout = timeout or self.CONNECT_TIMEOUT + result = self.execute(command, timeout=effective_timeout) + return result or "Timeout" + def test_credentials(self, credentials: M365Credentials) -> bool: """ Test Microsoft 365 credentials by attempting to authenticate against Entra ID. @@ -141,24 +160,32 @@ class M365PowerShell(PowerShellSession): # Test Certificate Auth if credentials.certificate_content and credentials.client_id: try: - self.test_teams_certificate_connection() or self.test_exchange_certificate_connection() + logger.info("Testing Microsoft Graph Certificate connection...") + self.test_graph_certificate_connection() + logger.info("Microsoft Graph Certificate connection successful") + teams_connection_successful = self.test_teams_certificate_connection() + if not teams_connection_successful: + self.test_exchange_certificate_connection() return True except Exception as e: - logger.error(f"Exchange Online Certificate connection failed: {e}") - - else: - # Test Microsoft Graph connection - try: - logger.info("Testing Microsoft Graph connection...") - self.test_graph_connection() - logger.info("Microsoft Graph connection successful") - return True - except Exception as e: - logger.error(f"Microsoft Graph connection failed: {e}") + logger.error(f"Microsoft Graph Cer connection failed: {e}") raise M365GraphConnectionError( file=os.path.basename(__file__), original_exception=e, - message="Check your Microsoft Application credentials and ensure the app has proper permissions", + message="Check your Microsoft Application Certificate and ensure the app has proper permissions", + ) + else: + try: + logger.info("Testing Microsoft Graph Client Secret connection...") + self.test_graph_connection() + logger.info("Microsoft Graph Client Secret connection successful") + return True + except Exception as e: + logger.error(f"Microsoft Graph Client Secret connection failed: {e}") + raise M365GraphConnectionError( + file=os.path.basename(__file__), + original_exception=e, + message="Check your Microsoft Application Client Secret and ensure the app has proper permissions", ) def test_graph_connection(self) -> bool: @@ -178,6 +205,16 @@ class M365PowerShell(PowerShellSession): message=f"Failed to connect to Microsoft Graph API: {str(e)}", ) + def test_graph_certificate_connection(self) -> bool: + """Test Microsoft Graph API connection using certificate and raise exception if it fails.""" + result = self._execute_connect_command( + "Connect-Graph -Certificate $certificate -AppId $clientID -TenantId $tenantID" + ) + if "Welcome to Microsoft Graph!" not in result: + logger.error(f"Microsoft Graph Certificate connection failed: {result}") + return False + return True + def test_teams_connection(self) -> bool: """Test Microsoft Teams API connection and raise exception if it fails.""" try: @@ -195,7 +232,7 @@ class M365PowerShell(PowerShellSession): "Microsoft Teams connection failed: Please check your permissions and try again." ) return False - self.execute( + self._execute_connect_command( 'Connect-MicrosoftTeams -AccessTokens @("$graphToken","$teamsToken")' ) return True @@ -207,7 +244,7 @@ class M365PowerShell(PowerShellSession): def test_teams_certificate_connection(self) -> bool: """Test Microsoft Teams API connection using certificate and raise exception if it fails.""" - result = self.execute( + result = self._execute_connect_command( "Connect-MicrosoftTeams -Certificate $certificate -ApplicationId $clientID -TenantId $tenantID" ) if self.tenant_identity.identity_id not in result: @@ -231,8 +268,9 @@ class M365PowerShell(PowerShellSession): "Exchange Online connection failed: Please check your permissions and try again." ) return False - self.execute( - 'Connect-ExchangeOnline -AccessToken $exchangeToken.AccessToken -Organization "$tenantID"' + self._execute_connect_command( + 'Connect-ExchangeOnline -AccessToken $exchangeToken.AccessToken -Organization "$tenantID"', + timeout=self.CONNECT_TIMEOUT, ) return True except Exception as e: @@ -243,8 +281,9 @@ class M365PowerShell(PowerShellSession): def test_exchange_certificate_connection(self) -> bool: """Test Exchange Online API connection using certificate and raise exception if it fails.""" - result = self.execute( - "Connect-ExchangeOnline -Certificate $certificate -AppId $clientID -Organization $tenantDomain" + result = self._execute_connect_command( + "Connect-ExchangeOnline -Certificate $certificate -AppId $clientID -Organization $tenantDomain", + timeout=self.CONNECT_TIMEOUT, ) if "https://aka.ms/exov3-module" not in result: logger.error(f"Exchange Online Certificate connection failed: {result}") @@ -290,7 +329,8 @@ class M365PowerShell(PowerShellSession): } """ return self.execute( - "Get-CsTeamsClientConfiguration | ConvertTo-Json", json_parse=True + "Get-CsTeamsClientConfiguration | ConvertTo-Json -Depth 10", + json_parse=True, ) def get_global_meeting_policy(self) -> dict: @@ -309,7 +349,7 @@ class M365PowerShell(PowerShellSession): } """ return self.execute( - "Get-CsTeamsMeetingPolicy -Identity Global | ConvertTo-Json", + "Get-CsTeamsMeetingPolicy -Identity Global | ConvertTo-Json -Depth 10", json_parse=True, ) @@ -329,7 +369,7 @@ class M365PowerShell(PowerShellSession): } """ return self.execute( - "Get-CsTeamsMessagingPolicy -Identity Global | ConvertTo-Json", + "Get-CsTeamsMessagingPolicy -Identity Global | ConvertTo-Json -Depth 10", json_parse=True, ) @@ -349,7 +389,8 @@ class M365PowerShell(PowerShellSession): } """ return self.execute( - "Get-CsTenantFederationConfiguration | ConvertTo-Json", json_parse=True + "Get-CsTenantFederationConfiguration | ConvertTo-Json -Depth 10", + json_parse=True, ) def connect_exchange_online(self) -> dict: @@ -389,7 +430,7 @@ class M365PowerShell(PowerShellSession): } """ return self.execute( - "Get-AdminAuditLogConfig | Select-Object UnifiedAuditLogIngestionEnabled | ConvertTo-Json", + "Get-AdminAuditLogConfig | Select-Object UnifiedAuditLogIngestionEnabled | ConvertTo-Json -Depth 10", json_parse=True, ) @@ -409,7 +450,9 @@ class M365PowerShell(PowerShellSession): "Identity": "Default" } """ - return self.execute("Get-MalwareFilterPolicy | ConvertTo-Json", json_parse=True) + return self.execute( + "Get-MalwareFilterPolicy | ConvertTo-Json -Depth 10", json_parse=True + ) def get_malware_filter_rule(self) -> dict: """ @@ -427,7 +470,9 @@ class M365PowerShell(PowerShellSession): "State": "Enabled" } """ - return self.execute("Get-MalwareFilterRule | ConvertTo-Json", json_parse=True) + return self.execute( + "Get-MalwareFilterRule | ConvertTo-Json -Depth 10", json_parse=True + ) def get_outbound_spam_filter_policy(self) -> dict: """ @@ -448,7 +493,8 @@ class M365PowerShell(PowerShellSession): } """ return self.execute( - "Get-HostedOutboundSpamFilterPolicy | ConvertTo-Json", json_parse=True + "Get-HostedOutboundSpamFilterPolicy | ConvertTo-Json -Depth 10", + json_parse=True, ) def get_outbound_spam_filter_rule(self) -> dict: @@ -467,7 +513,8 @@ class M365PowerShell(PowerShellSession): } """ return self.execute( - "Get-HostedOutboundSpamFilterRule | ConvertTo-Json", json_parse=True + "Get-HostedOutboundSpamFilterRule | ConvertTo-Json -Depth 10", + json_parse=True, ) def get_antiphishing_policy(self) -> dict: @@ -493,7 +540,9 @@ class M365PowerShell(PowerShellSession): "IsDefault": false } """ - return self.execute("Get-AntiPhishPolicy | ConvertTo-Json", json_parse=True) + return self.execute( + "Get-AntiPhishPolicy | ConvertTo-Json -Depth 10", json_parse=True + ) def get_antiphishing_rules(self) -> dict: """ @@ -511,7 +560,9 @@ class M365PowerShell(PowerShellSession): "State": Enabled, } """ - return self.execute("Get-AntiPhishRule | ConvertTo-Json", json_parse=True) + return self.execute( + "Get-AntiPhishRule | ConvertTo-Json -Depth 10", json_parse=True + ) def get_organization_config(self) -> dict: """ @@ -530,7 +581,9 @@ class M365PowerShell(PowerShellSession): "AuditDisabled": false } """ - return self.execute("Get-OrganizationConfig | ConvertTo-Json", json_parse=True) + return self.execute( + "Get-OrganizationConfig | ConvertTo-Json -Depth 10", json_parse=True + ) def get_mailbox_audit_config(self) -> dict: """ @@ -550,7 +603,8 @@ class M365PowerShell(PowerShellSession): } """ return self.execute( - "Get-MailboxAuditBypassAssociation | ConvertTo-Json", json_parse=True + "Get-MailboxAuditBypassAssociation | ConvertTo-Json -Depth 10", + json_parse=True, ) def get_mailbox_policy(self) -> dict: @@ -569,7 +623,9 @@ class M365PowerShell(PowerShellSession): "AdditionalStorageProvidersAvailable": True } """ - return self.execute("Get-OwaMailboxPolicy | ConvertTo-Json", json_parse=True) + return self.execute( + "Get-OwaMailboxPolicy | ConvertTo-Json -Depth 10", json_parse=True + ) def get_external_mail_config(self) -> dict: """ @@ -587,7 +643,9 @@ class M365PowerShell(PowerShellSession): "ExternalMailTagEnabled": true } """ - return self.execute("Get-ExternalInOutlook | ConvertTo-Json", json_parse=True) + return self.execute( + "Get-ExternalInOutlook | ConvertTo-Json -Depth 10", json_parse=True + ) def get_transport_rules(self) -> dict: """ @@ -606,7 +664,9 @@ class M365PowerShell(PowerShellSession): "SenderDomainIs": ["example.com"] } """ - return self.execute("Get-TransportRule | ConvertTo-Json", json_parse=True) + return self.execute( + "Get-TransportRule | ConvertTo-Json -Depth 10", json_parse=True + ) def get_connection_filter_policy(self) -> dict: """ @@ -625,7 +685,7 @@ class M365PowerShell(PowerShellSession): } """ return self.execute( - "Get-HostedConnectionFilterPolicy -Identity Default | ConvertTo-Json", + "Get-HostedConnectionFilterPolicy -Identity Default | ConvertTo-Json -Depth 10", json_parse=True, ) @@ -645,7 +705,9 @@ class M365PowerShell(PowerShellSession): "Enabled": true } """ - return self.execute("Get-DkimSigningConfig | ConvertTo-Json", json_parse=True) + return self.execute( + "Get-DkimSigningConfig | ConvertTo-Json -Depth 10", json_parse=True + ) def get_inbound_spam_filter_policy(self) -> dict: """ @@ -664,7 +726,8 @@ class M365PowerShell(PowerShellSession): } """ return self.execute( - "Get-HostedContentFilterPolicy | ConvertTo-Json", json_parse=True + "Get-HostedContentFilterPolicy | ConvertTo-Json -Depth 10", + json_parse=True, ) def get_inbound_spam_filter_rule(self) -> dict: @@ -684,7 +747,8 @@ class M365PowerShell(PowerShellSession): } """ return self.execute( - "Get-HostedContentFilterRule | ConvertTo-Json", json_parse=True + "Get-HostedContentFilterRule | ConvertTo-Json -Depth 10", + json_parse=True, ) def get_report_submission_policy(self) -> dict: @@ -715,7 +779,8 @@ class M365PowerShell(PowerShellSession): } """ return self.execute( - "Get-ReportSubmissionPolicy | ConvertTo-Json", json_parse=True + "Get-ReportSubmissionPolicy | ConvertTo-Json -Depth 10", + json_parse=True, ) def get_role_assignment_policies(self) -> dict: @@ -736,7 +801,8 @@ class M365PowerShell(PowerShellSession): } """ return self.execute( - "Get-RoleAssignmentPolicy | ConvertTo-Json", json_parse=True + "Get-RoleAssignmentPolicy | ConvertTo-Json -Depth 10", + json_parse=True, ) def get_mailbox_audit_properties(self) -> dict: @@ -801,7 +867,7 @@ class M365PowerShell(PowerShellSession): } """ return self.execute( - "Get-EXOMailbox -PropertySets Audit -ResultSize Unlimited | ConvertTo-Json", + "Get-EXOMailbox -PropertySets Audit -ResultSize Unlimited | ConvertTo-Json -Depth 10", json_parse=True, ) @@ -820,7 +886,9 @@ class M365PowerShell(PowerShellSession): "SmtpClientAuthenticationDisabled": True, } """ - return self.execute("Get-TransportConfig | ConvertTo-Json", json_parse=True) + return self.execute( + "Get-TransportConfig | ConvertTo-Json -Depth 10", json_parse=True + ) def get_sharing_policy(self) -> dict: """ @@ -838,7 +906,9 @@ class M365PowerShell(PowerShellSession): "Enabled": true } """ - return self.execute("Get-SharingPolicy | ConvertTo-Json", json_parse=True) + return self.execute( + "Get-SharingPolicy | ConvertTo-Json -Depth 10", json_parse=True + ) def get_user_account_status(self) -> dict: """ @@ -850,7 +920,7 @@ class M365PowerShell(PowerShellSession): dict: User account status settings in JSON format. """ return self.execute( - "$dict=@{}; Get-User -ResultSize Unlimited | ForEach-Object { $dict[$_.Id] = @{ AccountDisabled = $_.AccountDisabled } }; $dict | ConvertTo-Json", + "$dict=@{}; Get-User -ResultSize Unlimited | ForEach-Object { $dict[$_.Id] = @{ AccountDisabled = $_.AccountDisabled } }; $dict | ConvertTo-Json -Depth 10", json_parse=True, ) diff --git a/tests/lib/powershell/powershell_test.py b/tests/lib/powershell/powershell_test.py index 49108e1cd2..e96740b1d9 100644 --- a/tests/lib/powershell/powershell_test.py +++ b/tests/lib/powershell/powershell_test.py @@ -216,6 +216,14 @@ class TestPowerShellSession: result = session.json_parse_output('prefix [{"key": "value"}] suffix') assert result == [{"key": "value"}] + result = session.json_parse_output( + 'INFO {context data} {"key": "value", "list": [1, 2]} extra' + ) + assert result == {"key": "value", "list": [1, 2]} + + result = session.json_parse_output('{"key": "value"} trailing {log}') + assert result == {"key": "value"} + # Test non-JSON text returns empty dict result = session.json_parse_output("just some text") assert result == {} diff --git a/tests/providers/m365/lib/powershell/m365_powershell_test.py b/tests/providers/m365/lib/powershell/m365_powershell_test.py index 6f6a7e0b95..8d37b6c095 100644 --- a/tests/providers/m365/lib/powershell/m365_powershell_test.py +++ b/tests/providers/m365/lib/powershell/m365_powershell_test.py @@ -936,7 +936,8 @@ class Testm365PowerShell: assert result is True session.execute.assert_called_once_with( - "Connect-ExchangeOnline -Certificate $certificate -AppId $clientID -Organization $tenantDomain" + "Connect-ExchangeOnline -Certificate $certificate -AppId $clientID -Organization $tenantDomain", + timeout=M365PowerShell.CONNECT_TIMEOUT, ) session.close() @@ -960,7 +961,8 @@ class Testm365PowerShell: assert result is False session.execute.assert_called_once_with( - "Connect-ExchangeOnline -Certificate $certificate -AppId $clientID -Organization $tenantDomain" + "Connect-ExchangeOnline -Certificate $certificate -AppId $clientID -Organization $tenantDomain", + timeout=M365PowerShell.CONNECT_TIMEOUT, ) session.close() @@ -979,7 +981,7 @@ class Testm365PowerShell: session = M365PowerShell(credentials, identity) # Mock successful Teams connection - the method returns bool - def mock_execute_side_effect(command): + def mock_execute_side_effect(command, *_, **__): if "Connect-MicrosoftTeams" in command: # Return result that contains the identity_id for success return "Connected successfully test_identity_id" @@ -991,7 +993,8 @@ class Testm365PowerShell: assert result is True session.execute.assert_called_once_with( - "Connect-MicrosoftTeams -Certificate $certificate -ApplicationId $clientID -TenantId $tenantID" + "Connect-MicrosoftTeams -Certificate $certificate -ApplicationId $clientID -TenantId $tenantID", + timeout=M365PowerShell.CONNECT_TIMEOUT, ) session.close() @@ -1007,7 +1010,7 @@ class Testm365PowerShell: session = M365PowerShell(credentials, identity) # Mock failed Teams connection - def mock_execute_side_effect(command, json_parse=False): + def mock_execute_side_effect(command, **kwargs): if "Connect-MicrosoftTeams" in command: raise Exception("Connection failed: Authentication error") return "" @@ -1090,7 +1093,7 @@ class Testm365PowerShell: # Mock certificate variable check and teams connection execute_calls = [] - def mock_execute_side_effect(command, json_parse=False): + def mock_execute_side_effect(command): execute_calls.append(command) if "Write-Output $certificate" in command: return "certificate_content" # Non-empty means certificate exists @@ -1123,7 +1126,7 @@ class Testm365PowerShell: # Mock certificate variable check and exchange connection execute_calls = [] - def mock_execute_side_effect(command, json_parse=False): + def mock_execute_side_effect(command): execute_calls.append(command) if "Write-Output $certificate" in command: return "certificate_content" # Non-empty means certificate exists