mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 20:42:02 +00:00
fix(m365): enhance execution to avoid multiple error calls (#8353)
This commit is contained in:
committed by
GitHub
parent
2c86b3a990
commit
b63f70ac82
@@ -31,6 +31,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- `sns_topics_not_publicly_accessible` false positive with `aws:SourceArn` conditions [(#8326)](https://github.com/prowler-cloud/prowler/issues/8326)
|
||||
- Remove typo from description req 1.2.3 - Prowler ThreatScore m365 [(#8384)](https://github.com/prowler-cloud/prowler/pull/8384)
|
||||
- Way of counting FAILED/PASS reqs from `kisa_isms_p_2023_aws` table [(#8382)](https://github.com/prowler-cloud/prowler/pull/8382)
|
||||
- Avoid multiple module error calls in M365 provider [(#8353)](https://github.com/prowler-cloud/prowler/pull/8353)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
|
||||
|
||||
def decode_jwt(token: str) -> dict:
|
||||
"""
|
||||
Decodes the payload of a JWT without verifying its signature.
|
||||
|
||||
Args:
|
||||
token (str): JWT string in the format 'header.payload.signature'
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing the decoded payload (claims), or an empty dict on failure.
|
||||
"""
|
||||
try:
|
||||
# Split the JWT into its 3 parts
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
raise ValueError(
|
||||
"The token does not have the expected three-part structure."
|
||||
)
|
||||
|
||||
# Extract and decode the payload (second part)
|
||||
payload_b64 = parts[1]
|
||||
|
||||
# Add padding if necessary for base64 decoding
|
||||
padding = "=" * (-len(payload_b64) % 4)
|
||||
payload_b64 += padding
|
||||
|
||||
payload_bytes = base64.urlsafe_b64decode(payload_b64)
|
||||
payload_json = payload_bytes.decode("utf-8")
|
||||
payload = json.loads(payload_json)
|
||||
|
||||
return payload
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to decode the token: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def decode_msal_token(text: str) -> dict:
|
||||
"""
|
||||
Extracts and decodes the payload of a MSAL token from a given string.
|
||||
|
||||
Args:
|
||||
text (str): A string that contains the MSAL token, possibly over multiple lines.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing the decoded payload (claims), or an empty dict on failure.
|
||||
"""
|
||||
try:
|
||||
# Join all lines and remove whitespace
|
||||
flattened = "".join(text.split())
|
||||
|
||||
# Search for a valid JWT pattern (three base64url parts separated by dots)
|
||||
match = re.search(
|
||||
r"([A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+)", flattened
|
||||
)
|
||||
if not match:
|
||||
raise ValueError("No valid JWT found in the input.")
|
||||
|
||||
token = match.group(1)
|
||||
return decode_jwt(token)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to extract and decode the token: {e}")
|
||||
return {}
|
||||
@@ -1,16 +1,14 @@
|
||||
import os
|
||||
import platform
|
||||
|
||||
import msal
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.lib.powershell.powershell import PowerShellSession
|
||||
from prowler.providers.m365.exceptions.exceptions import (
|
||||
M365ExchangeConnectionError,
|
||||
M365GraphConnectionError,
|
||||
M365TeamsConnectionError,
|
||||
M365UserCredentialsError,
|
||||
M365UserNotBelongingToTenantError,
|
||||
)
|
||||
from prowler.providers.m365.lib.jwt.jwt_decoder import decode_jwt, decode_msal_token
|
||||
from prowler.providers.m365.models import M365Credentials, M365IdentityInfo
|
||||
|
||||
|
||||
@@ -162,26 +160,29 @@ class M365PowerShell(PowerShellSession):
|
||||
message=f"The user domain {user_domain} does not match any of the tenant domains: {', '.join(self.tenant_identity.tenant_domains)}",
|
||||
)
|
||||
|
||||
app = msal.ConfidentialClientApplication(
|
||||
client_id=credentials.client_id,
|
||||
client_credential=credentials.client_secret,
|
||||
authority=f"https://login.microsoftonline.com/{credentials.tenant_id}",
|
||||
)
|
||||
|
||||
# Validate credentials
|
||||
result = app.acquire_token_by_username_password(
|
||||
username=credentials.user,
|
||||
password=credentials.passwd,
|
||||
scopes=["https://graph.microsoft.com/.default"],
|
||||
)
|
||||
|
||||
if result is None:
|
||||
raise Exception(
|
||||
"Unexpected error: Acquiring token in behalf of user did not return a result."
|
||||
)
|
||||
|
||||
if "access_token" not in result:
|
||||
raise Exception(f"MsGraph Error {result.get('error_description')}")
|
||||
result = self.execute("Connect-ExchangeOnline -Credential $credential")
|
||||
if "https://aka.ms/exov3-module" not in result:
|
||||
if "AADSTS" in result: # Entra Security Token Service Error
|
||||
raise M365UserCredentialsError(
|
||||
file=os.path.basename(__file__),
|
||||
message=result,
|
||||
)
|
||||
else: # Could not connect to Exchange Online, try Microsoft Teams
|
||||
result = self.execute(
|
||||
"Connect-MicrosoftTeams -Credential $credential"
|
||||
)
|
||||
if self.tenant_identity.tenant_id not in result:
|
||||
if "AADSTS" in result: # Entra Security Token Service Error
|
||||
raise M365UserCredentialsError(
|
||||
file=os.path.basename(__file__),
|
||||
message=result,
|
||||
)
|
||||
else: # Unknown error, could be a permission issue or modules not installed
|
||||
raise Exception(
|
||||
file=os.path.basename(__file__),
|
||||
message=f"Error connecting to PowerShell modules: {result}",
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -191,6 +192,7 @@ class M365PowerShell(PowerShellSession):
|
||||
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}")
|
||||
raise M365GraphConnectionError(
|
||||
@@ -199,34 +201,6 @@ class M365PowerShell(PowerShellSession):
|
||||
message="Check your Microsoft Application credentials and ensure the app has proper permissions",
|
||||
)
|
||||
|
||||
# Test Microsoft Teams connection
|
||||
try:
|
||||
logger.info("Testing Microsoft Teams connection...")
|
||||
self.test_teams_connection()
|
||||
logger.info("Microsoft Teams connection successful")
|
||||
except Exception as e:
|
||||
logger.error(f"Microsoft Teams connection failed: {e}")
|
||||
raise M365TeamsConnectionError(
|
||||
file=os.path.basename(__file__),
|
||||
original_exception=e,
|
||||
message="Ensure the application has proper permission granted to access Microsoft Teams.",
|
||||
)
|
||||
|
||||
# Test Exchange Online connection
|
||||
try:
|
||||
logger.info("Testing Exchange Online connection...")
|
||||
self.test_exchange_connection()
|
||||
logger.info("Exchange Online connection successful")
|
||||
except Exception as e:
|
||||
logger.error(f"Exchange Online connection failed: {e}")
|
||||
raise M365ExchangeConnectionError(
|
||||
file=os.path.basename(__file__),
|
||||
original_exception=e,
|
||||
message="Ensure the application has proper permission granted to access Exchange Online.",
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def test_graph_connection(self) -> bool:
|
||||
"""Test Microsoft Graph API connection and raise exception if it fails."""
|
||||
try:
|
||||
@@ -253,19 +227,20 @@ class M365PowerShell(PowerShellSession):
|
||||
self.execute(
|
||||
'$teamsToken = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$tenantID/oauth2/v2.0/token" -Method POST -Body $teamstokenBody | Select-Object -ExpandProperty Access_Token'
|
||||
)
|
||||
if self.execute("Write-Output $teamsToken") == "":
|
||||
raise M365TeamsConnectionError(
|
||||
file=os.path.basename(__file__),
|
||||
message="Microsoft Teams token is empty or invalid.",
|
||||
permissions = decode_jwt(self.execute("Write-Output $teamsToken")).get(
|
||||
"roles", []
|
||||
)
|
||||
if "application_access" not in permissions:
|
||||
logger.error(
|
||||
"Microsoft Teams connection failed: Please check your permissions and try again."
|
||||
)
|
||||
return False
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Microsoft Teams connection failed: {e}")
|
||||
raise M365TeamsConnectionError(
|
||||
file=os.path.basename(__file__),
|
||||
original_exception=e,
|
||||
message=f"Failed to connect to Microsoft Teams API: {str(e)}",
|
||||
logger.error(
|
||||
f"Microsoft Teams connection failed: {e}. Please check your permissions and try again."
|
||||
)
|
||||
return False
|
||||
|
||||
def test_exchange_connection(self) -> bool:
|
||||
"""Test Exchange Online API connection and raise exception if it fails."""
|
||||
@@ -276,19 +251,19 @@ class M365PowerShell(PowerShellSession):
|
||||
self.execute(
|
||||
'$exchangeToken = Get-MsalToken -clientID "$clientID" -tenantID "$tenantID" -clientSecret $SecureSecret -Scopes "https://outlook.office365.com/.default"'
|
||||
)
|
||||
if self.execute("Write-Output $exchangeToken") == "":
|
||||
raise M365ExchangeConnectionError(
|
||||
file=os.path.basename(__file__),
|
||||
message="Exchange Online token is empty or invalid.",
|
||||
token = decode_msal_token(self.execute("Write-Output $exchangeToken"))
|
||||
permissions = token.get("roles", [])
|
||||
if "Exchange.ManageAsApp" not in permissions:
|
||||
logger.error(
|
||||
"Exchange Online connection failed: Please check your permissions and try again."
|
||||
)
|
||||
return False
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Exchange Online connection failed: {e}")
|
||||
raise M365ExchangeConnectionError(
|
||||
file=os.path.basename(__file__),
|
||||
original_exception=e,
|
||||
message=f"Failed to connect to Exchange Online API: {str(e)}",
|
||||
logger.error(
|
||||
f"Exchange Online connection failed: {e}. Please check your permissions and try again."
|
||||
)
|
||||
return False
|
||||
|
||||
def connect_microsoft_teams(self) -> dict:
|
||||
"""
|
||||
@@ -302,18 +277,26 @@ class M365PowerShell(PowerShellSession):
|
||||
Note:
|
||||
This method requires the Microsoft Teams PowerShell module to be installed.
|
||||
"""
|
||||
if self.execute("Write-Output $credential") != "": # User Auth
|
||||
return self.execute("Connect-MicrosoftTeams -Credential $credential")
|
||||
else: # Application Auth
|
||||
self.execute(
|
||||
'$teamstokenBody = @{ Grant_Type = "client_credentials"; Scope = "48ac35b8-9aa8-4d74-927d-1f4a14a0b239/.default"; Client_Id = $clientID; Client_Secret = $clientSecret }'
|
||||
)
|
||||
self.execute(
|
||||
'$teamsToken = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$tenantID/oauth2/v2.0/token" -Method POST -Body $teamstokenBody | Select-Object -ExpandProperty Access_Token'
|
||||
)
|
||||
return self.execute(
|
||||
'Connect-MicrosoftTeams -AccessTokens @("$graphToken","$teamsToken")'
|
||||
)
|
||||
# User Auth
|
||||
if self.execute("Write-Output $credential") != "":
|
||||
self.execute("Connect-MicrosoftTeams -Credential $credential")
|
||||
# Test connection with a simple call
|
||||
connection = self.execute("Get-CsTeamsClientConfiguration")
|
||||
if connection:
|
||||
return True
|
||||
else:
|
||||
logger.error(
|
||||
"Microsoft Teams connection failed: Please check your permissions and try again."
|
||||
)
|
||||
return connection
|
||||
# Application Auth
|
||||
else:
|
||||
connection = self.test_teams_connection()
|
||||
if connection:
|
||||
self.execute(
|
||||
'Connect-MicrosoftTeams -AccessTokens @("$graphToken","$teamsToken")'
|
||||
)
|
||||
return connection
|
||||
|
||||
def get_teams_settings(self) -> dict:
|
||||
"""
|
||||
@@ -407,18 +390,25 @@ class M365PowerShell(PowerShellSession):
|
||||
Note:
|
||||
This method requires the Exchange Online PowerShell module to be installed.
|
||||
"""
|
||||
if self.execute("Write-Output $credential") != "": # User Auth
|
||||
return self.execute("Connect-ExchangeOnline -Credential $credential")
|
||||
else: # Application Auth
|
||||
self.execute(
|
||||
'$SecureSecret = ConvertTo-SecureString "$clientSecret" -AsPlainText -Force'
|
||||
)
|
||||
self.execute(
|
||||
'$exchangeToken = Get-MsalToken -clientID "$clientID" -tenantID "$tenantID" -clientSecret $SecureSecret -Scopes "https://outlook.office365.com/.default"'
|
||||
)
|
||||
return self.execute(
|
||||
'Connect-ExchangeOnline -AccessToken $exchangeToken.AccessToken -Organization "$tenantID"'
|
||||
)
|
||||
# User Auth
|
||||
if self.execute("Write-Output $credential") != "":
|
||||
self.execute("Connect-ExchangeOnline -Credential $credential")
|
||||
connection = self.execute("Get-OrganizationConfig")
|
||||
if connection:
|
||||
return True
|
||||
else:
|
||||
logger.error(
|
||||
"Exchange Online connection failed: Please check your permissions and try again."
|
||||
)
|
||||
return False
|
||||
# Application Auth
|
||||
else:
|
||||
connection = self.test_exchange_connection()
|
||||
if connection:
|
||||
self.execute(
|
||||
'Connect-ExchangeOnline -AccessToken $exchangeToken.AccessToken -Organization "$tenantID"'
|
||||
)
|
||||
return connection
|
||||
|
||||
def get_audit_log_config(self) -> dict:
|
||||
"""
|
||||
|
||||
@@ -15,9 +15,9 @@ class AdminCenter(M365Service):
|
||||
self.organization_config = None
|
||||
self.sharing_policy = None
|
||||
if self.powershell:
|
||||
self.powershell.connect_exchange_online()
|
||||
self.organization_config = self._get_organization_config()
|
||||
self.sharing_policy = self._get_sharing_policy()
|
||||
if self.powershell.connect_exchange_online():
|
||||
self.organization_config = self._get_organization_config()
|
||||
self.sharing_policy = self._get_sharing_policy()
|
||||
self.powershell.close()
|
||||
|
||||
loop = get_event_loop()
|
||||
|
||||
@@ -21,18 +21,18 @@ class Defender(M365Service):
|
||||
self.inbound_spam_rules = {}
|
||||
self.report_submission_policy = None
|
||||
if self.powershell:
|
||||
self.powershell.connect_exchange_online()
|
||||
self.malware_policies = self._get_malware_filter_policy()
|
||||
self.malware_rules = self._get_malware_filter_rule()
|
||||
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_antiphishing_policy()
|
||||
self.antiphishing_rules = self._get_antiphishing_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.inbound_spam_rules = self._get_inbound_spam_filter_rule()
|
||||
self.report_submission_policy = self._get_report_submission_policy()
|
||||
if self.powershell.connect_exchange_online():
|
||||
self.malware_policies = self._get_malware_filter_policy()
|
||||
self.malware_rules = self._get_malware_filter_rule()
|
||||
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_antiphishing_policy()
|
||||
self.antiphishing_rules = self._get_antiphishing_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.inbound_spam_rules = self._get_inbound_spam_filter_rule()
|
||||
self.report_submission_policy = self._get_report_submission_policy()
|
||||
self.powershell.close()
|
||||
|
||||
def _get_malware_filter_policy(self):
|
||||
|
||||
@@ -21,15 +21,15 @@ class Exchange(M365Service):
|
||||
self.mailbox_audit_properties = []
|
||||
|
||||
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.transport_config = self._get_transport_config()
|
||||
self.mailbox_policy = self._get_mailbox_policy()
|
||||
self.role_assignment_policies = self._get_role_assignment_policies()
|
||||
self.mailbox_audit_properties = self._get_mailbox_audit_properties()
|
||||
if 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.transport_config = self._get_transport_config()
|
||||
self.mailbox_policy = self._get_mailbox_policy()
|
||||
self.role_assignment_policies = self._get_role_assignment_policies()
|
||||
self.mailbox_audit_properties = self._get_mailbox_audit_properties()
|
||||
self.powershell.close()
|
||||
|
||||
def _get_organization_config(self):
|
||||
|
||||
@@ -11,8 +11,8 @@ class Purview(M365Service):
|
||||
self.audit_log_config = None
|
||||
|
||||
if self.powershell:
|
||||
self.powershell.connect_exchange_online()
|
||||
self.audit_log_config = self._get_audit_log_config()
|
||||
if self.powershell.connect_exchange_online():
|
||||
self.audit_log_config = self._get_audit_log_config()
|
||||
self.powershell.close()
|
||||
|
||||
def _get_audit_log_config(self):
|
||||
|
||||
@@ -14,11 +14,11 @@ class Teams(M365Service):
|
||||
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.global_messaging_policy = self._get_global_messaging_policy()
|
||||
self.user_settings = self._get_user_settings()
|
||||
if self.powershell.connect_microsoft_teams():
|
||||
self.teams_settings = self._get_teams_client_configuration()
|
||||
self.global_meeting_policy = self._get_global_meeting_policy()
|
||||
self.global_messaging_policy = self._get_global_messaging_policy()
|
||||
self.user_settings = self._get_user_settings()
|
||||
self.powershell.close()
|
||||
|
||||
def _get_teams_client_configuration(self):
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import base64
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from prowler.providers.m365.lib.jwt.jwt_decoder import decode_jwt, decode_msal_token
|
||||
|
||||
|
||||
class TestJwtDecoder:
|
||||
def test_decode_jwt_valid_token(self):
|
||||
"""Test decode_jwt with a valid JWT token"""
|
||||
# Create a mock JWT token
|
||||
header = {"alg": "HS256", "typ": "JWT"}
|
||||
payload = {
|
||||
"sub": "1234567890",
|
||||
"name": "John Doe",
|
||||
"iat": 1516239022,
|
||||
"roles": ["application_access", "user_read"],
|
||||
}
|
||||
|
||||
# Encode header and payload
|
||||
header_b64 = (
|
||||
base64.urlsafe_b64encode(json.dumps(header).encode("utf-8"))
|
||||
.decode("utf-8")
|
||||
.rstrip("=")
|
||||
)
|
||||
|
||||
payload_b64 = (
|
||||
base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8"))
|
||||
.decode("utf-8")
|
||||
.rstrip("=")
|
||||
)
|
||||
|
||||
# Create JWT with dummy signature
|
||||
token = f"{header_b64}.{payload_b64}.dummy_signature"
|
||||
|
||||
result = decode_jwt(token)
|
||||
|
||||
assert result == payload
|
||||
assert result["sub"] == "1234567890"
|
||||
assert result["name"] == "John Doe"
|
||||
assert result["roles"] == ["application_access", "user_read"]
|
||||
|
||||
def test_decode_jwt_valid_token_with_padding(self):
|
||||
"""Test decode_jwt with a token that needs base64 padding"""
|
||||
# Create mock payload that will need padding
|
||||
payload = {"test": "data"}
|
||||
payload_json = json.dumps(payload)
|
||||
|
||||
# Encode mock payload without padding
|
||||
payload_b64 = (
|
||||
base64.urlsafe_b64encode(payload_json.encode("utf-8"))
|
||||
.decode("utf-8")
|
||||
.rstrip("=")
|
||||
)
|
||||
|
||||
token = f"header.{payload_b64}.signature"
|
||||
|
||||
result = decode_jwt(token)
|
||||
|
||||
assert result == payload
|
||||
|
||||
def test_decode_jwt_invalid_structure_two_parts(self):
|
||||
"""Test decode_jwt with token that has only 2 parts"""
|
||||
token = "header.payload" # Missing signature
|
||||
|
||||
result = decode_jwt(token)
|
||||
|
||||
assert result == {}
|
||||
|
||||
def test_decode_jwt_invalid_structure_four_parts(self):
|
||||
"""Test decode_jwt with token that has 4 parts"""
|
||||
token = "header.payload.signature.extra"
|
||||
|
||||
result = decode_jwt(token)
|
||||
|
||||
assert result == {}
|
||||
|
||||
def test_decode_jwt_invalid_base64(self):
|
||||
"""Test decode_jwt with invalid base64 in payload"""
|
||||
token = "header.invalid_base64!@#.signature"
|
||||
|
||||
result = decode_jwt(token)
|
||||
|
||||
assert result == {}
|
||||
|
||||
def test_decode_jwt_invalid_json(self):
|
||||
"""Test decode_jwt with invalid JSON in payload"""
|
||||
# Create invalid JSON base64
|
||||
invalid_json = "{'invalid': json,}"
|
||||
payload_b64 = (
|
||||
base64.urlsafe_b64encode(invalid_json.encode("utf-8"))
|
||||
.decode("utf-8")
|
||||
.rstrip("=")
|
||||
)
|
||||
|
||||
token = f"header.{payload_b64}.signature"
|
||||
|
||||
result = decode_jwt(token)
|
||||
|
||||
assert result == {}
|
||||
|
||||
def test_decode_jwt_empty_token(self):
|
||||
"""Test decode_jwt with empty token"""
|
||||
result = decode_jwt("")
|
||||
assert result == {}
|
||||
|
||||
def test_decode_jwt_none_token(self):
|
||||
"""Test decode_jwt with None token"""
|
||||
assert decode_jwt(None) == {}
|
||||
|
||||
@patch("builtins.print")
|
||||
def test_decode_jwt_prints_error_on_failure(self, mock_print):
|
||||
"""Test that decode_jwt prints error message on failure"""
|
||||
token = "invalid.token"
|
||||
|
||||
result = decode_jwt(token)
|
||||
|
||||
assert result == {}
|
||||
mock_print.assert_called_once()
|
||||
assert "Failed to decode the token:" in mock_print.call_args[0][0]
|
||||
|
||||
def test_decode_msal_token_valid_single_line(self):
|
||||
"""Test decode_msal_token with valid JWT in single line"""
|
||||
# Create a valid JWT
|
||||
payload = {"roles": ["Exchange.ManageAsApp"], "tenant": "test-tenant"}
|
||||
payload_b64 = (
|
||||
base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8"))
|
||||
.decode("utf-8")
|
||||
.rstrip("=")
|
||||
)
|
||||
|
||||
jwt_token = f"header.{payload_b64}.signature"
|
||||
text = f"Some text before {jwt_token} some text after"
|
||||
|
||||
result = decode_msal_token(text)
|
||||
|
||||
assert result == payload
|
||||
assert result["roles"] == ["Exchange.ManageAsApp"]
|
||||
|
||||
def test_decode_msal_token_valid_multiline(self):
|
||||
"""Test decode_msal_token with valid JWT across multiple lines"""
|
||||
payload = {"roles": ["application_access"], "user": "test@contoso.com"}
|
||||
payload_b64 = (
|
||||
base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8"))
|
||||
.decode("utf-8")
|
||||
.rstrip("=")
|
||||
)
|
||||
|
||||
jwt_token = f"header.{payload_b64}.signature"
|
||||
text = f"""Line 1
|
||||
Line 2 with {jwt_token}
|
||||
Line 3"""
|
||||
|
||||
result = decode_msal_token(text)
|
||||
|
||||
assert result == payload
|
||||
assert result["user"] == "test@contoso.com"
|
||||
|
||||
def test_decode_msal_token_with_whitespace(self):
|
||||
"""Test decode_msal_token with JWT containing whitespace"""
|
||||
payload = {"test": "data"}
|
||||
payload_b64 = (
|
||||
base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8"))
|
||||
.decode("utf-8")
|
||||
.rstrip("=")
|
||||
)
|
||||
|
||||
jwt_token = f"header.{payload_b64}.signature"
|
||||
text = f" Token: {jwt_token} "
|
||||
|
||||
result = decode_msal_token(text)
|
||||
|
||||
assert result == payload
|
||||
|
||||
def test_decode_msal_token_no_jwt_found(self):
|
||||
"""Test decode_msal_token when no JWT pattern is found"""
|
||||
text = "This text contains no JWT tokens at all"
|
||||
|
||||
result = decode_msal_token(text)
|
||||
|
||||
assert result == {}
|
||||
|
||||
def test_decode_msal_token_invalid_jwt_pattern(self):
|
||||
"""Test decode_msal_token with text that looks like JWT but isn't"""
|
||||
text = "header.payload" # Only 2 parts, not valid JWT
|
||||
|
||||
result = decode_msal_token(text)
|
||||
|
||||
assert result == {}
|
||||
|
||||
def test_decode_msal_token_empty_text(self):
|
||||
"""Test decode_msal_token with empty text"""
|
||||
result = decode_msal_token("")
|
||||
assert result == {}
|
||||
|
||||
def test_decode_msal_token_none_text(self):
|
||||
"""Test decode_msal_token with None text"""
|
||||
assert decode_msal_token(None) == {}
|
||||
|
||||
@patch("builtins.print")
|
||||
def test_decode_msal_token_prints_error_on_failure(self, mock_print):
|
||||
"""Test that decode_msal_token prints error message on failure"""
|
||||
text = "No JWT here"
|
||||
|
||||
result = decode_msal_token(text)
|
||||
|
||||
assert result == {}
|
||||
mock_print.assert_called_once()
|
||||
assert "Failed to extract and decode the token:" in mock_print.call_args[0][0]
|
||||
|
||||
def test_decode_msal_token_real_world_scenario(self):
|
||||
"""Test decode_msal_token with a realistic PowerShell output scenario"""
|
||||
# Simulate output from Get-MsalToken or similar
|
||||
payload = {
|
||||
"aud": "https://graph.microsoft.com",
|
||||
"iss": "https://sts.windows.net/tenant-id/",
|
||||
"iat": 1640995200,
|
||||
"exp": 1641081600,
|
||||
"roles": ["Application.ReadWrite.All"],
|
||||
"sub": "app-subject-id",
|
||||
}
|
||||
payload_b64 = (
|
||||
base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8"))
|
||||
.decode("utf-8")
|
||||
.rstrip("=")
|
||||
)
|
||||
|
||||
jwt_token = f"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.{payload_b64}.signature123"
|
||||
|
||||
# Simulate PowerShell output format
|
||||
powershell_output = f"""
|
||||
AccessToken : {jwt_token}
|
||||
TokenType : Bearer
|
||||
ExpiresOn : 1/2/2022 12:00:00 AM +00:00
|
||||
ExtendedExpiresOn : 1/2/2022 12:00:00 AM +00:00
|
||||
"""
|
||||
|
||||
result = decode_msal_token(powershell_output)
|
||||
|
||||
assert result == payload
|
||||
assert result["roles"] == ["Application.ReadWrite.All"]
|
||||
assert result["aud"] == "https://graph.microsoft.com"
|
||||
|
||||
def test_decode_msal_token_with_jwt_in_json(self):
|
||||
"""Test decode_msal_token with JWT embedded in JSON-like structure"""
|
||||
payload = {"tenant": "test", "scope": "https://graph.microsoft.com/.default"}
|
||||
payload_b64 = (
|
||||
base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8"))
|
||||
.decode("utf-8")
|
||||
.rstrip("=")
|
||||
)
|
||||
|
||||
jwt_token = f"header.{payload_b64}.signature"
|
||||
|
||||
json_like_text = f'{{"access_token": "{jwt_token}", "token_type": "Bearer"}}'
|
||||
|
||||
result = decode_msal_token(json_like_text)
|
||||
|
||||
assert result == payload
|
||||
@@ -4,9 +4,8 @@ import pytest
|
||||
|
||||
from prowler.lib.powershell.powershell import PowerShellSession
|
||||
from prowler.providers.m365.exceptions.exceptions import (
|
||||
M365ExchangeConnectionError,
|
||||
M365GraphConnectionError,
|
||||
M365TeamsConnectionError,
|
||||
M365UserCredentialsError,
|
||||
M365UserNotBelongingToTenantError,
|
||||
)
|
||||
from prowler.providers.m365.lib.powershell.m365_powershell import M365PowerShell
|
||||
@@ -113,15 +112,9 @@ class Testm365PowerShell:
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
@patch("msal.ConfidentialClientApplication")
|
||||
def test_test_credentials(self, mock_msal, mock_popen):
|
||||
def test_test_credentials(self, mock_popen):
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
mock_msal_instance = MagicMock()
|
||||
mock_msal.return_value = mock_msal_instance
|
||||
mock_msal_instance.acquire_token_by_username_password.return_value = {
|
||||
"access_token": "test_token"
|
||||
}
|
||||
|
||||
credentials = M365Credentials(
|
||||
user="test@contoso.onmicrosoft.com",
|
||||
@@ -143,7 +136,11 @@ class Testm365PowerShell:
|
||||
|
||||
# Mock encrypt_password to return a known value
|
||||
session.encrypt_password = MagicMock(return_value="encrypted_password")
|
||||
session.execute = MagicMock()
|
||||
|
||||
# Mock execute to simulate successful Connect-ExchangeOnline
|
||||
session.execute = MagicMock(
|
||||
return_value="Connected successfully https://aka.ms/exov3-module"
|
||||
)
|
||||
|
||||
# Execute the test
|
||||
result = session.test_credentials(credentials)
|
||||
@@ -156,18 +153,10 @@ class Testm365PowerShell:
|
||||
session.execute.assert_any_call(
|
||||
f'$credential = New-Object System.Management.Automation.PSCredential("{session.sanitize(credentials.user)}", $securePassword)'
|
||||
)
|
||||
session.execute.assert_any_call(
|
||||
"Connect-ExchangeOnline -Credential $credential"
|
||||
)
|
||||
|
||||
# Verify MSAL was called with the correct parameters
|
||||
mock_msal.assert_called_once_with(
|
||||
client_id="test_client_id",
|
||||
client_credential="test_client_secret",
|
||||
authority="https://login.microsoftonline.com/test_tenant_id",
|
||||
)
|
||||
mock_msal_instance.acquire_token_by_username_password.assert_called_once_with(
|
||||
username="test@contoso.onmicrosoft.com",
|
||||
password="test_password", # Original password, not encrypted
|
||||
scopes=["https://graph.microsoft.com/.default"],
|
||||
)
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
@@ -255,13 +244,9 @@ class Testm365PowerShell:
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
@patch("msal.ConfidentialClientApplication")
|
||||
def test_test_credentials_auth_failure(self, mock_msal, mock_popen):
|
||||
def test_test_credentials_auth_failure_aadsts_error(self, mock_popen):
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
mock_msal_instance = MagicMock()
|
||||
mock_msal.return_value = mock_msal_instance
|
||||
mock_msal_instance.acquire_token_by_username_password.return_value = None
|
||||
|
||||
credentials = M365Credentials(
|
||||
user="test@contoso.onmicrosoft.com",
|
||||
@@ -281,46 +266,37 @@ class Testm365PowerShell:
|
||||
)
|
||||
session = M365PowerShell(credentials, identity)
|
||||
|
||||
# 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
|
||||
# Mock encrypt_password and execute to simulate AADSTS error
|
||||
session.encrypt_password = MagicMock(return_value="encrypted_password")
|
||||
session.execute = MagicMock(
|
||||
return_value="AADSTS50126: Error validating credentials due to invalid username or password"
|
||||
)
|
||||
|
||||
session.execute = MagicMock(side_effect=mock_execute)
|
||||
session.process.stdin.write = MagicMock()
|
||||
session.read_output = MagicMock(return_value="decrypted_password")
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
with pytest.raises(M365UserCredentialsError) as exc_info:
|
||||
session.test_credentials(credentials)
|
||||
|
||||
assert (
|
||||
"Unexpected error: Acquiring token in behalf of user did not return a result."
|
||||
"AADSTS50126: Error validating credentials due to invalid username or password"
|
||||
in str(exc_info.value)
|
||||
)
|
||||
|
||||
mock_msal.assert_called_once_with(
|
||||
client_id="test_client_id",
|
||||
client_credential="test_client_secret",
|
||||
authority="https://login.microsoftonline.com/test_tenant_id",
|
||||
# Verify execute was called with the correct commands
|
||||
session.execute.assert_any_call(
|
||||
f'$securePassword = "{credentials.encrypted_passwd}" | ConvertTo-SecureString'
|
||||
)
|
||||
mock_msal_instance.acquire_token_by_username_password.assert_called_once_with(
|
||||
username="test@contoso.onmicrosoft.com",
|
||||
password="test_password",
|
||||
scopes=["https://graph.microsoft.com/.default"],
|
||||
session.execute.assert_any_call(
|
||||
f'$credential = New-Object System.Management.Automation.PSCredential("{session.sanitize(credentials.user)}", $securePassword)'
|
||||
)
|
||||
session.execute.assert_any_call(
|
||||
"Connect-ExchangeOnline -Credential $credential"
|
||||
)
|
||||
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
@patch("msal.ConfidentialClientApplication")
|
||||
def test_test_credentials_auth_failure_no_access_token(self, mock_msal, mock_popen):
|
||||
def test_test_credentials_auth_failure_no_access_token(self, mock_popen):
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
mock_msal_instance = MagicMock()
|
||||
mock_msal.return_value = mock_msal_instance
|
||||
mock_msal_instance.acquire_token_by_username_password.return_value = {
|
||||
"error_description": "invalid_grant: authentication failed"
|
||||
}
|
||||
|
||||
credentials = M365Credentials(
|
||||
user="test@contoso.onmicrosoft.com",
|
||||
@@ -340,31 +316,29 @@ class Testm365PowerShell:
|
||||
)
|
||||
session = M365PowerShell(credentials, identity)
|
||||
|
||||
# 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
|
||||
# Mock encrypt_password and execute to simulate AADSTS invalid grant error
|
||||
session.encrypt_password = MagicMock(return_value="encrypted_password")
|
||||
session.execute = MagicMock(
|
||||
return_value="AADSTS70002: The request body must contain the following parameter: 'client_secret' or 'client_assertion'."
|
||||
)
|
||||
|
||||
session.execute = MagicMock(side_effect=mock_execute)
|
||||
session.process.stdin.write = MagicMock()
|
||||
session.read_output = MagicMock(return_value="decrypted_password")
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
with pytest.raises(M365UserCredentialsError) as exc_info:
|
||||
session.test_credentials(credentials)
|
||||
assert "MsGraph Error invalid_grant: authentication failed" in str(
|
||||
exc_info.value
|
||||
|
||||
assert (
|
||||
"AADSTS70002: The request body must contain the following parameter: 'client_secret' or 'client_assertion'."
|
||||
in str(exc_info.value)
|
||||
)
|
||||
|
||||
mock_msal.assert_called_once_with(
|
||||
client_id="test_client_id",
|
||||
client_credential="test_client_secret",
|
||||
authority="https://login.microsoftonline.com/test_tenant_id",
|
||||
# Verify execute was called with the correct commands
|
||||
session.execute.assert_any_call(
|
||||
f'$securePassword = "{credentials.encrypted_passwd}" | ConvertTo-SecureString'
|
||||
)
|
||||
mock_msal_instance.acquire_token_by_username_password.assert_called_once_with(
|
||||
username="test@contoso.onmicrosoft.com",
|
||||
password="test_password",
|
||||
scopes=["https://graph.microsoft.com/.default"],
|
||||
session.execute.assert_any_call(
|
||||
f'$credential = New-Object System.Management.Automation.PSCredential("{session.sanitize(credentials.user)}", $securePassword)'
|
||||
)
|
||||
session.execute.assert_any_call(
|
||||
"Connect-ExchangeOnline -Credential $credential"
|
||||
)
|
||||
|
||||
session.close()
|
||||
@@ -744,7 +718,8 @@ class Testm365PowerShell:
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_test_teams_connection_success(self, mock_popen):
|
||||
@patch("prowler.providers.m365.lib.powershell.m365_powershell.decode_jwt")
|
||||
def test_test_teams_connection_success(self, mock_decode_jwt, mock_popen):
|
||||
"""Test test_teams_connection when token is valid"""
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
@@ -766,17 +741,23 @@ class Testm365PowerShell:
|
||||
return None
|
||||
|
||||
session.execute = MagicMock(side_effect=mock_execute)
|
||||
# Mock JWT decode to return proper permissions
|
||||
mock_decode_jwt.return_value = {"roles": ["application_access"]}
|
||||
|
||||
result = session.test_teams_connection()
|
||||
|
||||
assert result is True
|
||||
# Verify all expected PowerShell commands were called
|
||||
assert session.execute.call_count == 3
|
||||
mock_decode_jwt.assert_called_once_with("valid_teams_token")
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_test_teams_connection_empty_token(self, mock_popen):
|
||||
"""Test test_teams_connection when token is empty"""
|
||||
@patch("prowler.providers.m365.lib.powershell.m365_powershell.decode_jwt")
|
||||
def test_test_teams_connection_missing_permissions(
|
||||
self, mock_decode_jwt, mock_popen
|
||||
):
|
||||
"""Test test_teams_connection when token lacks required permissions"""
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
credentials = M365Credentials(user="test@example.com", passwd="test_password")
|
||||
@@ -790,18 +771,23 @@ class Testm365PowerShell:
|
||||
)
|
||||
session = M365PowerShell(credentials, identity)
|
||||
|
||||
# Mock execute to return empty token when checking
|
||||
# Mock execute to return valid token but decode returns no permissions
|
||||
def mock_execute(command, *args, **kwargs):
|
||||
if "Write-Output $teamsToken" in command:
|
||||
return ""
|
||||
return "valid_teams_token"
|
||||
return None
|
||||
|
||||
session.execute = MagicMock(side_effect=mock_execute)
|
||||
# Mock JWT decode to return missing required permission
|
||||
mock_decode_jwt.return_value = {"roles": ["other_permission"]}
|
||||
|
||||
with pytest.raises(M365TeamsConnectionError) as exc_info:
|
||||
session.test_teams_connection()
|
||||
with patch("prowler.lib.logger.logger.error") as mock_error:
|
||||
result = session.test_teams_connection()
|
||||
|
||||
assert "Microsoft Teams token is empty or invalid" in str(exc_info.value)
|
||||
assert result is False
|
||||
mock_error.assert_called_once_with(
|
||||
"Microsoft Teams connection failed: Please check your permissions and try again."
|
||||
)
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
@@ -823,16 +809,18 @@ class Testm365PowerShell:
|
||||
# Mock execute to raise an exception
|
||||
session.execute = MagicMock(side_effect=Exception("Teams API error"))
|
||||
|
||||
with pytest.raises(M365TeamsConnectionError) as exc_info:
|
||||
session.test_teams_connection()
|
||||
with patch("prowler.lib.logger.logger.error") as mock_error:
|
||||
result = session.test_teams_connection()
|
||||
|
||||
assert "Failed to connect to Microsoft Teams API: Teams API error" in str(
|
||||
exc_info.value
|
||||
assert result is False
|
||||
mock_error.assert_called_once_with(
|
||||
"Microsoft Teams connection failed: Teams API error. Please check your permissions and try again."
|
||||
)
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_test_exchange_connection_success(self, mock_popen):
|
||||
@patch("prowler.providers.m365.lib.powershell.m365_powershell.decode_msal_token")
|
||||
def test_test_exchange_connection_success(self, mock_decode_msal_token, mock_popen):
|
||||
"""Test test_exchange_connection when token is valid"""
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
@@ -854,17 +842,23 @@ class Testm365PowerShell:
|
||||
return None
|
||||
|
||||
session.execute = MagicMock(side_effect=mock_execute)
|
||||
# Mock MSAL token decode to return proper permissions
|
||||
mock_decode_msal_token.return_value = {"roles": ["Exchange.ManageAsApp"]}
|
||||
|
||||
result = session.test_exchange_connection()
|
||||
|
||||
assert result is True
|
||||
# Verify all expected PowerShell commands were called
|
||||
assert session.execute.call_count == 3
|
||||
mock_decode_msal_token.assert_called_once_with("valid_exchange_token")
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_test_exchange_connection_empty_token(self, mock_popen):
|
||||
"""Test test_exchange_connection when token is empty"""
|
||||
@patch("prowler.providers.m365.lib.powershell.m365_powershell.decode_msal_token")
|
||||
def test_test_exchange_connection_missing_permissions(
|
||||
self, mock_decode_msal_token, mock_popen
|
||||
):
|
||||
"""Test test_exchange_connection when token lacks required permissions"""
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
credentials = M365Credentials(user="test@example.com", passwd="test_password")
|
||||
@@ -878,18 +872,23 @@ class Testm365PowerShell:
|
||||
)
|
||||
session = M365PowerShell(credentials, identity)
|
||||
|
||||
# Mock execute to return empty token when checking
|
||||
# Mock execute to return valid token but decode returns no permissions
|
||||
def mock_execute(command, *args, **kwargs):
|
||||
if "Write-Output $exchangeToken" in command:
|
||||
return ""
|
||||
return "valid_exchange_token"
|
||||
return None
|
||||
|
||||
session.execute = MagicMock(side_effect=mock_execute)
|
||||
# Mock MSAL token decode to return missing required permission
|
||||
mock_decode_msal_token.return_value = {"roles": ["other_permission"]}
|
||||
|
||||
with pytest.raises(M365ExchangeConnectionError) as exc_info:
|
||||
session.test_exchange_connection()
|
||||
with patch("prowler.lib.logger.logger.error") as mock_error:
|
||||
result = session.test_exchange_connection()
|
||||
|
||||
assert "Exchange Online token is empty or invalid" in str(exc_info.value)
|
||||
assert result is False
|
||||
mock_error.assert_called_once_with(
|
||||
"Exchange Online connection failed: Please check your permissions and try again."
|
||||
)
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
@@ -911,11 +910,12 @@ class Testm365PowerShell:
|
||||
# Mock execute to raise an exception
|
||||
session.execute = MagicMock(side_effect=Exception("Exchange API error"))
|
||||
|
||||
with pytest.raises(M365ExchangeConnectionError) as exc_info:
|
||||
session.test_exchange_connection()
|
||||
with patch("prowler.lib.logger.logger.error") as mock_error:
|
||||
result = session.test_exchange_connection()
|
||||
|
||||
assert "Failed to connect to Exchange Online API: Exchange API error" in str(
|
||||
exc_info.value
|
||||
assert result is False
|
||||
mock_error.assert_called_once_with(
|
||||
"Exchange Online connection failed: Exchange API error. Please check your permissions and try again."
|
||||
)
|
||||
session.close()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user